mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
Billing: - Passthrough SSE streams now bill the usage the upstream reports. The gateway falls back to a request-size estimate only on a 2xx stream or a truncated JSON body. - A unified stream that fails after it starts is estimated, not billed at zero. - Billing uses the built-in LiteLLM price table and honours modelMeta free: true. - Pricing entries are validated. Cost arithmetic rounds the total and saturates on overflow. - Gemini thinking tokens count as completion tokens. Limits and storage: - A rejected request rolls back its counts on the other scopes, so one user cannot lock out a group. - Redis EXPIRE replies are checked. Keys recreated without a TTL are repaired at once. - A mutating pipeline is not retried after reply bytes arrive. - History, model totals and target usage report a store outage. Routing and translation: - Discovered ids with a provider-like prefix are listed only in owner/id form, so listing and routing agree. - Anthropic and Gemini discovery follows pagination. - The developer role maps to system. - stream_options.include_usage is honoured on translated streams. - tool_choice none keeps tools when history holds tool blocks. - refusal maps to content_filter. Data URI parameters are stripped. Streaming, retry and failover: - SSE accepts CR, LF and CRLF line ends. This closes a CR injection path. - Retry-After is never jittered. A cancelled wait returns the context error. - The watchdog timer stops at end of body. - Oversized bodies return 413. - A failed stream ends with an error event. - Failover restores pre-existing headers. Client cancels do not count as provider successes. Also: "admin" is a reserved provider name. metrics.path must start with "/". Pricing warnings go to the right gateway instance. The admin targets view sends access as null or [] so the two states differ. Upstream organisation headers are stripped. The users-file reload error is logged once per error. Dead code is removed, and test-only helpers move to test files. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
1513 lines
56 KiB
Go
1513 lines
56 KiB
Go
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:<media-type>;base64,<data>" 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"}
|
|
}
|
|
// L2 fix: media_type is everything before the FIRST ";" after
|
|
// "data:" — any further parameters are stripped rather than left
|
|
// attached, mirroring geminiInlineDataFromDataURI's identical parsing
|
|
// (translate_gemini.go) and reusing its dataURIHasBase64Param helper.
|
|
// The previous strings.CutSuffix(header, ";base64") only stripped a
|
|
// trailing ";base64" token, so a header carrying an earlier parameter
|
|
// too ("image/png;charset=x;base64") left media_type as
|
|
// "image/png;charset=x" — a value Anthropic rejects.
|
|
mediaType, rawParams, _ := strings.Cut(header, ";")
|
|
if !dataURIHasBase64Param(rawParams) {
|
|
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" should be dropped from
|
|
// the request. tool_choice:"none" maps to Anthropic's own
|
|
// {"type":"none"} (M2 fix — Anthropic does support it) and requests
|
|
// "tools" be dropped by default; the caller (anthropicRequestFromOpenAI)
|
|
// overrides that back to false when the conversation history carries
|
|
// tool_use/tool_result blocks, since Anthropic requires the tools those
|
|
// blocks reference to still be defined even while tool_choice forbids
|
|
// calling them again.
|
|
func anthropicToolChoiceFromOpenAI(tc any) (choice map[string]any, dropTools bool) {
|
|
switch t := tc.(type) {
|
|
case string:
|
|
switch t {
|
|
case "none":
|
|
return map[string]any{"type": "none"}, 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
|
|
// historyHasToolBlocks (M2 fix) latches true when any message in this
|
|
// request carries a tool_use/tool_result-shaped block once translated
|
|
// — an assistant message with tool_calls, or any role:"tool" message.
|
|
// anthropicRequestFromOpenAI reads this after the loop to decide
|
|
// whether tool_choice:"none" may still drop "tools" from the outgoing
|
|
// request; see anthropicToolChoiceFromOpenAI's doc comment.
|
|
historyHasToolBlocks := false
|
|
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", "developer":
|
|
// "developer" is the OpenAI o-series/gpt-5 replacement for
|
|
// "system" — Anthropic has no separate developer role, so it
|
|
// folds into the same top-level "system" text field.
|
|
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()
|
|
if role == "assistant" {
|
|
if tc, _ := msg["tool_calls"].([]any); len(tc) > 0 {
|
|
historyHasToolBlocks = true
|
|
}
|
|
}
|
|
am, err := anthropicMessageFromOpenAI(msg, role)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
anthMsgs = append(anthMsgs, am)
|
|
case "tool":
|
|
historyHasToolBlocks = true
|
|
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)
|
|
// M2 fix: tool_choice:"none" only drops "tools" when the history
|
|
// has no tool_use/tool_result blocks of its own — see
|
|
// anthropicToolChoiceFromOpenAI's doc comment.
|
|
if dropTools && historyHasToolBlocks {
|
|
dropTools = false
|
|
}
|
|
// M2 fix (review round 4): tool_choice must be omitted too,
|
|
// whenever tools end up dropped — Anthropic's API rejects a
|
|
// tool_choice sent without "tools" in the same request. This
|
|
// restores the pre-M2 wire shape (both fields absent) for the
|
|
// common case, a "none" request whose history never used a
|
|
// tool; {"type":"none"} is sent — alongside "tools", never
|
|
// without it — only when the history's own tool_use/tool_result
|
|
// blocks forced dropTools back to false just above.
|
|
if choice != nil && !dropTools {
|
|
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",
|
|
// refusal -> "content_filter" (L1 fix). end_turn, stop_sequence,
|
|
// pause_turn, and any unrecognized value all default 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"
|
|
case "refusal":
|
|
// L1 fix: Anthropic's "refusal" stop_reason (the model declined to
|
|
// continue, e.g. a safety refusal) has no dedicated OpenAI
|
|
// finish_reason — "content_filter" is the closest existing one and
|
|
// keeps a refusal from being reported as an ordinary successful
|
|
// completion.
|
|
return "content_filter"
|
|
default:
|
|
// "pause_turn" (a long-running server-side tool call paused, not
|
|
// finished) falls through here along with "end_turn"/
|
|
// "stop_sequence" and anything unrecognized — left as "stop" per
|
|
// review: there is no OpenAI finish_reason that means "paused, the
|
|
// client is expected to continue the turn", so mapping it to
|
|
// anything more specific than "stop" would not be a clear
|
|
// improvement.
|
|
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
|
|
}
|
|
|
|
// usageChunk builds the final usage-only "chat.completion.chunk" payload —
|
|
// empty "choices" plus a populated "usage" — mirroring the shape
|
|
// provider_openai.go's forwardStream relays from a native OpenAI upstream
|
|
// when stream_options.include_usage is set (M1 fix). Anthropic's own
|
|
// stream has no equivalent chunk, so the adapter calls this once, after
|
|
// the stream ends, only when the client asked for usage; st.u already
|
|
// holds the message_start/message_delta totals by then.
|
|
func (st *anthropicStreamState) usageChunk() []byte {
|
|
b, err := json.Marshal(map[string]any{
|
|
"id": chatCompletionIDPrefix + st.id,
|
|
"object": "chat.completion.chunk",
|
|
"created": st.created,
|
|
"model": st.model,
|
|
"choices": []any{},
|
|
"usage": map[string]any{
|
|
"prompt_tokens": st.u.prompt,
|
|
"completion_tokens": st.u.completion,
|
|
"total_tokens": st.u.total(),
|
|
},
|
|
})
|
|
if err != nil {
|
|
// Built entirely from this file's own map[string]any/string/int64
|
|
// literals plus st.u's int64 fields — never a value that can fail
|
|
// to marshal.
|
|
panic(fmt.Sprintf("llmgateway: anthropic stream usage 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.
|
|
//
|
|
// The second return value names every content-block "type" this function
|
|
// saw but has no OpenAI equivalent for — "document", "thinking", and
|
|
// "redacted_thinking" (L9 fix) — one entry per block TYPE encountered in
|
|
// this message, matching the granularity openAIRequestFromAnthropic's own
|
|
// "thinking"/"top_k" request-field checks use; the caller dedupes across
|
|
// a whole request's messages before logging.
|
|
func openAIMessagesFromAnthropic(role string, content any) ([]any, []string, error) {
|
|
if s, ok := content.(string); ok {
|
|
return []any{map[string]any{"role": role, "content": s}}, nil, 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, nil
|
|
}
|
|
|
|
var out []any
|
|
var dropped []string
|
|
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, nil, err
|
|
}
|
|
pendingContent = append(pendingContent, part)
|
|
case "tool_use":
|
|
tc, err := openAIToolCallFromAnthropic(bm)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
pendingToolCalls = append(pendingToolCalls, tc)
|
|
case "tool_result":
|
|
flushPending()
|
|
out = append(out, openAIToolMessageFromAnthropic(bm))
|
|
default:
|
|
// L9 fix: "document", "thinking", "redacted_thinking", and any
|
|
// other block type this translator does not recognize — dropped
|
|
// silently before this fix, exactly like "thinking" at the
|
|
// request-field level was before item 11's own fix. bt=="" (no
|
|
// "type" field at all) is not reported: that is a malformed
|
|
// block, not a recognized-but-unsupported one.
|
|
if bt != "" {
|
|
dropped = append(dropped, bt)
|
|
}
|
|
}
|
|
}
|
|
flushPending()
|
|
return out, dropped, 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 (and, per L9's fix,
|
|
// content-block types) this translator recognizes but has no OpenAI
|
|
// equivalent for and therefore dropped (item 11 fix, 2026-08-22 review):
|
|
// the top-level "thinking" and "top_k" fields, plus any "document"/
|
|
// "thinking"/"redacted_thinking" content block openAIMessagesFromAnthropic
|
|
// encountered — deduplicated across every message in the request, so a
|
|
// transcript carrying ten thinking blocks still logs it once. The caller
|
|
// (routes_messages.go's callTranslatedMessages) logs a warning per
|
|
// dropped entry 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
|
|
}
|
|
|
|
// droppedSeen dedupes addDropped's calls (L9 fix): a block type
|
|
// appended once per message by openAIMessagesFromAnthropic, or a
|
|
// top-level field checked once below, must still log only once per
|
|
// request regardless of how many times it recurs.
|
|
var dropped []string
|
|
droppedSeen := map[string]bool{}
|
|
addDropped := func(name string) {
|
|
if !droppedSeen[name] {
|
|
droppedSeen[name] = true
|
|
dropped = append(dropped, name)
|
|
}
|
|
}
|
|
|
|
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, droppedBlocks, err := openAIMessagesFromAnthropic(role, msg["content"])
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
msgs = append(msgs, converted...)
|
|
for _, bt := range droppedBlocks {
|
|
addDropped(bt)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
// L10 fix: no "stream" copy here — routes_messages.go's
|
|
// isStreamingRequested check (its own doc comment) rejects any
|
|
// streaming /v1/messages request with a 400 before
|
|
// openAIRequestFromAnthropic ever runs, so req["stream"] reaching
|
|
// this point can only be absent, false, or null. Copying it through
|
|
// was dead code with no observable effect (the downstream adapter's
|
|
// own `streaming, _ := req["stream"].(bool)` treats all three
|
|
// identically to omitting the field outright) — removed rather than
|
|
// left in place, per this repo's dead-code-hygiene rule.
|
|
|
|
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
|
|
} else if tm, ok := tc.(map[string]any); ok && tm["type"] == "none" {
|
|
// L9 fix: Anthropic's tool_choice {"type":"none"} has no
|
|
// OpenAI equivalent (openAIToolChoiceFromAnthropic's own doc
|
|
// comment) and was silently dropped — reported the same way
|
|
// "thinking"/"top_k" are, rather than left the one dropped
|
|
// field with no warning.
|
|
addDropped("tool_choice:none")
|
|
}
|
|
}
|
|
|
|
if meta, ok := req["metadata"].(map[string]any); ok {
|
|
if uid, ok := meta["user_id"]; ok {
|
|
out["user"] = uid
|
|
}
|
|
}
|
|
|
|
if _, ok := req["thinking"]; ok {
|
|
addDropped("thinking")
|
|
}
|
|
// L9 fix: "top_k" was silently dropped, unlike "thinking", which
|
|
// already had this same reporting since item 11 (2026-08-22 review) —
|
|
// there is no OpenAI equivalent for Anthropic's top_k sampling
|
|
// parameter, so it is reported the identical way.
|
|
if _, ok := req["top_k"]; ok {
|
|
addDropped("top_k")
|
|
}
|
|
|
|
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
|
|
}
|