mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
Preserve usage when response translation fails after a successful, billed upstream call. The route previously discarded already-billed tokens on that path. A client could induce unlimited free usage against any budget via a truncated tool-call response. Reject a streaming request with 400 instead of 501. Check it before the rate limiter runs. A 501 made anthropic-sdk-python retry the request, since it retries any status of 500 or higher. Each retry also counted against the caller's rate limit before this fix. Preserve block order when translating an Anthropic message to OpenAI's shape. A tool_result block before other content must stay before it. OpenAI otherwise rejects the translated conversation. Echo the client's requested model alias in both the passthrough and the translated response paths. The passthrough branch previously returned the upstream's own model id instead. Forward the anthropic-beta header to an anthropic-type upstream through an explicit allowlist. Never forward Authorization, x-api-key, Cookie, or hop-by-hop headers. Let the adapter's own credential injection win regardless. Bill Anthropic's prompt-cache tokens. anthropicUsagePayload now reads cache_creation_input_tokens and cache_read_input_tokens too. It folds all three into the billed prompt count. This applies to this route, the existing outbound Anthropic path, and the native passthrough route. Extract the metered-request pipeline shared by the unified route and this route into runMeteredCall. Thread an envelope writer through it so each route answers in its own error shape. This removes the duplication the original brief warned against. It also lets the rate-limit and body-admission error paths answer in this route's own shape. Treat a non-boolean "stream" value as a streaming request. A string or number value previously bypassed the streaming check silently. Emit max_completion_tokens instead of max_tokens for OpenAI's reasoning models. Those models reject max_tokens with a 400. Preserve tool_result.is_error by prefixing string content with "Error: ". OpenAI's tool message carries no error field of its own. Log a warning when the thinking field is dropped instead of discarding it silently. Classify a response-translation failure distinctly in the log. An operator must never see a genuine translation bug reported as an upstream connection error. Document the route in README.md. Cover both auth headers, the non-streaming limitation, and which providers translate versus pass through. Add the reviewer's yaegi-check probes to tools/yaegi-check/main.go, adjusted for the fixes above.
1350 lines
48 KiB
Go
1350 lines
48 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"}
|
|
}
|
|
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 fix,
|
|
// 2026-08-22 review) has no native OpenAI equivalent — a tool message
|
|
// carries no error flag — so it is preserved via prefixErrorMarker
|
|
// instead of being silently dropped: the previous version of this
|
|
// function read only tool_use_id/content and discarded is_error
|
|
// entirely, losing the failure signal for any OpenAI-backed agentic loop
|
|
// that branches on whether a tool call succeeded.
|
|
func openAIToolMessageFromAnthropic(bm map[string]any) map[string]any {
|
|
toolUseID, _ := bm["tool_use_id"].(string)
|
|
content := bm["content"]
|
|
if isErr, _ := bm["is_error"].(bool); isErr {
|
|
content = prefixErrorMarker(content)
|
|
}
|
|
return map[string]any{"role": "tool", "tool_call_id": toolUseID, "content": content}
|
|
}
|
|
|
|
// prefixErrorMarker preserves Anthropic's tool_result.is_error signal
|
|
// across the translation to OpenAI's tool message shape (item 11 fix,
|
|
// 2026-08-22 review): a string content gets an "Error: " prefix so the
|
|
// model still sees the failure signal in the one shape OpenAI's tool
|
|
// message actually carries. Any other content shape (a block array, or
|
|
// absent) is left as-is rather than guessing how to annotate it —
|
|
// OpenAI-compatible upstreams generally infer failure from content
|
|
// itself in that case, same as before this fix; only the common
|
|
// string-content case previously lost the signal entirely.
|
|
func prefixErrorMarker(content any) any {
|
|
if s, ok := content.(string); ok {
|
|
return "Error: " + s
|
|
}
|
|
return content
|
|
}
|
|
|
|
// 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
|
|
}
|