From fb7162ab719c357a88a809cc0d6b52ea78a0b148 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Thu, 20 Aug 2026 03:37:15 +0100 Subject: [PATCH] feat: anthropic adapter with bidirectional translation incl streaming Implements Task 9: the anthropic-type providerAdapter and its request/response/stream translation to and from OpenAI's wire shape. - translate_anthropic.go: anthropicRequestFromOpenAI (system messages joined, content blocks incl. base64 data-URI images, tool_calls -> tool_use, tool messages -> tool_result, max_tokens defaulting, stop/stop_sequences, tool_choice incl. the none-drops-tools rule, n>1/logit_bias/logprobs rejected), openAIResponseFromAnthropic (content blocks joined/tool_calls re-marshaled, stop_reason mapping, usage), and anthropicStreamState.translate mapping each SSE event to zero or more OpenAI chat.completion.chunk payloads. translateError carries a notSupported flag so the caller (Task 12) can tell a 400 from a 501. - provider_anthropic.go: anthropicAdapter implementing providerAdapter. newAnthropicAdapter errors on an empty API key per ruling (c), unlike openai-type's keyless ruling. Headers: x-api-key, anthropic-version. embeddings always returns a notSupported translateError without calling upstream (Anthropic has no embeddings endpoint). listModels GETs /v1/models and returns any non-2xx (incl. 404) as-is. - providers.go: wires the anthropic buildAdapters case to newAnthropicAdapter, propagating its constructor error. - provider_openai_test.go: replaces the now-stale placeholder TestBuildAdapters subtest with one asserting a keyed anthropic provider builds successfully and a keyless one errors. - translate_anthropic_test.go: golden-file harness walking testdata/anthropic/{chat,resp}_*_in.json against sibling _want.json (success) or _wanterr.json (*translateError) fixtures, plus a stream harness driving each *.sse fixture through anthropicStreamState and asserting the chunk list and usage(). - provider_anthropic_test.go: httptest-backed adapter wiring tests (streaming + non-streaming), asserting OpenAI-shaped output on the wire, per-event flushing, and the required auth headers. Gate: go test ./... -count=1 and -race both green; gofmt, go vet, fieldalignment, golangci-lint (incl. gosec) all clean. --- provider_anthropic.go | 217 ++++++ provider_anthropic_test.go | 285 +++++++ provider_openai_test.go | 27 +- providers.go | 20 +- testdata/anthropic/chat_basic_in.json | 10 + testdata/anthropic/chat_basic_want.json | 11 + .../anthropic/chat_images_http_error_in.json | 10 + .../chat_images_http_error_wanterr.json | 1 + testdata/anthropic/chat_images_in.json | 10 + testdata/anthropic/chat_images_want.json | 10 + testdata/anthropic/chat_system_tools_in.json | 18 + .../anthropic/chat_system_tools_want.json | 19 + .../anthropic/chat_tool_choice_none_in.json | 9 + .../anthropic/chat_tool_choice_none_want.json | 5 + .../chat_tool_choice_required_in.json | 9 + .../chat_tool_choice_required_want.json | 9 + .../chat_tool_choice_specific_in.json | 9 + .../chat_tool_choice_specific_want.json | 9 + .../chat_unsupported_logit_bias_in.json | 5 + .../chat_unsupported_logit_bias_wanterr.json | 1 + .../chat_unsupported_logprobs_in.json | 5 + .../chat_unsupported_logprobs_wanterr.json | 1 + testdata/anthropic/chat_unsupported_n_in.json | 5 + .../anthropic/chat_unsupported_n_wanterr.json | 1 + testdata/anthropic/resp_maxtokens_in.json | 12 + testdata/anthropic/resp_maxtokens_want.json | 14 + testdata/anthropic/resp_text_in.json | 12 + testdata/anthropic/resp_text_want.json | 14 + testdata/anthropic/resp_tooluse_in.json | 13 + testdata/anthropic/resp_tooluse_want.json | 20 + testdata/anthropic/stream_text.sse | 24 + testdata/anthropic/stream_text_usage.json | 1 + testdata/anthropic/stream_text_want.json | 6 + testdata/anthropic/stream_tooluse.sse | 21 + testdata/anthropic/stream_tooluse_usage.json | 1 + testdata/anthropic/stream_tooluse_want.json | 7 + translate_anthropic.go | 716 ++++++++++++++++++ translate_anthropic_test.go | 413 ++++++++++ 38 files changed, 1969 insertions(+), 11 deletions(-) create mode 100644 provider_anthropic.go create mode 100644 provider_anthropic_test.go create mode 100644 testdata/anthropic/chat_basic_in.json create mode 100644 testdata/anthropic/chat_basic_want.json create mode 100644 testdata/anthropic/chat_images_http_error_in.json create mode 100644 testdata/anthropic/chat_images_http_error_wanterr.json create mode 100644 testdata/anthropic/chat_images_in.json create mode 100644 testdata/anthropic/chat_images_want.json create mode 100644 testdata/anthropic/chat_system_tools_in.json create mode 100644 testdata/anthropic/chat_system_tools_want.json create mode 100644 testdata/anthropic/chat_tool_choice_none_in.json create mode 100644 testdata/anthropic/chat_tool_choice_none_want.json create mode 100644 testdata/anthropic/chat_tool_choice_required_in.json create mode 100644 testdata/anthropic/chat_tool_choice_required_want.json create mode 100644 testdata/anthropic/chat_tool_choice_specific_in.json create mode 100644 testdata/anthropic/chat_tool_choice_specific_want.json create mode 100644 testdata/anthropic/chat_unsupported_logit_bias_in.json create mode 100644 testdata/anthropic/chat_unsupported_logit_bias_wanterr.json create mode 100644 testdata/anthropic/chat_unsupported_logprobs_in.json create mode 100644 testdata/anthropic/chat_unsupported_logprobs_wanterr.json create mode 100644 testdata/anthropic/chat_unsupported_n_in.json create mode 100644 testdata/anthropic/chat_unsupported_n_wanterr.json create mode 100644 testdata/anthropic/resp_maxtokens_in.json create mode 100644 testdata/anthropic/resp_maxtokens_want.json create mode 100644 testdata/anthropic/resp_text_in.json create mode 100644 testdata/anthropic/resp_text_want.json create mode 100644 testdata/anthropic/resp_tooluse_in.json create mode 100644 testdata/anthropic/resp_tooluse_want.json create mode 100644 testdata/anthropic/stream_text.sse create mode 100644 testdata/anthropic/stream_text_usage.json create mode 100644 testdata/anthropic/stream_text_want.json create mode 100644 testdata/anthropic/stream_tooluse.sse create mode 100644 testdata/anthropic/stream_tooluse_usage.json create mode 100644 testdata/anthropic/stream_tooluse_want.json create mode 100644 translate_anthropic.go create mode 100644 translate_anthropic_test.go diff --git a/provider_anthropic.go b/provider_anthropic.go new file mode 100644 index 0000000..bff0058 --- /dev/null +++ b/provider_anthropic.go @@ -0,0 +1,217 @@ +package traefikllmgateway + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// anthropicMessagesPath and anthropicModelsPath are Anthropic's Messages +// and Models API endpoints, appended to the adapter's base URL. +const ( + anthropicMessagesPath = "/v1/messages" + anthropicModelsPath = "/v1/models" +) + +// anthropicAdapter is the providerAdapter for provider type "anthropic". +type anthropicAdapter struct { + client *http.Client + adapterName string + baseURL string + apiKey string +} + +// newAnthropicAdapter returns an anthropicAdapter for provider name, with +// base as its already-defaulted, trailing-slash-trimmed base URL and +// apiKey as already resolved by resolveSecret. Unlike openai-type +// providers, Anthropic's API requires an API key on every request — a +// resolved-empty apiKey is a constructor error here, per ruling (c), +// rather than the keyless-upstream case openai-type providers accept. +func newAnthropicAdapter(name, base, apiKey string) (*anthropicAdapter, error) { + if apiKey == "" { + return nil, fmt.Errorf("llmgateway: provider %q: anthropic requires an API key", name) + } + return &anthropicAdapter{ + adapterName: name, + baseURL: base, + apiKey: apiKey, + client: newAdapterHTTPClient(), + }, nil +} + +// name implements providerAdapter. +func (a *anthropicAdapter) name() string { return a.adapterName } + +// typeName implements providerAdapter. +func (a *anthropicAdapter) typeName() string { return providerTypeAnthropic } + +// base implements providerAdapter. +func (a *anthropicAdapter) base() string { return a.baseURL } + +// injectAuth implements providerAdapter: Anthropic's x-api-key scheme, +// plus the anthropic-version header every request must carry. Unlike +// openai-type's injectAuth, this is never a no-op — the constructor +// already rejected an empty apiKey. +func (a *anthropicAdapter) injectAuth(r *http.Request) { + r.Header.Set("x-api-key", a.apiKey) + r.Header.Set("anthropic-version", anthropicAPIVersion) +} + +// requestHeaders returns the headers for one upstream request: a +// Content-Type when the request carries a JSON body, plus whatever +// injectAuth adds. +func (a *anthropicAdapter) requestHeaders(hasBody bool) http.Header { + h := http.Header{} + if hasBody { + h.Set("Content-Type", "application/json") + } + a.injectAuth(&http.Request{Header: h}) + return h +} + +// chatCompletion implements providerAdapter: translates req to an +// Anthropic Messages API request via anthropicRequestFromOpenAI, then +// forwards the (possibly streamed) response back translated to OpenAI's +// shape. A translation failure — a *translateError from +// anthropicRequestFromOpenAI — is returned as-is, before any upstream +// request is made. +func (a *anthropicAdapter) chatCompletion(ctx context.Context, w http.ResponseWriter, req map[string]any) (usage, error) { + gatewayModel, _ := req["model"].(string) + streaming, _ := req["stream"].(bool) + + body, err := anthropicRequestFromOpenAI(req) + if err != nil { + return usage{}, err + } + + hdr := a.requestHeaders(true) + if streaming { + // Ask the upstream for an SSE response. The Content-Type check + // below still decides which forwarding path runs. + hdr.Set("Accept", "text/event-stream") + } + + resp, err := upstreamJSON(ctx, a.client, http.MethodPost, a.baseURL+anthropicMessagesPath, hdr, body) + if err != nil { + return usage{}, err + } + defer resp.Body.Close() //nolint:errcheck // read-side close; nothing actionable on failure + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return usage{}, newProviderHTTPError(resp) + } + + if streaming && strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") { + return a.forwardStream(w, resp.Body, gatewayModel) + } + // Either a non-streaming request, or a streaming one whose upstream + // ignored stream=true and answered with a normal JSON body. + return a.forwardJSON(w, resp, gatewayModel) +} + +// forwardJSON reads resp's full body (capped at maxResponseBytes), +// translates it to an OpenAI chat.completion response via +// openAIResponseFromAnthropic, and writes the translated JSON to w. +// gatewayModel becomes the translated response's "model" field, and the +// response's "created" timestamp is generated here — Anthropic's response +// carries neither. +func (a *anthropicAdapter) forwardJSON(w http.ResponseWriter, resp *http.Response, gatewayModel string) (usage, error) { + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return usage{}, fmt.Errorf("%w: read response body: %w", errUpstream, err) + } + + out, u, err := openAIResponseFromAnthropic(raw, gatewayModel, time.Now().Unix()) + if err != nil { + return usage{}, err + } + + b, err := json.Marshal(out) + if err != nil { + return usage{}, fmt.Errorf("%w: marshal translated response: %w", errUpstream, err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(b) + return u, nil +} + +// forwardStream reads body as an upstream Anthropic text/event-stream, +// translating each event via an anthropicStreamState and forwarding every +// resulting OpenAI-shaped chunk to w through a fresh sseWriter, flushing +// per event. gatewayModel and the stream's start time become every +// chunk's "model"/"created" fields. Per ruling (b), the terminal +// "[DONE]" is written once readSSE returns without error — whether that +// is because the upstream sent message_stop (which the translator itself +// emits nothing for) or because the connection reached a clean EOF +// without one. On a translate error (the "error" event, or a genuine +// stream read error), forwardStream returns without writing "[DONE]" — +// the caller already saw the connection fail or an explicit upstream +// error chunk. +func (a *anthropicAdapter) forwardStream(w http.ResponseWriter, body io.Reader, gatewayModel string) (usage, error) { + st := newAnthropicStreamState(gatewayModel, time.Now().Unix()) + sw := newSSEWriter(w) + + err := readSSE(body, func(ev sseEvent) error { + chunks, terr := st.translate(ev) + for _, c := range chunks { + if werr := sw.writeData(c); werr != nil { + return werr + } + } + return terr + }) + if err != nil { + return st.usage(), fmt.Errorf("%w: stream: %w", errUpstream, err) + } + sw.writeDone() + return st.usage(), nil +} + +// embeddings implements providerAdapter. Anthropic's API has no +// embeddings endpoint, so this never makes an upstream request — it +// always returns a *translateError with notSupported set, which the +// caller (Task 12) maps to HTTP 501 rather than 400. +func (a *anthropicAdapter) embeddings(_ context.Context, _ http.ResponseWriter, _ map[string]any) (usage, error) { + return usage{}, &translateError{msg: "embeddings not supported for anthropic models", notSupported: true} +} + +// listModels implements providerAdapter: GET {base}/v1/models, parsing +// the same "data": [{"id": ...}] shape OpenAI's endpoint uses (Anthropic's +// 2026 Models API responds in the same shape). A non-2xx response — +// including a 404 from an older Anthropic API version that predates this +// endpoint — returns a *providerHTTPError as-is; the registry (Task 11) +// falls back to a provider's explicitly configured models on error rather +// than this adapter guessing at a fallback itself. +func (a *anthropicAdapter) listModels(ctx context.Context) ([]string, error) { + resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+anthropicModelsPath, a.requestHeaders(false), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, newProviderHTTPError(resp) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("%w: read response body: %w", errUpstream, err) + } + + var payload modelsPayload + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("%w: decode models response: %w", errUpstream, err) + } + + ids := make([]string, len(payload.Data)) + for i, d := range payload.Data { + ids[i] = d.ID + } + return ids, nil +} diff --git a/provider_anthropic_test.go b/provider_anthropic_test.go new file mode 100644 index 0000000..54ccd16 --- /dev/null +++ b/provider_anthropic_test.go @@ -0,0 +1,285 @@ +package traefikllmgateway + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestAnthropicAdapter_ChatCompletion_NonStreaming proves the adapter +// translates an OpenAI-shaped request to Anthropic's wire format, sends +// the required x-api-key/anthropic-version headers, and translates the +// Anthropic response back to an OpenAI-shaped chat.completion body on the +// wire, with usage extracted from Anthropic's input_tokens/output_tokens. +func TestAnthropicAdapter_ChatCompletion_NonStreaming(t *testing.T) { + const anthResp = `{"id":"msg_01ABC","type":"message","role":"assistant","content":[{"type":"text","text":"hi there"}],"model":"claude-opus-5","stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":4}}` + + var gotBody []byte + var gotAPIKey, gotVersion string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/messages" { + t.Errorf("path = %q, want /v1/messages", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + gotBody, _ = io.ReadAll(r.Body) + gotAPIKey = r.Header.Get("x-api-key") + gotVersion = r.Header.Get("anthropic-version") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(anthResp)) + })) + defer srv.Close() + + a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test") + if err != nil { + t.Fatalf("newAnthropicAdapter: %v", err) + } + rec := httptest.NewRecorder() + + req := map[string]any{ + "model": "claude-opus-5", + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + } + u, err := a.chatCompletion(context.Background(), rec, req) + if err != nil { + t.Fatalf("chatCompletion: %v", err) + } + + if u.prompt != 10 || u.completion != 4 { + t.Errorf("usage = %+v, want {prompt:10 completion:4}", u) + } + if gotAPIKey != "sk-ant-test" { //nolint:gosec // test-only literal, not a real credential + t.Errorf("x-api-key = %q, want %q", gotAPIKey, "sk-ant-test") + } + if gotVersion != anthropicAPIVersion { + t.Errorf("anthropic-version = %q, want %q", gotVersion, anthropicAPIVersion) + } + + sent := decodeJSONBody(t, gotBody) + if sent["max_tokens"] != float64(anthropicDefaultMaxTokens) { + t.Errorf("upstream max_tokens = %v, want %v (default)", sent["max_tokens"], anthropicDefaultMaxTokens) + } + + if got := rec.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("client body is not valid JSON: %v (%s)", err, rec.Body.String()) + } + if out["object"] != "chat.completion" { + t.Errorf("object = %v, want %q", out["object"], "chat.completion") + } + if out["id"] != "chatcmpl-msg_01ABC" { + t.Errorf("id = %v, want %q", out["id"], "chatcmpl-msg_01ABC") + } + choices, ok := out["choices"].([]any) + if !ok || len(choices) != 1 { + t.Fatalf("choices = %v, want a single-element array", out["choices"]) + } + choice := choices[0].(map[string]any) + message := choice["message"].(map[string]any) + if message["content"] != "hi there" { + t.Errorf("message.content = %v, want %q", message["content"], "hi there") + } + if choice["finish_reason"] != "stop" { + t.Errorf("finish_reason = %v, want %q", choice["finish_reason"], "stop") + } +} + +// anthropicStreamFrames are the raw SSE events a fake streaming upstream +// emits for the tests below: a message_start, one text delta, and a +// message_delta/message_stop pair. +var anthropicStreamFrames = []struct { + event string + data string +}{ + {"message_start", `{"type":"message_start","message":{"id":"msg_01STREAM","usage":{"input_tokens":6,"output_tokens":1}}}`}, + {"content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`}, + {"content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi!"}}`}, + {"content_block_stop", `{"type":"content_block_stop","index":0}`}, + {"message_delta", `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}`}, + {"message_stop", `{"type":"message_stop"}`}, +} + +// TestAnthropicAdapter_ChatCompletion_Streaming proves the adapter +// forwards a streaming Anthropic response as OpenAI-shaped SSE chunks on +// the wire, flushing per event, terminated by "[DONE]" once the upstream +// stream ends (ruling (b): message_stop carries no output of its own — +// the adapter writes "[DONE]" after readSSE returns, not because of any +// particular event). +func TestAnthropicAdapter_ChatCompletion_Streaming(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + f, ok := w.(http.Flusher) + if !ok { + t.Fatalf("httptest ResponseWriter does not implement http.Flusher") + } + for _, frame := range anthropicStreamFrames { + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", frame.event, frame.data) + f.Flush() + } + })) + defer srv.Close() + + a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test") + if err != nil { + t.Fatalf("newAnthropicAdapter: %v", err) + } + rec := newRecordingResponseWriter() + + req := map[string]any{ + "model": "claude-opus-5", + "stream": true, + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + } + u, err := a.chatCompletion(context.Background(), rec, req) + if err != nil { + t.Fatalf("chatCompletion: %v", err) + } + + if u.prompt != 6 || u.completion != 3 { + t.Errorf("usage = %+v, want {prompt:6 completion:3}", u) + } + + body := rec.body.String() + if got := rec.header.Get("Content-Type"); got != "text/event-stream" { + t.Errorf("Content-Type = %q, want text/event-stream", got) + } + + // 4 chunks are expected on the wire: role, content delta, finish — the + // content_block_start/stop and message_stop events produce nothing — + // plus the terminal [DONE]. + wantSubstrings := []string{ + `"delta":{"role":"assistant"}`, + `"delta":{"content":"Hi!"}`, + `"finish_reason":"stop"`, + "data: [DONE]\n\n", + } + for _, want := range wantSubstrings { + if !strings.Contains(body, want) { + t.Errorf("streamed body missing %q; got:\n%s", want, body) + } + } + // 3 events translate to a chunk (message_start, the text delta, + // message_delta) — content_block_start/stop produce nothing — plus one + // more flush for the terminal [DONE]: 4 flushes, one per writeData/ + // writeDone call, proving the adapter flushes per event rather than + // buffering the whole stream. + const wantFlushes = 4 + if rec.flushed < wantFlushes { + t.Errorf("flushed = %d, want >= %d", rec.flushed, wantFlushes) + } +} + +// TestAnthropicAdapter_ChatCompletion_Upstream429 proves a non-2xx +// upstream response returns a *providerHTTPError with nothing written to +// the client, mirroring the openai-type adapter's contract. +func TestAnthropicAdapter_ChatCompletion_Upstream429(t *testing.T) { + const errBody = `{"type":"error","error":{"type":"rate_limit_error","message":"rate limited"}}` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(errBody)) + })) + defer srv.Close() + + a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test") + if err != nil { + t.Fatalf("newAnthropicAdapter: %v", err) + } + rec := httptest.NewRecorder() + + u, err := a.chatCompletion(context.Background(), rec, map[string]any{ + "model": "claude-opus-5", + "messages": []any{map[string]any{"role": "user", "content": "hi"}}, + }) + if err == nil { + t.Fatalf("chatCompletion: want error, got nil (usage=%+v)", u) + } + var httpErr *providerHTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("err = %v (%T), want *providerHTTPError", err, err) + } + if httpErr.status != http.StatusTooManyRequests { + t.Errorf("status = %d, want %d", httpErr.status, http.StatusTooManyRequests) + } + if rec.Body.Len() != 0 { + t.Errorf("client response body = %q, want empty", rec.Body.String()) + } +} + +// TestAnthropicAdapter_ChatCompletion_TranslateErrorSkipsUpstream proves a +// request field anthropicRequestFromOpenAI rejects (n>1, here) never +// reaches the upstream at all — the adapter returns the *translateError +// before making any HTTP request. +func TestAnthropicAdapter_ChatCompletion_TranslateErrorSkipsUpstream(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test") + if err != nil { + t.Fatalf("newAnthropicAdapter: %v", err) + } + rec := httptest.NewRecorder() + + _, err = a.chatCompletion(context.Background(), rec, map[string]any{ + "model": "claude-opus-5", + "messages": []any{map[string]any{"role": "user", "content": "hi"}}, + "n": float64(2), + }) + var terr *translateError + if !errors.As(err, &terr) { + t.Fatalf("err = %v (%T), want *translateError", err, err) + } + if called { + t.Errorf("upstream was called, want the translate error to short-circuit before any request") + } +} + +// TestAnthropicAdapter_ListModels proves listModels parses Anthropic's +// "data": [{"id": ...}] Models API response into a flat slice of ids. +func TestAnthropicAdapter_ListModels(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Errorf("path = %q, want /v1/models", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Errorf("method = %q, want GET", r.Method) + } + if got := r.Header.Get("x-api-key"); got != "sk-ant-test" { + t.Errorf("x-api-key = %q, want %q", got, "sk-ant-test") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"claude-opus-5"},{"id":"claude-sonnet-5"}]}`)) + })) + defer srv.Close() + + a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test") + if err != nil { + t.Fatalf("newAnthropicAdapter: %v", err) + } + + got, err := a.listModels(context.Background()) + if err != nil { + t.Fatalf("listModels: %v", err) + } + want := []string{"claude-opus-5", "claude-sonnet-5"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("got %#v, want %#v", got, want) + } +} diff --git a/provider_openai_test.go b/provider_openai_test.go index a48ee22..98db802 100644 --- a/provider_openai_test.go +++ b/provider_openai_test.go @@ -557,13 +557,32 @@ func TestBuildAdapters(t *testing.T) { } }) - t.Run("edge: anthropic type not yet implemented", func(t *testing.T) { + t.Run("good: anthropic with an API key builds successfully", func(t *testing.T) { cfg := &Config{Providers: map[string]*ProviderConfig{ "p1": {Type: "anthropic", APIKey: "sk-test"}, }} - _, err := buildAdapters(cfg) - if err == nil { - t.Fatalf("buildAdapters: want placeholder error for anthropic, got nil") + adapters, err := buildAdapters(cfg) + if err != nil { + t.Fatalf("buildAdapters: %v", err) + } + a, ok := adapters["p1"] + if !ok { + t.Fatalf("adapters[%q] missing", "p1") + } + if a.typeName() != "anthropic" { + t.Errorf("typeName() = %q, want %q", a.typeName(), "anthropic") + } + if a.base() != defaultBaseAnthropic { + t.Errorf("base() = %q, want %q", a.base(), defaultBaseAnthropic) + } + }) + + t.Run("bad: anthropic without an API key is a constructor error", func(t *testing.T) { + cfg := &Config{Providers: map[string]*ProviderConfig{ + "p1": {Type: "anthropic"}, + }} + if _, err := buildAdapters(cfg); err == nil { + t.Fatalf("buildAdapters: want error for keyless anthropic provider, got nil") } }) diff --git a/providers.go b/providers.go index 207528f..0bd235d 100644 --- a/providers.go +++ b/providers.go @@ -172,12 +172,14 @@ func upstreamJSON(ctx context.Context, client *http.Client, method, url string, // buildAdapters resolves cfg.Providers into a map of providerAdapter keyed // by provider name. For each provider it resolves APIKey via resolveSecret -// (propagating that error, but treating a resolved empty key as a valid -// keyless upstream rather than an error) and defaults BaseURL by provider -// type when the config omits one, trimming any trailing slash either way. -// An unknown provider type is a config error. A configured anthropic or -// gemini provider currently errors too — Tasks 9 and 10 fill in those -// switch cases; every other case here is unaffected by that. +// (propagating that error) and defaults BaseURL by provider type when the +// config omits one, trimming any trailing slash either way. An unknown +// provider type is a config error. A resolved empty key is a valid +// keyless upstream for openai-type providers, but a constructor error for +// anthropic-type ones — newAnthropicAdapter enforces that, per ruling (c); +// this function just propagates whatever error it returns. A configured +// gemini provider currently errors too — Task 10 fills in that switch +// case. func buildAdapters(cfg *Config) (map[string]providerAdapter, error) { adapters := make(map[string]providerAdapter, len(cfg.Providers)) for name, pc := range cfg.Providers { @@ -195,7 +197,11 @@ func buildAdapters(cfg *Config) (map[string]providerAdapter, error) { case providerTypeOpenAI: adapters[name] = newOpenAIAdapter(name, base, apiKey) case providerTypeAnthropic: - return nil, fmt.Errorf("llmgateway: provider %q: type %q not yet implemented", name, pc.Type) + a, err := newAnthropicAdapter(name, base, apiKey) + if err != nil { + return nil, err + } + adapters[name] = a case providerTypeGemini: return nil, fmt.Errorf("llmgateway: provider %q: type %q not yet implemented", name, pc.Type) default: diff --git a/testdata/anthropic/chat_basic_in.json b/testdata/anthropic/chat_basic_in.json new file mode 100644 index 0000000..b4ff3aa --- /dev/null +++ b/testdata/anthropic/chat_basic_in.json @@ -0,0 +1,10 @@ +{ + "model": "claude-opus-5", + "messages": [ + {"role": "user", "content": "Hello, how are you?"} + ], + "temperature": 0.7, + "top_p": 0.9, + "stop": "STOP", + "user": "user-123" +} diff --git a/testdata/anthropic/chat_basic_want.json b/testdata/anthropic/chat_basic_want.json new file mode 100644 index 0000000..3efbfc4 --- /dev/null +++ b/testdata/anthropic/chat_basic_want.json @@ -0,0 +1,11 @@ +{ + "model": "claude-opus-5", + "messages": [ + {"role": "user", "content": "Hello, how are you?"} + ], + "max_tokens": 4096, + "temperature": 0.7, + "top_p": 0.9, + "stop_sequences": ["STOP"], + "metadata": {"user_id": "user-123"} +} diff --git a/testdata/anthropic/chat_images_http_error_in.json b/testdata/anthropic/chat_images_http_error_in.json new file mode 100644 index 0000000..a36ffe7 --- /dev/null +++ b/testdata/anthropic/chat_images_http_error_in.json @@ -0,0 +1,10 @@ +{ + "model": "claude-opus-5", + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}} + ]} + ], + "max_tokens": 300 +} diff --git a/testdata/anthropic/chat_images_http_error_wanterr.json b/testdata/anthropic/chat_images_http_error_wanterr.json new file mode 100644 index 0000000..b4e55d0 --- /dev/null +++ b/testdata/anthropic/chat_images_http_error_wanterr.json @@ -0,0 +1 @@ +{"errContains": "data: URI", "notSupported": false} diff --git a/testdata/anthropic/chat_images_in.json b/testdata/anthropic/chat_images_in.json new file mode 100644 index 0000000..fe72994 --- /dev/null +++ b/testdata/anthropic/chat_images_in.json @@ -0,0 +1,10 @@ +{ + "model": "claude-opus-5", + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"}} + ]} + ], + "max_tokens": 300 +} diff --git a/testdata/anthropic/chat_images_want.json b/testdata/anthropic/chat_images_want.json new file mode 100644 index 0000000..7cf86d6 --- /dev/null +++ b/testdata/anthropic/chat_images_want.json @@ -0,0 +1,10 @@ +{ + "model": "claude-opus-5", + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"}} + ]} + ], + "max_tokens": 300 +} diff --git a/testdata/anthropic/chat_system_tools_in.json b/testdata/anthropic/chat_system_tools_in.json new file mode 100644 index 0000000..55f714b --- /dev/null +++ b/testdata/anthropic/chat_system_tools_in.json @@ -0,0 +1,18 @@ +{ + "model": "claude-opus-5", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "Always be concise."}, + {"role": "user", "content": "What's the weather in Paris?"}, + {"role": "assistant", "content": null, "tool_calls": [ + {"id": "call_abc123", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\":\"Paris\"}"}} + ]}, + {"role": "tool", "tool_call_id": "call_abc123", "content": "72F and sunny"} + ], + "max_tokens": 512, + "stop": ["END", "STOP"], + "tools": [ + {"type": "function", "function": {"name": "get_weather", "description": "Get current weather for a location", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}} + ], + "tool_choice": "auto" +} diff --git a/testdata/anthropic/chat_system_tools_want.json b/testdata/anthropic/chat_system_tools_want.json new file mode 100644 index 0000000..31a1a67 --- /dev/null +++ b/testdata/anthropic/chat_system_tools_want.json @@ -0,0 +1,19 @@ +{ + "model": "claude-opus-5", + "system": "You are a helpful assistant.\n\nAlways be concise.", + "messages": [ + {"role": "user", "content": "What's the weather in Paris?"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "call_abc123", "name": "get_weather", "input": {"location": "Paris"}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "call_abc123", "content": "72F and sunny"} + ]} + ], + "max_tokens": 512, + "stop_sequences": ["END", "STOP"], + "tools": [ + {"name": "get_weather", "description": "Get current weather for a location", "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}} + ], + "tool_choice": {"type": "auto"} +} diff --git a/testdata/anthropic/chat_tool_choice_none_in.json b/testdata/anthropic/chat_tool_choice_none_in.json new file mode 100644 index 0000000..860f3cb --- /dev/null +++ b/testdata/anthropic/chat_tool_choice_none_in.json @@ -0,0 +1,9 @@ +{ + "model": "claude-opus-5", + "messages": [{"role": "user", "content": "Do something"}], + "max_tokens": 100, + "tools": [ + {"type": "function", "function": {"name": "noop", "description": "does nothing", "parameters": {"type": "object", "properties": {}}}} + ], + "tool_choice": "none" +} diff --git a/testdata/anthropic/chat_tool_choice_none_want.json b/testdata/anthropic/chat_tool_choice_none_want.json new file mode 100644 index 0000000..842c8c5 --- /dev/null +++ b/testdata/anthropic/chat_tool_choice_none_want.json @@ -0,0 +1,5 @@ +{ + "model": "claude-opus-5", + "messages": [{"role": "user", "content": "Do something"}], + "max_tokens": 100 +} diff --git a/testdata/anthropic/chat_tool_choice_required_in.json b/testdata/anthropic/chat_tool_choice_required_in.json new file mode 100644 index 0000000..ceb3f94 --- /dev/null +++ b/testdata/anthropic/chat_tool_choice_required_in.json @@ -0,0 +1,9 @@ +{ + "model": "claude-opus-5", + "messages": [{"role": "user", "content": "Do something"}], + "max_tokens": 100, + "tools": [ + {"type": "function", "function": {"name": "act", "description": "acts", "parameters": {"type": "object", "properties": {}}}} + ], + "tool_choice": "required" +} diff --git a/testdata/anthropic/chat_tool_choice_required_want.json b/testdata/anthropic/chat_tool_choice_required_want.json new file mode 100644 index 0000000..7da46b6 --- /dev/null +++ b/testdata/anthropic/chat_tool_choice_required_want.json @@ -0,0 +1,9 @@ +{ + "model": "claude-opus-5", + "messages": [{"role": "user", "content": "Do something"}], + "max_tokens": 100, + "tools": [ + {"name": "act", "description": "acts", "input_schema": {"type": "object", "properties": {}}} + ], + "tool_choice": {"type": "any"} +} diff --git a/testdata/anthropic/chat_tool_choice_specific_in.json b/testdata/anthropic/chat_tool_choice_specific_in.json new file mode 100644 index 0000000..3425ca0 --- /dev/null +++ b/testdata/anthropic/chat_tool_choice_specific_in.json @@ -0,0 +1,9 @@ +{ + "model": "claude-opus-5", + "messages": [{"role": "user", "content": "Do something"}], + "max_tokens": 100, + "tools": [ + {"type": "function", "function": {"name": "act", "description": "acts", "parameters": {"type": "object", "properties": {}}}} + ], + "tool_choice": {"type": "function", "function": {"name": "act"}} +} diff --git a/testdata/anthropic/chat_tool_choice_specific_want.json b/testdata/anthropic/chat_tool_choice_specific_want.json new file mode 100644 index 0000000..25eb8b6 --- /dev/null +++ b/testdata/anthropic/chat_tool_choice_specific_want.json @@ -0,0 +1,9 @@ +{ + "model": "claude-opus-5", + "messages": [{"role": "user", "content": "Do something"}], + "max_tokens": 100, + "tools": [ + {"name": "act", "description": "acts", "input_schema": {"type": "object", "properties": {}}} + ], + "tool_choice": {"type": "tool", "name": "act"} +} diff --git a/testdata/anthropic/chat_unsupported_logit_bias_in.json b/testdata/anthropic/chat_unsupported_logit_bias_in.json new file mode 100644 index 0000000..6f0a070 --- /dev/null +++ b/testdata/anthropic/chat_unsupported_logit_bias_in.json @@ -0,0 +1,5 @@ +{ + "model": "claude-opus-5", + "messages": [{"role": "user", "content": "hi"}], + "logit_bias": {"50256": -100} +} diff --git a/testdata/anthropic/chat_unsupported_logit_bias_wanterr.json b/testdata/anthropic/chat_unsupported_logit_bias_wanterr.json new file mode 100644 index 0000000..bd7ba36 --- /dev/null +++ b/testdata/anthropic/chat_unsupported_logit_bias_wanterr.json @@ -0,0 +1 @@ +{"errContains": "logit_bias is not supported for anthropic models", "notSupported": false} diff --git a/testdata/anthropic/chat_unsupported_logprobs_in.json b/testdata/anthropic/chat_unsupported_logprobs_in.json new file mode 100644 index 0000000..6e1d870 --- /dev/null +++ b/testdata/anthropic/chat_unsupported_logprobs_in.json @@ -0,0 +1,5 @@ +{ + "model": "claude-opus-5", + "messages": [{"role": "user", "content": "hi"}], + "logprobs": true +} diff --git a/testdata/anthropic/chat_unsupported_logprobs_wanterr.json b/testdata/anthropic/chat_unsupported_logprobs_wanterr.json new file mode 100644 index 0000000..8484516 --- /dev/null +++ b/testdata/anthropic/chat_unsupported_logprobs_wanterr.json @@ -0,0 +1 @@ +{"errContains": "logprobs is not supported for anthropic models", "notSupported": false} diff --git a/testdata/anthropic/chat_unsupported_n_in.json b/testdata/anthropic/chat_unsupported_n_in.json new file mode 100644 index 0000000..8d21ba7 --- /dev/null +++ b/testdata/anthropic/chat_unsupported_n_in.json @@ -0,0 +1,5 @@ +{ + "model": "claude-opus-5", + "messages": [{"role": "user", "content": "hi"}], + "n": 2 +} diff --git a/testdata/anthropic/chat_unsupported_n_wanterr.json b/testdata/anthropic/chat_unsupported_n_wanterr.json new file mode 100644 index 0000000..858781a --- /dev/null +++ b/testdata/anthropic/chat_unsupported_n_wanterr.json @@ -0,0 +1 @@ +{"errContains": "n is not supported for anthropic models", "notSupported": false} diff --git a/testdata/anthropic/resp_maxtokens_in.json b/testdata/anthropic/resp_maxtokens_in.json new file mode 100644 index 0000000..7e64089 --- /dev/null +++ b/testdata/anthropic/resp_maxtokens_in.json @@ -0,0 +1,12 @@ +{ + "id": "msg_01TRUNC", + "type": "message", + "role": "assistant", + "content": [ + {"type": "text", "text": "This response was cut"} + ], + "model": "claude-opus-5", + "stop_reason": "max_tokens", + "stop_sequence": null, + "usage": {"input_tokens": 5, "output_tokens": 100} +} diff --git a/testdata/anthropic/resp_maxtokens_want.json b/testdata/anthropic/resp_maxtokens_want.json new file mode 100644 index 0000000..df8557e --- /dev/null +++ b/testdata/anthropic/resp_maxtokens_want.json @@ -0,0 +1,14 @@ +{ + "id": "chatcmpl-msg_01TRUNC", + "object": "chat.completion", + "created": 1734000000, + "model": "gw-anthropic-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "This response was cut"}, + "finish_reason": "length" + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 100, "total_tokens": 105} +} diff --git a/testdata/anthropic/resp_text_in.json b/testdata/anthropic/resp_text_in.json new file mode 100644 index 0000000..f11d824 --- /dev/null +++ b/testdata/anthropic/resp_text_in.json @@ -0,0 +1,12 @@ +{ + "id": "msg_01XYZ", + "type": "message", + "role": "assistant", + "content": [ + {"type": "text", "text": "The capital of France is Paris."} + ], + "model": "claude-opus-5", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": {"input_tokens": 12, "output_tokens": 8} +} diff --git a/testdata/anthropic/resp_text_want.json b/testdata/anthropic/resp_text_want.json new file mode 100644 index 0000000..2bed973 --- /dev/null +++ b/testdata/anthropic/resp_text_want.json @@ -0,0 +1,14 @@ +{ + "id": "chatcmpl-msg_01XYZ", + "object": "chat.completion", + "created": 1734000000, + "model": "gw-anthropic-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "The capital of France is Paris."}, + "finish_reason": "stop" + } + ], + "usage": {"prompt_tokens": 12, "completion_tokens": 8, "total_tokens": 20} +} diff --git a/testdata/anthropic/resp_tooluse_in.json b/testdata/anthropic/resp_tooluse_in.json new file mode 100644 index 0000000..407dcf6 --- /dev/null +++ b/testdata/anthropic/resp_tooluse_in.json @@ -0,0 +1,13 @@ +{ + "id": "msg_01ABC", + "type": "message", + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me check the weather."}, + {"type": "tool_use", "id": "toolu_01DEF", "name": "get_weather", "input": {"location": "Paris"}} + ], + "model": "claude-opus-5", + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": {"input_tokens": 20, "output_tokens": 15} +} diff --git a/testdata/anthropic/resp_tooluse_want.json b/testdata/anthropic/resp_tooluse_want.json new file mode 100644 index 0000000..4c5d668 --- /dev/null +++ b/testdata/anthropic/resp_tooluse_want.json @@ -0,0 +1,20 @@ +{ + "id": "chatcmpl-msg_01ABC", + "object": "chat.completion", + "created": 1734000000, + "model": "gw-anthropic-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Let me check the weather.", + "tool_calls": [ + {"id": "toolu_01DEF", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\":\"Paris\"}"}} + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": {"prompt_tokens": 20, "completion_tokens": 15, "total_tokens": 35} +} diff --git a/testdata/anthropic/stream_text.sse b/testdata/anthropic/stream_text.sse new file mode 100644 index 0000000..26d83f5 --- /dev/null +++ b/testdata/anthropic/stream_text.sse @@ -0,0 +1,24 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_01STREAM","type":"message","role":"assistant","content":[],"model":"claude-opus-5","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: ping +data: {"type":"ping"} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/testdata/anthropic/stream_text_usage.json b/testdata/anthropic/stream_text_usage.json new file mode 100644 index 0000000..ad03d21 --- /dev/null +++ b/testdata/anthropic/stream_text_usage.json @@ -0,0 +1 @@ +{"prompt": 10, "completion": 5} diff --git a/testdata/anthropic/stream_text_want.json b/testdata/anthropic/stream_text_want.json new file mode 100644 index 0000000..ee5e36d --- /dev/null +++ b/testdata/anthropic/stream_text_want.json @@ -0,0 +1,6 @@ +[ + {"id":"chatcmpl-msg_01STREAM","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}, + {"id":"chatcmpl-msg_01STREAM","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}, + {"id":"chatcmpl-msg_01STREAM","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}, + {"id":"chatcmpl-msg_01STREAM","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} +] diff --git a/testdata/anthropic/stream_tooluse.sse b/testdata/anthropic/stream_tooluse.sse new file mode 100644 index 0000000..3c9c13c --- /dev/null +++ b/testdata/anthropic/stream_tooluse.sse @@ -0,0 +1,21 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_01TOOL","type":"message","role":"assistant","content":[],"model":"claude-opus-5","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":15,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01XYZ","name":"get_weather","input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"location\": \""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"Paris\"}"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":7}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/testdata/anthropic/stream_tooluse_usage.json b/testdata/anthropic/stream_tooluse_usage.json new file mode 100644 index 0000000..f29c72f --- /dev/null +++ b/testdata/anthropic/stream_tooluse_usage.json @@ -0,0 +1 @@ +{"prompt": 15, "completion": 7} diff --git a/testdata/anthropic/stream_tooluse_want.json b/testdata/anthropic/stream_tooluse_want.json new file mode 100644 index 0000000..667d55b --- /dev/null +++ b/testdata/anthropic/stream_tooluse_want.json @@ -0,0 +1,7 @@ +[ + {"id":"chatcmpl-msg_01TOOL","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}, + {"id":"chatcmpl-msg_01TOOL","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"toolu_01XYZ","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}, + {"id":"chatcmpl-msg_01TOOL","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"location\": \""}}]},"finish_reason":null}]}, + {"id":"chatcmpl-msg_01TOOL","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Paris\"}"}}]},"finish_reason":null}]}, + {"id":"chatcmpl-msg_01TOOL","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} +] diff --git a/translate_anthropic.go b/translate_anthropic.go new file mode 100644 index 0000000..fa25558 --- /dev/null +++ b/translate_anthropic.go @@ -0,0 +1,716 @@ +package traefikllmgateway + +import ( + "encoding/json" + "fmt" + "strings" +) + +// anthropicAPIVersion is the "anthropic-version" header value every request +// to Anthropic's Messages API must carry. +const anthropicAPIVersion = "2023-06-01" + +// anthropicDefaultMaxTokens is the value anthropicRequestFromOpenAI sends +// for "max_tokens" when the OpenAI request supplies neither "max_tokens" +// nor "max_completion_tokens" — Anthropic requires the field, OpenAI does +// not. +const anthropicDefaultMaxTokens = 4096 + +// chatCompletionIDPrefix is prepended to Anthropic's message id to form the +// OpenAI-shaped response "id" field, both for a non-streaming response and +// for every streaming chunk's "id". +const chatCompletionIDPrefix = "chatcmpl-" + +// translateError is a client-facing translation failure: an OpenAI-shaped +// request or Anthropic-shaped response could not be mapped to the other +// side's wire format. It implements error. The caller (Task 12's unified +// route) maps a *translateError to HTTP 400, or 501 when notSupported is +// set — embeddings has no Anthropic equivalent at all, which is a +// different failure than a single unsupported field on an otherwise valid +// request. +type translateError struct { + msg string + notSupported bool +} + +// Error implements the error interface. +func (e *translateError) Error() string { return e.msg } + +// unsupportedFieldError returns a *translateError for an OpenAI request +// field Anthropic's API has no equivalent for, per the request mapping +// table's "n>1, logit_bias, logprobs" row. +func unsupportedFieldError(field string) *translateError { + return &translateError{msg: fmt.Sprintf("%s is not supported for anthropic models", field)} +} + +// toFloat64 extracts a numeric value from v, which may be float64 (the +// normal shape after json.Unmarshal into map[string]any) or a Go-native +// int/int64/float32 (a test, or a caller that builds req by hand). +func toFloat64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case float32: + return float64(n), true + case int: + return float64(n), true + case int64: + return float64(n), true + } + return 0, false +} + +// toInt64 is toFloat64 truncated to an integer, for fields (max_tokens) +// Anthropic's API requires as a JSON integer. +func toInt64(v any) (int64, bool) { + f, ok := toFloat64(v) + if !ok { + return 0, false + } + return int64(f), true +} + +// stopSequencesFrom maps OpenAI's "stop" field (a string or an array of +// strings) to Anthropic's "stop_sequences" array. A present-but-empty +// string returns a nil slice — anthropicRequestFromOpenAI omits the field +// entirely rather than sending an empty array. +func stopSequencesFrom(v any) ([]string, error) { + switch s := v.(type) { + case string: + if s == "" { + return nil, nil + } + return []string{s}, nil + case []any: + seqs := make([]string, 0, len(s)) + for _, e := range s { + str, ok := e.(string) + if !ok { + return nil, &translateError{msg: "stop[] entries must be strings"} + } + seqs = append(seqs, str) + } + return seqs, nil + default: + return nil, nil + } +} + +// anthropicImageSourceFromDataURI parses an OpenAI image_url.url as a +// "data:;base64," URI and returns Anthropic's image +// source object. Any URL that is not a base64 data URI (an http(s) image +// URL, which Anthropic's Messages API does not fetch) returns a +// *translateError, per the request mapping table's image row. +func anthropicImageSourceFromDataURI(uri string) (map[string]any, error) { + rest, ok := strings.CutPrefix(uri, "data:") + if !ok { + return nil, &translateError{msg: "image_url must be a data: URI (http(s) image URLs are not supported for anthropic models)"} + } + header, data, ok := strings.Cut(rest, ",") + if !ok { + return nil, &translateError{msg: "malformed data: URI in image_url"} + } + mediaType, ok := strings.CutSuffix(header, ";base64") + if !ok { + return nil, &translateError{msg: "data: URI in image_url must be base64-encoded"} + } + return map[string]any{"type": "base64", "media_type": mediaType, "data": data}, nil +} + +// anthropicContentBlockFromOpenAI maps one OpenAI content-array entry +// ({"type":"text",...} or {"type":"image_url",...}) to an Anthropic +// content block. An entry of any other type is dropped (nil, nil) rather +// than erroring — an unrecognized part type is forwarded content this +// translator does not understand, not necessarily an invalid request. +func anthropicContentBlockFromOpenAI(pm map[string]any) (map[string]any, error) { + switch ptype, _ := pm["type"].(string); ptype { + case "text": + text, _ := pm["text"].(string) + return map[string]any{"type": "text", "text": text}, nil + case "image_url": + iu, _ := pm["image_url"].(map[string]any) + url, _ := iu["url"].(string) + src, err := anthropicImageSourceFromDataURI(url) + if err != nil { + return nil, err + } + return map[string]any{"type": "image", "source": src}, nil + default: + return nil, nil + } +} + +// anthropicToolUseBlockFromOpenAI maps one assistant tool_calls[] entry +// ({"id","type":"function","function":{"name","arguments"}}) to an +// Anthropic {"type":"tool_use","id","name","input"} content block, +// parsing the OpenAI arguments JSON string into Anthropic's structured +// "input" object. +func anthropicToolUseBlockFromOpenAI(tc any) (map[string]any, error) { + tm, ok := tc.(map[string]any) + if !ok { + return nil, &translateError{msg: "assistant tool_calls[] entry is malformed"} + } + id, _ := tm["id"].(string) + fn, _ := tm["function"].(map[string]any) + name, _ := fn["name"].(string) + argsStr, _ := fn["arguments"].(string) + + input := any(map[string]any{}) + if argsStr != "" { + if err := json.Unmarshal([]byte(argsStr), &input); err != nil { + return nil, &translateError{msg: "assistant tool_calls[].function.arguments is not valid JSON"} + } + } + return map[string]any{"type": "tool_use", "id": id, "name": name, "input": input}, nil +} + +// anthropicMessageFromOpenAI maps one OpenAI user/assistant message to an +// Anthropic message with the same role. A plain string content with no +// tool_calls passes through as a string (the mapping table's "content +// string" row); any other combination — a content array, an assistant +// message carrying tool_calls, or both — produces a content block array, +// per the mapping table's remaining rows. +func anthropicMessageFromOpenAI(msg map[string]any, role string) (map[string]any, error) { + contentVal := msg["content"] + + var toolCalls []any + if role == "assistant" { + toolCalls, _ = msg["tool_calls"].([]any) + } + + if s, ok := contentVal.(string); ok && len(toolCalls) == 0 { + return map[string]any{"role": role, "content": s}, nil + } + + var blocks []any + switch c := contentVal.(type) { + case string: + if c != "" { + blocks = append(blocks, map[string]any{"type": "text", "text": c}) + } + case []any: + for _, part := range c { + pm, ok := part.(map[string]any) + if !ok { + continue + } + block, err := anthropicContentBlockFromOpenAI(pm) + if err != nil { + return nil, err + } + if block != nil { + blocks = append(blocks, block) + } + } + } + + for _, tc := range toolCalls { + block, err := anthropicToolUseBlockFromOpenAI(tc) + if err != nil { + return nil, err + } + blocks = append(blocks, block) + } + + if blocks == nil { + blocks = []any{} + } + return map[string]any{"role": role, "content": blocks}, nil +} + +// anthropicToolResultMessage maps one OpenAI role:"tool" message to an +// Anthropic user message carrying a single tool_result content block, per +// the mapping table's "messages[role=tool]" row. content passes through +// unchanged — Anthropic's tool_result.content accepts a string, matching +// the common case, and this translator does not need to further interpret +// it. +func anthropicToolResultMessage(msg map[string]any) map[string]any { + toolCallID, _ := msg["tool_call_id"].(string) + return map[string]any{ + "role": "user", + "content": []any{map[string]any{ + "type": "tool_result", + "tool_use_id": toolCallID, + "content": msg["content"], + }}, + } +} + +// anthropicToolChoiceFromOpenAI maps OpenAI's "tool_choice" to Anthropic's +// tool_choice object, and reports whether "tools" must be dropped from the +// request. Per the mapping table, tool_choice:"none" has no valid +// Anthropic {"type":"none"} equivalent for this translator to use, so +// "none" is expressed by omitting "tools" (and "tool_choice") entirely +// instead. +func anthropicToolChoiceFromOpenAI(tc any) (choice map[string]any, dropTools bool) { + switch t := tc.(type) { + case string: + switch t { + case "none": + return nil, true + case "auto": + return map[string]any{"type": "auto"}, false + case "required": + return map[string]any{"type": "any"}, false + } + case map[string]any: + if fn, ok := t["function"].(map[string]any); ok { + if name, ok := fn["name"].(string); ok { + return map[string]any{"type": "tool", "name": name}, false + } + } + } + return nil, false +} + +// anthropicToolsFromOpenAI maps OpenAI's tools[].function{name, +// description, parameters} to Anthropic's tools[]{name, description, +// input_schema}, per the mapping table's tools row. +func anthropicToolsFromOpenAI(toolsRaw []any) []any { + out := make([]any, 0, len(toolsRaw)) + for _, t := range toolsRaw { + tm, ok := t.(map[string]any) + if !ok { + continue + } + fn, _ := tm["function"].(map[string]any) + entry := map[string]any{ + "name": fn["name"], + "input_schema": fn["parameters"], + } + if desc, ok := fn["description"]; ok { + entry["description"] = desc + } + out = append(out, entry) + } + return out +} + +// anthropicRequestFromOpenAI maps an OpenAI chat-completion request body +// to an Anthropic Messages API request body, per this file's request +// mapping table. It returns a *translateError — never wrapped — for a +// field Anthropic has no equivalent for (n>1, logit_bias, logprobs) or a +// content part this translator cannot map (a non-data-URI image URL, a +// malformed tool_calls argument string). +func anthropicRequestFromOpenAI(req map[string]any) (map[string]any, error) { + if v, ok := req["n"]; ok { + if n, ok2 := toFloat64(v); ok2 && n > 1 { + return nil, unsupportedFieldError("n") + } + } + if _, ok := req["logit_bias"]; ok { + return nil, unsupportedFieldError("logit_bias") + } + if _, ok := req["logprobs"]; ok { + return nil, unsupportedFieldError("logprobs") + } + + out := map[string]any{} + if model, ok := req["model"].(string); ok { + out["model"] = model + } + + msgsRaw, _ := req["messages"].([]any) + var systemParts []string + anthMsgs := make([]any, 0, len(msgsRaw)) + for _, m := range msgsRaw { + msg, ok := m.(map[string]any) + if !ok { + continue + } + switch role, _ := msg["role"].(string); role { + case "system": + if s, ok := msg["content"].(string); ok { + systemParts = append(systemParts, s) + } + case "user", "assistant": + am, err := anthropicMessageFromOpenAI(msg, role) + if err != nil { + return nil, err + } + anthMsgs = append(anthMsgs, am) + case "tool": + anthMsgs = append(anthMsgs, anthropicToolResultMessage(msg)) + } + } + if len(systemParts) > 0 { + out["system"] = strings.Join(systemParts, "\n\n") + } + out["messages"] = anthMsgs + + maxTokens := int64(anthropicDefaultMaxTokens) + if v, ok := req["max_completion_tokens"]; ok { + if n, ok2 := toInt64(v); ok2 { + maxTokens = n + } + } else if v, ok := req["max_tokens"]; ok { + if n, ok2 := toInt64(v); ok2 { + maxTokens = n + } + } + out["max_tokens"] = maxTokens + + if v, ok := req["temperature"]; ok { + out["temperature"] = v + } + if v, ok := req["top_p"]; ok { + out["top_p"] = v + } + if v, ok := req["stop"]; ok { + seqs, err := stopSequencesFrom(v) + if err != nil { + return nil, err + } + if len(seqs) > 0 { + out["stop_sequences"] = seqs + } + } + if v, ok := req["stream"]; ok { + out["stream"] = v + } + + toolsRaw, hasTools := req["tools"].([]any) + dropTools := false + if tc, ok := req["tool_choice"]; ok { + var choice map[string]any + choice, dropTools = anthropicToolChoiceFromOpenAI(tc) + if choice != nil { + out["tool_choice"] = choice + } + } + if hasTools && !dropTools { + out["tools"] = anthropicToolsFromOpenAI(toolsRaw) + } + + if v, ok := req["user"]; ok { + out["metadata"] = map[string]any{"user_id": v} + } + + return out, nil +} + +// anthropicContentBlock is the subset of one Anthropic response content +// block's JSON this translator reads: "text" blocks carry Text, "tool_use" +// blocks carry ID/Name/Input. +type anthropicContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` +} + +// anthropicUsagePayload is the "usage" object on both a non-streaming +// Anthropic response and a streaming message_start/message_delta event. +type anthropicUsagePayload struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` +} + +// anthropicResponseBody is the subset of a non-streaming Anthropic +// Messages API response this translator reads. +type anthropicResponseBody struct { + ID string `json:"id"` + StopReason string `json:"stop_reason"` + Content []anthropicContentBlock `json:"content"` + Usage anthropicUsagePayload `json:"usage"` +} + +// anthropicFinishReason maps an Anthropic stop_reason to an OpenAI +// finish_reason: max_tokens -> "length", tool_use -> "tool_calls", +// end_turn and stop_sequence both -> "stop". An unrecognized value +// defaults to "stop", the OpenAI-safe default for a completed turn. +func anthropicFinishReason(stopReason string) string { + switch stopReason { + case "max_tokens": + return "length" + case "tool_use": + return "tool_calls" + default: + return "stop" + } +} + +// openAIResponseFromAnthropic maps a non-streaming Anthropic Messages API +// response body to an OpenAI chat.completion response, per this file's +// response mapping table. model and created are supplied by the caller — +// Anthropic's response carries neither the gateway-facing model id nor a +// response timestamp, so this function has no other source for them. +func openAIResponseFromAnthropic(body []byte, model string, created int64) (map[string]any, usage, error) { + var resp anthropicResponseBody + if err := json.Unmarshal(body, &resp); err != nil { + return nil, usage{}, fmt.Errorf("%w: decode anthropic response: %w", errUpstream, err) + } + + var textParts []string + var toolCalls []any + for _, b := range resp.Content { + switch b.Type { + case "text": + textParts = append(textParts, b.Text) + case "tool_use": + args, err := reMarshalToolInput(b.Input) + if err != nil { + return nil, usage{}, err + } + toolCalls = append(toolCalls, map[string]any{ + "id": b.ID, + "type": "function", + "function": map[string]any{ + "name": b.Name, + "arguments": args, + }, + }) + } + } + + message := map[string]any{"role": "assistant"} + if len(textParts) > 0 { + message["content"] = strings.Join(textParts, "") + } else { + message["content"] = nil + } + if len(toolCalls) > 0 { + message["tool_calls"] = toolCalls + } + + u := usage{prompt: resp.Usage.InputTokens, completion: resp.Usage.OutputTokens} + + out := map[string]any{ + "id": chatCompletionIDPrefix + resp.ID, + "object": "chat.completion", + "created": created, + "model": model, + "choices": []any{map[string]any{ + "index": 0, + "message": message, + "finish_reason": anthropicFinishReason(resp.StopReason), + }}, + "usage": map[string]any{ + "prompt_tokens": u.prompt, + "completion_tokens": u.completion, + "total_tokens": u.total(), + }, + } + return out, u, nil +} + +// reMarshalToolInput decodes a tool_use block's raw "input" JSON and +// re-marshals it to a compact JSON string, the shape OpenAI's +// tool_calls[].function.arguments field expects. An empty input decodes +// to "{}", matching a tool call with no arguments. +func reMarshalToolInput(raw json.RawMessage) (string, error) { + parsed := any(map[string]any{}) + if len(raw) > 0 { + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", fmt.Errorf("%w: decode tool_use input: %w", errUpstream, err) + } + } + b, err := json.Marshal(parsed) + if err != nil { + return "", fmt.Errorf("%w: re-marshal tool_use input: %w", errUpstream, err) + } + return string(b), nil +} + +// anthropicStreamState is one streaming chat completion's translation +// state: the OpenAI-shaped chunk envelope fields (id, captured from the +// message_start event; model and created, fixed at construction) and the +// running usage total. A caller constructs one per stream and calls +// translate once per upstream sseEvent, in order. +type anthropicStreamState struct { + id string + model string + created int64 + u usage +} + +// newAnthropicStreamState returns an anthropicStreamState for one +// streaming chat completion. model is the gateway-facing model id and +// created the response timestamp every emitted chunk carries — the +// adapter fixes both once, before the first upstream event arrives. +func newAnthropicStreamState(model string, created int64) *anthropicStreamState { + return &anthropicStreamState{model: model, created: created} +} + +// usage returns the prompt/completion token counts captured so far, from +// the message_start and message_delta events already translated. +func (st *anthropicStreamState) usage() usage { + return st.u +} + +// chunk builds one OpenAI-shaped "chat.completion.chunk" payload carrying +// delta, and finishReason as its choices[0].finish_reason — nil for the +// JSON null every non-terminal chunk carries. +func (st *anthropicStreamState) chunk(delta map[string]any, finishReason *string) []byte { + var fr any + if finishReason != nil { + fr = *finishReason + } + b, err := json.Marshal(map[string]any{ + "id": chatCompletionIDPrefix + st.id, + "object": "chat.completion.chunk", + "created": st.created, + "model": st.model, + "choices": []any{map[string]any{ + "index": 0, + "delta": delta, + "finish_reason": fr, + }}, + }) + if err != nil { + // delta is always built from this file's own map[string]any/string/ + // int64 literals — never a value that can fail to marshal. + panic(fmt.Sprintf("llmgateway: anthropic stream chunk failed to marshal: %v", err)) + } + return b +} + +// anthropicMessageStartPayload is the "message_start" event's data shape. +type anthropicMessageStartPayload struct { + Message struct { + ID string `json:"id"` + Usage anthropicUsagePayload `json:"usage"` + } `json:"message"` +} + +// anthropicContentBlockStartPayload is the "content_block_start" event's +// data shape. +type anthropicContentBlockStartPayload struct { + ContentBlock struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + } `json:"content_block"` + Index int `json:"index"` +} + +// anthropicContentBlockDeltaPayload is the "content_block_delta" event's +// data shape: Delta.Type distinguishes "text_delta" (Delta.Text) from +// "input_json_delta" (Delta.PartialJSON). +type anthropicContentBlockDeltaPayload struct { + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + PartialJSON string `json:"partial_json"` + } `json:"delta"` + Index int `json:"index"` +} + +// anthropicMessageDeltaPayload is the "message_delta" event's data shape. +type anthropicMessageDeltaPayload struct { + Delta struct { + StopReason string `json:"stop_reason"` + } `json:"delta"` + Usage anthropicUsagePayload `json:"usage"` +} + +// anthropicErrorEventPayload is the "error" event's data shape. +type anthropicErrorEventPayload struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` +} + +// translate maps one upstream Anthropic SSE event to zero or more +// OpenAI-shaped "chat.completion.chunk" payloads, per this file's stream +// mapping table: +// +// - message_start: a {role:"assistant"} chunk, and captures input_tokens. +// - content_block_start (tool_use): a delta.tool_calls[0] chunk carrying +// id, type, and function.name with an empty arguments string. A +// content_block_start for any other block type produces nothing. +// - content_block_delta (text_delta): a {content: text} chunk. +// - content_block_delta (input_json_delta): a delta.tool_calls[0] chunk +// carrying only index and function.arguments, the incremental JSON +// fragment. +// - message_delta: captures output_tokens, and a finish_reason chunk +// mapped per anthropicFinishReason. +// - message_stop, ping, content_block_stop: nothing — the caller (the +// adapter) writes the terminal "[DONE]" itself once the upstream +// stream ends, on message_stop or on a clean EOF alike, per ruling +// (b): a data-less "event:" frame is never relied on to signal the +// end of the stream. +// - error: one OpenAI-shaped {"error":{...}} payload, and a non-nil +// error — translate's caller must stop reading the stream after this +// return, but forwards the returned chunk to the client first. +// +// Any other or malformed event is ignored (nil, nil) rather than erroring +// — an event this translator does not recognize is not necessarily a +// broken stream. +func (st *anthropicStreamState) translate(ev sseEvent) ([][]byte, error) { + switch ev.event { + case "message_start": + var p anthropicMessageStartPayload + if err := json.Unmarshal(ev.data, &p); err != nil { + return nil, nil + } + st.id = p.Message.ID + st.u.prompt = p.Message.Usage.InputTokens + return [][]byte{st.chunk(map[string]any{"role": "assistant"}, nil)}, nil + + case "content_block_start": + var p anthropicContentBlockStartPayload + if err := json.Unmarshal(ev.data, &p); err != nil { + return nil, nil + } + if p.ContentBlock.Type != "tool_use" { + return nil, nil + } + delta := map[string]any{"tool_calls": []any{map[string]any{ + "index": p.Index, + "id": p.ContentBlock.ID, + "type": "function", + "function": map[string]any{ + "name": p.ContentBlock.Name, + "arguments": "", + }, + }}} + return [][]byte{st.chunk(delta, nil)}, nil + + case "content_block_delta": + var p anthropicContentBlockDeltaPayload + if err := json.Unmarshal(ev.data, &p); err != nil { + return nil, nil + } + switch p.Delta.Type { + case "text_delta": + return [][]byte{st.chunk(map[string]any{"content": p.Delta.Text}, nil)}, nil + case "input_json_delta": + delta := map[string]any{"tool_calls": []any{map[string]any{ + "index": p.Index, + "function": map[string]any{"arguments": p.Delta.PartialJSON}, + }}} + return [][]byte{st.chunk(delta, nil)}, nil + default: + return nil, nil + } + + case "message_delta": + var p anthropicMessageDeltaPayload + if err := json.Unmarshal(ev.data, &p); err != nil { + return nil, nil + } + st.u.completion = p.Usage.OutputTokens + reason := anthropicFinishReason(p.Delta.StopReason) + return [][]byte{st.chunk(map[string]any{}, &reason)}, nil + + case "error": + var p anthropicErrorEventPayload + if err := json.Unmarshal(ev.data, &p); err != nil { + return nil, fmt.Errorf("%w: decode anthropic error event: %w", errUpstream, err) + } + chunk, err := json.Marshal(map[string]any{ + "error": map[string]any{"message": p.Error.Message, "type": p.Error.Type}, + }) + if err != nil { + return nil, fmt.Errorf("%w: marshal anthropic error chunk: %w", errUpstream, err) + } + return [][]byte{chunk}, fmt.Errorf("llmgateway: anthropic stream error: %s", p.Error.Message) + + default: + // message_stop, ping, content_block_stop, and anything unrecognized. + return nil, nil + } +} diff --git a/translate_anthropic_test.go b/translate_anthropic_test.go new file mode 100644 index 0000000..f172343 --- /dev/null +++ b/translate_anthropic_test.go @@ -0,0 +1,413 @@ +package traefikllmgateway + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +// goldenTestdataDir is where every fixture this file walks lives. +const goldenTestdataDir = "testdata/anthropic" + +// goldenGatewayModel and goldenCreated are the fixed model/created values +// every response- and stream-translation golden fixture's "_want.json" +// bakes in — openAIResponseFromAnthropic and newAnthropicStreamState both +// take these as caller-supplied parameters (Anthropic's own wire formats +// carry neither), so the golden tests must supply the same fixed values +// the "_want.json" files were written against. +const ( + goldenGatewayModel = "gw-anthropic-model" + goldenCreated = int64(1734000000) +) + +// normalizeJSON round-trips b through json.Unmarshal into a generic +// map[string]any (or []any), so a comparison via reflect.DeepEqual is not +// tripped up by Go-native numeric types (int64, e.g.) that differ from +// the float64 every JSON number decodes to — both v and w.json.Unmarshal +// output use the same generic decode, so both land on float64 either way. +func normalizeJSON(t *testing.T, v any) any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("normalizeJSON: marshal: %v", err) + } + var out any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("normalizeJSON: unmarshal: %v", err) + } + return out +} + +// readGoldenJSON reads and json.Unmarshals name (relative to +// testdata/anthropic) into a fresh map[string]any. +func readGoldenJSON(t *testing.T, name string) map[string]any { + t.Helper() + b, err := os.ReadFile(filepath.Join(goldenTestdataDir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("decode %s: %v", name, err) + } + return m +} + +// wantErrFixture is the shape a "{name}_wanterr.json" fixture decodes +// into: a substring the produced *translateError.Error() must contain, +// and whether it must set notSupported. +type wantErrFixture struct { + ErrContains string `json:"errContains"` + NotSupported bool `json:"notSupported"` +} + +// assertTranslateError asserts err is a *translateError matching want. +func assertTranslateError(t *testing.T, err error, want wantErrFixture) { + t.Helper() + if err == nil { + t.Fatalf("want a *translateError containing %q, got nil", want.ErrContains) + } + var terr *translateError + if !errors.As(err, &terr) { + t.Fatalf("err = %v (%T), want *translateError", err, err) + } + if !strings.Contains(terr.Error(), want.ErrContains) { + t.Errorf("error message = %q, want it to contain %q", terr.Error(), want.ErrContains) + } + if terr.notSupported != want.NotSupported { + t.Errorf("notSupported = %v, want %v", terr.notSupported, want.NotSupported) + } +} + +// TestAnthropicRequestGolden walks every "chat_*_in.json" fixture under +// testdata/anthropic, translating it via anthropicRequestFromOpenAI, and +// asserts the result against the sibling "_want.json" fixture (a +// successful translation) or "_wanterr.json" fixture (a *translateError), +// whichever is present — exactly one of the two exists per fixture. +func TestAnthropicRequestGolden(t *testing.T) { + entries, err := os.ReadDir(goldenTestdataDir) + if err != nil { + t.Fatalf("read testdata dir: %v", err) + } + + var names []string + for _, e := range entries { + if n, ok := strings.CutSuffix(e.Name(), "_in.json"); ok && strings.HasPrefix(n, "chat_") { + names = append(names, n) + } + } + sort.Strings(names) + if len(names) == 0 { + t.Fatalf("no chat_*_in.json fixtures found under %s", goldenTestdataDir) + } + + for _, name := range names { + t.Run(name, func(t *testing.T) { + in := readGoldenJSON(t, name+"_in.json") + + got, err := anthropicRequestFromOpenAI(in) + + wantErrPath := filepath.Join(goldenTestdataDir, name+"_wanterr.json") + if _, statErr := os.Stat(wantErrPath); statErr == nil { + var want wantErrFixture + b, rerr := os.ReadFile(wantErrPath) + if rerr != nil { + t.Fatalf("read %s: %v", wantErrPath, rerr) + } + if uerr := json.Unmarshal(b, &want); uerr != nil { + t.Fatalf("decode %s: %v", wantErrPath, uerr) + } + assertTranslateError(t, err, want) + return + } + + if err != nil { + t.Fatalf("anthropicRequestFromOpenAI: %v", err) + } + want := readGoldenJSON(t, name+"_want.json") + gotN := normalizeJSON(t, got) + wantN := normalizeJSON(t, want) + if !reflect.DeepEqual(gotN, wantN) { + gotB, _ := json.MarshalIndent(gotN, "", " ") + wantB, _ := json.MarshalIndent(wantN, "", " ") + t.Errorf("anthropicRequestFromOpenAI(%s) mismatch:\ngot:\n%s\nwant:\n%s", name, gotB, wantB) + } + }) + } +} + +// TestAnthropicResponseGolden walks every "resp_*_in.json" fixture, +// translating it via openAIResponseFromAnthropic with the fixed +// goldenGatewayModel/goldenCreated, and asserts the result against the +// sibling "_want.json" fixture. +func TestAnthropicResponseGolden(t *testing.T) { + entries, err := os.ReadDir(goldenTestdataDir) + if err != nil { + t.Fatalf("read testdata dir: %v", err) + } + + var names []string + for _, e := range entries { + if n, ok := strings.CutSuffix(e.Name(), "_in.json"); ok && strings.HasPrefix(n, "resp_") { + names = append(names, n) + } + } + sort.Strings(names) + if len(names) == 0 { + t.Fatalf("no resp_*_in.json fixtures found under %s", goldenTestdataDir) + } + + for _, name := range names { + t.Run(name, func(t *testing.T) { + body, err := os.ReadFile(filepath.Join(goldenTestdataDir, name+"_in.json")) + if err != nil { + t.Fatalf("read %s_in.json: %v", name, err) + } + + got, _, err := openAIResponseFromAnthropic(body, goldenGatewayModel, goldenCreated) + if err != nil { + t.Fatalf("openAIResponseFromAnthropic: %v", err) + } + + want := readGoldenJSON(t, name+"_want.json") + gotN := normalizeJSON(t, got) + wantN := normalizeJSON(t, want) + if !reflect.DeepEqual(gotN, wantN) { + gotB, _ := json.MarshalIndent(gotN, "", " ") + wantB, _ := json.MarshalIndent(wantN, "", " ") + t.Errorf("openAIResponseFromAnthropic(%s) mismatch:\ngot:\n%s\nwant:\n%s", name, gotB, wantB) + } + }) + } +} + +// streamUsageFixture is the shape a "{name}_usage.json" fixture decodes +// into: the prompt/completion totals anthropicStreamState.usage() must +// report once every event in the sibling ".sse" fixture has been +// translated. +type streamUsageFixture struct { + Prompt int64 `json:"prompt"` + Completion int64 `json:"completion"` +} + +// runStreamGolden translates every event in testdata/anthropic/{name}.sse +// through a fresh anthropicStreamState, in order, and asserts the +// flattened list of emitted chunks against "{name}_want.json" and the +// final usage() against "{name}_usage.json". +func runStreamGolden(t *testing.T, name string) { + t.Helper() + + f, err := os.Open(filepath.Join(goldenTestdataDir, name+".sse")) + if err != nil { + t.Fatalf("open %s.sse: %v", name, err) + } + defer f.Close() //nolint:errcheck + + st := newAnthropicStreamState(goldenGatewayModel, goldenCreated) + var got []any + terminalErr := readSSE(f, func(ev sseEvent) error { + chunks, terr := st.translate(ev) + for _, c := range chunks { + var v any + if uerr := json.Unmarshal(c, &v); uerr != nil { + t.Fatalf("translate(%s) produced invalid JSON: %v (%s)", ev.event, uerr, c) + } + got = append(got, v) + } + return terr + }) + if terminalErr != nil { + t.Fatalf("readSSE/translate(%s): %v", name, terminalErr) + } + + wantBytes, err := os.ReadFile(filepath.Join(goldenTestdataDir, name+"_want.json")) + if err != nil { + t.Fatalf("read %s_want.json: %v", name, err) + } + var want []any + if uerr := json.Unmarshal(wantBytes, &want); uerr != nil { + t.Fatalf("decode %s_want.json: %v", name, uerr) + } + + if !reflect.DeepEqual(got, want) { + gotB, _ := json.MarshalIndent(got, "", " ") + wantB, _ := json.MarshalIndent(want, "", " ") + t.Errorf("translate(%s) chunk list mismatch:\ngot:\n%s\nwant:\n%s", name, gotB, wantB) + } + + usageBytes, err := os.ReadFile(filepath.Join(goldenTestdataDir, name+"_usage.json")) + if err != nil { + t.Fatalf("read %s_usage.json: %v", name, err) + } + var wantUsage streamUsageFixture + if err := json.Unmarshal(usageBytes, &wantUsage); err != nil { + t.Fatalf("decode %s_usage.json: %v", name, err) + } + if gotUsage := st.usage(); gotUsage.prompt != wantUsage.Prompt || gotUsage.completion != wantUsage.Completion { + t.Errorf("usage() = %+v, want {prompt:%d completion:%d}", gotUsage, wantUsage.Prompt, wantUsage.Completion) + } +} + +// TestAnthropicStreamGolden_Text covers a text-only stream: role chunk, +// two content deltas, a finish chunk mapped from end_turn, with a ping +// and a content_block_start/stop interspersed that must produce nothing. +func TestAnthropicStreamGolden_Text(t *testing.T) { + runStreamGolden(t, "stream_text") +} + +// TestAnthropicStreamGolden_ToolUse covers a tool_use stream: role chunk, +// a tool_calls-start chunk, two input_json_delta argument fragments, and +// a finish chunk mapped from tool_use. +func TestAnthropicStreamGolden_ToolUse(t *testing.T) { + runStreamGolden(t, "stream_tooluse") +} + +// TestAnthropicStreamState_ErrorEvent proves an "error" SSE event +// translates to one OpenAI-shaped {"error":{...}} chunk and a non-nil +// terminal error, per the stream mapping table's error row — built +// inline rather than as a golden fixture since it is the one case +// runStreamGolden's readSSE-drives-to-completion harness cannot express +// (the whole point is that translation stops here). +func TestAnthropicStreamState_ErrorEvent(t *testing.T) { + st := newAnthropicStreamState(goldenGatewayModel, goldenCreated) + ev := sseEvent{ + event: "error", + data: []byte(`{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`), + } + + chunks, err := st.translate(ev) + if err == nil { + t.Fatalf("translate(error event): want a terminal error, got nil") + } + if len(chunks) != 1 { + t.Fatalf("translate(error event) returned %d chunks, want 1", len(chunks)) + } + + var payload map[string]any + if uerr := json.Unmarshal(chunks[0], &payload); uerr != nil { + t.Fatalf("emitted error chunk is not valid JSON: %v (%s)", uerr, chunks[0]) + } + errObj, ok := payload["error"].(map[string]any) + if !ok { + t.Fatalf("emitted error chunk missing \"error\" object: %s", chunks[0]) + } + if errObj["message"] != "Overloaded" { + t.Errorf("error.message = %v, want %q", errObj["message"], "Overloaded") + } + if errObj["type"] != "overloaded_error" { + t.Errorf("error.type = %v, want %q", errObj["type"], "overloaded_error") + } +} + +// TestAnthropicListModels_404NotSpeciallyHandled proves listModels +// returns a plain *providerHTTPError on a 404 (an older Anthropic API +// version that predates the Models endpoint) rather than any special +// fallback — the registry, not this adapter, decides what to do with a +// provider's explicitly configured models on a listModels failure. +func TestAnthropicListModels_404NotSpeciallyHandled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"message":"not found"}}`)) + })) + defer srv.Close() + + a, err := newAnthropicAdapter("p1", srv.URL, "sk-test") + if err != nil { + t.Fatalf("newAnthropicAdapter: %v", err) + } + + _, err = a.listModels(context.Background()) + if err == nil { + t.Fatalf("listModels: want error on 404, got nil") + } + var httpErr *providerHTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("err = %v (%T), want *providerHTTPError", err, err) + } + if httpErr.status != http.StatusNotFound { + t.Errorf("status = %d, want 404", httpErr.status) + } +} + +// TestAnthropicAdapter_Embeddings_NotSupported proves embeddings never +// makes an upstream request and always returns a *translateError with +// notSupported set — the caller (Task 12) maps that to HTTP 501. +func TestAnthropicAdapter_Embeddings_NotSupported(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + a, err := newAnthropicAdapter("p1", srv.URL, "sk-test") + if err != nil { + t.Fatalf("newAnthropicAdapter: %v", err) + } + + _, err = a.embeddings(context.Background(), httptest.NewRecorder(), map[string]any{"model": "claude-opus-5", "input": "hi"}) + var terr *translateError + if !errors.As(err, &terr) { + t.Fatalf("err = %v (%T), want *translateError", err, err) + } + if !terr.notSupported { + t.Errorf("notSupported = false, want true") + } + if called { + t.Errorf("embeddings made an upstream request, want none") + } +} + +// TestNewAnthropicAdapter_KeylessIsAConstructorError proves ruling (c): +// an empty resolved API key is a constructor error for anthropic-type +// providers, unlike the openai-type keyless ruling. +func TestNewAnthropicAdapter_KeylessIsAConstructorError(t *testing.T) { + if _, err := newAnthropicAdapter("p1", "https://api.anthropic.com", ""); err == nil { + t.Fatalf("newAnthropicAdapter: want error for empty API key, got nil") + } +} + +// TestAnthropicAdapter_InjectAuth proves injectAuth sets both the +// x-api-key and anthropic-version headers unconditionally. +func TestAnthropicAdapter_InjectAuth(t *testing.T) { + a, err := newAnthropicAdapter("p1", "https://api.anthropic.com", "sk-test") + if err != nil { + t.Fatalf("newAnthropicAdapter: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/", nil) + a.injectAuth(req) + + if got := req.Header.Get("x-api-key"); got != "sk-test" { + t.Errorf("x-api-key = %q, want %q", got, "sk-test") + } + if got := req.Header.Get("anthropic-version"); got != anthropicAPIVersion { + t.Errorf("anthropic-version = %q, want %q", got, anthropicAPIVersion) + } +} + +// TestAnthropicRequestFromOpenAI_MaxCompletionTokensPrecedence proves +// max_completion_tokens takes precedence over max_tokens when an OpenAI +// request sends both — the newer field wins. +func TestAnthropicRequestFromOpenAI_MaxCompletionTokensPrecedence(t *testing.T) { + req := map[string]any{ + "model": "claude-opus-5", + "messages": []any{map[string]any{"role": "user", "content": "hi"}}, + "max_tokens": float64(100), + "max_completion_tokens": float64(200), + } + got, err := anthropicRequestFromOpenAI(req) + if err != nil { + t.Fatalf("anthropicRequestFromOpenAI: %v", err) + } + if got["max_tokens"] != int64(200) { + t.Errorf("max_tokens = %v, want 200 (max_completion_tokens takes precedence)", got["max_tokens"]) + } +}