Skip to content

Anthropic provider basic implementation with /chat/completions and /models endpoints#4

Merged
SantiagoDePolonia merged 9 commits into
mainfrom
provider/anthropic
Dec 7, 2025
Merged

Anthropic provider basic implementation with /chat/completions and /models endpoints#4
SantiagoDePolonia merged 9 commits into
mainfrom
provider/anthropic

Conversation

@SantiagoDePolonia

Copy link
Copy Markdown
Contributor

No description provided.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread ARCHITECTURE.md Outdated
Comment on lines +1 to +163
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)
}
}

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Anthropic provider is missing critical test coverage. The following test cases should be added to match the comprehensive coverage in openai_test.go:

  1. TestChatCompletion - Test actual HTTP requests with success/error scenarios
  2. TestStreamChatCompletion - Test streaming functionality
  3. TestListModels - Test the ListModels endpoint (currently only returns static data)
  4. TestChatCompletionWithContext - Test context cancellation
  5. 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.

Copilot uses AI. Check for mistakes.
Comment thread ARCHITECTURE.md Outdated
Comment thread go.mod Outdated
SantiagoDePolonia and others added 4 commits December 7, 2025 12:10
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>
@SantiagoDePolonia SantiagoDePolonia merged commit 5338dbc into main Dec 7, 2025
6 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread README.md
-p 8080:8080 \
-e OPENAI_API_KEY="your-openai-key" \
-e ANTHROPIC_API_KEY="your-anthropic-key" \
golang:1.21-alpine \

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
golang:1.21-alpine \
golang:1.23-alpine \

Copilot uses AI. Check for mistakes.

const (
defaultBaseURL = "https://api.anthropic.com/v1"
anthropicAPIVersion = "2023-06-01"

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
anthropicAPIVersion = "2023-06-01"
anthropicAPIVersion = "2023-12-15"

Copilot uses AI. Check for mistakes.
},
},
}
jsonData, _ := json.Marshal(chunk)

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ""
}

Copilot uses AI. Check for mistakes.
Comment thread config/config.go
Comment on lines +44 to +56
// 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
// }

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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
// }

Copilot uses AI. Check for mistakes.
Comment on lines +284 to +293
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

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
},
},
}
jsonData, _ := json.Marshal(chunk)

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread config/config.yaml
# OpenAI API key can be set here or via OPENAI_API_KEY environment variable
# openai:
# api_key: "your-api-key-here"

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +50
// 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)
}

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread EXAMPLES.md
Comment on lines +169 to +176
```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
)

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread EXAMPLES.md
Comment on lines +213 to +215
const client = new OpenAI({
baseURL: 'http://localhost:8080/v1',
apiKey: 'not-needed' // API key is configured on the server side

Copilot AI Dec 7, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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'
}

Copilot uses AI. Check for mistakes.
@SantiagoDePolonia SantiagoDePolonia deleted the provider/anthropic branch December 13, 2025 12:43
SantiagoDePolonia added a commit that referenced this pull request Jul 4, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants