mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
feat: anthropic adapter with bidirectional translation incl streaming
Implements Task 9: the anthropic-type providerAdapter and its
request/response/stream translation to and from OpenAI's wire shape.
- translate_anthropic.go: anthropicRequestFromOpenAI (system messages
joined, content blocks incl. base64 data-URI images, tool_calls ->
tool_use, tool messages -> tool_result, max_tokens defaulting,
stop/stop_sequences, tool_choice incl. the none-drops-tools rule,
n>1/logit_bias/logprobs rejected), openAIResponseFromAnthropic
(content blocks joined/tool_calls re-marshaled, stop_reason mapping,
usage), and anthropicStreamState.translate mapping each SSE event to
zero or more OpenAI chat.completion.chunk payloads. translateError
carries a notSupported flag so the caller (Task 12) can tell a 400
from a 501.
- provider_anthropic.go: anthropicAdapter implementing providerAdapter.
newAnthropicAdapter errors on an empty API key per ruling (c), unlike
openai-type's keyless ruling. Headers: x-api-key, anthropic-version.
embeddings always returns a notSupported translateError without
calling upstream (Anthropic has no embeddings endpoint). listModels
GETs /v1/models and returns any non-2xx (incl. 404) as-is.
- providers.go: wires the anthropic buildAdapters case to
newAnthropicAdapter, propagating its constructor error.
- provider_openai_test.go: replaces the now-stale placeholder
TestBuildAdapters subtest with one asserting a keyed anthropic
provider builds successfully and a keyless one errors.
- translate_anthropic_test.go: golden-file harness walking
testdata/anthropic/{chat,resp}_*_in.json against sibling _want.json
(success) or _wanterr.json (*translateError) fixtures, plus a stream
harness driving each *.sse fixture through anthropicStreamState and
asserting the chunk list and usage().
- provider_anthropic_test.go: httptest-backed adapter wiring tests
(streaming + non-streaming), asserting OpenAI-shaped output on the
wire, per-event flushing, and the required auth headers.
Gate: go test ./... -count=1 and -race both green; gofmt, go vet,
fieldalignment, golangci-lint (incl. gosec) all clean.
This commit is contained in:
@@ -0,0 +1,217 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// anthropicMessagesPath and anthropicModelsPath are Anthropic's Messages
|
||||||
|
// and Models API endpoints, appended to the adapter's base URL.
|
||||||
|
const (
|
||||||
|
anthropicMessagesPath = "/v1/messages"
|
||||||
|
anthropicModelsPath = "/v1/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// anthropicAdapter is the providerAdapter for provider type "anthropic".
|
||||||
|
type anthropicAdapter struct {
|
||||||
|
client *http.Client
|
||||||
|
adapterName string
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAnthropicAdapter returns an anthropicAdapter for provider name, with
|
||||||
|
// base as its already-defaulted, trailing-slash-trimmed base URL and
|
||||||
|
// apiKey as already resolved by resolveSecret. Unlike openai-type
|
||||||
|
// providers, Anthropic's API requires an API key on every request — a
|
||||||
|
// resolved-empty apiKey is a constructor error here, per ruling (c),
|
||||||
|
// rather than the keyless-upstream case openai-type providers accept.
|
||||||
|
func newAnthropicAdapter(name, base, apiKey string) (*anthropicAdapter, error) {
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("llmgateway: provider %q: anthropic requires an API key", name)
|
||||||
|
}
|
||||||
|
return &anthropicAdapter{
|
||||||
|
adapterName: name,
|
||||||
|
baseURL: base,
|
||||||
|
apiKey: apiKey,
|
||||||
|
client: newAdapterHTTPClient(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// name implements providerAdapter.
|
||||||
|
func (a *anthropicAdapter) name() string { return a.adapterName }
|
||||||
|
|
||||||
|
// typeName implements providerAdapter.
|
||||||
|
func (a *anthropicAdapter) typeName() string { return providerTypeAnthropic }
|
||||||
|
|
||||||
|
// base implements providerAdapter.
|
||||||
|
func (a *anthropicAdapter) base() string { return a.baseURL }
|
||||||
|
|
||||||
|
// injectAuth implements providerAdapter: Anthropic's x-api-key scheme,
|
||||||
|
// plus the anthropic-version header every request must carry. Unlike
|
||||||
|
// openai-type's injectAuth, this is never a no-op — the constructor
|
||||||
|
// already rejected an empty apiKey.
|
||||||
|
func (a *anthropicAdapter) injectAuth(r *http.Request) {
|
||||||
|
r.Header.Set("x-api-key", a.apiKey)
|
||||||
|
r.Header.Set("anthropic-version", anthropicAPIVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestHeaders returns the headers for one upstream request: a
|
||||||
|
// Content-Type when the request carries a JSON body, plus whatever
|
||||||
|
// injectAuth adds.
|
||||||
|
func (a *anthropicAdapter) requestHeaders(hasBody bool) http.Header {
|
||||||
|
h := http.Header{}
|
||||||
|
if hasBody {
|
||||||
|
h.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
a.injectAuth(&http.Request{Header: h})
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatCompletion implements providerAdapter: translates req to an
|
||||||
|
// Anthropic Messages API request via anthropicRequestFromOpenAI, then
|
||||||
|
// forwards the (possibly streamed) response back translated to OpenAI's
|
||||||
|
// shape. A translation failure — a *translateError from
|
||||||
|
// anthropicRequestFromOpenAI — is returned as-is, before any upstream
|
||||||
|
// request is made.
|
||||||
|
func (a *anthropicAdapter) chatCompletion(ctx context.Context, w http.ResponseWriter, req map[string]any) (usage, error) {
|
||||||
|
gatewayModel, _ := req["model"].(string)
|
||||||
|
streaming, _ := req["stream"].(bool)
|
||||||
|
|
||||||
|
body, err := anthropicRequestFromOpenAI(req)
|
||||||
|
if err != nil {
|
||||||
|
return usage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr := a.requestHeaders(true)
|
||||||
|
if streaming {
|
||||||
|
// Ask the upstream for an SSE response. The Content-Type check
|
||||||
|
// below still decides which forwarding path runs.
|
||||||
|
hdr.Set("Accept", "text/event-stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := upstreamJSON(ctx, a.client, http.MethodPost, a.baseURL+anthropicMessagesPath, hdr, body)
|
||||||
|
if err != nil {
|
||||||
|
return usage{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck // read-side close; nothing actionable on failure
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return usage{}, newProviderHTTPError(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if streaming && strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") {
|
||||||
|
return a.forwardStream(w, resp.Body, gatewayModel)
|
||||||
|
}
|
||||||
|
// Either a non-streaming request, or a streaming one whose upstream
|
||||||
|
// ignored stream=true and answered with a normal JSON body.
|
||||||
|
return a.forwardJSON(w, resp, gatewayModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardJSON reads resp's full body (capped at maxResponseBytes),
|
||||||
|
// translates it to an OpenAI chat.completion response via
|
||||||
|
// openAIResponseFromAnthropic, and writes the translated JSON to w.
|
||||||
|
// gatewayModel becomes the translated response's "model" field, and the
|
||||||
|
// response's "created" timestamp is generated here — Anthropic's response
|
||||||
|
// carries neither.
|
||||||
|
func (a *anthropicAdapter) forwardJSON(w http.ResponseWriter, resp *http.Response, gatewayModel string) (usage, error) {
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||||
|
if err != nil {
|
||||||
|
return usage{}, fmt.Errorf("%w: read response body: %w", errUpstream, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, u, err := openAIResponseFromAnthropic(raw, gatewayModel, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return usage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return usage{}, fmt.Errorf("%w: marshal translated response: %w", errUpstream, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardStream reads body as an upstream Anthropic text/event-stream,
|
||||||
|
// translating each event via an anthropicStreamState and forwarding every
|
||||||
|
// resulting OpenAI-shaped chunk to w through a fresh sseWriter, flushing
|
||||||
|
// per event. gatewayModel and the stream's start time become every
|
||||||
|
// chunk's "model"/"created" fields. Per ruling (b), the terminal
|
||||||
|
// "[DONE]" is written once readSSE returns without error — whether that
|
||||||
|
// is because the upstream sent message_stop (which the translator itself
|
||||||
|
// emits nothing for) or because the connection reached a clean EOF
|
||||||
|
// without one. On a translate error (the "error" event, or a genuine
|
||||||
|
// stream read error), forwardStream returns without writing "[DONE]" —
|
||||||
|
// the caller already saw the connection fail or an explicit upstream
|
||||||
|
// error chunk.
|
||||||
|
func (a *anthropicAdapter) forwardStream(w http.ResponseWriter, body io.Reader, gatewayModel string) (usage, error) {
|
||||||
|
st := newAnthropicStreamState(gatewayModel, time.Now().Unix())
|
||||||
|
sw := newSSEWriter(w)
|
||||||
|
|
||||||
|
err := readSSE(body, func(ev sseEvent) error {
|
||||||
|
chunks, terr := st.translate(ev)
|
||||||
|
for _, c := range chunks {
|
||||||
|
if werr := sw.writeData(c); werr != nil {
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return terr
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return st.usage(), fmt.Errorf("%w: stream: %w", errUpstream, err)
|
||||||
|
}
|
||||||
|
sw.writeDone()
|
||||||
|
return st.usage(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// embeddings implements providerAdapter. Anthropic's API has no
|
||||||
|
// embeddings endpoint, so this never makes an upstream request — it
|
||||||
|
// always returns a *translateError with notSupported set, which the
|
||||||
|
// caller (Task 12) maps to HTTP 501 rather than 400.
|
||||||
|
func (a *anthropicAdapter) embeddings(_ context.Context, _ http.ResponseWriter, _ map[string]any) (usage, error) {
|
||||||
|
return usage{}, &translateError{msg: "embeddings not supported for anthropic models", notSupported: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listModels implements providerAdapter: GET {base}/v1/models, parsing
|
||||||
|
// the same "data": [{"id": ...}] shape OpenAI's endpoint uses (Anthropic's
|
||||||
|
// 2026 Models API responds in the same shape). A non-2xx response —
|
||||||
|
// including a 404 from an older Anthropic API version that predates this
|
||||||
|
// endpoint — returns a *providerHTTPError as-is; the registry (Task 11)
|
||||||
|
// falls back to a provider's explicitly configured models on error rather
|
||||||
|
// than this adapter guessing at a fallback itself.
|
||||||
|
func (a *anthropicAdapter) listModels(ctx context.Context) ([]string, error) {
|
||||||
|
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+anthropicModelsPath, a.requestHeaders(false), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, newProviderHTTPError(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: read response body: %w", errUpstream, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload modelsPayload
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: decode models response: %w", errUpstream, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]string, len(payload.Data))
|
||||||
|
for i, d := range payload.Data {
|
||||||
|
ids[i] = d.ID
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAnthropicAdapter_ChatCompletion_NonStreaming proves the adapter
|
||||||
|
// translates an OpenAI-shaped request to Anthropic's wire format, sends
|
||||||
|
// the required x-api-key/anthropic-version headers, and translates the
|
||||||
|
// Anthropic response back to an OpenAI-shaped chat.completion body on the
|
||||||
|
// wire, with usage extracted from Anthropic's input_tokens/output_tokens.
|
||||||
|
func TestAnthropicAdapter_ChatCompletion_NonStreaming(t *testing.T) {
|
||||||
|
const anthResp = `{"id":"msg_01ABC","type":"message","role":"assistant","content":[{"type":"text","text":"hi there"}],"model":"claude-opus-5","stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":4}}`
|
||||||
|
|
||||||
|
var gotBody []byte
|
||||||
|
var gotAPIKey, gotVersion string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/messages" {
|
||||||
|
t.Errorf("path = %q, want /v1/messages", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %q, want POST", r.Method)
|
||||||
|
}
|
||||||
|
gotBody, _ = io.ReadAll(r.Body)
|
||||||
|
gotAPIKey = r.Header.Get("x-api-key")
|
||||||
|
gotVersion = r.Header.Get("anthropic-version")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(anthResp))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAnthropicAdapter: %v", err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
req := map[string]any{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||||
|
}
|
||||||
|
u, err := a.chatCompletion(context.Background(), rec, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chatCompletion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.prompt != 10 || u.completion != 4 {
|
||||||
|
t.Errorf("usage = %+v, want {prompt:10 completion:4}", u)
|
||||||
|
}
|
||||||
|
if gotAPIKey != "sk-ant-test" { //nolint:gosec // test-only literal, not a real credential
|
||||||
|
t.Errorf("x-api-key = %q, want %q", gotAPIKey, "sk-ant-test")
|
||||||
|
}
|
||||||
|
if gotVersion != anthropicAPIVersion {
|
||||||
|
t.Errorf("anthropic-version = %q, want %q", gotVersion, anthropicAPIVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := decodeJSONBody(t, gotBody)
|
||||||
|
if sent["max_tokens"] != float64(anthropicDefaultMaxTokens) {
|
||||||
|
t.Errorf("upstream max_tokens = %v, want %v (default)", sent["max_tokens"], anthropicDefaultMaxTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := rec.Header().Get("Content-Type"); got != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", got)
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("client body is not valid JSON: %v (%s)", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if out["object"] != "chat.completion" {
|
||||||
|
t.Errorf("object = %v, want %q", out["object"], "chat.completion")
|
||||||
|
}
|
||||||
|
if out["id"] != "chatcmpl-msg_01ABC" {
|
||||||
|
t.Errorf("id = %v, want %q", out["id"], "chatcmpl-msg_01ABC")
|
||||||
|
}
|
||||||
|
choices, ok := out["choices"].([]any)
|
||||||
|
if !ok || len(choices) != 1 {
|
||||||
|
t.Fatalf("choices = %v, want a single-element array", out["choices"])
|
||||||
|
}
|
||||||
|
choice := choices[0].(map[string]any)
|
||||||
|
message := choice["message"].(map[string]any)
|
||||||
|
if message["content"] != "hi there" {
|
||||||
|
t.Errorf("message.content = %v, want %q", message["content"], "hi there")
|
||||||
|
}
|
||||||
|
if choice["finish_reason"] != "stop" {
|
||||||
|
t.Errorf("finish_reason = %v, want %q", choice["finish_reason"], "stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicStreamFrames are the raw SSE events a fake streaming upstream
|
||||||
|
// emits for the tests below: a message_start, one text delta, and a
|
||||||
|
// message_delta/message_stop pair.
|
||||||
|
var anthropicStreamFrames = []struct {
|
||||||
|
event string
|
||||||
|
data string
|
||||||
|
}{
|
||||||
|
{"message_start", `{"type":"message_start","message":{"id":"msg_01STREAM","usage":{"input_tokens":6,"output_tokens":1}}}`},
|
||||||
|
{"content_block_start", `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`},
|
||||||
|
{"content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi!"}}`},
|
||||||
|
{"content_block_stop", `{"type":"content_block_stop","index":0}`},
|
||||||
|
{"message_delta", `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}`},
|
||||||
|
{"message_stop", `{"type":"message_stop"}`},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthropicAdapter_ChatCompletion_Streaming proves the adapter
|
||||||
|
// forwards a streaming Anthropic response as OpenAI-shaped SSE chunks on
|
||||||
|
// the wire, flushing per event, terminated by "[DONE]" once the upstream
|
||||||
|
// stream ends (ruling (b): message_stop carries no output of its own —
|
||||||
|
// the adapter writes "[DONE]" after readSSE returns, not because of any
|
||||||
|
// particular event).
|
||||||
|
func TestAnthropicAdapter_ChatCompletion_Streaming(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
f, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("httptest ResponseWriter does not implement http.Flusher")
|
||||||
|
}
|
||||||
|
for _, frame := range anthropicStreamFrames {
|
||||||
|
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", frame.event, frame.data)
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAnthropicAdapter: %v", err)
|
||||||
|
}
|
||||||
|
rec := newRecordingResponseWriter()
|
||||||
|
|
||||||
|
req := map[string]any{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"stream": true,
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||||
|
}
|
||||||
|
u, err := a.chatCompletion(context.Background(), rec, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chatCompletion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.prompt != 6 || u.completion != 3 {
|
||||||
|
t.Errorf("usage = %+v, want {prompt:6 completion:3}", u)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.body.String()
|
||||||
|
if got := rec.header.Get("Content-Type"); got != "text/event-stream" {
|
||||||
|
t.Errorf("Content-Type = %q, want text/event-stream", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4 chunks are expected on the wire: role, content delta, finish — the
|
||||||
|
// content_block_start/stop and message_stop events produce nothing —
|
||||||
|
// plus the terminal [DONE].
|
||||||
|
wantSubstrings := []string{
|
||||||
|
`"delta":{"role":"assistant"}`,
|
||||||
|
`"delta":{"content":"Hi!"}`,
|
||||||
|
`"finish_reason":"stop"`,
|
||||||
|
"data: [DONE]\n\n",
|
||||||
|
}
|
||||||
|
for _, want := range wantSubstrings {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("streamed body missing %q; got:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3 events translate to a chunk (message_start, the text delta,
|
||||||
|
// message_delta) — content_block_start/stop produce nothing — plus one
|
||||||
|
// more flush for the terminal [DONE]: 4 flushes, one per writeData/
|
||||||
|
// writeDone call, proving the adapter flushes per event rather than
|
||||||
|
// buffering the whole stream.
|
||||||
|
const wantFlushes = 4
|
||||||
|
if rec.flushed < wantFlushes {
|
||||||
|
t.Errorf("flushed = %d, want >= %d", rec.flushed, wantFlushes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthropicAdapter_ChatCompletion_Upstream429 proves a non-2xx
|
||||||
|
// upstream response returns a *providerHTTPError with nothing written to
|
||||||
|
// the client, mirroring the openai-type adapter's contract.
|
||||||
|
func TestAnthropicAdapter_ChatCompletion_Upstream429(t *testing.T) {
|
||||||
|
const errBody = `{"type":"error","error":{"type":"rate_limit_error","message":"rate limited"}}`
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
_, _ = w.Write([]byte(errBody))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAnthropicAdapter: %v", err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
u, err := a.chatCompletion(context.Background(), rec, map[string]any{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("chatCompletion: want error, got nil (usage=%+v)", u)
|
||||||
|
}
|
||||||
|
var httpErr *providerHTTPError
|
||||||
|
if !errors.As(err, &httpErr) {
|
||||||
|
t.Fatalf("err = %v (%T), want *providerHTTPError", err, err)
|
||||||
|
}
|
||||||
|
if httpErr.status != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("status = %d, want %d", httpErr.status, http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
if rec.Body.Len() != 0 {
|
||||||
|
t.Errorf("client response body = %q, want empty", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthropicAdapter_ChatCompletion_TranslateErrorSkipsUpstream proves a
|
||||||
|
// request field anthropicRequestFromOpenAI rejects (n>1, here) never
|
||||||
|
// reaches the upstream at all — the adapter returns the *translateError
|
||||||
|
// before making any HTTP request.
|
||||||
|
func TestAnthropicAdapter_ChatCompletion_TranslateErrorSkipsUpstream(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-ant-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAnthropicAdapter: %v", err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
_, err = a.chatCompletion(context.Background(), rec, map[string]any{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
||||||
|
"n": float64(2),
|
||||||
|
})
|
||||||
|
var terr *translateError
|
||||||
|
if !errors.As(err, &terr) {
|
||||||
|
t.Fatalf("err = %v (%T), want *translateError", err, err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Errorf("upstream was called, want the translate error to short-circuit before any request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthropicAdapter_ListModels proves listModels parses Anthropic's
|
||||||
|
// "data": [{"id": ...}] Models API response into a flat slice of ids.
|
||||||
|
func TestAnthropicAdapter_ListModels(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/models" {
|
||||||
|
t.Errorf("path = %q, want /v1/models", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Errorf("method = %q, want GET", r.Method)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("x-api-key"); got != "sk-ant-test" {
|
||||||
|
t.Errorf("x-api-key = %q, want %q", got, "sk-ant-test")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":[{"id":"claude-opus-5"},{"id":"claude-sonnet-5"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAnthropicAdapter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := a.listModels(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listModels: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"claude-opus-5", "claude-sonnet-5"}
|
||||||
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Errorf("got %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-4
@@ -557,13 +557,32 @@ func TestBuildAdapters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("edge: anthropic type not yet implemented", func(t *testing.T) {
|
t.Run("good: anthropic with an API key builds successfully", func(t *testing.T) {
|
||||||
cfg := &Config{Providers: map[string]*ProviderConfig{
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
||||||
"p1": {Type: "anthropic", APIKey: "sk-test"},
|
"p1": {Type: "anthropic", APIKey: "sk-test"},
|
||||||
}}
|
}}
|
||||||
_, err := buildAdapters(cfg)
|
adapters, err := buildAdapters(cfg)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatalf("buildAdapters: want placeholder error for anthropic, got nil")
|
t.Fatalf("buildAdapters: %v", err)
|
||||||
|
}
|
||||||
|
a, ok := adapters["p1"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("adapters[%q] missing", "p1")
|
||||||
|
}
|
||||||
|
if a.typeName() != "anthropic" {
|
||||||
|
t.Errorf("typeName() = %q, want %q", a.typeName(), "anthropic")
|
||||||
|
}
|
||||||
|
if a.base() != defaultBaseAnthropic {
|
||||||
|
t.Errorf("base() = %q, want %q", a.base(), defaultBaseAnthropic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bad: anthropic without an API key is a constructor error", func(t *testing.T) {
|
||||||
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
||||||
|
"p1": {Type: "anthropic"},
|
||||||
|
}}
|
||||||
|
if _, err := buildAdapters(cfg); err == nil {
|
||||||
|
t.Fatalf("buildAdapters: want error for keyless anthropic provider, got nil")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+13
-7
@@ -172,12 +172,14 @@ func upstreamJSON(ctx context.Context, client *http.Client, method, url string,
|
|||||||
|
|
||||||
// buildAdapters resolves cfg.Providers into a map of providerAdapter keyed
|
// buildAdapters resolves cfg.Providers into a map of providerAdapter keyed
|
||||||
// by provider name. For each provider it resolves APIKey via resolveSecret
|
// by provider name. For each provider it resolves APIKey via resolveSecret
|
||||||
// (propagating that error, but treating a resolved empty key as a valid
|
// (propagating that error) and defaults BaseURL by provider type when the
|
||||||
// keyless upstream rather than an error) and defaults BaseURL by provider
|
// config omits one, trimming any trailing slash either way. An unknown
|
||||||
// type when the config omits one, trimming any trailing slash either way.
|
// provider type is a config error. A resolved empty key is a valid
|
||||||
// An unknown provider type is a config error. A configured anthropic or
|
// keyless upstream for openai-type providers, but a constructor error for
|
||||||
// gemini provider currently errors too — Tasks 9 and 10 fill in those
|
// anthropic-type ones — newAnthropicAdapter enforces that, per ruling (c);
|
||||||
// switch cases; every other case here is unaffected by that.
|
// this function just propagates whatever error it returns. A configured
|
||||||
|
// gemini provider currently errors too — Task 10 fills in that switch
|
||||||
|
// case.
|
||||||
func buildAdapters(cfg *Config) (map[string]providerAdapter, error) {
|
func buildAdapters(cfg *Config) (map[string]providerAdapter, error) {
|
||||||
adapters := make(map[string]providerAdapter, len(cfg.Providers))
|
adapters := make(map[string]providerAdapter, len(cfg.Providers))
|
||||||
for name, pc := range cfg.Providers {
|
for name, pc := range cfg.Providers {
|
||||||
@@ -195,7 +197,11 @@ func buildAdapters(cfg *Config) (map[string]providerAdapter, error) {
|
|||||||
case providerTypeOpenAI:
|
case providerTypeOpenAI:
|
||||||
adapters[name] = newOpenAIAdapter(name, base, apiKey)
|
adapters[name] = newOpenAIAdapter(name, base, apiKey)
|
||||||
case providerTypeAnthropic:
|
case providerTypeAnthropic:
|
||||||
return nil, fmt.Errorf("llmgateway: provider %q: type %q not yet implemented", name, pc.Type)
|
a, err := newAnthropicAdapter(name, base, apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
adapters[name] = a
|
||||||
case providerTypeGemini:
|
case providerTypeGemini:
|
||||||
return nil, fmt.Errorf("llmgateway: provider %q: type %q not yet implemented", name, pc.Type)
|
return nil, fmt.Errorf("llmgateway: provider %q: type %q not yet implemented", name, pc.Type)
|
||||||
default:
|
default:
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hello, how are you?"}
|
||||||
|
],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"stop": "STOP",
|
||||||
|
"user": "user-123"
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hello, how are you?"}
|
||||||
|
],
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"stop_sequences": ["STOP"],
|
||||||
|
"metadata": {"user_id": "user-123"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": "What is in this image?"},
|
||||||
|
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
"max_tokens": 300
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"errContains": "data: URI", "notSupported": false}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": "What is in this image?"},
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"}}
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
"max_tokens": 300
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": "What is in this image?"},
|
||||||
|
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"}}
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
"max_tokens": 300
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are a helpful assistant."},
|
||||||
|
{"role": "system", "content": "Always be concise."},
|
||||||
|
{"role": "user", "content": "What's the weather in Paris?"},
|
||||||
|
{"role": "assistant", "content": null, "tool_calls": [
|
||||||
|
{"id": "call_abc123", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\":\"Paris\"}"}}
|
||||||
|
]},
|
||||||
|
{"role": "tool", "tool_call_id": "call_abc123", "content": "72F and sunny"}
|
||||||
|
],
|
||||||
|
"max_tokens": 512,
|
||||||
|
"stop": ["END", "STOP"],
|
||||||
|
"tools": [
|
||||||
|
{"type": "function", "function": {"name": "get_weather", "description": "Get current weather for a location", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}
|
||||||
|
],
|
||||||
|
"tool_choice": "auto"
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"system": "You are a helpful assistant.\n\nAlways be concise.",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "What's the weather in Paris?"},
|
||||||
|
{"role": "assistant", "content": [
|
||||||
|
{"type": "tool_use", "id": "call_abc123", "name": "get_weather", "input": {"location": "Paris"}}
|
||||||
|
]},
|
||||||
|
{"role": "user", "content": [
|
||||||
|
{"type": "tool_result", "tool_use_id": "call_abc123", "content": "72F and sunny"}
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
"max_tokens": 512,
|
||||||
|
"stop_sequences": ["END", "STOP"],
|
||||||
|
"tools": [
|
||||||
|
{"name": "get_weather", "description": "Get current weather for a location", "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}
|
||||||
|
],
|
||||||
|
"tool_choice": {"type": "auto"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [{"role": "user", "content": "Do something"}],
|
||||||
|
"max_tokens": 100,
|
||||||
|
"tools": [
|
||||||
|
{"type": "function", "function": {"name": "noop", "description": "does nothing", "parameters": {"type": "object", "properties": {}}}}
|
||||||
|
],
|
||||||
|
"tool_choice": "none"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [{"role": "user", "content": "Do something"}],
|
||||||
|
"max_tokens": 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [{"role": "user", "content": "Do something"}],
|
||||||
|
"max_tokens": 100,
|
||||||
|
"tools": [
|
||||||
|
{"type": "function", "function": {"name": "act", "description": "acts", "parameters": {"type": "object", "properties": {}}}}
|
||||||
|
],
|
||||||
|
"tool_choice": "required"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [{"role": "user", "content": "Do something"}],
|
||||||
|
"max_tokens": 100,
|
||||||
|
"tools": [
|
||||||
|
{"name": "act", "description": "acts", "input_schema": {"type": "object", "properties": {}}}
|
||||||
|
],
|
||||||
|
"tool_choice": {"type": "any"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [{"role": "user", "content": "Do something"}],
|
||||||
|
"max_tokens": 100,
|
||||||
|
"tools": [
|
||||||
|
{"type": "function", "function": {"name": "act", "description": "acts", "parameters": {"type": "object", "properties": {}}}}
|
||||||
|
],
|
||||||
|
"tool_choice": {"type": "function", "function": {"name": "act"}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [{"role": "user", "content": "Do something"}],
|
||||||
|
"max_tokens": 100,
|
||||||
|
"tools": [
|
||||||
|
{"name": "act", "description": "acts", "input_schema": {"type": "object", "properties": {}}}
|
||||||
|
],
|
||||||
|
"tool_choice": {"type": "tool", "name": "act"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [{"role": "user", "content": "hi"}],
|
||||||
|
"logit_bias": {"50256": -100}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"errContains": "logit_bias is not supported for anthropic models", "notSupported": false}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [{"role": "user", "content": "hi"}],
|
||||||
|
"logprobs": true
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"errContains": "logprobs is not supported for anthropic models", "notSupported": false}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"messages": [{"role": "user", "content": "hi"}],
|
||||||
|
"n": 2
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"errContains": "n is not supported for anthropic models", "notSupported": false}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"id": "msg_01TRUNC",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "This response was cut"}
|
||||||
|
],
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"stop_reason": "max_tokens",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {"input_tokens": 5, "output_tokens": 100}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-msg_01TRUNC",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1734000000,
|
||||||
|
"model": "gw-anthropic-model",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "This response was cut"},
|
||||||
|
"finish_reason": "length"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 5, "completion_tokens": 100, "total_tokens": 105}
|
||||||
|
}
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"id": "msg_01XYZ",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "The capital of France is Paris."}
|
||||||
|
],
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {"input_tokens": 12, "output_tokens": 8}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-msg_01XYZ",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1734000000,
|
||||||
|
"model": "gw-anthropic-model",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "The capital of France is Paris."},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 12, "completion_tokens": 8, "total_tokens": 20}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "msg_01ABC",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Let me check the weather."},
|
||||||
|
{"type": "tool_use", "id": "toolu_01DEF", "name": "get_weather", "input": {"location": "Paris"}}
|
||||||
|
],
|
||||||
|
"model": "claude-opus-5",
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {"input_tokens": 20, "output_tokens": 15}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"id": "chatcmpl-msg_01ABC",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1734000000,
|
||||||
|
"model": "gw-anthropic-model",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Let me check the weather.",
|
||||||
|
"tool_calls": [
|
||||||
|
{"id": "toolu_01DEF", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\":\"Paris\"}"}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 20, "completion_tokens": 15, "total_tokens": 35}
|
||||||
|
}
|
||||||
Vendored
+24
@@ -0,0 +1,24 @@
|
|||||||
|
event: message_start
|
||||||
|
data: {"type":"message_start","message":{"id":"msg_01STREAM","type":"message","role":"assistant","content":[],"model":"claude-opus-5","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":1}}}
|
||||||
|
|
||||||
|
event: content_block_start
|
||||||
|
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||||
|
|
||||||
|
event: ping
|
||||||
|
data: {"type":"ping"}
|
||||||
|
|
||||||
|
event: content_block_delta
|
||||||
|
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
|
||||||
|
|
||||||
|
event: content_block_delta
|
||||||
|
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}
|
||||||
|
|
||||||
|
event: content_block_stop
|
||||||
|
data: {"type":"content_block_stop","index":0}
|
||||||
|
|
||||||
|
event: message_delta
|
||||||
|
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}}
|
||||||
|
|
||||||
|
event: message_stop
|
||||||
|
data: {"type":"message_stop"}
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"prompt": 10, "completion": 5}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{"id":"chatcmpl-msg_01STREAM","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]},
|
||||||
|
{"id":"chatcmpl-msg_01STREAM","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]},
|
||||||
|
{"id":"chatcmpl-msg_01STREAM","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]},
|
||||||
|
{"id":"chatcmpl-msg_01STREAM","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||||
|
]
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
event: message_start
|
||||||
|
data: {"type":"message_start","message":{"id":"msg_01TOOL","type":"message","role":"assistant","content":[],"model":"claude-opus-5","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":15,"output_tokens":1}}}
|
||||||
|
|
||||||
|
event: content_block_start
|
||||||
|
data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01XYZ","name":"get_weather","input":{}}}
|
||||||
|
|
||||||
|
event: content_block_delta
|
||||||
|
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"location\": \""}}
|
||||||
|
|
||||||
|
event: content_block_delta
|
||||||
|
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"Paris\"}"}}
|
||||||
|
|
||||||
|
event: content_block_stop
|
||||||
|
data: {"type":"content_block_stop","index":0}
|
||||||
|
|
||||||
|
event: message_delta
|
||||||
|
data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":7}}
|
||||||
|
|
||||||
|
event: message_stop
|
||||||
|
data: {"type":"message_stop"}
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"prompt": 15, "completion": 7}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{"id":"chatcmpl-msg_01TOOL","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]},
|
||||||
|
{"id":"chatcmpl-msg_01TOOL","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"toolu_01XYZ","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]},
|
||||||
|
{"id":"chatcmpl-msg_01TOOL","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"location\": \""}}]},"finish_reason":null}]},
|
||||||
|
{"id":"chatcmpl-msg_01TOOL","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Paris\"}"}}]},"finish_reason":null}]},
|
||||||
|
{"id":"chatcmpl-msg_01TOOL","object":"chat.completion.chunk","created":1734000000,"model":"gw-anthropic-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
|
||||||
|
]
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
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.
|
||||||
|
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)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicToolResultMessage maps one OpenAI role:"tool" message to an
|
||||||
|
// Anthropic user message carrying a single tool_result 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.
|
||||||
|
func anthropicToolResultMessage(msg map[string]any) map[string]any {
|
||||||
|
toolCallID, _ := msg["tool_call_id"].(string)
|
||||||
|
return map[string]any{
|
||||||
|
"role": "user",
|
||||||
|
"content": []any{map[string]any{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": toolCallID,
|
||||||
|
"content": msg["content"],
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, logit_bias, logprobs) or a
|
||||||
|
// content part this translator cannot map (a non-data-URI image URL, a
|
||||||
|
// malformed tool_calls argument string).
|
||||||
|
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 _, ok := req["logit_bias"]; ok {
|
||||||
|
return nil, unsupportedFieldError("logit_bias")
|
||||||
|
}
|
||||||
|
if _, ok := req["logprobs"]; ok {
|
||||||
|
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))
|
||||||
|
for _, m := range msgsRaw {
|
||||||
|
msg, ok := m.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch role, _ := msg["role"].(string); role {
|
||||||
|
case "system":
|
||||||
|
if s, ok := msg["content"].(string); ok {
|
||||||
|
systemParts = append(systemParts, s)
|
||||||
|
}
|
||||||
|
case "user", "assistant":
|
||||||
|
am, err := anthropicMessageFromOpenAI(msg, role)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
anthMsgs = append(anthMsgs, am)
|
||||||
|
case "tool":
|
||||||
|
anthMsgs = append(anthMsgs, anthropicToolResultMessage(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
type anthropicUsagePayload struct {
|
||||||
|
InputTokens int64 `json:"input_tokens"`
|
||||||
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.InputTokens, 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 {
|
||||||
|
id string
|
||||||
|
model string
|
||||||
|
created int64
|
||||||
|
u usage
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Any other or malformed event is ignored (nil, nil) rather than erroring
|
||||||
|
// — an event this translator does not recognize is not necessarily a
|
||||||
|
// broken stream.
|
||||||
|
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, nil
|
||||||
|
}
|
||||||
|
st.id = p.Message.ID
|
||||||
|
st.u.prompt = p.Message.Usage.InputTokens
|
||||||
|
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, nil
|
||||||
|
}
|
||||||
|
if p.ContentBlock.Type != "tool_use" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
delta := map[string]any{"tool_calls": []any{map[string]any{
|
||||||
|
"index": p.Index,
|
||||||
|
"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, nil
|
||||||
|
}
|
||||||
|
switch p.Delta.Type {
|
||||||
|
case "text_delta":
|
||||||
|
return [][]byte{st.chunk(map[string]any{"content": p.Delta.Text}, nil)}, nil
|
||||||
|
case "input_json_delta":
|
||||||
|
delta := map[string]any{"tool_calls": []any{map[string]any{
|
||||||
|
"index": p.Index,
|
||||||
|
"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, nil
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user