Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/advanced/anthropic-messages-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ different name), then relays the provider-native response or SSE stream
unchanged. This preserves everything the canonical translation cannot —
`cache_control` breakpoints, thinking-block signatures, `anthropic-beta`
headers — which coding agents like Claude Code depend on. Rate limits,
budgets, audit logging, and streaming usage tracking still apply.
budgets, audit logging, and usage tracking still apply, for both streaming
and non-streaming responses.

Native forwarding is automatic. Requests fall back to the translated pipeline
when a feature that operates on the canonical request is in play: guardrails
Expand Down
5 changes: 5 additions & 0 deletions docs/features/passthrough-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ Because passthrough is provider-native, the response is also provider-native.
For Anthropic messages, the response uses Anthropic's message schema, not an
OpenAI chat completion schema.

When usage tracking is enabled, successful passthrough inference responses
are recorded: token counts are read from SSE usage events on streaming
responses and from the `usage` member of JSON responses, so costs and budgets
account for passthrough traffic like any other route.

## Anthropic SDK example

Set the Anthropic SDK base URL to GoModel's Anthropic passthrough route. Use the
Expand Down
170 changes: 170 additions & 0 deletions internal/server/messages_native_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,104 @@ func TestBuildPassthroughHeadersDropsAcceptEncoding(t *testing.T) {
}
}

const anthropicNonStreamingJSON = `{"id":"msg_1","type":"message","role":"assistant","model":"claude-fable-5","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":100,"cache_creation_input_tokens":7,"cache_read_input_tokens":9,"output_tokens":25}}`

// A non-streaming /v1/messages request through the native forwarding path
// must record a usage entry from the response object's usage member, so cost
// accounting and budgets see the spend just like on the translated pipeline.
func TestMessages_NativeNonStreamingLogsUsage(t *testing.T) {
provider := &mockProvider{
supportedModels: []string{"claude-fable-5"},
providerTypes: map[string]string{"claude-fable-5": "anthropic"},
passthroughResponse: &core.PassthroughResponse{
StatusCode: http.StatusOK,
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(anthropicNonStreamingJSON)),
},
}
usageLogger := &collectingUsageLogger{config: usage.Config{Enabled: true}}

e := echo.New()
handler := NewHandler(provider, nil, usageLogger, nil)

reqBody := `{"model":"claude-fable-5","max_tokens":64,"messages":[{"role":"user","content":"Hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()

if err := handler.Messages(e.NewContext(req, rec)); err != nil {
t.Fatalf("Messages: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if provider.lastPassthroughReq == nil {
t.Fatal("native path was not taken")
}
if rec.Body.String() != anthropicNonStreamingJSON {
t.Fatalf("body not relayed verbatim: %s", rec.Body.String())
}

if len(usageLogger.entries) != 1 {
t.Fatalf("usage entries = %d, want 1", len(usageLogger.entries))
}
entry := usageLogger.entries[0]
if entry.InputTokens != 100 {
t.Errorf("InputTokens = %d, want 100", entry.InputTokens)
}
if entry.OutputTokens != 25 {
t.Errorf("OutputTokens = %d, want 25", entry.OutputTokens)
}
if entry.RawData["cache_creation_input_tokens"] != 7 {
t.Errorf("cache_creation_input_tokens = %v, want 7", entry.RawData["cache_creation_input_tokens"])
}
if entry.RawData["cache_read_input_tokens"] != 9 {
t.Errorf("cache_read_input_tokens = %v, want 9", entry.RawData["cache_read_input_tokens"])
}
if entry.ProviderID != "msg_1" {
t.Errorf("ProviderID = %q, want msg_1", entry.ProviderID)
}
}

// A provider body that fails mid-relay must not produce a usage entry: the
// client received an incomplete response and there is no trustworthy usage.
func TestMessages_NativeNonStreamingBodyErrorSkipsUsage(t *testing.T) {
provider := &mockProvider{
supportedModels: []string{"claude-fable-5"},
providerTypes: map[string]string{"claude-fable-5": "anthropic"},
passthroughResponse: &core.PassthroughResponse{
StatusCode: http.StatusOK,
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(io.MultiReader(
strings.NewReader(anthropicNonStreamingJSON[:40]),
&failingReader{},
)),
},
}
usageLogger := &collectingUsageLogger{config: usage.Config{Enabled: true}}

e := echo.New()
handler := NewHandler(provider, nil, usageLogger, nil)

reqBody := `{"model":"claude-fable-5","max_tokens":64,"messages":[{"role":"user","content":"Hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()

if err := handler.Messages(e.NewContext(req, rec)); err == nil {
t.Fatal("Messages: expected relay error, got nil")
}
if len(usageLogger.entries) != 0 {
t.Fatalf("usage entries = %d, want 0", len(usageLogger.entries))
}
}

type failingReader struct{}

func (*failingReader) Read([]byte) (int, error) {
return 0, io.ErrUnexpectedEOF
}

type recordingFeedbackObserver struct {
input, read, write int
observed bool
Expand Down Expand Up @@ -183,3 +281,75 @@ func TestMessages_NativeStreamingNotifiesFeedbackObservers(t *testing.T) {
t.Errorf("cacheWriteInputTokens = %d, want 100", observer.write)
}
}

// Extensions that requested response feedback must also hear about
// non-streaming native responses, with usage read from the response object.
func TestMessages_NativeNonStreamingNotifiesFeedbackObservers(t *testing.T) {
provider := &mockProvider{
supportedModels: []string{"claude-fable-5"},
providerTypes: map[string]string{"claude-fable-5": "anthropic"},
passthroughResponse: &core.PassthroughResponse{
StatusCode: http.StatusOK,
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(anthropicNonStreamingJSON)),
},
}

e := echo.New()
handler := NewHandler(provider, nil, nil, nil)

reqBody := `{"model":"claude-fable-5","max_tokens":64,"messages":[{"role":"user","content":"Hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

observer := &recordingFeedbackObserver{}
setResponseFeedbackObservers(c, []ext.ResponseFeedbackObserver{observer})

if err := handler.Messages(c); err != nil {
t.Fatalf("Messages: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if observer.calls != 1 {
t.Fatalf("ObserveResponse calls = %d, want 1", observer.calls)
}
if !observer.observed {
t.Error("usageObserved = false, want true")
}
if observer.input != 100 {
t.Errorf("inputTokens = %d, want 100", observer.input)
}
if observer.read != 9 {
t.Errorf("cachedInputTokens = %d, want 9", observer.read)
}
if observer.write != 7 {
t.Errorf("cacheWriteInputTokens = %d, want 7", observer.write)
}
}

// The capture buffer must abandon oversized bodies without disturbing the
// relay: writes keep succeeding, and Captured reports nothing usable.
func TestCappedCaptureBufferOverflow(t *testing.T) {
capture := newCappedCaptureBuffer(8)
for range 3 {
n, err := capture.Write([]byte("abcde"))
if n != 5 || err != nil {
t.Fatalf("Write = (%d, %v), want (5, nil)", n, err)
}
}
if body, ok := capture.Captured(); ok {
t.Fatalf("Captured = (%q, true), want abandoned", body)
}

capture = newCappedCaptureBuffer(8)
if _, err := capture.Write([]byte("abcde")); err != nil {
t.Fatalf("Write: %v", err)
}
body, ok := capture.Captured()
if !ok || string(body) != "abcde" {
t.Fatalf("Captured = (%q, %v), want (abcde, true)", body, ok)
}
}
Loading