From d47dd06a467eeea265f22e6b45d645fd36740bc7 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 22 Aug 2026 22:03:48 +0100 Subject: [PATCH] feat(routes): serve inbound Anthropic Messages API Add POST /v1/messages, serving Anthropic SDK clients directly instead of outbound-only support. A request resolved to an anthropic-type provider passes through untranslated. A request resolved to any other provider type translates to and from OpenAI's chat-completion shape through the resolved adapter's own chatCompletion, since that method's contract is OpenAI-shape in and out regardless of provider type. The route reuses admitRequest, readAndDecodeUnifiedBody, registry.resolve, withAttemptRecorder, and limiter.account exactly as routes_unified.go does, so groups, model resolution, limits, accounting, and caching semantics stay identical to the unified route. Client-facing errors use Anthropic's envelope shape. A streaming request is rejected explicitly with a 501, never silently answered non-streamed; streaming support is a follow-up branch. Auth reuses authStore.identify and logAuthEvent unchanged. presentedKey already reads both x-api-key and Authorization: Bearer, so no new credential-checking code was needed. Add openAIRequestFromAnthropic and anthropicResponseFromOpenAI to translate_anthropic.go, the mirror of the existing outbound translators, plus their supporting helpers and unit tests. --- llmgateway.go | 19 ++ routes_messages.go | 409 ++++++++++++++++++++++++++++++++++++ routes_messages_test.go | 363 ++++++++++++++++++++++++++++++++ translate_anthropic.go | 405 +++++++++++++++++++++++++++++++++++ translate_anthropic_test.go | 142 +++++++++++++ 5 files changed, 1338 insertions(+) create mode 100644 routes_messages.go create mode 100644 routes_messages_test.go diff --git a/llmgateway.go b/llmgateway.go index 91f513d..27cef8c 100644 --- a/llmgateway.go +++ b/llmgateway.go @@ -785,6 +785,25 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if r.Method == http.MethodPost && r.URL.Path == messagesPath { + // Anthropic SDKs authenticate with x-api-key, not Authorization: + // Bearer — g.auth.identify (auth.go) already reads both headers, + // Bearer taking priority when a caller somehow sends both, so no + // route-specific auth handling is needed here beyond using the + // same identify/logAuthEvent call every other route already + // makes. A failed auth gets the Anthropic error shape + // (writeAnthropicError, routes_messages.go), not writeOAIError's + // — the one thing that differs from every route above. + u, grp, ok := g.auth.identify(r) + g.logAuthEvent(ok, authEventUserName(u), r) + if !ok { + writeAnthropicError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key") + return + } + g.handleMessages(sw, r, u, grp) + return + } + if r.Method == http.MethodPost && (r.URL.Path == imagesGenerationsPath || r.URL.Path == audioSpeechPath || r.URL.Path == audioTranscriptionsPath) { u, grp, ok := g.auth.identify(r) g.logAuthEvent(ok, authEventUserName(u), r) diff --git a/routes_messages.go b/routes_messages.go new file mode 100644 index 0000000..8e9e2fa --- /dev/null +++ b/routes_messages.go @@ -0,0 +1,409 @@ +package traefikllmgateway + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "strconv" +) + +// messagesPath is the inbound Anthropic Messages API route this gateway +// serves: POST /v1/messages. Distinct from anthropicMessagesPath +// (provider_anthropic.go), which is the SAME literal path string but +// names where an anthropic-type adapter's OWN outbound call lands on its +// configured upstream — two different constants for two different +// directions, kept apart so a future change to one is never mistaken for +// the other. +const messagesPath = "/v1/messages" + +// cacheEndpointMessages is cacheKey's endpoint argument (cache.go) for +// this route, mirroring cacheEndpointChat/cacheEndpointEmbeddings +// (routes_unified.go): keeps a /v1/messages request from ever colliding +// into a /v1/chat/completions cache entry even when a request happens to +// canonicalize to an identical provider/upstreamModel/body — the two +// routes answer in different wire shapes (Anthropic vs OpenAI), so a +// cache hit across them would hand a client the wrong envelope entirely. +const cacheEndpointMessages = "messages" + +// handleMessages implements POST /v1/messages: the Anthropic Messages API +// shape, inbound. It shares every pipeline step routes_unified.go's +// runUnified already established for /v1/chat/completions and +// /v1/embeddings — body-admission-gated decode, model resolution, +// request-rate limiting, response caching, usage accounting, and +// per-provider attempt recording — by calling the exact same functions +// runUnified calls, rather than reimplementing any of them: g.admitRequest, +// g.readAndDecodeUnifiedBody, g.registry.resolve, withAttemptRecorder, +// g.limiter.account, unifiedCostMicros, cacheKey/groupCacheEnabled/ +// effectiveTTL, and cacheCaptureWriter. Only two things are genuinely +// specific to this route and are NOT shared: which wire shape a request/ +// response is in (Anthropic, not OpenAI — see callAnthropicMessagesPassthrough/ +// callTranslatedMessages below) and which error envelope shape a failure +// gets written in (Anthropic's {"type":"error","error":{...}}, not +// OpenAI's {"error":{...}} — see writeAnthropicError and its callers, +// below). +func (g *Gateway) handleMessages(w http.ResponseWriter, r *http.Request, u *user, grp *group) { + sw := &statusTrackingWriter{ResponseWriter: w} + + scopes, ok := g.admitRequest(sw, u, grp) + if !ok { + return + } + + body, req, ok := g.readAndDecodeUnifiedBody(sw, r) + if !ok { + return + } + + requestedModel, _ := req["model"].(string) + if requestedModel == "" { + writeAnthropicError(sw, http.StatusBadRequest, "invalid_request_error", "model is required") + return + } + + // Streaming is rejected cleanly and explicitly here, before model + // resolution or any upstream call: this cut of the route ships + // non-streaming only (brief scope) — a follow-up branch adds the + // Anthropic SSE event translator (message_start/content_block_delta/ + // message_stop), a genuinely different event model from OpenAI's + // stream shape this route already knows how to forward. A caller + // that asked for streaming must see a clear, typed error, never a + // silently-ignored flag answered non-streamed. + if streaming, _ := req["stream"].(bool); streaming { + writeAnthropicError(sw, http.StatusNotImplemented, "invalid_request_error", "streaming is not yet supported on /v1/messages") + return + } + + adapter, upstreamModel, canonical, err := g.registry.resolve(requestedModel, grp) + if err != nil { + writeAnthropicModelResolveError(sw, err) + return + } + + req["model"] = upstreamModel + delete(req, gatewayAliasKey) // defensive: this route never sets it, but a client-sent "__alias" field must never reach either upstream shape + + cacheable := g.cache != nil && groupCacheEnabled(grp) + var cacheKeyStr string + if cacheable { + cacheKeyStr = cacheKey(adapter.name(), upstreamModel, requestedModel, cacheEndpointMessages, req) + if cached, hit := g.cache.lookup(cacheKeyStr); hit { + sw.Header().Set("X-Llmgw-Cache", "hit") + if cached.ContentType != "" { + sw.Header().Set("Content-Type", cached.ContentType) + } + sw.WriteHeader(cached.Status) + _, _ = sw.Write(cached.Body) + return + } + sw.Header().Set("X-Llmgw-Cache", "miss") + } + + var respWriter http.ResponseWriter = sw + var capture *cacheCaptureWriter + if cacheable { + capture = newCacheCaptureWriter(sw, g.cache.maxBodyBytes) + respWriter = capture + } + + ctx := withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) { + g.limiter.recordProviderAttempt(adapter.name(), upstreamModel, resp, attemptErr) + }) + + var result usage + var callErr error + if adapter.typeName() == providerTypeAnthropic { + result, callErr = g.callAnthropicMessagesPassthrough(ctx, respWriter, adapter, req) + } else { + result, callErr = g.callTranslatedMessages(ctx, respWriter, adapter, req, requestedModel) + } + + // Zero-usage fallback, mirroring runUnified's own (routes_unified.go): + // a non-streaming failure always carries zero usage by contract, so + // this is a no-op whenever callErr != nil (account below skips every + // write once both total tokens and cost are zero). Unlike runUnified, + // there is no streaming branch to skip here — streaming was already + // rejected above, so every call into this route that reaches here is + // non-streaming. + if callErr == nil && result.total() == 0 { + result.prompt = int64(math.Ceil(float64(len(body)) / 4)) + result.estimated = true + } + g.limiter.account(scopes, result, unifiedCostMicros(canonical, upstreamModel, result, g.cfg.Pricing)) + if result.estimated { + g.logf("messages route: usage for model %q logged as estimated (%d prompt tokens derived from request body size, not the provider's reported usage)", canonical, result.prompt) + } + + if cacheable && callErr == nil && capture.status == http.StatusOK && !capture.oversize { + g.cache.store(cacheKeyStr, capture.status, capture.contentType, capture.buf.Bytes(), effectiveTTL(g.cache, grp)) + } + + if callErr != nil { + g.handleMessagesAdapterError(sw, callErr, adapter.name()) + return + } +} + +// callAnthropicMessagesPassthrough handles a /v1/messages request already +// resolved to an anthropic-type provider: forward req to that provider's +// own Messages endpoint UNTRANSLATED (brief: "pass through, no +// translation of the message body") and copy its response back to w +// exactly as Anthropic sent it. req's only mutation, applied by the +// caller (handleMessages) before this function ever runs, is the "model" +// field rewrite to the resolved upstream model id — the same rewrite +// every route in this package applies before any adapter call +// (runUnified, resolveMediaModel); it is request ROUTING, not message +// translation. +// +// This deliberately bypasses providerAdapter.chatCompletion: +// chatCompletion's contract (providers.go) is OpenAI-shape in, OpenAI- +// shape out for every provider type — anthropicAdapter.chatCompletion +// always calls anthropicRequestFromOpenAI on whatever it is handed, which +// would corrupt an already-Anthropic-shaped body instead of passing it +// through. +// +// The response is written back byte-for-byte: this function reads +// Anthropic's raw JSON only to extract usage (input_tokens/output_tokens, +// via the existing anthropicResponseBody type, translate_anthropic.go — +// reused rather than declared again) for accounting, and never re- +// encodes what reaches the client. One accepted consequence: the +// response's own "model" field carries the upstream model id Anthropic +// actually served, not the client's requested alias — unlike every +// translating adapter's gatewayAliasKey echo (routes_unified.go's own +// doc comment on that mechanism), rewriting it here would mean decoding +// and re-marshaling the full response body, which is itself a +// translation step the brief's "no translation of the message body" rule +// for this branch rules out. +func (g *Gateway) callAnthropicMessagesPassthrough(ctx context.Context, w http.ResponseWriter, adapter providerAdapter, req map[string]any) (usage, error) { + // Plain type assertion, not an added providerAdapter interface method: + // this branch only ever runs when the caller already confirmed + // adapter.typeName() == providerTypeAnthropic, and buildAdapters + // (providers.go) only ever constructs a *anthropicAdapter for that + // type, so the assertion is guaranteed to succeed. aa.retry is + // nil-safe either way (retryPolicy.do's own contract, retry.go), so a + // failed assertion still degrades safely to one-attempt behavior + // rather than panicking. + aa, _ := adapter.(*anthropicAdapter) + var retry *retryPolicy + if aa != nil { + retry = aa.retry + } + + hdr := http.Header{} + hdr.Set("Content-Type", "application/json") + adapter.injectAuth(&http.Request{Header: hdr}) + + resp, err := upstreamJSON(ctx, adapter.httpClient(), http.MethodPost, adapter.base()+anthropicMessagesPath, hdr, req, retry) + 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) + } + + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return usage{}, fmt.Errorf("%w: read response body: %w", errUpstream, err) + } + + var parsed anthropicResponseBody + if err := json.Unmarshal(raw, &parsed); err != nil { + return usage{}, fmt.Errorf("%w: decode anthropic response: %w", errUpstream, err) + } + + if ct := resp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } else { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(raw) + + return usage{prompt: parsed.Usage.InputTokens, completion: parsed.Usage.OutputTokens}, nil +} + +// callTranslatedMessages handles a /v1/messages request resolved to a +// non-anthropic-type provider (openai, or gemini — both implement +// providerAdapter.chatCompletion against the SAME OpenAI-shaped +// contract, providers.go, regardless of what their own upstream wire +// format is): translate req from Anthropic's shape to OpenAI's +// (openAIRequestFromAnthropic, translate_anthropic.go), run it through +// the resolved adapter's own chatCompletion exactly as routes_unified.go's +// handleChat does, capturing what it writes into an in-memory recorder +// instead of letting it reach the client directly, then translate that +// captured OpenAI-shaped response back to Anthropic's shape +// (anthropicResponseFromOpenAI) before writing the result to w. +func (g *Gateway) callTranslatedMessages(ctx context.Context, w http.ResponseWriter, adapter providerAdapter, req map[string]any, requestedModel string) (usage, error) { + openaiReq, err := openAIRequestFromAnthropic(req) + if err != nil { + return usage{}, err + } + + rec := newMessagesResponseRecorder() + result, err := adapter.chatCompletion(ctx, rec, openaiReq) + if err != nil { + return usage{}, err + } + + out, err := anthropicResponseFromOpenAI(rec.body.Bytes(), requestedModel) + 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) + } + + status := rec.status + if status == 0 { + status = http.StatusOK + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(b) + + return result, nil +} + +// messagesResponseRecorder is a minimal in-memory http.ResponseWriter +// callTranslatedMessages hands to a resolved adapter's own chatCompletion +// in place of the real client connection. chatCompletion's contract +// (providerAdapter, providers.go) is OpenAI-shape in, OpenAI-shape out +// regardless of provider type, and this route needs to translate that +// output to Anthropic's shape before anything reaches the real client — +// so the adapter's write is captured here and never forwarded; the real +// ResponseWriter only ever sees the translated bytes callTranslatedMessages +// writes afterward. Streaming can never reach this recorder: handleMessages +// rejects "stream": true before either call path runs, so chatCompletion's +// non-streaming forwardJSON is the only path that ever writes to it. +type messagesResponseRecorder struct { + header http.Header + body bytes.Buffer + status int +} + +func newMessagesResponseRecorder() *messagesResponseRecorder { + return &messagesResponseRecorder{header: http.Header{}} +} + +func (r *messagesResponseRecorder) Header() http.Header { return r.header } + +func (r *messagesResponseRecorder) WriteHeader(status int) { r.status = status } + +func (r *messagesResponseRecorder) Write(b []byte) (int, error) { + if r.status == 0 { + r.status = http.StatusOK // mirrors net/http's implicit-200 default + } + return r.body.Write(b) +} + +// writeAnthropicError writes an Anthropic Messages API-shaped error +// envelope: {"type":"error","error":{"type":...,"message":...}} — what an +// Anthropic SDK client parses. errType uses the same vocabulary this +// gateway's OpenAI-shaped envelope already uses everywhere else +// (writeOAIError, errors.go: invalid_request_error, authentication_error, +// rate_limit_error, server_error), so an operator reading logs sees one +// consistent taxonomy regardless of which route answered; only the JSON +// shape around it differs, to match what this route's own clients expect. +func writeAnthropicError(w http.ResponseWriter, status int, errType, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "error", + "error": map[string]any{"type": errType, "message": msg}, + }) +} + +// writeAnthropicModelResolveError is writeModelResolveError's +// (routes_unified.go) Anthropic-shaped counterpart: the SAME status-code +// decision (aliasTargetError/errModelUnknown/errModelDenied), written +// through writeAnthropicError instead of writeOAIError. Kept as its own +// small function, rather than a routes_unified.go refactor threading an +// envelope-writer parameter through, so this route's addition stays +// isolated to its own file while a sibling branch is concurrently editing +// registry.go/admin.go — the decision logic below is intentionally +// identical to writeModelResolveError's, not independently re-derived. +func writeAnthropicModelResolveError(w http.ResponseWriter, err error) { + if aerr, ok := err.(*aliasTargetError); ok { + writeAnthropicError(w, http.StatusNotFound, "invalid_request_error", aerr.Error()) + return + } + switch { + case errors.Is(err, errModelUnknown): + writeAnthropicError(w, http.StatusNotFound, "invalid_request_error", "unknown model") + case errors.Is(err, errModelDenied): + writeAnthropicError(w, http.StatusForbidden, "invalid_request_error", "model access denied") + default: + writeAnthropicError(w, http.StatusInternalServerError, "server_error", "internal error") + } +} + +// handleMessagesAdapterError is handleAdapterError's (routes_unified.go) +// Anthropic-shaped counterpart, covering the identical set of adapter +// error cases with the identical yaegi-safety rationale (plain type +// assertions for providerHTTPError/translateError, never errors.As — +// see providerHTTPError's own doc comment, providers.go) but writing +// through writeAnthropicError/writeAnthropicProviderUpstreamError instead +// of writeOAIError/writeProviderUpstreamError, and its own "messages +// route:" log prefix so a log line always names which route it came from. +func (g *Gateway) handleMessagesAdapterError(sw *statusTrackingWriter, err error, providerName string) { + sw.Header().Del("X-Llmgw-Cache") + + if sw.wroteHeader { + g.errorf("messages route: adapter error after response started (provider %q): %v", providerName, err) + return + } + + if perr, ok := err.(*providerHTTPError); ok { + writeAnthropicProviderUpstreamError(sw, providerName, perr) + return + } + + if terr, ok := err.(*translateError); ok { + status := http.StatusBadRequest + if terr.notSupported { + status = http.StatusNotImplemented + } + writeAnthropicError(sw, status, "invalid_request_error", terr.msg) + return + } + + if errors.Is(err, context.Canceled) { + g.logf("messages route: client canceled request to provider %q: %v", providerName, err) + return + } + + g.errorf("messages route: upstream connection error (provider %q): %v", providerName, err) + writeAnthropicError(sw, http.StatusBadGateway, "server_error", "upstream connection error") +} + +// writeAnthropicProviderUpstreamError is writeProviderUpstreamError's +// (routes_unified.go) Anthropic-shaped counterpart: perr's upstream body +// is embedded under error.upstream (decoded to a JSON value when it +// parses as one, left as a raw string otherwise) inside the Anthropic +// {"type":"error","error":{...}} envelope instead of the OpenAI one. +func writeAnthropicProviderUpstreamError(w http.ResponseWriter, providerName string, perr *providerHTTPError) { + var upstream any = string(perr.body) + var parsed any + if json.Unmarshal(perr.body, &parsed) == nil { + upstream = parsed + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(perr.status) + _ = json.NewEncoder(w).Encode(map[string]any{ // headers already committed; nothing useful to do on encode failure + "type": "error", + "error": map[string]any{ + "type": "upstream_error", + "message": providerName + " upstream error", + "code": strconv.Itoa(perr.status), + "upstream": upstream, + }, + }) +} diff --git a/routes_messages_test.go b/routes_messages_test.go new file mode 100644 index 0000000..f8856d1 --- /dev/null +++ b/routes_messages_test.go @@ -0,0 +1,363 @@ +package traefikllmgateway + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// newMessagesRequest builds a POST /v1/messages request, JSON-encoding +// body. authHeader/authValue let a test choose which credential header to +// send; authHeader == "" sends no credential at all. +func newMessagesRequest(t *testing.T, body map[string]any, authHeader, authValue string) *http.Request { + t.Helper() + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + req := httptest.NewRequest(http.MethodPost, messagesPath, bytes.NewReader(b)) + if authHeader != "" { + req.Header.Set(authHeader, authValue) + } + return req +} + +// newMessagesTestGateway builds a *Gateway wired with an "anthropic" +// provider pointed at anthropicURL (skipped when "") and an "openai" +// provider pointed at openaiURL (skipped when ""), one permissive +// "default" group, and one inline user "alice" — the shared fixture +// every handleMessages test below starts from. +func newMessagesTestGateway(t *testing.T, anthropicURL, openaiURL string) *Gateway { + t.Helper() + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{} + if anthropicURL != "" { + cfg.Providers["anthropic"] = &ProviderConfig{Type: providerTypeAnthropic, BaseURL: anthropicURL, APIKey: "sk-ant-up", Models: []string{"claude-test"}} // #nosec G101 -- test fixture literal, not a real credential + } + if openaiURL != "" { + cfg.Providers["openai"] = &ProviderConfig{Type: providerTypeOpenAI, BaseURL: openaiURL, APIKey: "sk-oai-up", Models: []string{"gpt-test"}} + } + cfg.Groups = map[string]*GroupConfig{"default": {}} + cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}} + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + return gw +} + +// TestHandleMessages_AnthropicProvider_Passthrough proves a /v1/messages +// request resolved to an anthropic-type provider is forwarded to that +// provider's own Messages endpoint and its response copied back to the +// client byte-for-byte (brief: "pass through, no translation of the +// message body"), while still authenticating upstream and accounting +// usage exactly like every other route. +func TestHandleMessages_AnthropicProvider_Passthrough(t *testing.T) { + const anthResp = `{"id":"msg_01ABC","type":"message","role":"assistant","content":[{"type":"text","text":"hi there"}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":6}}` + + var gotBody map[string]any + var gotAPIKey, gotVersion, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAPIKey = r.Header.Get("x-api-key") + gotVersion = r.Header.Get("anthropic-version") + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(anthResp)) + })) + defer srv.Close() + + gw := newMessagesTestGateway(t, srv.URL, "") + + body := map[string]any{ + "model": "claude-test", + "max_tokens": 100, + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + } + req := newMessagesRequest(t, body, "x-api-key", "sk-alice") + rec := httptest.NewRecorder() + gw.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + if rec.Body.String() != anthResp { + t.Errorf("body = %q, want verbatim upstream body %q (no translation)", rec.Body.String(), anthResp) + } + if gotPath != anthropicMessagesPath { + t.Errorf("upstream path = %q, want %q", gotPath, anthropicMessagesPath) + } + if gotAPIKey != "sk-ant-up" { // #nosec G101 -- test fixture literal, not a real credential + t.Errorf("upstream x-api-key = %q, want sk-ant-up", gotAPIKey) + } + if gotVersion != anthropicAPIVersion { + t.Errorf("upstream anthropic-version = %q, want %q", gotVersion, anthropicAPIVersion) + } + if gotBody["model"] != "claude-test" { + t.Errorf("upstream model = %v, want claude-test (resolved upstream id)", gotBody["model"]) + } + if _, hasAlias := gotBody[gatewayAliasKey]; hasAlias { + t.Error("upstream request carries gatewayAliasKey; must never reach an upstream provider") + } + + tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) + if !ok || tokIn != 12 { + t.Errorf("user tokin/day counter = %d (ok=%v), want 12", tokIn, ok) + } + tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) + if !ok || tokOut != 6 { + t.Errorf("user tokout/day counter = %d (ok=%v), want 6", tokOut, ok) + } +} + +// TestHandleMessages_OpenAIProvider_TranslatesBothDirections proves a +// /v1/messages request resolved to an openai-type provider is translated +// to OpenAI's chat-completion shape going out (Anthropic's top-level +// "system" field folded into a role:"system" message) and the upstream's +// OpenAI-shaped response translated back to Anthropic's Messages shape +// coming back, with usage accounted from the translated numbers. +func TestHandleMessages_OpenAIProvider_TranslatesBothDirections(t *testing.T) { + const oaiResp = `{"id":"chatcmpl-99","object":"chat.completion","model":"gpt-real","choices":[{"index":0,"message":{"role":"assistant","content":"hi there"},"finish_reason":"stop"}],"usage":{"prompt_tokens":8,"completion_tokens":4}}` + + var gotReq map[string]any + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&gotReq) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(oaiResp)) + })) + defer srv.Close() + + gw := newMessagesTestGateway(t, "", srv.URL) + + body := map[string]any{ + "model": "gpt-test", + "max_tokens": 100, + "system": "You are terse.", + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + } + req := newMessagesRequest(t, body, "x-api-key", "sk-alice") + rec := httptest.NewRecorder() + gw.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + if gotPath != "/v1/chat/completions" { + t.Errorf("upstream path = %q, want /v1/chat/completions", gotPath) + } + + msgs, _ := gotReq["messages"].([]any) + if len(msgs) != 2 { + t.Fatalf("upstream messages = %v, want 2 (system + user)", gotReq["messages"]) + } + first, _ := msgs[0].(map[string]any) + if first["role"] != "system" || first["content"] != "You are terse." { + t.Errorf("upstream messages[0] = %v, want the system message translated into OpenAI's shape", first) + } + if _, hasSystem := gotReq["system"]; hasSystem { + t.Error("upstream request still carries Anthropic's own \"system\" field; want it folded into messages[]") + } + + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode client response: %v", err) + } + if got["type"] != "message" || got["role"] != "assistant" { + t.Errorf("response type/role = %v/%v, want message/assistant (Anthropic shape)", got["type"], got["role"]) + } + content, _ := got["content"].([]any) + if len(content) != 1 { + t.Fatalf("response content = %v, want one text block", got["content"]) + } + block, _ := content[0].(map[string]any) + if block["type"] != "text" || block["text"] != "hi there" { + t.Errorf("response content[0] = %v, want {type: text, text: \"hi there\"}", block) + } + if got["model"] != "gpt-test" { + t.Errorf("response model = %v, want gpt-test (the client's own requested alias echoed back)", got["model"]) + } + if got["stop_reason"] != "end_turn" { + t.Errorf("response stop_reason = %v, want end_turn", got["stop_reason"]) + } + + usageMap, _ := got["usage"].(map[string]any) + if usageMap["input_tokens"] != float64(8) || usageMap["output_tokens"] != float64(4) { + t.Errorf("response usage = %v, want input_tokens=8 output_tokens=4", usageMap) + } + + tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) + if !ok || tokIn != 8 { + t.Errorf("user tokin/day counter = %d (ok=%v), want 8", tokIn, ok) + } + tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) + if !ok || tokOut != 4 { + t.Errorf("user tokout/day counter = %d (ok=%v), want 4", tokOut, ok) + } +} + +// TestHandleMessages_Auth table-drives the three credential cases the +// brief calls out explicitly: x-api-key authenticates, Authorization: +// Bearer authenticates, and a bad key gets a 401 in ANTHROPIC error +// shape ({"type":"error","error":{"type":...}}), not OpenAI's +// ({"error":{...}}). +func TestHandleMessages_Auth(t *testing.T) { + const anthResp = `{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(anthResp)) + })) + defer srv.Close() + + cases := []struct { + name string + authHeader string + authValue string + wantStatus int + }{ + {"x-api-key header authenticates", "x-api-key", "sk-alice", http.StatusOK}, + {"Authorization Bearer header authenticates", "Authorization", "Bearer sk-alice", http.StatusOK}, + {"bad key is rejected in Anthropic error shape", "x-api-key", "sk-not-a-real-key", http.StatusUnauthorized}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gw := newMessagesTestGateway(t, srv.URL, "") + body := map[string]any{"model": "claude-test", "max_tokens": 10, "messages": []any{map[string]any{"role": "user", "content": "hi"}}} + req := newMessagesRequest(t, body, tc.authHeader, tc.authValue) + rec := httptest.NewRecorder() + gw.ServeHTTP(rec, req) + + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, tc.wantStatus, rec.Body.String()) + } + if tc.wantStatus != http.StatusUnauthorized { + return + } + + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode error body: %v", err) + } + if got["type"] != "error" { + t.Errorf(`response["type"] = %v, want "error" (Anthropic's envelope shape, not OpenAI's flat one)`, got["type"]) + } + errObj, _ := got["error"].(map[string]any) + if errObj["type"] != "authentication_error" { + t.Errorf("error.type = %v, want authentication_error", errObj["type"]) + } + if _, hasTopLevelCode := got["code"]; hasTopLevelCode { + t.Error(`response carries a top-level "code" field — that is OpenAI's writeOAIError shape, not Anthropic's`) + } + }) + } +} + +// TestHandleMessages_StreamingRejected proves "stream": true is rejected +// cleanly with a typed, Anthropic-shaped error before any upstream call +// is made — never silently ignored, never answered non-streamed. +func TestHandleMessages_StreamingRejected(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() + + gw := newMessagesTestGateway(t, srv.URL, "") + body := map[string]any{"model": "claude-test", "max_tokens": 10, "stream": true, "messages": []any{map[string]any{"role": "user", "content": "hi"}}} + req := newMessagesRequest(t, body, "x-api-key", "sk-alice") + rec := httptest.NewRecorder() + gw.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotImplemented { + t.Fatalf("status = %d, want 501, body=%s", rec.Code, rec.Body.String()) + } + if called { + t.Error("upstream was called; streaming must be rejected before any upstream request") + } + + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode error body: %v", err) + } + if got["type"] != "error" { + t.Errorf(`response["type"] = %v, want "error"`, got["type"]) + } + errObj, _ := got["error"].(map[string]any) + msg, _ := errObj["message"].(string) + if !strings.Contains(msg, "stream") { + t.Errorf("error message = %q, want it to mention streaming is not supported", msg) + } +} + +// TestHandleMessages_RateLimited_Returns429 proves this route enforces +// the caller's group/user requestsPerMinute limit exactly like +// routes_unified.go's runUnified: a second request past the limit is +// refused before any upstream call, carries a Retry-After header, and +// only the first, successful request's usage is accounted. +func TestHandleMessages_RateLimited_Returns429(t *testing.T) { + upstreamCalls := 0 + const anthResp = `{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(anthResp)) + })) + defer srv.Close() + + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{"anthropic": {Type: providerTypeAnthropic, BaseURL: srv.URL, APIKey: "sk-ant-up", Models: []string{"claude-test"}}} // #nosec G101 -- test fixture literal, not a real credential + cfg.Groups = map[string]*GroupConfig{"default": {}} + cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{RequestsPerMinute: 1}}}} + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + + body := map[string]any{"model": "claude-test", "max_tokens": 10, "messages": []any{map[string]any{"role": "user", "content": "hi"}}} + + req1 := newMessagesRequest(t, body, "x-api-key", "sk-alice") + rec1 := httptest.NewRecorder() + gw.ServeHTTP(rec1, req1) + if rec1.Code != http.StatusOK { + t.Fatalf("first request status = %d, want 200, body=%s", rec1.Code, rec1.Body.String()) + } + + req2 := newMessagesRequest(t, body, "x-api-key", "sk-alice") + rec2 := httptest.NewRecorder() + gw.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusTooManyRequests { + t.Fatalf("second request status = %d, want 429, body=%s", rec2.Code, rec2.Body.String()) + } + if rec2.Header().Get("Retry-After") == "" { + t.Error("429 response missing Retry-After header") + } + if upstreamCalls != 1 { + t.Errorf("upstream called %d times, want 1 (second request refused before any upstream call)", upstreamCalls) + } + + tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) + if !ok || tokIn != 1 { + t.Errorf("user tokin/day counter = %d (ok=%v), want 1 (only the first, successful request accounted)", tokIn, ok) + } +} diff --git a/translate_anthropic.go b/translate_anthropic.go index d3ad8ba..b07410d 100644 --- a/translate_anthropic.go +++ b/translate_anthropic.go @@ -824,3 +824,408 @@ func (st *anthropicStreamState) translate(ev sseEvent) ([][]byte, error) { return nil, nil } } + +// --- Anthropic -> OpenAI request translation (routes_messages.go) --- +// +// The functions below are the mirror of anthropicRequestFromOpenAI/ +// openAIResponseFromAnthropic above: routes_messages.go's /v1/messages +// route receives an Anthropic-shaped request and, when it resolves to a +// non-anthropic-type provider, must translate it to OpenAI's shape going +// out and translate that provider's OpenAI-shaped response back to +// Anthropic's shape coming back — the opposite direction from every +// function above this comment, which exist to let an OpenAI-shaped +// /v1/chat/completions request reach an anthropic-type provider. Neither +// direction reuses the other's helpers beyond the truly shape-agnostic +// ones (isTruthy, toFloat64/toInt64, systemTextFromContentParts): the two +// wire formats are similar but not inverses of each other field-for-field +// (Anthropic's tool_use.input is a decoded JSON value, OpenAI's +// tool_calls[].function.arguments is that same value re-encoded as a +// string, for instance), so each direction earns its own small mapping +// functions rather than one trying to serve both. + +// anthropicSystemToText maps Anthropic's "system" request field — a plain +// string, or (newer API versions) an array of {"type":"text","text":...} +// content blocks — to plain text. Reuses systemTextFromContentParts +// (above) for the array form, since Anthropic's system content-block +// shape and OpenAI's system-message content-array shape are identical. +// Any other type (absent, or already-invalid JSON) returns "", nil — +// openAIRequestFromAnthropic only emits a system message when this +// returns non-empty text. +func anthropicSystemToText(sys any) (string, error) { + switch s := sys.(type) { + case string: + return s, nil + case []any: + return systemTextFromContentParts(s) + default: + return "", nil + } +} + +// openAIContentPartFromAnthropicImage maps one Anthropic {"type":"image", +// "source":{"type":"base64","media_type":...,"data":...}} content block to +// OpenAI's {"type":"image_url","image_url":{"url":"data:...;base64,..."}}, +// the mirror of anthropicImageSourceFromDataURI above. Anthropic's image +// source "type" is currently always "base64" (a URL-sourced image source +// is a separate, newer Anthropic feature this translator does not yet +// support) — any other value is a *translateError. +func openAIContentPartFromAnthropicImage(bm map[string]any) (map[string]any, error) { + src, _ := bm["source"].(map[string]any) + srcType, _ := src["type"].(string) + if srcType != "base64" { + return nil, &translateError{msg: fmt.Sprintf("image source type %q is not supported for this provider", srcType)} + } + mediaType, _ := src["media_type"].(string) + data, _ := src["data"].(string) + url := "data:" + mediaType + ";base64," + data + return map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}}, nil +} + +// openAIToolCallFromAnthropic maps one Anthropic {"type":"tool_use","id", +// "name","input"} content block to an OpenAI tool_calls[] entry +// ({"id","type":"function","function":{"name","arguments"}}), the mirror +// of anthropicToolUseBlockFromOpenAI above: input, already a decoded Go +// value (this translator's caller decoded the whole request body into +// map[string]any), is re-marshaled to a compact JSON string for OpenAI's +// "arguments" field. +func openAIToolCallFromAnthropic(bm map[string]any) (map[string]any, error) { + id, _ := bm["id"].(string) + name, _ := bm["name"].(string) + input := bm["input"] + if input == nil { + input = map[string]any{} + } + argsBytes, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("%w: marshal tool_use input: %w", errUpstream, err) + } + return map[string]any{ + "id": id, + "type": "function", + "function": map[string]any{ + "name": name, + "arguments": string(argsBytes), + }, + }, nil +} + +// openAIToolMessageFromAnthropic maps one Anthropic {"type":"tool_result", +// "tool_use_id","content"} content block to an OpenAI role:"tool" message +// ({"role":"tool","tool_call_id","content"}), the mirror of +// anthropicToolResultBlock above. +func openAIToolMessageFromAnthropic(bm map[string]any) map[string]any { + toolUseID, _ := bm["tool_use_id"].(string) + return map[string]any{"role": "tool", "tool_call_id": toolUseID, "content": bm["content"]} +} + +// openAIMessagesFromAnthropic maps one Anthropic message ({"role", +// "content"}, content either a plain string or a content-block array) to +// one or more OpenAI messages: a plain string passes through unchanged; a +// block array splits into at most one {role, content, tool_calls?} +// message carrying every text/image/tool_use block, followed by one +// role:"tool" message per tool_result block (Anthropic allows a single +// user turn to carry several tool_result blocks answering a prior +// assistant turn's several tool_use calls — OpenAI's shape represents +// each as its own separate message, the mirror of +// anthropicRequestFromOpenAI's pendingToolResults accumulation above, +// which does the same collapse in the opposite direction). +func openAIMessagesFromAnthropic(role string, content any) ([]any, error) { + if s, ok := content.(string); ok { + return []any{map[string]any{"role": role, "content": s}}, nil + } + + blocksRaw, ok := content.([]any) + if !ok { + // Absent or unrecognized content shape: an empty message rather + // than an error — mirrors anthropicMessageFromOpenAI's own + // tolerance of a missing "content" field above. + return []any{map[string]any{"role": role, "content": ""}}, nil + } + + var contentParts []any + var toolCalls []any + var toolResults []any + for _, b := range blocksRaw { + bm, ok := b.(map[string]any) + if !ok { + continue + } + switch bt, _ := bm["type"].(string); bt { + case "text": + text, _ := bm["text"].(string) + contentParts = append(contentParts, map[string]any{"type": "text", "text": text}) + case "image": + part, err := openAIContentPartFromAnthropicImage(bm) + if err != nil { + return nil, err + } + contentParts = append(contentParts, part) + case "tool_use": + tc, err := openAIToolCallFromAnthropic(bm) + if err != nil { + return nil, err + } + toolCalls = append(toolCalls, tc) + case "tool_result": + toolResults = append(toolResults, openAIToolMessageFromAnthropic(bm)) + } + } + + var out []any + if len(contentParts) > 0 || len(toolCalls) > 0 { + m := map[string]any{"role": role} + if len(contentParts) > 0 { + m["content"] = contentParts + } else { + m["content"] = nil + } + if len(toolCalls) > 0 { + m["tool_calls"] = toolCalls + } + out = append(out, m) + } + out = append(out, toolResults...) + return out, nil +} + +// openAIToolsFromAnthropic maps Anthropic's tools[]{name, description, +// input_schema} to OpenAI's tools[].function{name, description, +// parameters}, the mirror of anthropicToolsFromOpenAI above. +func openAIToolsFromAnthropic(toolsRaw []any) []any { + out := make([]any, 0, len(toolsRaw)) + for _, t := range toolsRaw { + tm, ok := t.(map[string]any) + if !ok { + continue + } + fn := map[string]any{"name": tm["name"], "parameters": tm["input_schema"]} + if desc, ok := tm["description"]; ok { + fn["description"] = desc + } + out = append(out, map[string]any{"type": "function", "function": fn}) + } + return out +} + +// openAIToolChoiceFromAnthropic maps Anthropic's tool_choice +// ({"type":"auto"|"any"|"tool","name":...}) to OpenAI's tool_choice, the +// mirror of anthropicToolChoiceFromOpenAI above. Anthropic has no +// equivalent of OpenAI's "none" (a tool_choice this translator's forward +// direction can only produce by omitting the field, per that function's +// own doc comment), so there is no case here that ever needs to signal +// "drop tools" the way that direction's dropTools return does. +func openAIToolChoiceFromAnthropic(tc any) any { + tm, ok := tc.(map[string]any) + if !ok { + return nil + } + switch t, _ := tm["type"].(string); t { + case "auto": + return "auto" + case "any": + return "required" + case "tool": + name, _ := tm["name"].(string) + return map[string]any{"type": "function", "function": map[string]any{"name": name}} + default: + return nil + } +} + +// openAIRequestFromAnthropic maps an Anthropic Messages API request body +// (already decoded into map[string]any by the messages route's shared +// admission/decode path) to an OpenAI chat-completion request body, the +// mirror of anthropicRequestFromOpenAI above. It returns a +// *translateError — never wrapped, matching this file's established +// convention — for a content block this translator cannot map (an +// unsupported image source type). +func openAIRequestFromAnthropic(req map[string]any) (map[string]any, error) { + out := map[string]any{} + if model, ok := req["model"].(string); ok { + out["model"] = model + } + + msgs := make([]any, 0, 1) + if sys, ok := req["system"]; ok { + text, err := anthropicSystemToText(sys) + if err != nil { + return nil, err + } + if text != "" { + msgs = append(msgs, map[string]any{"role": "system", "content": text}) + } + } + + msgsRaw, _ := req["messages"].([]any) + for _, m := range msgsRaw { + msg, ok := m.(map[string]any) + if !ok { + continue + } + role, _ := msg["role"].(string) + converted, err := openAIMessagesFromAnthropic(role, msg["content"]) + if err != nil { + return nil, err + } + msgs = append(msgs, converted...) + } + out["messages"] = msgs + + if v, ok := req["max_tokens"]; ok { + if n, ok2 := toInt64(v); ok2 { + out["max_tokens"] = n + } + } + if v, ok := req["temperature"]; ok { + out["temperature"] = v + } + if v, ok := req["top_p"]; ok { + out["top_p"] = v + } + if seqs, ok := req["stop_sequences"].([]any); ok && len(seqs) > 0 { + out["stop"] = seqs + } + if v, ok := req["stream"]; ok { + out["stream"] = v + } + + if toolsRaw, ok := req["tools"].([]any); ok { + out["tools"] = openAIToolsFromAnthropic(toolsRaw) + } + if tc, ok := req["tool_choice"]; ok { + if choice := openAIToolChoiceFromAnthropic(tc); choice != nil { + out["tool_choice"] = choice + } + } + + if meta, ok := req["metadata"].(map[string]any); ok { + if uid, ok := meta["user_id"]; ok { + out["user"] = uid + } + } + + return out, nil +} + +// openAIChatResponseBody is the subset of a non-streaming OpenAI +// chat.completion response this translator reads, the mirror of +// anthropicResponseBody above. Message.Content is a pointer so a +// tool-calls-only response (content: null) is told apart from one +// carrying empty text — mirroring openAIResponseFromAnthropic's own +// len(textParts) > 0 check in the other direction. +type openAIChatResponseBody struct { + ID string `json:"id"` + Choices []struct { + FinishReason string `json:"finish_reason"` + Message struct { + Content *string `json:"content"` + ToolCalls []struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + Usage chatStreamUsage `json:"usage"` +} + +// anthropicToolInputFromOpenAI decodes an OpenAI tool_calls[].function. +// arguments JSON string into a generic Go value for Anthropic's +// tool_use.input field, the mirror of reMarshalToolInput above (which +// goes the other way: a decoded value re-marshaled to a string). An empty +// arguments string decodes to an empty object, matching a tool call with +// no arguments. +func anthropicToolInputFromOpenAI(argsStr string) (any, error) { + input := any(map[string]any{}) + if argsStr != "" { + if err := json.Unmarshal([]byte(argsStr), &input); err != nil { + return nil, fmt.Errorf("%w: decode tool_calls[].function.arguments: %w", errUpstream, err) + } + } + return input, nil +} + +// anthropicStopReasonFromOpenAI maps an OpenAI finish_reason to an +// Anthropic stop_reason, the mirror of anthropicFinishReason above: +// length -> max_tokens, tool_calls -> tool_use, everything else +// (including "stop") -> end_turn. OpenAI's finish_reason carries no +// equivalent of Anthropic's "stop_sequence" (which stop string matched), +// so this translator can never populate the response's stop_sequence +// field — the same loss of fidelity anthropicFinishReason already +// accepts by collapsing end_turn and stop_sequence into one OpenAI value. +func anthropicStopReasonFromOpenAI(finishReason string) string { + switch finishReason { + case "length": + return "max_tokens" + case "tool_calls": + return "tool_use" + default: + return "end_turn" + } +} + +// anthropicMessageIDFromOpenAI derives an Anthropic-shaped message id +// from an OpenAI chat.completion response id, stripping +// chatCompletionIDPrefix ("chatcmpl-") when present and prefixing +// "msg_" — Anthropic's own id convention — the mirror of +// openAIResponseFromAnthropic's chatCompletionIDPrefix+resp.ID above. +func anthropicMessageIDFromOpenAI(id string) string { + return "msg_" + strings.TrimPrefix(id, chatCompletionIDPrefix) +} + +// anthropicResponseFromOpenAI maps a non-streaming OpenAI chat.completion +// response body to an Anthropic Messages API response body, the mirror of +// openAIResponseFromAnthropic above. model is supplied by the caller +// (routes_messages.go's callTranslatedMessages) — the client's original +// requested alias, echoed back into the response's "model" field the same +// way every translating adapter's gatewayAliasKey mechanism already does +// for the outbound direction (routes_unified.go's gatewayAliasKey doc +// comment). +func anthropicResponseFromOpenAI(body []byte, model string) (map[string]any, error) { + var resp openAIChatResponseBody + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("%w: decode openai response: %w", errUpstream, err) + } + + var content []any + stopReason := "end_turn" + if len(resp.Choices) > 0 { + choice := resp.Choices[0] + if choice.Message.Content != nil && *choice.Message.Content != "" { + content = append(content, map[string]any{"type": "text", "text": *choice.Message.Content}) + } + for _, tc := range choice.Message.ToolCalls { + input, err := anthropicToolInputFromOpenAI(tc.Function.Arguments) + if err != nil { + return nil, err + } + content = append(content, map[string]any{ + "type": "tool_use", + "id": tc.ID, + "name": tc.Function.Name, + "input": input, + }) + } + stopReason = anthropicStopReasonFromOpenAI(choice.FinishReason) + } + if content == nil { + content = []any{} + } + + out := map[string]any{ + "id": anthropicMessageIDFromOpenAI(resp.ID), + "type": "message", + "role": "assistant", + "model": model, + "content": content, + "stop_reason": stopReason, + "stop_sequence": nil, + "usage": map[string]any{ + "input_tokens": resp.Usage.PromptTokens, + "output_tokens": resp.Usage.CompletionTokens, + }, + } + return out, nil +} diff --git a/translate_anthropic_test.go b/translate_anthropic_test.go index 610591e..92bd18e 100644 --- a/translate_anthropic_test.go +++ b/translate_anthropic_test.go @@ -507,3 +507,145 @@ func TestToFloat64(t *testing.T) { }) } } + +// --- openAIRequestFromAnthropic / anthropicResponseFromOpenAI: the +// reverse-direction translators routes_messages.go uses (/v1/messages +// resolved to a non-anthropic-type provider) --- + +// TestOpenAIRequestFromAnthropic_ToolResultBecomesToolMessage proves an +// assistant tool_use block becomes an OpenAI tool_calls[] entry (with its +// decoded "input" re-marshaled to a JSON string, matching OpenAI's +// arguments field) and a following user tool_result block becomes its +// own separate role:"tool" message — the mirror of +// anthropicRequestFromOpenAI's pendingToolResults collapse in the other +// direction. +func TestOpenAIRequestFromAnthropic_ToolResultBecomesToolMessage(t *testing.T) { + req := map[string]any{ + "model": "claude-x", + "messages": []any{ + map[string]any{"role": "assistant", "content": []any{ + map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{"city": "London"}}, + }}, + map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "tool_result", "tool_use_id": "call_1", "content": "sunny"}, + }}, + }, + } + + out, err := openAIRequestFromAnthropic(req) + if err != nil { + t.Fatalf("openAIRequestFromAnthropic: %v", err) + } + msgs, _ := out["messages"].([]any) + if len(msgs) != 2 { + t.Fatalf("messages = %v, want 2 (assistant tool_calls + tool result)", msgs) + } + assistant, _ := msgs[0].(map[string]any) + toolCalls, _ := assistant["tool_calls"].([]any) + if len(toolCalls) != 1 { + t.Fatalf("assistant tool_calls = %v, want 1 entry", assistant["tool_calls"]) + } + tc, _ := toolCalls[0].(map[string]any) + fn, _ := tc["function"].(map[string]any) + assert.Equal(t, "get_weather", fn["name"]) + assert.JSONEq(t, `{"city":"London"}`, fn["arguments"].(string)) + + toolMsg, _ := msgs[1].(map[string]any) + assert.Equal(t, "tool", toolMsg["role"]) + assert.Equal(t, "call_1", toolMsg["tool_call_id"]) + assert.Equal(t, "sunny", toolMsg["content"]) +} + +// TestOpenAIRequestFromAnthropic_SystemStringFoldedIntoMessages proves a +// plain-string Anthropic "system" field becomes a role:"system" message +// prepended ahead of the rest, and the Anthropic-only top-level "system" +// key never survives into the OpenAI-shaped output. +func TestOpenAIRequestFromAnthropic_SystemStringFoldedIntoMessages(t *testing.T) { + req := map[string]any{ + "model": "claude-x", + "system": "Be terse.", + "messages": []any{map[string]any{"role": "user", "content": "hi"}}, + } + + out, err := openAIRequestFromAnthropic(req) + if err != nil { + t.Fatalf("openAIRequestFromAnthropic: %v", err) + } + if _, ok := out["system"]; ok { + t.Error(`out carries a "system" key; Anthropic's system field has no OpenAI equivalent and must be folded into messages[]`) + } + msgs, _ := out["messages"].([]any) + if len(msgs) != 2 { + t.Fatalf("messages = %v, want 2 (system + user)", msgs) + } + sysMsg, _ := msgs[0].(map[string]any) + assert.Equal(t, "system", sysMsg["role"]) + assert.Equal(t, "Be terse.", sysMsg["content"]) +} + +// TestAnthropicResponseFromOpenAI_ToolCallsBecomeToolUseBlocks proves an +// OpenAI tool_calls[] entry becomes an Anthropic tool_use content block +// with its arguments STRING decoded back to a structured "input" value, +// stop_reason maps tool_calls -> tool_use, and the caller-supplied model +// (the client's own requested alias) is echoed into the response. +func TestAnthropicResponseFromOpenAI_ToolCallsBecomeToolUseBlocks(t *testing.T) { + const body = `{"id":"chatcmpl-77","choices":[{"index":0,"message":{"content":null,"tool_calls":[{"id":"call_9","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":2}}` + + out, err := anthropicResponseFromOpenAI([]byte(body), "aliased/model") + if err != nil { + t.Fatalf("anthropicResponseFromOpenAI: %v", err) + } + assert.Equal(t, "aliased/model", out["model"]) + assert.Equal(t, "tool_use", out["stop_reason"]) + + content, _ := out["content"].([]any) + if len(content) != 1 { + t.Fatalf("content = %v, want one tool_use block", out["content"]) + } + block, _ := content[0].(map[string]any) + assert.Equal(t, "tool_use", block["type"]) + assert.Equal(t, "get_weather", block["name"]) + assert.Equal(t, "call_9", block["id"]) + input, _ := block["input"].(map[string]any) + assert.Equal(t, "Paris", input["city"]) + + usageMap, _ := out["usage"].(map[string]any) + assert.Equal(t, int64(3), usageMap["input_tokens"]) + assert.Equal(t, int64(2), usageMap["output_tokens"]) +} + +// TestAnthropicResponseFromOpenAI_PlainTextResponse proves the common +// case: a plain-text OpenAI response becomes a single Anthropic text +// content block with stop_reason "stop" -> "end_turn". +func TestAnthropicResponseFromOpenAI_PlainTextResponse(t *testing.T) { + const body = `{"id":"chatcmpl-1","choices":[{"index":0,"message":{"content":"hi there"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}` + + out, err := anthropicResponseFromOpenAI([]byte(body), "claude-x") + if err != nil { + t.Fatalf("anthropicResponseFromOpenAI: %v", err) + } + assert.Equal(t, "message", out["type"]) + assert.Equal(t, "assistant", out["role"]) + assert.Equal(t, "end_turn", out["stop_reason"]) + content, _ := out["content"].([]any) + if len(content) != 1 { + t.Fatalf("content = %v, want one text block", out["content"]) + } + block, _ := content[0].(map[string]any) + assert.Equal(t, "text", block["type"]) + assert.Equal(t, "hi there", block["text"]) +} + +// TestOpenAIContentPartFromAnthropicImage_RejectsNonBase64Source proves an +// Anthropic image block whose source is not base64-encoded (a URL-sourced +// image, which this translator does not support) returns a +// *translateError rather than silently dropping or mistranslating it. +func TestOpenAIContentPartFromAnthropicImage_RejectsNonBase64Source(t *testing.T) { + _, err := openAIContentPartFromAnthropicImage(map[string]any{"source": map[string]any{"type": "url", "url": "https://example.com/x.png"}}) + if err == nil { + t.Fatal("want an error for a non-base64 image source, got nil") + } + if _, ok := err.(*translateError); !ok { + t.Errorf("err = %T, want *translateError", err) + } +}