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
1130 lines
45 KiB
Go
1130 lines
45 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// goldenTestdataDir is where every fixture this file walks lives.
|
|
const goldenTestdataDir = "testdata/anthropic"
|
|
|
|
// goldenGatewayModel and goldenCreated are the fixed model/created values
|
|
// every response- and stream-translation golden fixture's "_want.json"
|
|
// bakes in — openAIResponseFromAnthropic and newAnthropicStreamState both
|
|
// take these as caller-supplied parameters (Anthropic's own wire formats
|
|
// carry neither), so the golden tests must supply the same fixed values
|
|
// the "_want.json" files were written against.
|
|
const (
|
|
goldenGatewayModel = "gw-anthropic-model"
|
|
goldenCreated = int64(1734000000)
|
|
)
|
|
|
|
// normalizeJSON round-trips b through json.Unmarshal into a generic
|
|
// map[string]any (or []any), so a comparison via reflect.DeepEqual is not
|
|
// tripped up by Go-native numeric types (int64, e.g.) that differ from
|
|
// the float64 every JSON number decodes to — both v and w.json.Unmarshal
|
|
// output use the same generic decode, so both land on float64 either way.
|
|
func normalizeJSON(t *testing.T, v any) any {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("normalizeJSON: marshal: %v", err)
|
|
}
|
|
var out any
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
t.Fatalf("normalizeJSON: unmarshal: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// readGoldenJSON reads and json.Unmarshals name (relative to
|
|
// testdata/anthropic) into a fresh map[string]any.
|
|
func readGoldenJSON(t *testing.T, name string) map[string]any {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(goldenTestdataDir, name))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", name, err)
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
t.Fatalf("decode %s: %v", name, err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// wantErrFixture is the shape a "{name}_wanterr.json" fixture decodes
|
|
// into: a substring the produced *translateError.Error() must contain,
|
|
// and whether it must set notSupported.
|
|
type wantErrFixture struct {
|
|
ErrContains string `json:"errContains"`
|
|
NotSupported bool `json:"notSupported"`
|
|
}
|
|
|
|
// assertTranslateError asserts err is a *translateError matching want.
|
|
func assertTranslateError(t *testing.T, err error, want wantErrFixture) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatalf("want a *translateError containing %q, got nil", want.ErrContains)
|
|
}
|
|
var terr *translateError
|
|
if !errors.As(err, &terr) {
|
|
t.Fatalf("err = %v (%T), want *translateError", err, err)
|
|
}
|
|
if !strings.Contains(terr.Error(), want.ErrContains) {
|
|
t.Errorf("error message = %q, want it to contain %q", terr.Error(), want.ErrContains)
|
|
}
|
|
if terr.notSupported != want.NotSupported {
|
|
t.Errorf("notSupported = %v, want %v", terr.notSupported, want.NotSupported)
|
|
}
|
|
}
|
|
|
|
// TestAnthropicRequestGolden walks every "chat_*_in.json" fixture under
|
|
// testdata/anthropic, translating it via anthropicRequestFromOpenAI, and
|
|
// asserts the result against the sibling "_want.json" fixture (a
|
|
// successful translation) or "_wanterr.json" fixture (a *translateError),
|
|
// whichever is present — exactly one of the two exists per fixture.
|
|
func TestAnthropicRequestGolden(t *testing.T) {
|
|
entries, err := os.ReadDir(goldenTestdataDir)
|
|
if err != nil {
|
|
t.Fatalf("read testdata dir: %v", err)
|
|
}
|
|
|
|
var names []string
|
|
for _, e := range entries {
|
|
if n, ok := strings.CutSuffix(e.Name(), "_in.json"); ok && strings.HasPrefix(n, "chat_") {
|
|
names = append(names, n)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
if len(names) == 0 {
|
|
t.Fatalf("no chat_*_in.json fixtures found under %s", goldenTestdataDir)
|
|
}
|
|
|
|
for _, name := range names {
|
|
t.Run(name, func(t *testing.T) {
|
|
in := readGoldenJSON(t, name+"_in.json")
|
|
|
|
got, err := anthropicRequestFromOpenAI(in)
|
|
|
|
wantErrPath := filepath.Join(goldenTestdataDir, name+"_wanterr.json")
|
|
if _, statErr := os.Stat(wantErrPath); statErr == nil {
|
|
var want wantErrFixture
|
|
b, rerr := os.ReadFile(wantErrPath)
|
|
if rerr != nil {
|
|
t.Fatalf("read %s: %v", wantErrPath, rerr)
|
|
}
|
|
if uerr := json.Unmarshal(b, &want); uerr != nil {
|
|
t.Fatalf("decode %s: %v", wantErrPath, uerr)
|
|
}
|
|
assertTranslateError(t, err, want)
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("anthropicRequestFromOpenAI: %v", err)
|
|
}
|
|
want := readGoldenJSON(t, name+"_want.json")
|
|
gotN := normalizeJSON(t, got)
|
|
wantN := normalizeJSON(t, want)
|
|
if !reflect.DeepEqual(gotN, wantN) {
|
|
gotB, _ := json.MarshalIndent(gotN, "", " ")
|
|
wantB, _ := json.MarshalIndent(wantN, "", " ")
|
|
t.Errorf("anthropicRequestFromOpenAI(%s) mismatch:\ngot:\n%s\nwant:\n%s", name, gotB, wantB)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAnthropicResponseGolden walks every "resp_*_in.json" fixture,
|
|
// translating it via openAIResponseFromAnthropic with the fixed
|
|
// goldenGatewayModel/goldenCreated, and asserts the result against the
|
|
// sibling "_want.json" fixture.
|
|
func TestAnthropicResponseGolden(t *testing.T) {
|
|
entries, err := os.ReadDir(goldenTestdataDir)
|
|
if err != nil {
|
|
t.Fatalf("read testdata dir: %v", err)
|
|
}
|
|
|
|
var names []string
|
|
for _, e := range entries {
|
|
if n, ok := strings.CutSuffix(e.Name(), "_in.json"); ok && strings.HasPrefix(n, "resp_") {
|
|
names = append(names, n)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
if len(names) == 0 {
|
|
t.Fatalf("no resp_*_in.json fixtures found under %s", goldenTestdataDir)
|
|
}
|
|
|
|
for _, name := range names {
|
|
t.Run(name, func(t *testing.T) {
|
|
body, err := os.ReadFile(filepath.Join(goldenTestdataDir, name+"_in.json"))
|
|
if err != nil {
|
|
t.Fatalf("read %s_in.json: %v", name, err)
|
|
}
|
|
|
|
got, _, err := openAIResponseFromAnthropic(body, goldenGatewayModel, goldenCreated)
|
|
if err != nil {
|
|
t.Fatalf("openAIResponseFromAnthropic: %v", err)
|
|
}
|
|
|
|
want := readGoldenJSON(t, name+"_want.json")
|
|
gotN := normalizeJSON(t, got)
|
|
wantN := normalizeJSON(t, want)
|
|
if !reflect.DeepEqual(gotN, wantN) {
|
|
gotB, _ := json.MarshalIndent(gotN, "", " ")
|
|
wantB, _ := json.MarshalIndent(wantN, "", " ")
|
|
t.Errorf("openAIResponseFromAnthropic(%s) mismatch:\ngot:\n%s\nwant:\n%s", name, gotB, wantB)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// streamUsageFixture is the shape a "{name}_usage.json" fixture decodes
|
|
// into: the prompt/completion totals anthropicStreamState.usage() must
|
|
// report once every event in the sibling ".sse" fixture has been
|
|
// translated.
|
|
type streamUsageFixture struct {
|
|
Prompt int64 `json:"prompt"`
|
|
Completion int64 `json:"completion"`
|
|
}
|
|
|
|
// runStreamGolden translates every event in testdata/anthropic/{name}.sse
|
|
// through a fresh anthropicStreamState, in order, and asserts the
|
|
// flattened list of emitted chunks against "{name}_want.json" and the
|
|
// final usage() against "{name}_usage.json".
|
|
func runStreamGolden(t *testing.T, name string) {
|
|
t.Helper()
|
|
|
|
f, err := os.Open(filepath.Join(goldenTestdataDir, name+".sse"))
|
|
if err != nil {
|
|
t.Fatalf("open %s.sse: %v", name, err)
|
|
}
|
|
defer f.Close() //nolint:errcheck
|
|
|
|
st := newAnthropicStreamState(goldenGatewayModel, goldenCreated)
|
|
var got []any
|
|
terminalErr := readSSE(f, func(ev sseEvent) error {
|
|
chunks, terr := st.translate(ev)
|
|
for _, c := range chunks {
|
|
var v any
|
|
if uerr := json.Unmarshal(c, &v); uerr != nil {
|
|
t.Fatalf("translate(%s) produced invalid JSON: %v (%s)", ev.event, uerr, c)
|
|
}
|
|
got = append(got, v)
|
|
}
|
|
return terr
|
|
})
|
|
if terminalErr != nil {
|
|
t.Fatalf("readSSE/translate(%s): %v", name, terminalErr)
|
|
}
|
|
|
|
wantBytes, err := os.ReadFile(filepath.Join(goldenTestdataDir, name+"_want.json"))
|
|
if err != nil {
|
|
t.Fatalf("read %s_want.json: %v", name, err)
|
|
}
|
|
var want []any
|
|
if uerr := json.Unmarshal(wantBytes, &want); uerr != nil {
|
|
t.Fatalf("decode %s_want.json: %v", name, uerr)
|
|
}
|
|
|
|
if !reflect.DeepEqual(got, want) {
|
|
gotB, _ := json.MarshalIndent(got, "", " ")
|
|
wantB, _ := json.MarshalIndent(want, "", " ")
|
|
t.Errorf("translate(%s) chunk list mismatch:\ngot:\n%s\nwant:\n%s", name, gotB, wantB)
|
|
}
|
|
|
|
usageBytes, err := os.ReadFile(filepath.Join(goldenTestdataDir, name+"_usage.json"))
|
|
if err != nil {
|
|
t.Fatalf("read %s_usage.json: %v", name, err)
|
|
}
|
|
var wantUsage streamUsageFixture
|
|
if err := json.Unmarshal(usageBytes, &wantUsage); err != nil {
|
|
t.Fatalf("decode %s_usage.json: %v", name, err)
|
|
}
|
|
if gotUsage := st.usage(); gotUsage.prompt != wantUsage.Prompt || gotUsage.completion != wantUsage.Completion {
|
|
t.Errorf("usage() = %+v, want {prompt:%d completion:%d}", gotUsage, wantUsage.Prompt, wantUsage.Completion)
|
|
}
|
|
}
|
|
|
|
// TestAnthropicStreamGolden_Text covers a text-only stream: role chunk,
|
|
// two content deltas, a finish chunk mapped from end_turn, with a ping
|
|
// and a content_block_start/stop interspersed that must produce nothing.
|
|
func TestAnthropicStreamGolden_Text(t *testing.T) {
|
|
runStreamGolden(t, "stream_text")
|
|
}
|
|
|
|
// TestAnthropicStreamGolden_ToolUse covers a tool_use stream: role chunk,
|
|
// a tool_calls-start chunk, two input_json_delta argument fragments, and
|
|
// a finish chunk mapped from tool_use.
|
|
func TestAnthropicStreamGolden_ToolUse(t *testing.T) {
|
|
runStreamGolden(t, "stream_tooluse")
|
|
}
|
|
|
|
// TestAnthropicStreamGolden_TextThenTool covers a text block followed by
|
|
// a tool_use block (Anthropic content-block index 0 then 1) and proves
|
|
// the emitted tool_calls[0].index is the 0-based tool-call ordinal (0),
|
|
// not the raw Anthropic content-block index (1) — review fix: OpenAI's
|
|
// contract requires tool_calls[].index to be contiguous from 0 over the
|
|
// tool-call array, regardless of how many non-tool_use blocks preceded it.
|
|
func TestAnthropicStreamGolden_TextThenTool(t *testing.T) {
|
|
runStreamGolden(t, "stream_text_then_tool")
|
|
}
|
|
|
|
// TestAnthropicStreamState_ErrorEvent proves an "error" SSE event
|
|
// translates to one OpenAI-shaped {"error":{...}} chunk and a non-nil
|
|
// terminal error, per the stream mapping table's error row — built
|
|
// inline rather than as a golden fixture since it is the one case
|
|
// runStreamGolden's readSSE-drives-to-completion harness cannot express
|
|
// (the whole point is that translation stops here).
|
|
func TestAnthropicStreamState_ErrorEvent(t *testing.T) {
|
|
st := newAnthropicStreamState(goldenGatewayModel, goldenCreated)
|
|
ev := sseEvent{
|
|
event: "error",
|
|
data: []byte(`{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`),
|
|
}
|
|
|
|
chunks, err := st.translate(ev)
|
|
if err == nil {
|
|
t.Fatalf("translate(error event): want a terminal error, got nil")
|
|
}
|
|
if len(chunks) != 1 {
|
|
t.Fatalf("translate(error event) returned %d chunks, want 1", len(chunks))
|
|
}
|
|
|
|
var payload map[string]any
|
|
if uerr := json.Unmarshal(chunks[0], &payload); uerr != nil {
|
|
t.Fatalf("emitted error chunk is not valid JSON: %v (%s)", uerr, chunks[0])
|
|
}
|
|
errObj, ok := payload["error"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("emitted error chunk missing \"error\" object: %s", chunks[0])
|
|
}
|
|
if errObj["message"] != "Overloaded" {
|
|
t.Errorf("error.message = %v, want %q", errObj["message"], "Overloaded")
|
|
}
|
|
if errObj["type"] != "overloaded_error" {
|
|
t.Errorf("error.type = %v, want %q", errObj["type"], "overloaded_error")
|
|
}
|
|
}
|
|
|
|
// TestAnthropicStreamState_MalformedEventReturnsError proves a malformed
|
|
// (invalid-JSON) data payload on each recognized event type returns an
|
|
// error wrapping errUpstream, rather than being swallowed as (nil, nil) —
|
|
// review fix: a dropped input_json_delta would otherwise silently corrupt
|
|
// the client's reassembled tool-call arguments. The "error" event type
|
|
// itself already returned an error before this fix and is covered by
|
|
// TestAnthropicStreamState_ErrorEvent, not repeated here.
|
|
func TestAnthropicStreamState_MalformedEventReturnsError(t *testing.T) {
|
|
const corrupt = `{"type":"message_start","message":{`
|
|
|
|
for _, event := range []string{"message_start", "content_block_start", "content_block_delta", "message_delta"} {
|
|
t.Run(event, func(t *testing.T) {
|
|
st := newAnthropicStreamState(goldenGatewayModel, goldenCreated)
|
|
chunks, err := st.translate(sseEvent{event: event, data: []byte(corrupt)})
|
|
if err == nil {
|
|
t.Fatalf("translate(%s, corrupt data): want error, got nil (chunks=%v)", event, chunks)
|
|
}
|
|
if !errors.Is(err, errUpstream) {
|
|
t.Errorf("translate(%s, corrupt data): err = %v, want errors.Is(err, errUpstream)", event, err)
|
|
}
|
|
if len(chunks) != 0 {
|
|
t.Errorf("translate(%s, corrupt data): chunks = %v, want none", event, chunks)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAnthropicListModels_404NotSpeciallyHandled proves listModels
|
|
// returns a plain *providerHTTPError on a 404 (an older Anthropic API
|
|
// version that predates the Models endpoint) rather than any special
|
|
// fallback — the registry, not this adapter, decides what to do with a
|
|
// provider's explicitly configured models on a listModels failure.
|
|
func TestAnthropicListModels_404NotSpeciallyHandled(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":{"message":"not found"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
a, err := newAnthropicAdapter("p1", srv.URL, "sk-test")
|
|
if err != nil {
|
|
t.Fatalf("newAnthropicAdapter: %v", err)
|
|
}
|
|
|
|
_, err = a.listModels(context.Background())
|
|
if err == nil {
|
|
t.Fatalf("listModels: want error on 404, got nil")
|
|
}
|
|
var httpErr *providerHTTPError
|
|
if !errors.As(err, &httpErr) {
|
|
t.Fatalf("err = %v (%T), want *providerHTTPError", err, err)
|
|
}
|
|
if httpErr.status != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", httpErr.status)
|
|
}
|
|
}
|
|
|
|
// TestAnthropicAdapter_Embeddings_NotSupported proves embeddings never
|
|
// makes an upstream request and always returns a *translateError with
|
|
// notSupported set — the caller (Task 12) maps that to HTTP 501.
|
|
func TestAnthropicAdapter_Embeddings_NotSupported(t *testing.T) {
|
|
called := false
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
a, err := newAnthropicAdapter("p1", srv.URL, "sk-test")
|
|
if err != nil {
|
|
t.Fatalf("newAnthropicAdapter: %v", err)
|
|
}
|
|
|
|
_, err = a.embeddings(context.Background(), httptest.NewRecorder(), map[string]any{"model": "claude-opus-5", "input": "hi"})
|
|
var terr *translateError
|
|
if !errors.As(err, &terr) {
|
|
t.Fatalf("err = %v (%T), want *translateError", err, err)
|
|
}
|
|
if !terr.notSupported {
|
|
t.Errorf("notSupported = false, want true")
|
|
}
|
|
if called {
|
|
t.Errorf("embeddings made an upstream request, want none")
|
|
}
|
|
}
|
|
|
|
// TestNewAnthropicAdapter_KeylessIsAConstructorError proves ruling (c):
|
|
// an empty resolved API key is a constructor error for anthropic-type
|
|
// providers, unlike the openai-type keyless ruling.
|
|
func TestNewAnthropicAdapter_KeylessIsAConstructorError(t *testing.T) {
|
|
if _, err := newAnthropicAdapter("p1", "https://api.anthropic.com", ""); err == nil {
|
|
t.Fatalf("newAnthropicAdapter: want error for empty API key, got nil")
|
|
}
|
|
}
|
|
|
|
// TestAnthropicAdapter_InjectAuth proves injectAuth sets both the
|
|
// x-api-key and anthropic-version headers unconditionally.
|
|
func TestAnthropicAdapter_InjectAuth(t *testing.T) {
|
|
a, err := newAnthropicAdapter("p1", "https://api.anthropic.com", "sk-test")
|
|
if err != nil {
|
|
t.Fatalf("newAnthropicAdapter: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
|
a.injectAuth(req)
|
|
|
|
if got := req.Header.Get("x-api-key"); got != "sk-test" {
|
|
t.Errorf("x-api-key = %q, want %q", got, "sk-test")
|
|
}
|
|
if got := req.Header.Get("anthropic-version"); got != anthropicAPIVersion {
|
|
t.Errorf("anthropic-version = %q, want %q", got, anthropicAPIVersion)
|
|
}
|
|
}
|
|
|
|
// TestAnthropicRequestFromOpenAI_MaxCompletionTokensPrecedence proves
|
|
// max_completion_tokens takes precedence over max_tokens when an OpenAI
|
|
// request sends both — the newer field wins.
|
|
func TestAnthropicRequestFromOpenAI_MaxCompletionTokensPrecedence(t *testing.T) {
|
|
req := map[string]any{
|
|
"model": "claude-opus-5",
|
|
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
|
"max_tokens": float64(100),
|
|
"max_completion_tokens": float64(200),
|
|
}
|
|
got, err := anthropicRequestFromOpenAI(req)
|
|
if err != nil {
|
|
t.Fatalf("anthropicRequestFromOpenAI: %v", err)
|
|
}
|
|
if got["max_tokens"] != int64(200) {
|
|
t.Errorf("max_tokens = %v, want 200 (max_completion_tokens takes precedence)", got["max_tokens"])
|
|
}
|
|
}
|
|
|
|
// TestIsTruthy pins isTruthy's truthy-only semantics (per its own doc
|
|
// comment): only a present AND non-empty value counts as "the client
|
|
// actually asked for this" — nil, false, 0, "", and an empty map/slice
|
|
// must not trip a truthy-only unsupported-field check.
|
|
func TestIsTruthy(t *testing.T) {
|
|
cases := []struct {
|
|
v any
|
|
name string
|
|
want bool
|
|
}{
|
|
{name: "nil is falsy", v: nil, want: false},
|
|
{name: "bool true is truthy", v: true, want: true},
|
|
{name: "bool false is falsy", v: false, want: false},
|
|
{name: "nonzero float64 is truthy", v: float64(1.5), want: true},
|
|
{name: "zero float64 is falsy", v: float64(0), want: false},
|
|
{name: "nonempty string is truthy", v: "x", want: true},
|
|
{name: "empty string is falsy", v: "", want: false},
|
|
{name: "nonempty map is truthy", v: map[string]any{"a": 1}, want: true},
|
|
{name: "empty map is falsy", v: map[string]any{}, want: false},
|
|
{name: "nonempty slice is truthy", v: []any{1}, want: true},
|
|
{name: "empty slice is falsy", v: []any{}, want: false},
|
|
{name: "an unrecognized non-nil type is conservatively truthy", v: 42, want: true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
assert.Equal(t, c.want, isTruthy(c.v))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestToFloat64 covers every numeric shape toFloat64 accepts — the normal
|
|
// post-json.Unmarshal float64, plus the Go-native int/int64/float32 shapes
|
|
// a test or hand-built request can supply — and the fallback for anything
|
|
// else.
|
|
func TestToFloat64(t *testing.T) {
|
|
cases := []struct {
|
|
v any
|
|
name string
|
|
want float64
|
|
wantOK bool
|
|
}{
|
|
{name: "float64", v: float64(3.5), want: 3.5, wantOK: true},
|
|
{name: "float32", v: float32(2.5), want: 2.5, wantOK: true},
|
|
{name: "int", v: int(7), want: 7, wantOK: true},
|
|
{name: "int64", v: int64(9), want: 9, wantOK: true},
|
|
{name: "unsupported type returns ok=false", v: "7", want: 0, wantOK: false},
|
|
{name: "nil returns ok=false", v: nil, want: 0, wantOK: false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got, ok := toFloat64(c.v)
|
|
assert.Equal(t, c.wantOK, ok)
|
|
assert.Equal(t, c.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- openAIRequestFromAnthropic / anthropicResponseFromOpenAI: the
|
|
// reverse-direction translators routes_messages.go uses (/v1/messages
|
|
// resolved to a non-anthropic-type provider) ---
|
|
|
|
// TestOpenAIRequestFromAnthropic_ToolResultBecomesToolMessage proves an
|
|
// assistant tool_use block becomes an OpenAI tool_calls[] entry (with its
|
|
// decoded "input" re-marshaled to a JSON string, matching OpenAI's
|
|
// arguments field) and a following user tool_result block becomes its
|
|
// own separate role:"tool" message — the mirror of
|
|
// anthropicRequestFromOpenAI's pendingToolResults collapse in the other
|
|
// direction.
|
|
func TestOpenAIRequestFromAnthropic_ToolResultBecomesToolMessage(t *testing.T) {
|
|
req := map[string]any{
|
|
"model": "claude-x",
|
|
"messages": []any{
|
|
map[string]any{"role": "assistant", "content": []any{
|
|
map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{"city": "London"}},
|
|
}},
|
|
map[string]any{"role": "user", "content": []any{
|
|
map[string]any{"type": "tool_result", "tool_use_id": "call_1", "content": "sunny"},
|
|
}},
|
|
},
|
|
}
|
|
|
|
out, _, err := openAIRequestFromAnthropic(req)
|
|
if err != nil {
|
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
|
}
|
|
msgs, _ := out["messages"].([]any)
|
|
if len(msgs) != 2 {
|
|
t.Fatalf("messages = %v, want 2 (assistant tool_calls + tool result)", msgs)
|
|
}
|
|
assistant, _ := msgs[0].(map[string]any)
|
|
toolCalls, _ := assistant["tool_calls"].([]any)
|
|
if len(toolCalls) != 1 {
|
|
t.Fatalf("assistant tool_calls = %v, want 1 entry", assistant["tool_calls"])
|
|
}
|
|
tc, _ := toolCalls[0].(map[string]any)
|
|
fn, _ := tc["function"].(map[string]any)
|
|
assert.Equal(t, "get_weather", fn["name"])
|
|
assert.JSONEq(t, `{"city":"London"}`, fn["arguments"].(string))
|
|
|
|
toolMsg, _ := msgs[1].(map[string]any)
|
|
assert.Equal(t, "tool", toolMsg["role"])
|
|
assert.Equal(t, "call_1", toolMsg["tool_call_id"])
|
|
assert.Equal(t, "sunny", toolMsg["content"])
|
|
}
|
|
|
|
// TestOpenAIRequestFromAnthropic_SystemStringFoldedIntoMessages proves a
|
|
// plain-string Anthropic "system" field becomes a role:"system" message
|
|
// prepended ahead of the rest, and the Anthropic-only top-level "system"
|
|
// key never survives into the OpenAI-shaped output.
|
|
func TestOpenAIRequestFromAnthropic_SystemStringFoldedIntoMessages(t *testing.T) {
|
|
req := map[string]any{
|
|
"model": "claude-x",
|
|
"system": "Be terse.",
|
|
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
|
}
|
|
|
|
out, _, err := openAIRequestFromAnthropic(req)
|
|
if err != nil {
|
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
|
}
|
|
if _, ok := out["system"]; ok {
|
|
t.Error(`out carries a "system" key; Anthropic's system field has no OpenAI equivalent and must be folded into messages[]`)
|
|
}
|
|
msgs, _ := out["messages"].([]any)
|
|
if len(msgs) != 2 {
|
|
t.Fatalf("messages = %v, want 2 (system + user)", msgs)
|
|
}
|
|
sysMsg, _ := msgs[0].(map[string]any)
|
|
assert.Equal(t, "system", sysMsg["role"])
|
|
assert.Equal(t, "Be terse.", sysMsg["content"])
|
|
}
|
|
|
|
// TestAnthropicResponseFromOpenAI_ToolCallsBecomeToolUseBlocks proves an
|
|
// OpenAI tool_calls[] entry becomes an Anthropic tool_use content block
|
|
// with its arguments STRING decoded back to a structured "input" value,
|
|
// stop_reason maps tool_calls -> tool_use, and the caller-supplied model
|
|
// (the client's own requested alias) is echoed into the response.
|
|
func TestAnthropicResponseFromOpenAI_ToolCallsBecomeToolUseBlocks(t *testing.T) {
|
|
const body = `{"id":"chatcmpl-77","choices":[{"index":0,"message":{"content":null,"tool_calls":[{"id":"call_9","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":2}}`
|
|
|
|
out, err := anthropicResponseFromOpenAI([]byte(body), "aliased/model")
|
|
if err != nil {
|
|
t.Fatalf("anthropicResponseFromOpenAI: %v", err)
|
|
}
|
|
assert.Equal(t, "aliased/model", out["model"])
|
|
assert.Equal(t, "tool_use", out["stop_reason"])
|
|
|
|
content, _ := out["content"].([]any)
|
|
if len(content) != 1 {
|
|
t.Fatalf("content = %v, want one tool_use block", out["content"])
|
|
}
|
|
block, _ := content[0].(map[string]any)
|
|
assert.Equal(t, "tool_use", block["type"])
|
|
assert.Equal(t, "get_weather", block["name"])
|
|
assert.Equal(t, "call_9", block["id"])
|
|
input, _ := block["input"].(map[string]any)
|
|
assert.Equal(t, "Paris", input["city"])
|
|
|
|
usageMap, _ := out["usage"].(map[string]any)
|
|
assert.Equal(t, int64(3), usageMap["input_tokens"])
|
|
assert.Equal(t, int64(2), usageMap["output_tokens"])
|
|
}
|
|
|
|
// TestAnthropicResponseFromOpenAI_PlainTextResponse proves the common
|
|
// case: a plain-text OpenAI response becomes a single Anthropic text
|
|
// content block with stop_reason "stop" -> "end_turn".
|
|
func TestAnthropicResponseFromOpenAI_PlainTextResponse(t *testing.T) {
|
|
const body = `{"id":"chatcmpl-1","choices":[{"index":0,"message":{"content":"hi there"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}`
|
|
|
|
out, err := anthropicResponseFromOpenAI([]byte(body), "claude-x")
|
|
if err != nil {
|
|
t.Fatalf("anthropicResponseFromOpenAI: %v", err)
|
|
}
|
|
assert.Equal(t, "message", out["type"])
|
|
assert.Equal(t, "assistant", out["role"])
|
|
assert.Equal(t, "end_turn", out["stop_reason"])
|
|
content, _ := out["content"].([]any)
|
|
if len(content) != 1 {
|
|
t.Fatalf("content = %v, want one text block", out["content"])
|
|
}
|
|
block, _ := content[0].(map[string]any)
|
|
assert.Equal(t, "text", block["type"])
|
|
assert.Equal(t, "hi there", block["text"])
|
|
}
|
|
|
|
// TestOpenAIContentPartFromAnthropicImage_RejectsNonBase64Source proves an
|
|
// Anthropic image block whose source is not base64-encoded (a URL-sourced
|
|
// image, which this translator does not support) returns a
|
|
// *translateError rather than silently dropping or mistranslating it.
|
|
func TestOpenAIContentPartFromAnthropicImage_RejectsNonBase64Source(t *testing.T) {
|
|
_, err := openAIContentPartFromAnthropicImage(map[string]any{"source": map[string]any{"type": "url", "url": "https://example.com/x.png"}})
|
|
if err == nil {
|
|
t.Fatal("want an error for a non-base64 image source, got nil")
|
|
}
|
|
if _, ok := err.(*translateError); !ok {
|
|
t.Errorf("err = %T, want *translateError", err)
|
|
}
|
|
}
|
|
|
|
// TestOpenAIMessagesFromAnthropic_PreservesToolResultThenTextOrder is the
|
|
// regression for item 3 (IMPORTANT, 2026-08-22 review): a legal Anthropic
|
|
// user turn shaped [tool_result, text] must translate preserving that
|
|
// order — tool message first, then the content message — since 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 its
|
|
// original position, which turned this exact shape into
|
|
// [assistant(tool_calls), user(text), tool(...)] — an OpenAI 400, since
|
|
// user(text) sits between the tool_calls and its answer.
|
|
func TestOpenAIMessagesFromAnthropic_PreservesToolResultThenTextOrder(t *testing.T) {
|
|
content := []any{
|
|
map[string]any{"type": "tool_result", "tool_use_id": "call_1", "content": "sunny"},
|
|
map[string]any{"type": "text", "text": "thanks"},
|
|
}
|
|
|
|
out, _, err := openAIMessagesFromAnthropic("user", content)
|
|
if err != nil {
|
|
t.Fatalf("openAIMessagesFromAnthropic: %v", err)
|
|
}
|
|
if len(out) != 2 {
|
|
t.Fatalf("messages = %v, want 2 (tool result, then text)", out)
|
|
}
|
|
|
|
first, _ := out[0].(map[string]any)
|
|
if first["role"] != "tool" || first["tool_call_id"] != "call_1" {
|
|
t.Errorf("messages[0] = %v, want the tool_result message FIRST, matching its original position", first)
|
|
}
|
|
|
|
second, _ := out[1].(map[string]any)
|
|
if second["role"] != "user" {
|
|
t.Errorf("messages[1] = %v, want the text-content message SECOND", second)
|
|
}
|
|
parts, _ := second["content"].([]any)
|
|
if len(parts) != 1 {
|
|
t.Fatalf("messages[1].content = %v, want one text part", second["content"])
|
|
}
|
|
}
|
|
|
|
// TestOpenAIMessagesFromAnthropic_InterspersedToolResults proves the fix
|
|
// generalizes beyond one tool_result: [tool_result A, text, tool_result
|
|
// B] must translate to [tool(A), user(text), tool(B)] — each tool_result
|
|
// flushing whatever content/tool_calls had accumulated before it, not
|
|
// just the first one.
|
|
func TestOpenAIMessagesFromAnthropic_InterspersedToolResults(t *testing.T) {
|
|
content := []any{
|
|
map[string]any{"type": "tool_result", "tool_use_id": "call_A", "content": "a"},
|
|
map[string]any{"type": "text", "text": "middle"},
|
|
map[string]any{"type": "tool_result", "tool_use_id": "call_B", "content": "b"},
|
|
}
|
|
|
|
out, _, err := openAIMessagesFromAnthropic("user", content)
|
|
if err != nil {
|
|
t.Fatalf("openAIMessagesFromAnthropic: %v", err)
|
|
}
|
|
if len(out) != 3 {
|
|
t.Fatalf("messages = %v, want 3 (tool A, text, tool B)", out)
|
|
}
|
|
m0, _ := out[0].(map[string]any)
|
|
m1, _ := out[1].(map[string]any)
|
|
m2, _ := out[2].(map[string]any)
|
|
if m0["tool_call_id"] != "call_A" {
|
|
t.Errorf("messages[0].tool_call_id = %v, want call_A", m0["tool_call_id"])
|
|
}
|
|
if m1["role"] != "user" {
|
|
t.Errorf("messages[1].role = %v, want user (the interspersed text)", m1["role"])
|
|
}
|
|
if m2["tool_call_id"] != "call_B" {
|
|
t.Errorf("messages[2].tool_call_id = %v, want call_B", m2["tool_call_id"])
|
|
}
|
|
}
|
|
|
|
// TestOpenAIToolMessageFromAnthropic_PreservesIsError is the regression
|
|
// for item 11a and F4 (IMPORTANT/BLOCKING-adjacent, 2026-08-22/23
|
|
// review): Anthropic's tool_result.is_error has no native OpenAI
|
|
// tool-message field, so a prior version of this function silently
|
|
// dropped it — and a second prior version prefixed only a Go string,
|
|
// silently dropping the signal again for the array-content form Claude
|
|
// Code actually sends. All content shapes now normalize to a string
|
|
// (openAIToolResultContentString) before the "Error: " prefix applies,
|
|
// and an absent content becomes "" rather than the JSON null OpenAI
|
|
// rejects for a tool message.
|
|
func TestOpenAIToolMessageFromAnthropic_PreservesIsError(t *testing.T) {
|
|
cases := []struct {
|
|
content any
|
|
wantContent any
|
|
name string
|
|
isError bool
|
|
}{
|
|
{name: "string content with is_error prefixes Error:", content: "boom", isError: true, wantContent: "Error: boom"},
|
|
{name: "string content without is_error is unchanged", content: "ok", isError: false, wantContent: "ok"},
|
|
{name: "array-form content with is_error is normalized to a string and prefixed (F4)", content: []any{map[string]any{"type": "text", "text": "boom"}}, isError: true, wantContent: "Error: boom"},
|
|
{name: "array-form content without is_error joins its text blocks (F4)", content: []any{map[string]any{"type": "text", "text": "part1"}, map[string]any{"type": "text", "text": "part2"}}, isError: false, wantContent: "part1part2"},
|
|
{name: "absent content with is_error still prefixes onto an empty string, never null (F4)", content: nil, isError: true, wantContent: "Error: "},
|
|
{name: "absent content without is_error is an empty string, never null (F4)", content: nil, isError: false, wantContent: ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
bm := map[string]any{"tool_use_id": "call_1", "is_error": tc.isError}
|
|
if tc.content != nil {
|
|
bm["content"] = tc.content
|
|
}
|
|
got := openAIToolMessageFromAnthropic(bm)
|
|
assert.Equal(t, tc.wantContent, got["content"])
|
|
if _, ok := got["content"].(string); !ok {
|
|
t.Errorf("content = %#v (%T), want a string (OpenAI rejects a null/non-string tool message content)", got["content"], got["content"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOpenAIRequestFromAnthropic_ThinkingFieldReportedAsDropped is the
|
|
// regression for item 11b (IMPORTANT, 2026-08-22 review): Anthropic's
|
|
// "thinking" (extended-thinking config) has no OpenAI equivalent.
|
|
// openAIRequestFromAnthropic must report it in its dropped-fields return
|
|
// rather than silently discarding it, so the caller (routes_messages.go's
|
|
// callTranslatedMessages) can log a warning instead of the drop being
|
|
// invisible.
|
|
func TestOpenAIRequestFromAnthropic_ThinkingFieldReportedAsDropped(t *testing.T) {
|
|
req := map[string]any{
|
|
"model": "claude-x",
|
|
"thinking": map[string]any{"type": "enabled", "budget_tokens": float64(1024)},
|
|
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
|
}
|
|
|
|
_, dropped, err := openAIRequestFromAnthropic(req)
|
|
if err != nil {
|
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
|
}
|
|
if len(dropped) != 1 || dropped[0] != "thinking" {
|
|
t.Errorf("dropped = %v, want [\"thinking\"]", dropped)
|
|
}
|
|
}
|
|
|
|
// TestOpenAIRequestFromAnthropic_ThinkingAbsent_NothingDropped proves the
|
|
// dropped-fields list stays empty for a request that never set
|
|
// "thinking" in the first place — the previous test's absence is not
|
|
// itself evidence of a bug.
|
|
func TestOpenAIRequestFromAnthropic_ThinkingAbsent_NothingDropped(t *testing.T) {
|
|
req := map[string]any{
|
|
"model": "claude-x",
|
|
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
|
}
|
|
|
|
_, dropped, err := openAIRequestFromAnthropic(req)
|
|
if err != nil {
|
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
|
}
|
|
if len(dropped) != 0 {
|
|
t.Errorf("dropped = %v, want none", dropped)
|
|
}
|
|
}
|
|
|
|
// TestOpenAIRequestFromAnthropic_TopKReportedAsDropped is the L9
|
|
// regression test: "top_k" has no OpenAI equivalent, and — unlike
|
|
// "thinking", already covered above — was previously discarded with no
|
|
// entry in the dropped-fields return at all.
|
|
func TestOpenAIRequestFromAnthropic_TopKReportedAsDropped(t *testing.T) {
|
|
req := map[string]any{
|
|
"model": "claude-x",
|
|
"top_k": float64(40),
|
|
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
|
}
|
|
|
|
_, dropped, err := openAIRequestFromAnthropic(req)
|
|
if err != nil {
|
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
|
}
|
|
if len(dropped) != 1 || dropped[0] != "top_k" {
|
|
t.Errorf("dropped = %v, want [\"top_k\"]", dropped)
|
|
}
|
|
}
|
|
|
|
// TestOpenAIRequestFromAnthropic_ToolChoiceNoneReportedAsDropped is the
|
|
// L9 regression test for tool_choice: Anthropic's tool_choice
|
|
// {"type":"none"} has no OpenAI equivalent (openAIToolChoiceFromAnthropic
|
|
// simply omits the field), and was previously dropped with no entry in
|
|
// the dropped-fields return.
|
|
func TestOpenAIRequestFromAnthropic_ToolChoiceNoneReportedAsDropped(t *testing.T) {
|
|
req := map[string]any{
|
|
"model": "claude-x",
|
|
"tool_choice": map[string]any{"type": "none"},
|
|
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
|
}
|
|
|
|
out, dropped, err := openAIRequestFromAnthropic(req)
|
|
if err != nil {
|
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
|
}
|
|
if _, hasToolChoice := out["tool_choice"]; hasToolChoice {
|
|
t.Errorf(`out["tool_choice"] = %v, want absent (no OpenAI equivalent for Anthropic tool_choice:{type:none})`, out["tool_choice"])
|
|
}
|
|
if len(dropped) != 1 || dropped[0] != "tool_choice:none" {
|
|
t.Errorf(`dropped = %v, want ["tool_choice:none"]`, dropped)
|
|
}
|
|
}
|
|
|
|
// TestOpenAIRequestFromAnthropic_DroppedContentBlockTypesReported is the
|
|
// L9 regression test for the content-block half of the fix: a "thinking"
|
|
// block and a "document" block inside message content both have no
|
|
// OpenAI equivalent (translate_anthropic.go's openAIMessagesFromAnthropic
|
|
// switch has no case for either) and must be named in the dropped-fields
|
|
// return, not silently skipped the way they were before this fix.
|
|
func TestOpenAIRequestFromAnthropic_DroppedContentBlockTypesReported(t *testing.T) {
|
|
req := map[string]any{
|
|
"model": "claude-x",
|
|
"messages": []any{
|
|
map[string]any{"role": "user", "content": []any{
|
|
map[string]any{"type": "text", "text": "hi"},
|
|
map[string]any{"type": "document", "source": map[string]any{"type": "base64", "media_type": "application/pdf", "data": "AAAA"}},
|
|
}},
|
|
map[string]any{"role": "assistant", "content": []any{
|
|
map[string]any{"type": "thinking", "thinking": "reasoning...", "signature": "sig"},
|
|
map[string]any{"type": "text", "text": "the answer"},
|
|
}},
|
|
},
|
|
}
|
|
|
|
_, dropped, err := openAIRequestFromAnthropic(req)
|
|
if err != nil {
|
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
|
}
|
|
sort.Strings(dropped)
|
|
want := []string{"document", "thinking"}
|
|
if len(dropped) != len(want) || dropped[0] != want[0] || dropped[1] != want[1] {
|
|
t.Errorf("dropped = %v, want %v", dropped, want)
|
|
}
|
|
}
|
|
|
|
// TestOpenAIRequestFromAnthropic_DroppedFieldsDeduped proves repeated
|
|
// occurrences of the same dropped content-block type across several
|
|
// messages log once, not once per occurrence — a transcript with five
|
|
// "thinking" blocks must not flood the log with five identical warnings.
|
|
func TestOpenAIRequestFromAnthropic_DroppedFieldsDeduped(t *testing.T) {
|
|
thinkingBlock := map[string]any{"type": "thinking", "thinking": "...", "signature": "sig"}
|
|
req := map[string]any{
|
|
"model": "claude-x",
|
|
"messages": []any{
|
|
map[string]any{"role": "assistant", "content": []any{thinkingBlock, thinkingBlock}},
|
|
map[string]any{"role": "assistant", "content": []any{thinkingBlock}},
|
|
},
|
|
}
|
|
|
|
_, dropped, err := openAIRequestFromAnthropic(req)
|
|
if err != nil {
|
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
|
}
|
|
if len(dropped) != 1 || dropped[0] != "thinking" {
|
|
t.Errorf("dropped = %v, want exactly one [\"thinking\"] entry despite 3 occurrences", dropped)
|
|
}
|
|
}
|
|
|
|
// TestOpenAIRequestFromAnthropic_ReasoningModel_UsesMaxCompletionTokens is
|
|
// the regression for item 10 (IMPORTANT, 2026-08-22 review): OpenAI's
|
|
// o-series and gpt-5-family reasoning models reject the "max_tokens"
|
|
// request field with a 400 and require "max_completion_tokens" instead.
|
|
// Every other model keeps using "max_tokens" as before.
|
|
func TestOpenAIRequestFromAnthropic_ReasoningModel_UsesMaxCompletionTokens(t *testing.T) {
|
|
cases := []struct {
|
|
model string
|
|
wantField string
|
|
wantOthers []string
|
|
}{
|
|
{"o3-mini", "max_completion_tokens", []string{"max_tokens"}},
|
|
{"o1", "max_completion_tokens", []string{"max_tokens"}},
|
|
{"o4-mini-2025-04-16", "max_completion_tokens", []string{"max_tokens"}},
|
|
{"gpt-5", "max_completion_tokens", []string{"max_tokens"}},
|
|
{"gpt-5.1-chat-latest", "max_completion_tokens", []string{"max_tokens"}},
|
|
{"gpt-4o", "max_tokens", []string{"max_completion_tokens"}},
|
|
{"gpt-4o-mini", "max_tokens", []string{"max_completion_tokens"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.model, func(t *testing.T) {
|
|
req := map[string]any{
|
|
"model": tc.model,
|
|
"max_tokens": float64(256),
|
|
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
|
}
|
|
out, _, err := openAIRequestFromAnthropic(req)
|
|
if err != nil {
|
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
|
}
|
|
if _, ok := out[tc.wantField]; !ok {
|
|
t.Errorf("out[%q] missing, want it set to 256", tc.wantField)
|
|
}
|
|
for _, other := range tc.wantOthers {
|
|
if _, ok := out[other]; ok {
|
|
t.Errorf("out[%q] present, want only %q set", other, tc.wantField)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAnthropicUsagePayload_TotalInputTokens_FoldsCacheCounters is the
|
|
// unit-level regression for item 6 (IMPORTANT, 2026-08-22 review):
|
|
// cache_creation_input_tokens and cache_read_input_tokens must fold into
|
|
// the billed prompt count alongside fresh input_tokens, not be dropped.
|
|
func TestAnthropicUsagePayload_TotalInputTokens_FoldsCacheCounters(t *testing.T) {
|
|
p := anthropicUsagePayload{InputTokens: 4, CacheCreationInputTokens: 180000, CacheReadInputTokens: 20000}
|
|
if got, want := p.totalInputTokens(), int64(200004); got != want {
|
|
t.Errorf("totalInputTokens() = %d, want %d", got, want)
|
|
}
|
|
}
|
|
|
|
// --- openAIToolsFromAnthropic / openAIToolChoiceFromAnthropic: the
|
|
// Anthropic tools[]/tool_choice request translators, on the newest,
|
|
// heaviest-traffic path (/v1/messages -> a non-anthropic provider). Claude
|
|
// Code sends a tools[] array on nearly every call. Both functions were
|
|
// previously ZERO-COVERED: every existing openAIRequestFromAnthropic test
|
|
// above never set "tools" or "tool_choice" on its request map. ---
|
|
|
|
// TestOpenAIToolsFromAnthropic_MapsFieldsAndSkipsMalformedEntries proves
|
|
// each Anthropic tool{name, description, input_schema} maps IN ORDER to
|
|
// an OpenAI {"type":"function","function":{"name","description",
|
|
// "parameters"}} entry, that "description" is OMITTED (not set to a zero
|
|
// value) when the source tool carries none, and that a non-map entry in
|
|
// toolsRaw is skipped rather than panicking or producing a garbage entry.
|
|
//
|
|
// MUTATION PROVEN: changing `"parameters": tm["input_schema"]` to
|
|
// `"parameters": tm["name"]` made this test fail (parameters carried the
|
|
// tool's name string instead of its JSON schema) — confirmed via `go test
|
|
// -run TestOpenAIToolsFromAnthropic`, then reverted with `git checkout --
|
|
// translate_anthropic.go`.
|
|
func TestOpenAIToolsFromAnthropic_MapsFieldsAndSkipsMalformedEntries(t *testing.T) {
|
|
schema := map[string]any{"type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}}
|
|
toolsRaw := []any{
|
|
map[string]any{"name": "get_weather", "description": "Get the weather", "input_schema": schema},
|
|
map[string]any{"name": "no_desc_tool", "input_schema": map[string]any{"type": "object"}},
|
|
"not a map", // malformed entry: must be skipped, not panic
|
|
map[string]any{"name": "empty_schema_tool", "input_schema": map[string]any{}},
|
|
}
|
|
|
|
out := openAIToolsFromAnthropic(toolsRaw)
|
|
if len(out) != 3 {
|
|
t.Fatalf("len(out) = %d, want 3 (the malformed entry must be skipped)", len(out))
|
|
}
|
|
|
|
first, _ := out[0].(map[string]any)
|
|
if first["type"] != "function" {
|
|
t.Errorf(`out[0]["type"] = %v, want "function"`, first["type"])
|
|
}
|
|
fn, _ := first["function"].(map[string]any)
|
|
assert.Equal(t, "get_weather", fn["name"])
|
|
assert.Equal(t, "Get the weather", fn["description"])
|
|
assert.Equal(t, schema, fn["parameters"])
|
|
|
|
second, _ := out[1].(map[string]any)
|
|
fn2, _ := second["function"].(map[string]any)
|
|
if _, hasDesc := fn2["description"]; hasDesc {
|
|
t.Errorf(`function["description"] present (%v) for a tool with no description, want the key omitted`, fn2["description"])
|
|
}
|
|
|
|
third, _ := out[2].(map[string]any)
|
|
fn3, _ := third["function"].(map[string]any)
|
|
assert.Equal(t, "empty_schema_tool", fn3["name"])
|
|
assert.Equal(t, map[string]any{}, fn3["parameters"])
|
|
}
|
|
|
|
// TestOpenAIToolChoiceFromAnthropic_AllForms proves each of Anthropic's
|
|
// tool_choice shapes maps to OpenAI's, per the function's own doc
|
|
// comment: "auto"->"auto", "any"->"required" (OpenAI's force-some-tool
|
|
// value), {"type":"tool","name":n}->{"type":"function","function":
|
|
// {"name":n}}, and an unrecognized type, an absent type, a non-map value,
|
|
// or nil all map to a Go nil — the signal openAIRequestFromAnthropic's own
|
|
// caller-side `if choice := ...; choice != nil` uses to decide whether to
|
|
// set "tool_choice" on the OpenAI request at all.
|
|
//
|
|
// MUTATION PROVEN: changing the "any" case to `return "auto"` (collapsing
|
|
// it into the "auto" case, so a forced tool call silently became merely
|
|
// advisory) made this test fail — confirmed, then reverted.
|
|
func TestOpenAIToolChoiceFromAnthropic_AllForms(t *testing.T) {
|
|
cases := []struct {
|
|
tc any
|
|
want any
|
|
name string
|
|
}{
|
|
{name: "auto", tc: map[string]any{"type": "auto"}, want: "auto"},
|
|
{name: "any becomes OpenAI's required", tc: map[string]any{"type": "any"}, want: "required"},
|
|
{name: "tool-by-name", tc: map[string]any{"type": "tool", "name": "get_weather"}, want: map[string]any{"type": "function", "function": map[string]any{"name": "get_weather"}}},
|
|
{name: "unrecognized type is nil", tc: map[string]any{"type": "bogus"}, want: nil},
|
|
{name: "absent type is nil", tc: map[string]any{}, want: nil},
|
|
{name: "non-map value is nil", tc: "auto", want: nil},
|
|
{name: "nil value is nil", tc: nil, want: nil},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := openAIToolChoiceFromAnthropic(c.tc)
|
|
assert.Equal(t, c.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOpenAIRequestFromAnthropic_ToolsAndToolChoiceRoundTrip is the
|
|
// end-to-end regression for openAIToolsFromAnthropic and
|
|
// openAIToolChoiceFromAnthropic wired through their only real caller,
|
|
// openAIRequestFromAnthropic: an Anthropic /v1/messages request carrying a
|
|
// multi-tool tools[] array and a tool_choice forcing one specific tool
|
|
// translates to a request shape a real OpenAI chat-completions endpoint
|
|
// accepts, and the tool_calls[] such an endpoint would answer with
|
|
// translates back to Anthropic's own tool_use content-block shape — the
|
|
// full round trip Claude Code actually drives on this route.
|
|
func TestOpenAIRequestFromAnthropic_ToolsAndToolChoiceRoundTrip(t *testing.T) {
|
|
req := map[string]any{
|
|
"model": "claude-x",
|
|
"tools": []any{
|
|
map[string]any{"name": "get_weather", "description": "Get the weather for a city", "input_schema": map[string]any{
|
|
"type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}, "required": []any{"city"},
|
|
}},
|
|
map[string]any{"name": "get_time", "input_schema": map[string]any{"type": "object"}},
|
|
},
|
|
"tool_choice": map[string]any{"type": "tool", "name": "get_weather"},
|
|
"messages": []any{map[string]any{"role": "user", "content": "What's the weather in London?"}},
|
|
}
|
|
|
|
out, _, err := openAIRequestFromAnthropic(req)
|
|
if err != nil {
|
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
|
}
|
|
|
|
tools, _ := out["tools"].([]any)
|
|
if len(tools) != 2 {
|
|
t.Fatalf(`out["tools"] = %v, want 2 entries`, out["tools"])
|
|
}
|
|
firstFn, _ := tools[0].(map[string]any)["function"].(map[string]any)
|
|
assert.Equal(t, "get_weather", firstFn["name"])
|
|
secondFn, _ := tools[1].(map[string]any)["function"].(map[string]any)
|
|
assert.Equal(t, "get_time", secondFn["name"])
|
|
|
|
wantChoice := map[string]any{"type": "function", "function": map[string]any{"name": "get_weather"}}
|
|
assert.Equal(t, wantChoice, out["tool_choice"])
|
|
|
|
// A real OpenAI-shaped response answering that forced tool call...
|
|
const body = `{"id":"chatcmpl-1","choices":[{"index":0,"message":{"content":null,"tool_calls":[{"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":\"London\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}`
|
|
anthropicOut, err := anthropicResponseFromOpenAI([]byte(body), "claude-x")
|
|
if err != nil {
|
|
t.Fatalf("anthropicResponseFromOpenAI: %v", err)
|
|
}
|
|
// ...must translate back to Anthropic's tool_use shape.
|
|
assert.Equal(t, "tool_use", anthropicOut["stop_reason"])
|
|
content, _ := anthropicOut["content"].([]any)
|
|
if len(content) != 1 {
|
|
t.Fatalf("content = %v, want one tool_use block", anthropicOut["content"])
|
|
}
|
|
block, _ := content[0].(map[string]any)
|
|
assert.Equal(t, "get_weather", block["name"])
|
|
input, _ := block["input"].(map[string]any)
|
|
assert.Equal(t, "London", input["city"])
|
|
}
|
|
|
|
// TestOpenAIRequestFromAnthropic_NoTools_OmitsToolsAndToolChoiceFields
|
|
// proves a request that never set Anthropic's "tools"/"tool_choice"
|
|
// fields produces an OpenAI request with NEITHER key present at all — not
|
|
// an empty "tools":[] array, which some OpenAI-compatible upstreams treat
|
|
// differently from the field's outright absence.
|
|
//
|
|
// MUTATION PROVEN: changing `if toolsRaw, ok := req["tools"].([]any); ok`
|
|
// to always assign `out["tools"] = openAIToolsFromAnthropic(toolsRaw)`
|
|
// regardless of ok made this test fail (an empty "tools" key appeared) —
|
|
// confirmed, then reverted.
|
|
func TestOpenAIRequestFromAnthropic_NoTools_OmitsToolsAndToolChoiceFields(t *testing.T) {
|
|
req := map[string]any{
|
|
"model": "claude-x",
|
|
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
|
}
|
|
out, _, err := openAIRequestFromAnthropic(req)
|
|
if err != nil {
|
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
|
}
|
|
if _, ok := out["tools"]; ok {
|
|
t.Errorf(`out["tools"] = %v, want the key absent when no tools were requested`, out["tools"])
|
|
}
|
|
if _, ok := out["tool_choice"]; ok {
|
|
t.Errorf(`out["tool_choice"] = %v, want the key absent when no tool_choice was requested`, out["tool_choice"])
|
|
}
|
|
}
|