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. // // MUST be returned bare, never wrapped with fmt.Errorf("%w", ...): // routes_unified.go's handleAdapterError relies on a direct type assertion // (err.(*translateError)), not errors.As, because errors.As panics under // yaegi when checking whether an interpreted pointer type implements error // (verified empirically under real Traefik, Task 15's integration suite). // A wrapped translateError would make that assertion fail to find it. 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)} } // isTruthy reports whether v — an OpenAI request field's already-decoded // JSON value — is one this translator's truthy-only unsupported-field // checks (logprobs, logit_bias) should treat as "the client actually asked // for this", rather than merely "the field key was present in the request // body". Controller ruling: reject only when truthy/non-empty across both // anthropic and gemini translators — a present-but-falsy value (false, 0, // "", an empty {} or [], or JSON null) must not 400 a request that would // otherwise translate cleanly. Shared by translate_anthropic.go and // translate_gemini.go so both providers apply the identical rule. func isTruthy(v any) bool { switch t := v.(type) { case nil: return false case bool: return t case float64: return t != 0 case string: return t != "" case map[string]any: return len(t) > 0 case []any: return len(t) > 0 default: // An unrecognized-but-non-nil type: conservatively truthy, since // this translator has no basis to call it empty. return true } } // 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 } // anthropicToolResultBlock maps one OpenAI role:"tool" message to a single // Anthropic {"type":"tool_result","tool_use_id","content"} 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. The caller (anthropicRequestFromOpenAI) accumulates // consecutive tool messages' blocks into one Anthropic user turn — see // its doc comment. func anthropicToolResultBlock(msg map[string]any) map[string]any { toolCallID, _ := msg["tool_call_id"].(string) return map[string]any{ "type": "tool_result", "tool_use_id": toolCallID, "content": msg["content"], } } // systemTextFromContentParts joins an OpenAI system message's array-form // content ([{"type":"text","text":...}, ...]) into plain text, joined // with "\n". Any part that is not {"type":"text"} has no equivalent in // Anthropic's plain-string top-level "system" field, so it is a // *translateError rather than being silently dropped. func systemTextFromContentParts(parts []any) (string, error) { texts := make([]string, 0, len(parts)) for _, part := range parts { pm, ok := part.(map[string]any) if !ok { return "", &translateError{msg: "system message content[] entries must be objects"} } ptype, _ := pm["type"].(string) if ptype != "text" { return "", &translateError{msg: fmt.Sprintf("system message content part type %q is not supported for anthropic models", ptype)} } text, _ := pm["text"].(string) texts = append(texts, text) } return strings.Join(texts, "\n"), nil } // 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; a truthy logit_bias or // logprobs, per isTruthy's doc comment) or a content part this translator // cannot map (a non-data-URI image URL, a malformed tool_calls argument // string). Consecutive role:"tool" messages are merged into one Anthropic // user turn carrying all of their tool_result blocks — see // anthropicToolResultBlock's doc comment. 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 v, ok := req["logit_bias"]; ok && isTruthy(v) { return nil, unsupportedFieldError("logit_bias") } if v, ok := req["logprobs"]; ok && isTruthy(v) { 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)) // pendingToolResults accumulates consecutive role:"tool" messages' // blocks. Anthropic requires alternating user/assistant turns and // 400s on two consecutive same-role messages, and parallel tool calls // (one assistant message with N tool_calls, followed by N role:"tool" // messages) are the standard agentic transcript shape — so those N // messages must collapse into one Anthropic user turn carrying N // tool_result blocks, not N separate user turns. var pendingToolResults []any flushPendingToolResults := func() { if len(pendingToolResults) == 0 { return } anthMsgs = append(anthMsgs, map[string]any{"role": "user", "content": pendingToolResults}) pendingToolResults = nil } for _, m := range msgsRaw { msg, ok := m.(map[string]any) if !ok { continue } switch role, _ := msg["role"].(string); role { case "system": switch c := msg["content"].(type) { case string: systemParts = append(systemParts, c) case []any: text, err := systemTextFromContentParts(c) if err != nil { return nil, err } systemParts = append(systemParts, text) } case "user", "assistant": flushPendingToolResults() am, err := anthropicMessageFromOpenAI(msg, role) if err != nil { return nil, err } anthMsgs = append(anthMsgs, am) case "tool": pendingToolResults = append(pendingToolResults, anthropicToolResultBlock(msg)) } } flushPendingToolResults() 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. // CacheCreationInputTokens/CacheReadInputTokens (item 6 fix, 2026-08-22 // review) are Anthropic's prompt-cache counters: tokens written to the // cache and tokens read back from it, reported separately from // InputTokens (fresh, uncached input). Verified repro before this fix: // 180000 cache-creation + 20000 cache-read tokens billed as tokin=4 — the // two cache fields were decoded into nothing and silently dropped. // Caching is near-universal for Claude Code and any agentic client using // Anthropic's prompt cache, so ignoring them under-counted budgets by // orders of magnitude on the primary use case. type anthropicUsagePayload struct { InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` CacheReadInputTokens int64 `json:"cache_read_input_tokens"` } // totalInputTokens returns the full input-side token count Anthropic // actually billed for one request: fresh InputTokens plus both cache-tier // counters. This gateway's budgets/rate-limits (limits.go's usage.prompt) // track one combined "prompt" token count per request, not per-tier // pricing, so folding all three into one number here — rather than // pricing cache-write/cache-read tiers differently — fixes the under- // billing item 6 reported (2026-08-22 review) without a materially larger // change to pricing.go's cost model, which this fix deliberately leaves // alone: token-budget accuracy was the reported defect, not $ cost // accuracy for cache-tier pricing. func (p anthropicUsagePayload) totalInputTokens() int64 { return p.InputTokens + p.CacheCreationInputTokens + p.CacheReadInputTokens } // 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.totalInputTokens(), 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 { // toolOrdinals maps an Anthropic content-block index to the 0-based // ordinal of that tool call among all tool_use blocks seen so far in // this message. OpenAI's tool_calls[].index must be contiguous // starting at 0 over the tool-call array; Anthropic's content-block // index also counts any preceding non-tool_use blocks (a tool_use // block right after a text block is content-block index 1, but it is // still the first — index 0 — tool call). toolOrdinals map[int]int id string model string u usage created int64 nextToolOrdinal int } // 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, toolOrdinals: map[int]int{}} } // 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. // // An unrecognized event type is ignored (nil, nil) — this translator does // not treat an event it does not know about as a broken stream. Malformed // data on a recognized event type is different: it returns an error // wrapping errUpstream instead of silently producing nothing, since a // dropped input_json_delta (say) would otherwise corrupt the client's // reassembled tool-call arguments without either side noticing. 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, fmt.Errorf("%w: decode anthropic message_start event: %w", errUpstream, err) } st.id = p.Message.ID st.u.prompt = p.Message.Usage.totalInputTokens() 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, fmt.Errorf("%w: decode anthropic content_block_start event: %w", errUpstream, err) } if p.ContentBlock.Type != "tool_use" { return nil, nil } ordinal := st.nextToolOrdinal st.toolOrdinals[p.Index] = ordinal st.nextToolOrdinal++ delta := map[string]any{"tool_calls": []any{map[string]any{ "index": ordinal, "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, fmt.Errorf("%w: decode anthropic content_block_delta event: %w", errUpstream, err) } switch p.Delta.Type { case "text_delta": return [][]byte{st.chunk(map[string]any{"content": p.Delta.Text}, nil)}, nil case "input_json_delta": ordinal, ok := st.toolOrdinals[p.Index] if !ok { // Defensive: an input_json_delta for a content-block index // this translator never saw a tool_use content_block_start // for. Fall back to the raw content-block index rather than // dropping the fragment — a wrong index is recoverable by // the client's own reassembly; a silently dropped argument // fragment is not. ordinal = p.Index } delta := map[string]any{"tool_calls": []any{map[string]any{ "index": ordinal, "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, fmt.Errorf("%w: decode anthropic message_delta event: %w", errUpstream, err) } 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 } } // --- 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","is_error"} content block to an OpenAI // role:"tool" message ({"role":"tool","tool_call_id","content"}), the // mirror of anthropicToolResultBlock above. is_error (item 11/F4 fix, // 2026-08-22/23 review) has no native OpenAI equivalent — a tool message // carries no error flag — so it is preserved by prefixing "Error: " onto // content once openAIToolResultContentString (below) has normalized it // to a plain string, instead of being silently dropped: an earlier // version of this function read only tool_use_id/content and discarded // is_error entirely, and a second earlier version only prefixed a Go // string, still dropping the signal for Anthropic's array-content form. func openAIToolMessageFromAnthropic(bm map[string]any) map[string]any { toolUseID, _ := bm["tool_use_id"].(string) content := openAIToolResultContentString(bm["content"]) if isErr, _ := bm["is_error"].(bool); isErr { content = "Error: " + content } return map[string]any{"role": "tool", "tool_call_id": toolUseID, "content": content} } // openAIToolResultContentString normalizes an Anthropic tool_result // block's "content" into the single string OpenAI's tool message // "content" field requires (item 11/F4 fix, 2026-08-22/23 review). Three // source shapes all collapse to a string: a plain string passes through // unchanged; a content-block array (Anthropic permits this, and Claude // Code sends it for a tool_result carrying more than plain text) has its // text blocks concatenated; an absent/nil content becomes "". Handling // all three uniformly, here, is what lets openAIToolMessageFromAnthropic's // is_error prefix apply regardless of the source shape — the PREVIOUS // version only prefixed a Go string and left the array form unmarked // entirely, silently dropping the failure signal for real Claude Code // traffic (item 11 only covered half of it). It also fixes a second, // separate bug the array-only gap masked: an absent content previously // passed through as a literal Go nil, which this function's caller wrote // out as the JSON literal null — OpenAI rejects a null tool-message // content outright, where "" is accepted. func openAIToolResultContentString(content any) string { switch c := content.(type) { case string: return c case []any: var parts []string for _, block := range c { bm, ok := block.(map[string]any) if !ok { continue } if text, ok := bm["text"].(string); ok { parts = append(parts, text) } } return strings.Join(parts, "") default: return "" } } // openAIMessagesFromAnthropic maps one Anthropic message ({"role", // "content"}, content either a plain string or a content-block array) to // one or more OpenAI messages, preserving the ORIGINAL block order (item // 3 fix, 2026-08-22 review): a plain string passes through unchanged. For // a block array, text/image/tool_use blocks accumulate into one pending // {role, content, tool_calls?} message; each tool_result block flushes // that pending message (if it has anything to flush) and then emits its // own role:"tool" message, in the SAME relative position the tool_result // held in the original array. Any trailing accumulated content flushes at // the end. // // This ordering matters beyond cosmetics: OpenAI requires a role:"tool" // message to directly follow the assistant message carrying the // tool_calls it answers, with nothing in between. The PREVIOUS version of // this function always emitted every tool_result AFTER the combined // content/tool_calls message regardless of where it actually appeared in // the original block array — correct for the common shape (a user turn // that is pure tool_results, following an assistant's pure tool_use // turn), but wrong for a legal Anthropic user turn shaped [tool_result, // text]: the old code emitted [user(text), tool(...)], inserting a // non-tool message between an assistant's tool_calls and its answer and // getting a 400 from OpenAI ("messages with role 'tool' must be a // response to a preceding message with 'tool_calls'"). Preserving // original order fixes both shapes without special-casing either. 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 out []any var pendingContent []any var pendingToolCalls []any flushPending := func() { if len(pendingContent) == 0 && len(pendingToolCalls) == 0 { return } m := map[string]any{"role": role} if len(pendingContent) > 0 { m["content"] = pendingContent } else { m["content"] = nil } if len(pendingToolCalls) > 0 { m["tool_calls"] = pendingToolCalls } out = append(out, m) pendingContent = nil pendingToolCalls = nil } 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) pendingContent = append(pendingContent, map[string]any{"type": "text", "text": text}) case "image": part, err := openAIContentPartFromAnthropicImage(bm) if err != nil { return nil, err } pendingContent = append(pendingContent, part) case "tool_use": tc, err := openAIToolCallFromAnthropic(bm) if err != nil { return nil, err } pendingToolCalls = append(pendingToolCalls, tc) case "tool_result": flushPending() out = append(out, openAIToolMessageFromAnthropic(bm)) } } flushPending() 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 } } // openAIReasoningModelPrefixes are OpenAI (and OpenAI-compatible) model // id prefixes that reject the "max_tokens" request field with a 400 and // require "max_completion_tokens" instead (item 10 fix, 2026-08-22 // review): the o-series reasoning models and the gpt-5 family. Matched // against the resolved upstream model id, case-sensitively, the same // case OpenAI's own ids use. var openAIReasoningModelPrefixes = []string{"o1", "o3", "o4", "gpt-5"} // isOpenAIReasoningModel reports whether model is one of the model // families openAIRequestFromAnthropic (below) must emit // "max_completion_tokens" for instead of "max_tokens" — see // openAIReasoningModelPrefixes' own doc comment. model may carry a // "provider/" prefix; this only needs a prefix match on the bare id // portion after it. func isOpenAIReasoningModel(model string) bool { if _, rest, ok := strings.Cut(model, "/"); ok { model = rest } for _, prefix := range openAIReasoningModelPrefixes { if strings.HasPrefix(model, prefix) { return true } } return false } // 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). // // The second return value lists request fields this translator // recognizes but has no OpenAI equivalent for and therefore dropped // (item 11 fix, 2026-08-22 review) — currently only "thinking" // (Anthropic's extended-thinking/reasoning-budget config). The caller // (routes_messages.go's callTranslatedMessages) logs a warning per // dropped field rather than this function logging directly: every other // function in this file is a pure, Gateway-free translator, callable and // testable without a *Gateway instance, and this keeps it that way. func openAIRequestFromAnthropic(req map[string]any) (map[string]any, []string, error) { out := map[string]any{} modelStr, _ := req["model"].(string) if modelStr != "" { out["model"] = modelStr } msgs := make([]any, 0, 1) if sys, ok := req["system"]; ok { text, err := anthropicSystemToText(sys) if err != nil { return nil, 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, nil, err } msgs = append(msgs, converted...) } out["messages"] = msgs if v, ok := req["max_tokens"]; ok { if n, ok2 := toInt64(v); ok2 { // Reasoning models (o1/o3/o4/gpt-5 family) reject "max_tokens" // with a 400 and require "max_completion_tokens" instead // (item 10 fix, 2026-08-22 review) — isOpenAIReasoningModel's // own doc comment has the model-family list. if isOpenAIReasoningModel(modelStr) { out["max_completion_tokens"] = n } else { 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 } } var dropped []string if _, ok := req["thinking"]; ok { dropped = append(dropped, "thinking") } return out, dropped, 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 }