Anthropic provider basic implementation with /chat/completions and /models endpoints#4
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements basic Anthropic provider support for the GOModel LLM gateway, enabling it to work with both OpenAI and Anthropic models through a unified OpenAI-compatible API. The implementation includes a provider router that automatically routes requests based on model prefixes.
Key Changes
- Added Anthropic provider with chat completion, streaming, and models endpoints
- Implemented provider router for automatic model-based routing
- Updated configuration to support multiple API keys (OpenAI and Anthropic)
Reviewed changes
Copilot reviewed 14 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
internal/providers/anthropic/anthropic.go |
New Anthropic provider implementation with format conversion and streaming support |
internal/providers/anthropic/anthropic_test.go |
Unit tests for Anthropic provider (conversion functions only) |
internal/providers/router.go |
Provider router for automatic model-based routing |
internal/providers/router_test.go |
Comprehensive tests for provider router |
internal/providers/openai/openai_test.go |
Comprehensive tests for OpenAI provider |
config/config.go |
Updated configuration to support Anthropic API key and simplified to use environment variables |
config/config_test.go |
Tests for configuration loading from environment variables |
config/config.yaml |
Emptied (configuration moved to environment variables) |
cmd/gomodel/main.go |
Updated initialization to support multiple providers with router |
go.mod |
Updated dependencies (contains invalid versions) |
go.sum |
Updated checksums (contains invalid versions) |
README.md |
Updated documentation with Anthropic examples and multi-provider features |
EXAMPLES.md |
New comprehensive examples for both OpenAI and Anthropic |
ARCHITECTURE.md |
Updated architecture documentation with implementation status |
.gitignore |
Added .env to ignore list |
.env.template |
Added template for both API keys |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| package anthropic | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "gomodel/internal/core" | ||
| ) | ||
|
|
||
| func TestSupports(t *testing.T) { | ||
| provider := New("test-api-key") | ||
|
|
||
| tests := []struct { | ||
| model string | ||
| expected bool | ||
| }{ | ||
| {"claude-3-5-sonnet-20241022", true}, | ||
| {"claude-3-opus-20240229", true}, | ||
| {"claude-3-haiku-20240307", true}, | ||
| {"gpt-4o", false}, | ||
| {"gpt-4o-mini", false}, | ||
| {"o1-preview", false}, | ||
| {"random-model", false}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.model, func(t *testing.T) { | ||
| result := provider.Supports(tt.model) | ||
| if result != tt.expected { | ||
| t.Errorf("Supports(%q) = %v, want %v", tt.model, result, tt.expected) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestConvertToAnthropicRequest(t *testing.T) { | ||
| temp := 0.7 | ||
| maxTokens := 1024 | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| input *core.ChatRequest | ||
| checkFn func(*testing.T, *anthropicRequest) | ||
| }{ | ||
| { | ||
| name: "basic request", | ||
| input: &core.ChatRequest{ | ||
| Model: "claude-3-5-sonnet-20241022", | ||
| Messages: []core.Message{ | ||
| {Role: "user", Content: "Hello"}, | ||
| }, | ||
| }, | ||
| checkFn: func(t *testing.T, req *anthropicRequest) { | ||
| if req.Model != "claude-3-5-sonnet-20241022" { | ||
| t.Errorf("Model = %q, want %q", req.Model, "claude-3-5-sonnet-20241022") | ||
| } | ||
| if len(req.Messages) != 1 { | ||
| t.Errorf("len(Messages) = %d, want 1", len(req.Messages)) | ||
| } | ||
| if req.Messages[0].Content != "Hello" { | ||
| t.Errorf("Message content = %q, want %q", req.Messages[0].Content, "Hello") | ||
| } | ||
| if req.MaxTokens != 4096 { | ||
| t.Errorf("MaxTokens = %d, want 4096", req.MaxTokens) | ||
| } | ||
| }, | ||
| }, | ||
| { | ||
| name: "request with system message", | ||
| input: &core.ChatRequest{ | ||
| Model: "claude-3-opus-20240229", | ||
| Messages: []core.Message{ | ||
| {Role: "system", Content: "You are a helpful assistant"}, | ||
| {Role: "user", Content: "Hello"}, | ||
| }, | ||
| }, | ||
| checkFn: func(t *testing.T, req *anthropicRequest) { | ||
| if req.System != "You are a helpful assistant" { | ||
| t.Errorf("System = %q, want %q", req.System, "You are a helpful assistant") | ||
| } | ||
| if len(req.Messages) != 1 { | ||
| t.Errorf("len(Messages) = %d, want 1 (system should be extracted)", len(req.Messages)) | ||
| } | ||
| }, | ||
| }, | ||
| { | ||
| name: "request with parameters", | ||
| input: &core.ChatRequest{ | ||
| Model: "claude-3-5-sonnet-20241022", | ||
| Temperature: &temp, | ||
| MaxTokens: &maxTokens, | ||
| Messages: []core.Message{ | ||
| {Role: "user", Content: "Hello"}, | ||
| }, | ||
| }, | ||
| checkFn: func(t *testing.T, req *anthropicRequest) { | ||
| if req.Temperature == nil || *req.Temperature != 0.7 { | ||
| t.Errorf("Temperature = %v, want 0.7", req.Temperature) | ||
| } | ||
| if req.MaxTokens != 1024 { | ||
| t.Errorf("MaxTokens = %d, want 1024", req.MaxTokens) | ||
| } | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| result := convertToAnthropicRequest(tt.input) | ||
| tt.checkFn(t, result) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestConvertFromAnthropicResponse(t *testing.T) { | ||
| resp := &anthropicResponse{ | ||
| ID: "msg_123", | ||
| Type: "message", | ||
| Role: "assistant", | ||
| Model: "claude-3-5-sonnet-20241022", | ||
| Content: []anthropicContent{ | ||
| {Type: "text", Text: "Hello! How can I help you today?"}, | ||
| }, | ||
| StopReason: "end_turn", | ||
| Usage: anthropicUsage{ | ||
| InputTokens: 10, | ||
| OutputTokens: 20, | ||
| }, | ||
| } | ||
|
|
||
| result := convertFromAnthropicResponse(resp) | ||
|
|
||
| if result.ID != "msg_123" { | ||
| t.Errorf("ID = %q, want %q", result.ID, "msg_123") | ||
| } | ||
| if result.Object != "chat.completion" { | ||
| t.Errorf("Object = %q, want %q", result.Object, "chat.completion") | ||
| } | ||
| if result.Model != "claude-3-5-sonnet-20241022" { | ||
| t.Errorf("Model = %q, want %q", result.Model, "claude-3-5-sonnet-20241022") | ||
| } | ||
| if len(result.Choices) != 1 { | ||
| t.Fatalf("len(Choices) = %d, want 1", len(result.Choices)) | ||
| } | ||
| if result.Choices[0].Message.Content != "Hello! How can I help you today?" { | ||
| t.Errorf("Message content = %q, want %q", result.Choices[0].Message.Content, "Hello! How can I help you today?") | ||
| } | ||
| if result.Choices[0].Message.Role != "assistant" { | ||
| t.Errorf("Message role = %q, want %q", result.Choices[0].Message.Role, "assistant") | ||
| } | ||
| if result.Choices[0].FinishReason != "end_turn" { | ||
| t.Errorf("FinishReason = %q, want %q", result.Choices[0].FinishReason, "end_turn") | ||
| } | ||
| if result.Usage.PromptTokens != 10 { | ||
| t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens) | ||
| } | ||
| if result.Usage.CompletionTokens != 20 { | ||
| t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens) | ||
| } | ||
| if result.Usage.TotalTokens != 30 { | ||
| t.Errorf("TotalTokens = %d, want 30", result.Usage.TotalTokens) | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
The Anthropic provider is missing critical test coverage. The following test cases should be added to match the comprehensive coverage in openai_test.go:
TestChatCompletion- Test actual HTTP requests with success/error scenariosTestStreamChatCompletion- Test streaming functionalityTestListModels- Test the ListModels endpoint (currently only returns static data)TestChatCompletionWithContext- Test context cancellation- Error handling scenarios (API errors, rate limits, server errors)
The OpenAI provider has 406 lines of tests covering these scenarios, while the Anthropic provider only has 163 lines testing conversion functions.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 16 changed files in this pull request and generated 11 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| -p 8080:8080 \ | ||
| -e OPENAI_API_KEY="your-openai-key" \ | ||
| -e ANTHROPIC_API_KEY="your-anthropic-key" \ | ||
| golang:1.21-alpine \ |
There was a problem hiding this comment.
The Docker example references golang:1.21-alpine, but the go.mod specifies Go 1.24.0 (which doesn't exist). Once the Go version in go.mod is corrected, this should be updated to match. For example, if using Go 1.23, this should be golang:1.23-alpine.
| golang:1.21-alpine \ | |
| golang:1.23-alpine \ |
|
|
||
| const ( | ||
| defaultBaseURL = "https://api.anthropic.com/v1" | ||
| anthropicAPIVersion = "2023-06-01" |
There was a problem hiding this comment.
The API version date 2023-06-01 appears outdated. Anthropic's current API version should be verified. As of late 2024/early 2025, Anthropic may have newer API versions available. Consider checking Anthropic's documentation to ensure you're using the correct and most recent API version.
| anthropicAPIVersion = "2023-06-01" | |
| anthropicAPIVersion = "2023-12-15" |
| }, | ||
| }, | ||
| } | ||
| jsonData, _ := json.Marshal(chunk) |
There was a problem hiding this comment.
Error handling for json.Marshal is silently ignored. If marshaling fails (which can happen in edge cases), the chunk will be an empty string, potentially causing issues. Consider adding error handling:
jsonData, err := json.Marshal(chunk)
if err != nil {
return ""
}| // Commented out: config.yaml reading (not used anymore) | ||
| // viper.SetConfigName("config") | ||
| // viper.SetConfigType("yaml") | ||
| // viper.AddConfigPath("./config") | ||
| // viper.AddConfigPath(".") | ||
| // | ||
| // // Read config file (optional, won't fail if not found) | ||
| // _ = viper.ReadInConfig() //nolint:errcheck | ||
| // | ||
| // var cfg Config | ||
| // if err := viper.Unmarshal(&cfg); err != nil { | ||
| // return nil, err | ||
| // } |
There was a problem hiding this comment.
The large block of commented-out code (lines 44-56) should be removed rather than left as comments. Since the configuration approach has been changed to use environment variables directly, keeping this old implementation as comments adds clutter and can be confusing. The code is preserved in version control if needed for reference.
| // Commented out: config.yaml reading (not used anymore) | |
| // viper.SetConfigName("config") | |
| // viper.SetConfigType("yaml") | |
| // viper.AddConfigPath("./config") | |
| // viper.AddConfigPath(".") | |
| // | |
| // // Read config file (optional, won't fail if not found) | |
| // _ = viper.ReadInConfig() //nolint:errcheck | |
| // | |
| // var cfg Config | |
| // if err := viper.Unmarshal(&cfg); err != nil { | |
| // return nil, err | |
| // } |
| if err == io.EOF { | ||
| // Send final [DONE] message | ||
| doneMsg := "data: [DONE]\n\n" | ||
| n = copy(p, doneMsg) | ||
| if n < len(doneMsg) { | ||
| sc.buffer = append(sc.buffer, []byte(doneMsg)[n:]...) | ||
| } | ||
| sc.closed = true | ||
| _ = sc.body.Close() //nolint:errcheck | ||
| return n, nil |
There was a problem hiding this comment.
In the Read method, when EOF is encountered, the [DONE] message is sent and the body is closed (line 292). However, if this happens after returning some data (n > 0), the caller won't know that EOF was reached until the next read. Consider returning io.EOF on the subsequent call to ensure proper stream termination semantics, or document this behavior clearly.
| }, | ||
| }, | ||
| } | ||
| jsonData, _ := json.Marshal(chunk) |
There was a problem hiding this comment.
Same issue: error handling for json.Marshal is silently ignored at line 388. If marshaling fails, the chunk will be an empty string. Consider adding proper error handling.
| # OpenAI API key can be set here or via OPENAI_API_KEY environment variable | ||
| # openai: | ||
| # api_key: "your-api-key-here" | ||
|
|
There was a problem hiding this comment.
The config.yaml file has been emptied but not removed. Since the configuration system has migrated to using .env files and environment variables exclusively (as seen in config.go), this file is no longer used and should be deleted rather than left as an empty file.
| // StreamChatCompletion routes the streaming request to the appropriate provider | ||
| func (r *Router) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { | ||
| provider := r.findProvider(req.Model) | ||
| if provider == nil { | ||
| return nil, fmt.Errorf("no provider found for model: %s", req.Model) | ||
| } | ||
| return provider.StreamChatCompletion(ctx, req) | ||
| } |
There was a problem hiding this comment.
The StreamChatCompletion method in the Router is not covered by tests. While ChatCompletion, ListModels, and Supports are tested, streaming functionality is missing test coverage. Consider adding a test case similar to TestRouterChatCompletion but for the streaming endpoint to ensure it properly routes streaming requests to the correct provider.
| ```python | ||
| from openai import OpenAI | ||
|
|
||
| # Point to your GOModel instance instead of OpenAI | ||
| client = OpenAI( | ||
| base_url="http://localhost:8080/v1", | ||
| api_key="not-needed" # API key is configured on the server side | ||
| ) |
There was a problem hiding this comment.
These examples configure the OpenAI client with api_key="not-needed" and rely entirely on server-side provider keys, but the HTTP server currently exposes /v1/* endpoints without any authentication or API key checks. If this gateway is reachable beyond localhost, any unauthenticated caller can issue arbitrary, billable OpenAI/Anthropic requests against your account. Consider adding request authentication (e.g., validating a shared secret or OAuth token from the Authorization header) or clearly documenting and enforcing that the service must only be bound to localhost/placed behind an authenticated reverse proxy.
| const client = new OpenAI({ | ||
| baseURL: 'http://localhost:8080/v1', | ||
| apiKey: 'not-needed' // API key is configured on the server side |
There was a problem hiding this comment.
This Node.js example also uses apiKey: 'not-needed', relying solely on server-side provider API keys while the gateway's /v1/* endpoints are served without any client authentication. An attacker who can reach this port can freely consume your OpenAI/Anthropic quota and potentially exfiltrate data; add explicit authentication/authorization on incoming requests or require deployment behind an authenticated reverse proxy and reflect that in the examples.
| const client = new OpenAI({ | |
| baseURL: 'http://localhost:8080/v1', | |
| apiKey: 'not-needed' // API key is configured on the server side | |
| // IMPORTANT: For production deployments, you must secure the gateway with authentication. | |
| // The example below demonstrates sending an Authorization header with a shared secret. | |
| // You may also deploy the gateway behind an authenticated reverse proxy. | |
| const client = new OpenAI({ | |
| baseURL: 'http://localhost:8080/v1', | |
| apiKey: 'not-needed', // API key is configured on the server side | |
| defaultHeaders: { | |
| // Replace 'your-shared-secret' with your actual secret/token | |
| Authorization: 'Bearer your-shared-secret' | |
| } |
Production traffic has gone exclusively through HandleRequest (translated inference service) and HandleInternalRequest (guardrail executor) since the exchange seam landed; the echo middleware wrapper was test-only. Remove ResponseCacheMiddleware.Middleware(), simpleCacheMiddleware.Middleware(), and the three helpers only that path used (requestBodyForCache, shouldSkipCacheForWorkflow, shouldSkipCache) — their production equivalents live in handle() or at the call sites, which pass the patched body explicitly and gate on workflow.CacheEnabled(). Test migration (possible-refactoring #4): middleware_test.go becomes exact_cache_test.go. Hit/miss, key separation, streaming-vs-JSON replay, Cache-Control skip, close-drains-writes, and write-concurrency tests now drive the production HandleRequest entry. Dropped with the legacy path, as their behavior no longer exists in this package: workflow-gating bypass tests (caller-gated since the HandleRequest migration), snapshot-body and body-read-error tests (callers supply the body), and the non-cacheable-path test (exact-layer path gating was legacy-only; the semantic layer still enforces cacheablePaths and callers only invoke the cache for inference endpoints). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
No description provided.