Scaffold project
This commit is contained in:
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/gateway
|
||||
/cmd/gateway/gateway
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Environment variables and secrets
|
||||
.env
|
||||
.env.local
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
# IDE specific files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build output
|
||||
/bin/
|
||||
/dist/
|
||||
/build/
|
||||
|
||||
# Configuration files with secrets
|
||||
config.yaml
|
||||
config.json
|
||||
*-local.yaml
|
||||
*-local.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
176
README.md
Normal file
176
README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Go LLM Gateway
|
||||
|
||||
## Overview
|
||||
|
||||
A lightweight LLM proxy gateway written in Go that provides a unified API interface for multiple LLM providers. Similar to LiteLLM, but built natively in Go using each provider's official SDK.
|
||||
|
||||
## Purpose
|
||||
|
||||
Simplify LLM integration by exposing a single, consistent API that routes requests to different providers:
|
||||
- **Google Generative AI** (Gemini)
|
||||
- **Anthropic** (Claude)
|
||||
- **OpenAI** (GPT models)
|
||||
|
||||
Instead of managing multiple SDK integrations in your application, call one endpoint and let the gateway handle provider-specific implementations.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client Request
|
||||
↓
|
||||
Go LLM Gateway (unified API)
|
||||
↓
|
||||
├─→ Google Gen AI SDK
|
||||
├─→ Anthropic SDK
|
||||
└─→ OpenAI SDK
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Single API interface** for multiple LLM providers
|
||||
- **Native Go SDKs** for optimal performance and type safety
|
||||
- **Provider abstraction** - switch providers without changing client code
|
||||
- **Lightweight** - minimal overhead, fast routing
|
||||
- **Easy configuration** - manage API keys and provider settings centrally
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Applications that need multi-provider LLM support
|
||||
- Cost optimization (route to cheapest provider for specific tasks)
|
||||
- Failover and redundancy (fallback to alternative providers)
|
||||
- A/B testing across different models
|
||||
- Centralized LLM access for microservices
|
||||
|
||||
## 🎉 Status: **WORKING!**
|
||||
|
||||
✅ **All three providers integrated with official Go SDKs:**
|
||||
- OpenAI → `github.com/openai/openai-go`
|
||||
- Anthropic → `github.com/anthropics/anthropic-sdk-go`
|
||||
- Google → `google.golang.org/genai`
|
||||
|
||||
✅ **Compiles successfully** (36MB binary)
|
||||
✅ **Provider auto-selection** (gpt→OpenAI, claude→Anthropic, gemini→Google)
|
||||
✅ **Configuration system** (YAML with env var support)
|
||||
✅ **Streaming support** (Server-Sent Events for all providers)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Set API keys
|
||||
export OPENAI_API_KEY="your-key"
|
||||
export ANTHROPIC_API_KEY="your-key"
|
||||
export GOOGLE_API_KEY="your-key"
|
||||
|
||||
# 2. Build
|
||||
cd go-llm-gateway
|
||||
go build -o gateway ./cmd/gateway
|
||||
|
||||
# 3. Run
|
||||
./gateway
|
||||
|
||||
# 4. Test (non-streaming)
|
||||
curl -X POST http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "gpt-4o-mini",
|
||||
"input": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Hello!"}]
|
||||
}
|
||||
]
|
||||
}'
|
||||
|
||||
# 5. Test streaming
|
||||
curl -X POST http://localhost:8080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-N \
|
||||
-d '{
|
||||
"model": "claude-3-5-sonnet-20241022",
|
||||
"stream": true,
|
||||
"input": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Write a haiku about Go"}]
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## API Standard
|
||||
|
||||
This gateway implements the **[Open Responses](https://www.openresponses.org)** specification — an open-source, multi-provider API standard for LLM interfaces based on OpenAI's Responses API.
|
||||
|
||||
**Why Open Responses:**
|
||||
- **Multi-provider by default** - one schema that maps cleanly across providers
|
||||
- **Agentic workflow support** - consistent streaming events, tool invocation patterns, and "items" as atomic units
|
||||
- **Extensible** - stable core with room for provider-specific features
|
||||
|
||||
By following the Open Responses spec, this gateway ensures:
|
||||
- Interoperability across different LLM providers
|
||||
- Standard request/response formats (messages, tool calls, streaming)
|
||||
- Compatibility with existing Open Responses tooling and ecosystem
|
||||
|
||||
For full specification details, see: **https://www.openresponses.org**
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Language:** Go
|
||||
- **API Specification:** [Open Responses](https://www.openresponses.org)
|
||||
- **SDKs:**
|
||||
- `google.golang.org/genai` (Google Generative AI)
|
||||
- Anthropic Go SDK
|
||||
- OpenAI Go SDK
|
||||
- **Transport:** RESTful HTTP (potentially gRPC in the future)
|
||||
|
||||
## Status
|
||||
|
||||
🚧 **In Development** - Project specification and initial setup phase.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Copy the example config** and fill in provider API keys:
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
You can also override API keys via environment variables (`GOOGLE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`).
|
||||
|
||||
2. **Run the gateway** using the default configuration path:
|
||||
|
||||
```bash
|
||||
go run ./cmd/gateway --config config.yaml
|
||||
```
|
||||
|
||||
The server listens on the address configured under `server.address` (defaults to `:8080`).
|
||||
|
||||
3. **Call the Open Responses endpoint**:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/v1/responses \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "gpt-4o-mini",
|
||||
"input": [
|
||||
{"role": "user", "content": [{"type": "input_text", "text": "Hello!"}]}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
Include `"provider": "anthropic"` (or `google`, `openai`) to pin a provider; otherwise the gateway infers it from the model name.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `cmd/gateway`: Entry point that loads configuration, wires providers, and starts the HTTP server.
|
||||
- `internal/config`: YAML configuration loader with environment overrides for API keys.
|
||||
- `internal/api`: Open Responses request/response types and validation helpers.
|
||||
- `internal/server`: HTTP handlers that expose `/v1/responses`.
|
||||
- `internal/providers`: Provider abstractions plus provider-specific scaffolding in `google`, `anthropic`, and `openai` subpackages.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Implement the actual SDK calls inside each provider using the official Go clients.
|
||||
- Support streaming responses and tool invocation per the broader Open Responses spec.
|
||||
- Add structured logging, tracing, and request-level metrics.
|
||||
- Expand configuration to support routing policies (cost, latency, failover, etc.).
|
||||
61
cmd/gateway/main.go
Normal file
61
cmd/gateway/main.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/yourusername/go-llm-gateway/internal/config"
|
||||
"github.com/yourusername/go-llm-gateway/internal/providers"
|
||||
"github.com/yourusername/go-llm-gateway/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPath string
|
||||
flag.StringVar(&configPath, "config", "config.yaml", "path to config file")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
registry, err := providers.NewRegistry(cfg.Providers)
|
||||
if err != nil {
|
||||
log.Fatalf("init providers: %v", err)
|
||||
}
|
||||
|
||||
logger := log.New(os.Stdout, "gateway ", log.LstdFlags|log.Lshortfile)
|
||||
|
||||
gatewayServer := server.New(registry, logger)
|
||||
mux := http.NewServeMux()
|
||||
gatewayServer.RegisterRoutes(mux)
|
||||
|
||||
addr := cfg.Server.Address
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: loggingMiddleware(mux, logger),
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
logger.Printf("Open Responses gateway listening on %s", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loggingMiddleware(next http.Handler, logger *log.Logger) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
logger.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
|
||||
})
|
||||
}
|
||||
16
config.example.yaml
Normal file
16
config.example.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
server:
|
||||
address: ":8080"
|
||||
|
||||
providers:
|
||||
google:
|
||||
api_key: "YOUR_GOOGLE_API_KEY"
|
||||
model: "gemini-1.5-flash"
|
||||
endpoint: "https://generativelanguage.googleapis.com"
|
||||
anthropic:
|
||||
api_key: "YOUR_ANTHROPIC_API_KEY"
|
||||
model: "claude-3-5-sonnet"
|
||||
endpoint: "https://api.anthropic.com"
|
||||
openai:
|
||||
api_key: "YOUR_OPENAI_API_KEY"
|
||||
model: "gpt-4o-mini"
|
||||
endpoint: "https://api.openai.com"
|
||||
35
go.mod
Normal file
35
go.mod
Normal file
@@ -0,0 +1,35 @@
|
||||
module github.com/yourusername/go-llm-gateway
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/openai/openai-go v1.12.0
|
||||
google.golang.org/genai v1.48.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.116.0 // indirect
|
||||
cloud.google.com/go/auth v0.9.3 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/grpc v1.66.2 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
152
go.sum
Normal file
152
go.sum
Normal file
@@ -0,0 +1,152 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
||||
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
||||
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
|
||||
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genai v1.48.0 h1:1vb15G291wAjJJueisMDpUhssljhEdJU2t5qTidrVPs=
|
||||
google.golang.org/genai v1.48.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
|
||||
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
86
internal/api/types.go
Normal file
86
internal/api/types.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ResponseRequest models the Open Responses create request payload.
|
||||
type ResponseRequest struct {
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Input []Message `json:"input"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
// Message captures user, assistant, or system roles.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content []ContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
// ContentBlock represents a typed content element (text, data, tool call, etc.).
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Response is a simplified Open Responses response payload.
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
Output []Message `json:"output"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
// Usage captures token accounting.
|
||||
type Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// StreamChunk represents a single Server-Sent Event in a streaming response.
|
||||
type StreamChunk struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Delta *StreamDelta `json:"delta,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Done bool `json:"done,omitempty"`
|
||||
}
|
||||
|
||||
// StreamDelta represents incremental content in a stream chunk.
|
||||
type StreamDelta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content []ContentBlock `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// Validate performs basic structural validation.
|
||||
func (r *ResponseRequest) Validate() error {
|
||||
if r == nil {
|
||||
return errors.New("request is nil")
|
||||
}
|
||||
if r.Model == "" {
|
||||
return errors.New("model is required")
|
||||
}
|
||||
if len(r.Input) == 0 {
|
||||
return errors.New("input messages are required")
|
||||
}
|
||||
for i, msg := range r.Input {
|
||||
if msg.Role == "" {
|
||||
return fmt.Errorf("input[%d] role is required", i)
|
||||
}
|
||||
if len(msg.Content) == 0 {
|
||||
return fmt.Errorf("input[%d] content is required", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
64
internal/config/config.go
Normal file
64
internal/config/config.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config describes the full gateway configuration file.
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Providers ProvidersConfig `yaml:"providers"`
|
||||
}
|
||||
|
||||
// ServerConfig controls HTTP server values.
|
||||
type ServerConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
}
|
||||
|
||||
// ProvidersConfig wraps supported provider settings.
|
||||
type ProvidersConfig struct {
|
||||
Google ProviderConfig `yaml:"google"`
|
||||
Anthropic ProviderConfig `yaml:"anthropic"`
|
||||
OpenAI ProviderConfig `yaml:"openai"`
|
||||
}
|
||||
|
||||
// ProviderConfig contains shared provider configuration fields.
|
||||
type ProviderConfig struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
Model string `yaml:"model"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
}
|
||||
|
||||
// Load reads and parses a YAML configuration file and applies env overrides.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
cfg.applyEnvOverrides()
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (cfg *Config) applyEnvOverrides() {
|
||||
overrideAPIKey(&cfg.Providers.Google, "GOOGLE_API_KEY")
|
||||
overrideAPIKey(&cfg.Providers.Anthropic, "ANTHROPIC_API_KEY")
|
||||
overrideAPIKey(&cfg.Providers.OpenAI, "OPENAI_API_KEY")
|
||||
}
|
||||
|
||||
func overrideAPIKey(cfg *ProviderConfig, envKey string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if v := os.Getenv(envKey); v != "" {
|
||||
cfg.APIKey = v
|
||||
}
|
||||
}
|
||||
241
internal/providers/anthropic/anthropic.go
Normal file
241
internal/providers/anthropic/anthropic.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
|
||||
"github.com/yourusername/go-llm-gateway/internal/api"
|
||||
"github.com/yourusername/go-llm-gateway/internal/config"
|
||||
)
|
||||
|
||||
const Name = "anthropic"
|
||||
|
||||
// Provider implements the Anthropic SDK integration.
|
||||
type Provider struct {
|
||||
cfg config.ProviderConfig
|
||||
client *anthropic.Client
|
||||
}
|
||||
|
||||
// New constructs a Provider from configuration.
|
||||
func New(cfg config.ProviderConfig) *Provider {
|
||||
var client *anthropic.Client
|
||||
if cfg.APIKey != "" {
|
||||
c := anthropic.NewClient(option.WithAPIKey(cfg.APIKey))
|
||||
client = &c
|
||||
}
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return Name }
|
||||
|
||||
// Generate routes the Open Responses request to Anthropic's API.
|
||||
func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api.Response, error) {
|
||||
if p.cfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("anthropic api key missing")
|
||||
}
|
||||
if p.client == nil {
|
||||
return nil, fmt.Errorf("anthropic client not initialized")
|
||||
}
|
||||
|
||||
model := chooseModel(req.Model, p.cfg.Model)
|
||||
|
||||
// Convert Open Responses messages to Anthropic format
|
||||
messages := make([]anthropic.MessageParam, 0, len(req.Input))
|
||||
var system string
|
||||
|
||||
for _, msg := range req.Input {
|
||||
var content string
|
||||
for _, block := range msg.Content {
|
||||
if block.Type == "input_text" {
|
||||
content += block.Text
|
||||
}
|
||||
}
|
||||
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(content)))
|
||||
case "assistant":
|
||||
messages = append(messages, anthropic.NewAssistantMessage(anthropic.NewTextBlock(content)))
|
||||
case "system":
|
||||
system = content
|
||||
}
|
||||
}
|
||||
|
||||
// Build request params
|
||||
params := anthropic.MessageNewParams{
|
||||
Model: anthropic.Model(model),
|
||||
Messages: messages,
|
||||
MaxTokens: int64(4096),
|
||||
}
|
||||
|
||||
if system != "" {
|
||||
systemBlocks := []anthropic.TextBlockParam{
|
||||
{Text: system, Type: "text"},
|
||||
}
|
||||
params.System = systemBlocks
|
||||
}
|
||||
|
||||
// Call Anthropic API
|
||||
resp, err := p.client.Messages.New(ctx, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("anthropic api error: %w", err)
|
||||
}
|
||||
|
||||
// Convert Anthropic response to Open Responses format
|
||||
output := make([]api.Message, 0, 1)
|
||||
var text string
|
||||
|
||||
for _, block := range resp.Content {
|
||||
if block.Type == "text" {
|
||||
text += block.Text
|
||||
}
|
||||
}
|
||||
|
||||
output = append(output, api.Message{
|
||||
Role: "assistant",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "output_text", Text: text},
|
||||
},
|
||||
})
|
||||
|
||||
return &api.Response{
|
||||
ID: resp.ID,
|
||||
Object: "response",
|
||||
Created: time.Now().Unix(),
|
||||
Model: string(resp.Model),
|
||||
Provider: Name,
|
||||
Output: output,
|
||||
Usage: api.Usage{
|
||||
InputTokens: int(resp.Usage.InputTokens),
|
||||
OutputTokens: int(resp.Usage.OutputTokens),
|
||||
TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateStream handles streaming requests to Anthropic.
|
||||
func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest) (<-chan *api.StreamChunk, <-chan error) {
|
||||
chunkChan := make(chan *api.StreamChunk)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(chunkChan)
|
||||
defer close(errChan)
|
||||
|
||||
if p.cfg.APIKey == "" {
|
||||
errChan <- fmt.Errorf("anthropic api key missing")
|
||||
return
|
||||
}
|
||||
if p.client == nil {
|
||||
errChan <- fmt.Errorf("anthropic client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
model := chooseModel(req.Model, p.cfg.Model)
|
||||
|
||||
// Convert messages
|
||||
messages := make([]anthropic.MessageParam, 0, len(req.Input))
|
||||
var system string
|
||||
|
||||
for _, msg := range req.Input {
|
||||
var content string
|
||||
for _, block := range msg.Content {
|
||||
if block.Type == "input_text" {
|
||||
content += block.Text
|
||||
}
|
||||
}
|
||||
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(content)))
|
||||
case "assistant":
|
||||
messages = append(messages, anthropic.NewAssistantMessage(anthropic.NewTextBlock(content)))
|
||||
case "system":
|
||||
system = content
|
||||
}
|
||||
}
|
||||
|
||||
// Build params
|
||||
params := anthropic.MessageNewParams{
|
||||
Model: anthropic.Model(model),
|
||||
Messages: messages,
|
||||
MaxTokens: int64(4096),
|
||||
}
|
||||
|
||||
if system != "" {
|
||||
systemBlocks := []anthropic.TextBlockParam{
|
||||
{Text: system, Type: "text"},
|
||||
}
|
||||
params.System = systemBlocks
|
||||
}
|
||||
|
||||
// Create stream
|
||||
stream := p.client.Messages.NewStreaming(ctx, params)
|
||||
|
||||
// Process stream
|
||||
for stream.Next() {
|
||||
event := stream.Current()
|
||||
|
||||
delta := &api.StreamDelta{}
|
||||
var text string
|
||||
|
||||
// Handle different event types
|
||||
if event.Type == "content_block_delta" && event.Delta.Type == "text_delta" {
|
||||
text = event.Delta.Text
|
||||
delta.Content = []api.ContentBlock{
|
||||
{Type: "output_text", Text: text},
|
||||
}
|
||||
}
|
||||
|
||||
if event.Type == "message_start" {
|
||||
delta.Role = "assistant"
|
||||
}
|
||||
|
||||
streamChunk := &api.StreamChunk{
|
||||
Object: "response.chunk",
|
||||
Created: time.Now().Unix(),
|
||||
Model: string(model),
|
||||
Provider: Name,
|
||||
Delta: delta,
|
||||
}
|
||||
|
||||
select {
|
||||
case chunkChan <- streamChunk:
|
||||
case <-ctx.Done():
|
||||
errChan <- ctx.Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := stream.Err(); err != nil {
|
||||
errChan <- fmt.Errorf("anthropic stream error: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send final chunk
|
||||
select {
|
||||
case chunkChan <- &api.StreamChunk{Object: "response.chunk", Done: true}:
|
||||
case <-ctx.Done():
|
||||
errChan <- ctx.Err()
|
||||
}
|
||||
}()
|
||||
|
||||
return chunkChan, errChan
|
||||
}
|
||||
|
||||
func chooseModel(requested, defaultModel string) string {
|
||||
if requested != "" {
|
||||
return requested
|
||||
}
|
||||
if defaultModel != "" {
|
||||
return defaultModel
|
||||
}
|
||||
return "claude-3-5-sonnet"
|
||||
}
|
||||
227
internal/providers/google/google.go
Normal file
227
internal/providers/google/google.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/genai"
|
||||
|
||||
"github.com/yourusername/go-llm-gateway/internal/api"
|
||||
"github.com/yourusername/go-llm-gateway/internal/config"
|
||||
)
|
||||
|
||||
const Name = "google"
|
||||
|
||||
// Provider implements the Google Generative AI integration.
|
||||
type Provider struct {
|
||||
cfg config.ProviderConfig
|
||||
client *genai.Client
|
||||
}
|
||||
|
||||
// New constructs a Provider using the provided configuration.
|
||||
func New(cfg config.ProviderConfig) *Provider {
|
||||
var client *genai.Client
|
||||
if cfg.APIKey != "" {
|
||||
var err error
|
||||
client, err = genai.NewClient(context.Background(), &genai.ClientConfig{
|
||||
APIKey: cfg.APIKey,
|
||||
})
|
||||
if err != nil {
|
||||
// Log error but don't fail construction - will fail on Generate
|
||||
fmt.Printf("warning: failed to create google client: %v\n", err)
|
||||
}
|
||||
}
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return Name }
|
||||
|
||||
// Generate routes the Open Responses request to Gemini.
|
||||
func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api.Response, error) {
|
||||
if p.cfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("google api key missing")
|
||||
}
|
||||
if p.client == nil {
|
||||
return nil, fmt.Errorf("google client not initialized")
|
||||
}
|
||||
|
||||
model := chooseModel(req.Model, p.cfg.Model)
|
||||
|
||||
// Convert Open Responses messages to Gemini format
|
||||
var contents []*genai.Content
|
||||
|
||||
for _, msg := range req.Input {
|
||||
var parts []*genai.Part
|
||||
for _, block := range msg.Content {
|
||||
if block.Type == "input_text" {
|
||||
parts = append(parts, genai.NewPartFromText(block.Text))
|
||||
}
|
||||
}
|
||||
|
||||
role := "user"
|
||||
if msg.Role == "assistant" || msg.Role == "model" {
|
||||
role = "model"
|
||||
}
|
||||
|
||||
contents = append(contents, &genai.Content{
|
||||
Role: role,
|
||||
Parts: parts,
|
||||
})
|
||||
}
|
||||
|
||||
// Generate content
|
||||
resp, err := p.client.Models.GenerateContent(ctx, model, contents, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("google api error: %w", err)
|
||||
}
|
||||
|
||||
// Convert Gemini response to Open Responses format
|
||||
output := make([]api.Message, 0, 1)
|
||||
var text string
|
||||
|
||||
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
|
||||
for _, part := range resp.Candidates[0].Content.Parts {
|
||||
if part != nil {
|
||||
text += part.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output = append(output, api.Message{
|
||||
Role: "assistant",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "output_text", Text: text},
|
||||
},
|
||||
})
|
||||
|
||||
// Extract usage info if available
|
||||
var inputTokens, outputTokens int
|
||||
if resp.UsageMetadata != nil {
|
||||
inputTokens = int(resp.UsageMetadata.PromptTokenCount)
|
||||
outputTokens = int(resp.UsageMetadata.CandidatesTokenCount)
|
||||
}
|
||||
|
||||
return &api.Response{
|
||||
ID: uuid.NewString(),
|
||||
Object: "response",
|
||||
Created: time.Now().Unix(),
|
||||
Model: model,
|
||||
Provider: Name,
|
||||
Output: output,
|
||||
Usage: api.Usage{
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
TotalTokens: inputTokens + outputTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateStream handles streaming requests to Google.
|
||||
func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest) (<-chan *api.StreamChunk, <-chan error) {
|
||||
chunkChan := make(chan *api.StreamChunk)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(chunkChan)
|
||||
defer close(errChan)
|
||||
|
||||
if p.cfg.APIKey == "" {
|
||||
errChan <- fmt.Errorf("google api key missing")
|
||||
return
|
||||
}
|
||||
if p.client == nil {
|
||||
errChan <- fmt.Errorf("google client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
model := chooseModel(req.Model, p.cfg.Model)
|
||||
|
||||
// Convert messages
|
||||
var contents []*genai.Content
|
||||
|
||||
for _, msg := range req.Input {
|
||||
var parts []*genai.Part
|
||||
for _, block := range msg.Content {
|
||||
if block.Type == "input_text" {
|
||||
parts = append(parts, genai.NewPartFromText(block.Text))
|
||||
}
|
||||
}
|
||||
|
||||
role := "user"
|
||||
if msg.Role == "assistant" || msg.Role == "model" {
|
||||
role = "model"
|
||||
}
|
||||
|
||||
contents = append(contents, &genai.Content{
|
||||
Role: role,
|
||||
Parts: parts,
|
||||
})
|
||||
}
|
||||
|
||||
// Create stream
|
||||
stream := p.client.Models.GenerateContentStream(ctx, model, contents, nil)
|
||||
|
||||
// Process stream
|
||||
for resp, err := range stream {
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("google stream error: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
var text string
|
||||
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
|
||||
for _, part := range resp.Candidates[0].Content.Parts {
|
||||
if part != nil {
|
||||
text += part.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delta := &api.StreamDelta{}
|
||||
if text != "" {
|
||||
delta.Content = []api.ContentBlock{
|
||||
{Type: "output_text", Text: text},
|
||||
}
|
||||
}
|
||||
|
||||
streamChunk := &api.StreamChunk{
|
||||
Object: "response.chunk",
|
||||
Created: time.Now().Unix(),
|
||||
Model: model,
|
||||
Provider: Name,
|
||||
Delta: delta,
|
||||
}
|
||||
|
||||
select {
|
||||
case chunkChan <- streamChunk:
|
||||
case <-ctx.Done():
|
||||
errChan <- ctx.Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send final chunk
|
||||
select {
|
||||
case chunkChan <- &api.StreamChunk{Object: "response.chunk", Done: true}:
|
||||
case <-ctx.Done():
|
||||
errChan <- ctx.Err()
|
||||
}
|
||||
}()
|
||||
|
||||
return chunkChan, errChan
|
||||
}
|
||||
|
||||
func chooseModel(requested, defaultModel string) string {
|
||||
if requested != "" {
|
||||
return requested
|
||||
}
|
||||
if defaultModel != "" {
|
||||
return defaultModel
|
||||
}
|
||||
return "gemini-2.0-flash-exp"
|
||||
}
|
||||
210
internal/providers/openai/openai.go
Normal file
210
internal/providers/openai/openai.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/openai/openai-go"
|
||||
"github.com/openai/openai-go/option"
|
||||
|
||||
"github.com/yourusername/go-llm-gateway/internal/api"
|
||||
"github.com/yourusername/go-llm-gateway/internal/config"
|
||||
)
|
||||
|
||||
const Name = "openai"
|
||||
|
||||
// Provider implements the OpenAI SDK integration.
|
||||
type Provider struct {
|
||||
cfg config.ProviderConfig
|
||||
client *openai.Client
|
||||
}
|
||||
|
||||
// New constructs a Provider from configuration.
|
||||
func New(cfg config.ProviderConfig) *Provider {
|
||||
var client *openai.Client
|
||||
if cfg.APIKey != "" {
|
||||
c := openai.NewClient(option.WithAPIKey(cfg.APIKey))
|
||||
client = &c
|
||||
}
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the provider identifier.
|
||||
func (p *Provider) Name() string { return Name }
|
||||
|
||||
// Generate routes the Open Responses request to OpenAI.
|
||||
func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api.Response, error) {
|
||||
if p.cfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("openai api key missing")
|
||||
}
|
||||
if p.client == nil {
|
||||
return nil, fmt.Errorf("openai client not initialized")
|
||||
}
|
||||
|
||||
model := chooseModel(req.Model, p.cfg.Model)
|
||||
|
||||
// Convert Open Responses messages to OpenAI format
|
||||
messages := make([]openai.ChatCompletionMessageParamUnion, 0, len(req.Input))
|
||||
for _, msg := range req.Input {
|
||||
var content string
|
||||
for _, block := range msg.Content {
|
||||
if block.Type == "input_text" {
|
||||
content += block.Text
|
||||
}
|
||||
}
|
||||
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
messages = append(messages, openai.UserMessage(content))
|
||||
case "assistant":
|
||||
messages = append(messages, openai.AssistantMessage(content))
|
||||
case "system":
|
||||
messages = append(messages, openai.SystemMessage(content))
|
||||
}
|
||||
}
|
||||
|
||||
// Call OpenAI API
|
||||
resp, err := p.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
|
||||
Model: openai.ChatModel(model),
|
||||
Messages: messages,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openai api error: %w", err)
|
||||
}
|
||||
|
||||
// Convert OpenAI response to Open Responses format
|
||||
output := make([]api.Message, 0, len(resp.Choices))
|
||||
for _, choice := range resp.Choices {
|
||||
output = append(output, api.Message{
|
||||
Role: "assistant",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "output_text", Text: choice.Message.Content},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &api.Response{
|
||||
ID: resp.ID,
|
||||
Object: "response",
|
||||
Created: time.Now().Unix(),
|
||||
Model: resp.Model,
|
||||
Provider: Name,
|
||||
Output: output,
|
||||
Usage: api.Usage{
|
||||
InputTokens: int(resp.Usage.PromptTokens),
|
||||
OutputTokens: int(resp.Usage.CompletionTokens),
|
||||
TotalTokens: int(resp.Usage.TotalTokens),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateStream handles streaming requests to OpenAI.
|
||||
func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest) (<-chan *api.StreamChunk, <-chan error) {
|
||||
chunkChan := make(chan *api.StreamChunk)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(chunkChan)
|
||||
defer close(errChan)
|
||||
|
||||
if p.cfg.APIKey == "" {
|
||||
errChan <- fmt.Errorf("openai api key missing")
|
||||
return
|
||||
}
|
||||
if p.client == nil {
|
||||
errChan <- fmt.Errorf("openai client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
model := chooseModel(req.Model, p.cfg.Model)
|
||||
|
||||
// Convert messages
|
||||
messages := make([]openai.ChatCompletionMessageParamUnion, 0, len(req.Input))
|
||||
for _, msg := range req.Input {
|
||||
var content string
|
||||
for _, block := range msg.Content {
|
||||
if block.Type == "input_text" {
|
||||
content += block.Text
|
||||
}
|
||||
}
|
||||
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
messages = append(messages, openai.UserMessage(content))
|
||||
case "assistant":
|
||||
messages = append(messages, openai.AssistantMessage(content))
|
||||
case "system":
|
||||
messages = append(messages, openai.SystemMessage(content))
|
||||
}
|
||||
}
|
||||
|
||||
// Create streaming request
|
||||
stream := p.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{
|
||||
Model: openai.ChatModel(model),
|
||||
Messages: messages,
|
||||
})
|
||||
|
||||
// Process stream
|
||||
for stream.Next() {
|
||||
chunk := stream.Current()
|
||||
|
||||
for _, choice := range chunk.Choices {
|
||||
delta := &api.StreamDelta{}
|
||||
|
||||
if choice.Delta.Role != "" {
|
||||
delta.Role = string(choice.Delta.Role)
|
||||
}
|
||||
|
||||
if choice.Delta.Content != "" {
|
||||
delta.Content = []api.ContentBlock{
|
||||
{Type: "output_text", Text: choice.Delta.Content},
|
||||
}
|
||||
}
|
||||
|
||||
streamChunk := &api.StreamChunk{
|
||||
ID: chunk.ID,
|
||||
Object: "response.chunk",
|
||||
Created: time.Now().Unix(),
|
||||
Model: chunk.Model,
|
||||
Provider: Name,
|
||||
Delta: delta,
|
||||
}
|
||||
|
||||
select {
|
||||
case chunkChan <- streamChunk:
|
||||
case <-ctx.Done():
|
||||
errChan <- ctx.Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := stream.Err(); err != nil {
|
||||
errChan <- fmt.Errorf("openai stream error: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send final chunk
|
||||
select {
|
||||
case chunkChan <- &api.StreamChunk{Object: "response.chunk", Done: true}:
|
||||
case <-ctx.Done():
|
||||
errChan <- ctx.Err()
|
||||
}
|
||||
}()
|
||||
|
||||
return chunkChan, errChan
|
||||
}
|
||||
|
||||
func chooseModel(requested, defaultModel string) string {
|
||||
if requested != "" {
|
||||
return requested
|
||||
}
|
||||
if defaultModel != "" {
|
||||
return defaultModel
|
||||
}
|
||||
return "gpt-4o-mini"
|
||||
}
|
||||
78
internal/providers/providers.go
Normal file
78
internal/providers/providers.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yourusername/go-llm-gateway/internal/api"
|
||||
"github.com/yourusername/go-llm-gateway/internal/config"
|
||||
anthropicprovider "github.com/yourusername/go-llm-gateway/internal/providers/anthropic"
|
||||
googleprovider "github.com/yourusername/go-llm-gateway/internal/providers/google"
|
||||
openaiprovider "github.com/yourusername/go-llm-gateway/internal/providers/openai"
|
||||
)
|
||||
|
||||
// Provider represents a unified interface that each LLM provider must implement.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Generate(ctx context.Context, req *api.ResponseRequest) (*api.Response, error)
|
||||
GenerateStream(ctx context.Context, req *api.ResponseRequest) (<-chan *api.StreamChunk, <-chan error)
|
||||
}
|
||||
|
||||
// Registry keeps track of registered providers by key (e.g. "openai").
|
||||
type Registry struct {
|
||||
providers map[string]Provider
|
||||
}
|
||||
|
||||
// NewRegistry constructs provider implementations from configuration.
|
||||
func NewRegistry(cfg config.ProvidersConfig) (*Registry, error) {
|
||||
reg := &Registry{providers: make(map[string]Provider)}
|
||||
|
||||
if cfg.Google.APIKey != "" {
|
||||
reg.providers[googleprovider.Name] = googleprovider.New(cfg.Google)
|
||||
}
|
||||
if cfg.Anthropic.APIKey != "" {
|
||||
reg.providers[anthropicprovider.Name] = anthropicprovider.New(cfg.Anthropic)
|
||||
}
|
||||
if cfg.OpenAI.APIKey != "" {
|
||||
reg.providers[openaiprovider.Name] = openaiprovider.New(cfg.OpenAI)
|
||||
}
|
||||
|
||||
if len(reg.providers) == 0 {
|
||||
return nil, fmt.Errorf("no providers configured")
|
||||
}
|
||||
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// Get returns provider by key.
|
||||
func (r *Registry) Get(name string) (Provider, bool) {
|
||||
p, ok := r.providers[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Default returns provider based on inferred name.
|
||||
func (r *Registry) Default(model string) (Provider, error) {
|
||||
if model != "" {
|
||||
switch {
|
||||
case strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o1"):
|
||||
if p, ok := r.providers[openaiprovider.Name]; ok {
|
||||
return p, nil
|
||||
}
|
||||
case strings.HasPrefix(model, "claude"):
|
||||
if p, ok := r.providers[anthropicprovider.Name]; ok {
|
||||
return p, nil
|
||||
}
|
||||
case strings.HasPrefix(model, "gemini"):
|
||||
if p, ok := r.providers[googleprovider.Name]; ok {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range r.providers {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no providers available")
|
||||
}
|
||||
132
internal/server/server.go
Normal file
132
internal/server/server.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/yourusername/go-llm-gateway/internal/api"
|
||||
"github.com/yourusername/go-llm-gateway/internal/providers"
|
||||
)
|
||||
|
||||
// GatewayServer hosts the Open Responses API for the gateway.
|
||||
type GatewayServer struct {
|
||||
registry *providers.Registry
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// New creates a GatewayServer bound to the provider registry.
|
||||
func New(registry *providers.Registry, logger *log.Logger) *GatewayServer {
|
||||
return &GatewayServer{registry: registry, logger: logger}
|
||||
}
|
||||
|
||||
// RegisterRoutes wires the HTTP handlers onto the provided mux.
|
||||
func (s *GatewayServer) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/responses", s.handleResponses)
|
||||
}
|
||||
|
||||
func (s *GatewayServer) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.ResponseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := s.resolveProvider(&req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle streaming vs non-streaming
|
||||
if req.Stream {
|
||||
s.handleStreamingResponse(w, r, provider, &req)
|
||||
} else {
|
||||
s.handleSyncResponse(w, r, provider, &req)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GatewayServer) handleSyncResponse(w http.ResponseWriter, r *http.Request, provider providers.Provider, req *api.ResponseRequest) {
|
||||
resp, err := provider.Generate(r.Context(), req)
|
||||
if err != nil {
|
||||
s.logger.Printf("provider %s error: %v", provider.Name(), err)
|
||||
http.Error(w, "provider error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *GatewayServer) handleStreamingResponse(w http.ResponseWriter, r *http.Request, provider providers.Provider, req *api.ResponseRequest) {
|
||||
// Set headers for SSE
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
chunkChan, errChan := provider.GenerateStream(r.Context(), req)
|
||||
|
||||
for {
|
||||
select {
|
||||
case chunk, ok := <-chunkChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(chunk)
|
||||
if err != nil {
|
||||
s.logger.Printf("failed to marshal chunk: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
|
||||
if chunk.Done {
|
||||
return
|
||||
}
|
||||
|
||||
case err := <-errChan:
|
||||
if err != nil {
|
||||
s.logger.Printf("stream error: %v", err)
|
||||
errData, _ := json.Marshal(map[string]string{"error": err.Error()})
|
||||
fmt.Fprintf(w, "data: %s\n\n", errData)
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
|
||||
case <-r.Context().Done():
|
||||
s.logger.Printf("client disconnected")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GatewayServer) resolveProvider(req *api.ResponseRequest) (providers.Provider, error) {
|
||||
if req.Provider != "" {
|
||||
if provider, ok := s.registry.Get(req.Provider); ok {
|
||||
return provider, nil
|
||||
}
|
||||
return nil, fmt.Errorf("provider %s not configured", req.Provider)
|
||||
}
|
||||
return s.registry.Default(req.Model)
|
||||
}
|
||||
Reference in New Issue
Block a user