mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
feat(routes): serve inbound Anthropic Messages API
Add POST /v1/messages, serving Anthropic SDK clients directly instead of outbound-only support. A request resolved to an anthropic-type provider passes through untranslated. A request resolved to any other provider type translates to and from OpenAI's chat-completion shape through the resolved adapter's own chatCompletion, since that method's contract is OpenAI-shape in and out regardless of provider type. The route reuses admitRequest, readAndDecodeUnifiedBody, registry.resolve, withAttemptRecorder, and limiter.account exactly as routes_unified.go does, so groups, model resolution, limits, accounting, and caching semantics stay identical to the unified route. Client-facing errors use Anthropic's envelope shape. A streaming request is rejected explicitly with a 501, never silently answered non-streamed; streaming support is a follow-up branch. Auth reuses authStore.identify and logAuthEvent unchanged. presentedKey already reads both x-api-key and Authorization: Bearer, so no new credential-checking code was needed. Add openAIRequestFromAnthropic and anthropicResponseFromOpenAI to translate_anthropic.go, the mirror of the existing outbound translators, plus their supporting helpers and unit tests.
This commit is contained in:
@@ -785,6 +785,25 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost && r.URL.Path == messagesPath {
|
||||||
|
// Anthropic SDKs authenticate with x-api-key, not Authorization:
|
||||||
|
// Bearer — g.auth.identify (auth.go) already reads both headers,
|
||||||
|
// Bearer taking priority when a caller somehow sends both, so no
|
||||||
|
// route-specific auth handling is needed here beyond using the
|
||||||
|
// same identify/logAuthEvent call every other route already
|
||||||
|
// makes. A failed auth gets the Anthropic error shape
|
||||||
|
// (writeAnthropicError, routes_messages.go), not writeOAIError's
|
||||||
|
// — the one thing that differs from every route above.
|
||||||
|
u, grp, ok := g.auth.identify(r)
|
||||||
|
g.logAuthEvent(ok, authEventUserName(u), r)
|
||||||
|
if !ok {
|
||||||
|
writeAnthropicError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.handleMessages(sw, r, u, grp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodPost && (r.URL.Path == imagesGenerationsPath || r.URL.Path == audioSpeechPath || r.URL.Path == audioTranscriptionsPath) {
|
if r.Method == http.MethodPost && (r.URL.Path == imagesGenerationsPath || r.URL.Path == audioSpeechPath || r.URL.Path == audioTranscriptionsPath) {
|
||||||
u, grp, ok := g.auth.identify(r)
|
u, grp, ok := g.auth.identify(r)
|
||||||
g.logAuthEvent(ok, authEventUserName(u), r)
|
g.logAuthEvent(ok, authEventUserName(u), r)
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// messagesPath is the inbound Anthropic Messages API route this gateway
|
||||||
|
// serves: POST /v1/messages. Distinct from anthropicMessagesPath
|
||||||
|
// (provider_anthropic.go), which is the SAME literal path string but
|
||||||
|
// names where an anthropic-type adapter's OWN outbound call lands on its
|
||||||
|
// configured upstream — two different constants for two different
|
||||||
|
// directions, kept apart so a future change to one is never mistaken for
|
||||||
|
// the other.
|
||||||
|
const messagesPath = "/v1/messages"
|
||||||
|
|
||||||
|
// cacheEndpointMessages is cacheKey's endpoint argument (cache.go) for
|
||||||
|
// this route, mirroring cacheEndpointChat/cacheEndpointEmbeddings
|
||||||
|
// (routes_unified.go): keeps a /v1/messages request from ever colliding
|
||||||
|
// into a /v1/chat/completions cache entry even when a request happens to
|
||||||
|
// canonicalize to an identical provider/upstreamModel/body — the two
|
||||||
|
// routes answer in different wire shapes (Anthropic vs OpenAI), so a
|
||||||
|
// cache hit across them would hand a client the wrong envelope entirely.
|
||||||
|
const cacheEndpointMessages = "messages"
|
||||||
|
|
||||||
|
// handleMessages implements POST /v1/messages: the Anthropic Messages API
|
||||||
|
// shape, inbound. It shares every pipeline step routes_unified.go's
|
||||||
|
// runUnified already established for /v1/chat/completions and
|
||||||
|
// /v1/embeddings — body-admission-gated decode, model resolution,
|
||||||
|
// request-rate limiting, response caching, usage accounting, and
|
||||||
|
// per-provider attempt recording — by calling the exact same functions
|
||||||
|
// runUnified calls, rather than reimplementing any of them: g.admitRequest,
|
||||||
|
// g.readAndDecodeUnifiedBody, g.registry.resolve, withAttemptRecorder,
|
||||||
|
// g.limiter.account, unifiedCostMicros, cacheKey/groupCacheEnabled/
|
||||||
|
// effectiveTTL, and cacheCaptureWriter. Only two things are genuinely
|
||||||
|
// specific to this route and are NOT shared: which wire shape a request/
|
||||||
|
// response is in (Anthropic, not OpenAI — see callAnthropicMessagesPassthrough/
|
||||||
|
// callTranslatedMessages below) and which error envelope shape a failure
|
||||||
|
// gets written in (Anthropic's {"type":"error","error":{...}}, not
|
||||||
|
// OpenAI's {"error":{...}} — see writeAnthropicError and its callers,
|
||||||
|
// below).
|
||||||
|
func (g *Gateway) handleMessages(w http.ResponseWriter, r *http.Request, u *user, grp *group) {
|
||||||
|
sw := &statusTrackingWriter{ResponseWriter: w}
|
||||||
|
|
||||||
|
scopes, ok := g.admitRequest(sw, u, grp)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, req, ok := g.readAndDecodeUnifiedBody(sw, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedModel, _ := req["model"].(string)
|
||||||
|
if requestedModel == "" {
|
||||||
|
writeAnthropicError(sw, http.StatusBadRequest, "invalid_request_error", "model is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming is rejected cleanly and explicitly here, before model
|
||||||
|
// resolution or any upstream call: this cut of the route ships
|
||||||
|
// non-streaming only (brief scope) — a follow-up branch adds the
|
||||||
|
// Anthropic SSE event translator (message_start/content_block_delta/
|
||||||
|
// message_stop), a genuinely different event model from OpenAI's
|
||||||
|
// stream shape this route already knows how to forward. A caller
|
||||||
|
// that asked for streaming must see a clear, typed error, never a
|
||||||
|
// silently-ignored flag answered non-streamed.
|
||||||
|
if streaming, _ := req["stream"].(bool); streaming {
|
||||||
|
writeAnthropicError(sw, http.StatusNotImplemented, "invalid_request_error", "streaming is not yet supported on /v1/messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter, upstreamModel, canonical, err := g.registry.resolve(requestedModel, grp)
|
||||||
|
if err != nil {
|
||||||
|
writeAnthropicModelResolveError(sw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req["model"] = upstreamModel
|
||||||
|
delete(req, gatewayAliasKey) // defensive: this route never sets it, but a client-sent "__alias" field must never reach either upstream shape
|
||||||
|
|
||||||
|
cacheable := g.cache != nil && groupCacheEnabled(grp)
|
||||||
|
var cacheKeyStr string
|
||||||
|
if cacheable {
|
||||||
|
cacheKeyStr = cacheKey(adapter.name(), upstreamModel, requestedModel, cacheEndpointMessages, req)
|
||||||
|
if cached, hit := g.cache.lookup(cacheKeyStr); hit {
|
||||||
|
sw.Header().Set("X-Llmgw-Cache", "hit")
|
||||||
|
if cached.ContentType != "" {
|
||||||
|
sw.Header().Set("Content-Type", cached.ContentType)
|
||||||
|
}
|
||||||
|
sw.WriteHeader(cached.Status)
|
||||||
|
_, _ = sw.Write(cached.Body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sw.Header().Set("X-Llmgw-Cache", "miss")
|
||||||
|
}
|
||||||
|
|
||||||
|
var respWriter http.ResponseWriter = sw
|
||||||
|
var capture *cacheCaptureWriter
|
||||||
|
if cacheable {
|
||||||
|
capture = newCacheCaptureWriter(sw, g.cache.maxBodyBytes)
|
||||||
|
respWriter = capture
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) {
|
||||||
|
g.limiter.recordProviderAttempt(adapter.name(), upstreamModel, resp, attemptErr)
|
||||||
|
})
|
||||||
|
|
||||||
|
var result usage
|
||||||
|
var callErr error
|
||||||
|
if adapter.typeName() == providerTypeAnthropic {
|
||||||
|
result, callErr = g.callAnthropicMessagesPassthrough(ctx, respWriter, adapter, req)
|
||||||
|
} else {
|
||||||
|
result, callErr = g.callTranslatedMessages(ctx, respWriter, adapter, req, requestedModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-usage fallback, mirroring runUnified's own (routes_unified.go):
|
||||||
|
// a non-streaming failure always carries zero usage by contract, so
|
||||||
|
// this is a no-op whenever callErr != nil (account below skips every
|
||||||
|
// write once both total tokens and cost are zero). Unlike runUnified,
|
||||||
|
// there is no streaming branch to skip here — streaming was already
|
||||||
|
// rejected above, so every call into this route that reaches here is
|
||||||
|
// non-streaming.
|
||||||
|
if callErr == nil && result.total() == 0 {
|
||||||
|
result.prompt = int64(math.Ceil(float64(len(body)) / 4))
|
||||||
|
result.estimated = true
|
||||||
|
}
|
||||||
|
g.limiter.account(scopes, result, unifiedCostMicros(canonical, upstreamModel, result, g.cfg.Pricing))
|
||||||
|
if result.estimated {
|
||||||
|
g.logf("messages route: usage for model %q logged as estimated (%d prompt tokens derived from request body size, not the provider's reported usage)", canonical, result.prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cacheable && callErr == nil && capture.status == http.StatusOK && !capture.oversize {
|
||||||
|
g.cache.store(cacheKeyStr, capture.status, capture.contentType, capture.buf.Bytes(), effectiveTTL(g.cache, grp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if callErr != nil {
|
||||||
|
g.handleMessagesAdapterError(sw, callErr, adapter.name())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// callAnthropicMessagesPassthrough handles a /v1/messages request already
|
||||||
|
// resolved to an anthropic-type provider: forward req to that provider's
|
||||||
|
// own Messages endpoint UNTRANSLATED (brief: "pass through, no
|
||||||
|
// translation of the message body") and copy its response back to w
|
||||||
|
// exactly as Anthropic sent it. req's only mutation, applied by the
|
||||||
|
// caller (handleMessages) before this function ever runs, is the "model"
|
||||||
|
// field rewrite to the resolved upstream model id — the same rewrite
|
||||||
|
// every route in this package applies before any adapter call
|
||||||
|
// (runUnified, resolveMediaModel); it is request ROUTING, not message
|
||||||
|
// translation.
|
||||||
|
//
|
||||||
|
// This deliberately bypasses providerAdapter.chatCompletion:
|
||||||
|
// chatCompletion's contract (providers.go) is OpenAI-shape in, OpenAI-
|
||||||
|
// shape out for every provider type — anthropicAdapter.chatCompletion
|
||||||
|
// always calls anthropicRequestFromOpenAI on whatever it is handed, which
|
||||||
|
// would corrupt an already-Anthropic-shaped body instead of passing it
|
||||||
|
// through.
|
||||||
|
//
|
||||||
|
// The response is written back byte-for-byte: this function reads
|
||||||
|
// Anthropic's raw JSON only to extract usage (input_tokens/output_tokens,
|
||||||
|
// via the existing anthropicResponseBody type, translate_anthropic.go —
|
||||||
|
// reused rather than declared again) for accounting, and never re-
|
||||||
|
// encodes what reaches the client. One accepted consequence: the
|
||||||
|
// response's own "model" field carries the upstream model id Anthropic
|
||||||
|
// actually served, not the client's requested alias — unlike every
|
||||||
|
// translating adapter's gatewayAliasKey echo (routes_unified.go's own
|
||||||
|
// doc comment on that mechanism), rewriting it here would mean decoding
|
||||||
|
// and re-marshaling the full response body, which is itself a
|
||||||
|
// translation step the brief's "no translation of the message body" rule
|
||||||
|
// for this branch rules out.
|
||||||
|
func (g *Gateway) callAnthropicMessagesPassthrough(ctx context.Context, w http.ResponseWriter, adapter providerAdapter, req map[string]any) (usage, error) {
|
||||||
|
// Plain type assertion, not an added providerAdapter interface method:
|
||||||
|
// this branch only ever runs when the caller already confirmed
|
||||||
|
// adapter.typeName() == providerTypeAnthropic, and buildAdapters
|
||||||
|
// (providers.go) only ever constructs a *anthropicAdapter for that
|
||||||
|
// type, so the assertion is guaranteed to succeed. aa.retry is
|
||||||
|
// nil-safe either way (retryPolicy.do's own contract, retry.go), so a
|
||||||
|
// failed assertion still degrades safely to one-attempt behavior
|
||||||
|
// rather than panicking.
|
||||||
|
aa, _ := adapter.(*anthropicAdapter)
|
||||||
|
var retry *retryPolicy
|
||||||
|
if aa != nil {
|
||||||
|
retry = aa.retry
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr := http.Header{}
|
||||||
|
hdr.Set("Content-Type", "application/json")
|
||||||
|
adapter.injectAuth(&http.Request{Header: hdr})
|
||||||
|
|
||||||
|
resp, err := upstreamJSON(ctx, adapter.httpClient(), http.MethodPost, adapter.base()+anthropicMessagesPath, hdr, req, retry)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||||
|
if err != nil {
|
||||||
|
return usage{}, fmt.Errorf("%w: read response body: %w", errUpstream, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed anthropicResponseBody
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return usage{}, fmt.Errorf("%w: decode anthropic response: %w", errUpstream, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = w.Write(raw)
|
||||||
|
|
||||||
|
return usage{prompt: parsed.Usage.InputTokens, completion: parsed.Usage.OutputTokens}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callTranslatedMessages handles a /v1/messages request resolved to a
|
||||||
|
// non-anthropic-type provider (openai, or gemini — both implement
|
||||||
|
// providerAdapter.chatCompletion against the SAME OpenAI-shaped
|
||||||
|
// contract, providers.go, regardless of what their own upstream wire
|
||||||
|
// format is): translate req from Anthropic's shape to OpenAI's
|
||||||
|
// (openAIRequestFromAnthropic, translate_anthropic.go), run it through
|
||||||
|
// the resolved adapter's own chatCompletion exactly as routes_unified.go's
|
||||||
|
// handleChat does, capturing what it writes into an in-memory recorder
|
||||||
|
// instead of letting it reach the client directly, then translate that
|
||||||
|
// captured OpenAI-shaped response back to Anthropic's shape
|
||||||
|
// (anthropicResponseFromOpenAI) before writing the result to w.
|
||||||
|
func (g *Gateway) callTranslatedMessages(ctx context.Context, w http.ResponseWriter, adapter providerAdapter, req map[string]any, requestedModel string) (usage, error) {
|
||||||
|
openaiReq, err := openAIRequestFromAnthropic(req)
|
||||||
|
if err != nil {
|
||||||
|
return usage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := newMessagesResponseRecorder()
|
||||||
|
result, err := adapter.chatCompletion(ctx, rec, openaiReq)
|
||||||
|
if err != nil {
|
||||||
|
return usage{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := anthropicResponseFromOpenAI(rec.body.Bytes(), requestedModel)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := rec.status
|
||||||
|
if status == 0 {
|
||||||
|
status = http.StatusOK
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// messagesResponseRecorder is a minimal in-memory http.ResponseWriter
|
||||||
|
// callTranslatedMessages hands to a resolved adapter's own chatCompletion
|
||||||
|
// in place of the real client connection. chatCompletion's contract
|
||||||
|
// (providerAdapter, providers.go) is OpenAI-shape in, OpenAI-shape out
|
||||||
|
// regardless of provider type, and this route needs to translate that
|
||||||
|
// output to Anthropic's shape before anything reaches the real client —
|
||||||
|
// so the adapter's write is captured here and never forwarded; the real
|
||||||
|
// ResponseWriter only ever sees the translated bytes callTranslatedMessages
|
||||||
|
// writes afterward. Streaming can never reach this recorder: handleMessages
|
||||||
|
// rejects "stream": true before either call path runs, so chatCompletion's
|
||||||
|
// non-streaming forwardJSON is the only path that ever writes to it.
|
||||||
|
type messagesResponseRecorder struct {
|
||||||
|
header http.Header
|
||||||
|
body bytes.Buffer
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMessagesResponseRecorder() *messagesResponseRecorder {
|
||||||
|
return &messagesResponseRecorder{header: http.Header{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *messagesResponseRecorder) Header() http.Header { return r.header }
|
||||||
|
|
||||||
|
func (r *messagesResponseRecorder) WriteHeader(status int) { r.status = status }
|
||||||
|
|
||||||
|
func (r *messagesResponseRecorder) Write(b []byte) (int, error) {
|
||||||
|
if r.status == 0 {
|
||||||
|
r.status = http.StatusOK // mirrors net/http's implicit-200 default
|
||||||
|
}
|
||||||
|
return r.body.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAnthropicError writes an Anthropic Messages API-shaped error
|
||||||
|
// envelope: {"type":"error","error":{"type":...,"message":...}} — what an
|
||||||
|
// Anthropic SDK client parses. errType uses the same vocabulary this
|
||||||
|
// gateway's OpenAI-shaped envelope already uses everywhere else
|
||||||
|
// (writeOAIError, errors.go: invalid_request_error, authentication_error,
|
||||||
|
// rate_limit_error, server_error), so an operator reading logs sees one
|
||||||
|
// consistent taxonomy regardless of which route answered; only the JSON
|
||||||
|
// shape around it differs, to match what this route's own clients expect.
|
||||||
|
func writeAnthropicError(w http.ResponseWriter, status int, errType, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"type": "error",
|
||||||
|
"error": map[string]any{"type": errType, "message": msg},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAnthropicModelResolveError is writeModelResolveError's
|
||||||
|
// (routes_unified.go) Anthropic-shaped counterpart: the SAME status-code
|
||||||
|
// decision (aliasTargetError/errModelUnknown/errModelDenied), written
|
||||||
|
// through writeAnthropicError instead of writeOAIError. Kept as its own
|
||||||
|
// small function, rather than a routes_unified.go refactor threading an
|
||||||
|
// envelope-writer parameter through, so this route's addition stays
|
||||||
|
// isolated to its own file while a sibling branch is concurrently editing
|
||||||
|
// registry.go/admin.go — the decision logic below is intentionally
|
||||||
|
// identical to writeModelResolveError's, not independently re-derived.
|
||||||
|
func writeAnthropicModelResolveError(w http.ResponseWriter, err error) {
|
||||||
|
if aerr, ok := err.(*aliasTargetError); ok {
|
||||||
|
writeAnthropicError(w, http.StatusNotFound, "invalid_request_error", aerr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errModelUnknown):
|
||||||
|
writeAnthropicError(w, http.StatusNotFound, "invalid_request_error", "unknown model")
|
||||||
|
case errors.Is(err, errModelDenied):
|
||||||
|
writeAnthropicError(w, http.StatusForbidden, "invalid_request_error", "model access denied")
|
||||||
|
default:
|
||||||
|
writeAnthropicError(w, http.StatusInternalServerError, "server_error", "internal error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessagesAdapterError is handleAdapterError's (routes_unified.go)
|
||||||
|
// Anthropic-shaped counterpart, covering the identical set of adapter
|
||||||
|
// error cases with the identical yaegi-safety rationale (plain type
|
||||||
|
// assertions for providerHTTPError/translateError, never errors.As —
|
||||||
|
// see providerHTTPError's own doc comment, providers.go) but writing
|
||||||
|
// through writeAnthropicError/writeAnthropicProviderUpstreamError instead
|
||||||
|
// of writeOAIError/writeProviderUpstreamError, and its own "messages
|
||||||
|
// route:" log prefix so a log line always names which route it came from.
|
||||||
|
func (g *Gateway) handleMessagesAdapterError(sw *statusTrackingWriter, err error, providerName string) {
|
||||||
|
sw.Header().Del("X-Llmgw-Cache")
|
||||||
|
|
||||||
|
if sw.wroteHeader {
|
||||||
|
g.errorf("messages route: adapter error after response started (provider %q): %v", providerName, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if perr, ok := err.(*providerHTTPError); ok {
|
||||||
|
writeAnthropicProviderUpstreamError(sw, providerName, perr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if terr, ok := err.(*translateError); ok {
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
if terr.notSupported {
|
||||||
|
status = http.StatusNotImplemented
|
||||||
|
}
|
||||||
|
writeAnthropicError(sw, status, "invalid_request_error", terr.msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
g.logf("messages route: client canceled request to provider %q: %v", providerName, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.errorf("messages route: upstream connection error (provider %q): %v", providerName, err)
|
||||||
|
writeAnthropicError(sw, http.StatusBadGateway, "server_error", "upstream connection error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAnthropicProviderUpstreamError is writeProviderUpstreamError's
|
||||||
|
// (routes_unified.go) Anthropic-shaped counterpart: perr's upstream body
|
||||||
|
// is embedded under error.upstream (decoded to a JSON value when it
|
||||||
|
// parses as one, left as a raw string otherwise) inside the Anthropic
|
||||||
|
// {"type":"error","error":{...}} envelope instead of the OpenAI one.
|
||||||
|
func writeAnthropicProviderUpstreamError(w http.ResponseWriter, providerName string, perr *providerHTTPError) {
|
||||||
|
var upstream any = string(perr.body)
|
||||||
|
var parsed any
|
||||||
|
if json.Unmarshal(perr.body, &parsed) == nil {
|
||||||
|
upstream = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(perr.status)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{ // headers already committed; nothing useful to do on encode failure
|
||||||
|
"type": "error",
|
||||||
|
"error": map[string]any{
|
||||||
|
"type": "upstream_error",
|
||||||
|
"message": providerName + " upstream error",
|
||||||
|
"code": strconv.Itoa(perr.status),
|
||||||
|
"upstream": upstream,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newMessagesRequest builds a POST /v1/messages request, JSON-encoding
|
||||||
|
// body. authHeader/authValue let a test choose which credential header to
|
||||||
|
// send; authHeader == "" sends no credential at all.
|
||||||
|
func newMessagesRequest(t *testing.T, body map[string]any, authHeader, authValue string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal request body: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, messagesPath, bytes.NewReader(b))
|
||||||
|
if authHeader != "" {
|
||||||
|
req.Header.Set(authHeader, authValue)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMessagesTestGateway builds a *Gateway wired with an "anthropic"
|
||||||
|
// provider pointed at anthropicURL (skipped when "") and an "openai"
|
||||||
|
// provider pointed at openaiURL (skipped when ""), one permissive
|
||||||
|
// "default" group, and one inline user "alice" — the shared fixture
|
||||||
|
// every handleMessages test below starts from.
|
||||||
|
func newMessagesTestGateway(t *testing.T, anthropicURL, openaiURL string) *Gateway {
|
||||||
|
t.Helper()
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{}
|
||||||
|
if anthropicURL != "" {
|
||||||
|
cfg.Providers["anthropic"] = &ProviderConfig{Type: providerTypeAnthropic, BaseURL: anthropicURL, APIKey: "sk-ant-up", Models: []string{"claude-test"}} // #nosec G101 -- test fixture literal, not a real credential
|
||||||
|
}
|
||||||
|
if openaiURL != "" {
|
||||||
|
cfg.Providers["openai"] = &ProviderConfig{Type: providerTypeOpenAI, BaseURL: openaiURL, APIKey: "sk-oai-up", Models: []string{"gpt-test"}}
|
||||||
|
}
|
||||||
|
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
||||||
|
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
|
||||||
|
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||||
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
gw, ok := h.(*Gateway)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("handler is not *Gateway")
|
||||||
|
}
|
||||||
|
return gw
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMessages_AnthropicProvider_Passthrough proves a /v1/messages
|
||||||
|
// request resolved to an anthropic-type provider is forwarded to that
|
||||||
|
// provider's own Messages endpoint and its response copied back to the
|
||||||
|
// client byte-for-byte (brief: "pass through, no translation of the
|
||||||
|
// message body"), while still authenticating upstream and accounting
|
||||||
|
// usage exactly like every other route.
|
||||||
|
func TestHandleMessages_AnthropicProvider_Passthrough(t *testing.T) {
|
||||||
|
const anthResp = `{"id":"msg_01ABC","type":"message","role":"assistant","content":[{"type":"text","text":"hi there"}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":6}}`
|
||||||
|
|
||||||
|
var gotBody map[string]any
|
||||||
|
var gotAPIKey, gotVersion, gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotAPIKey = r.Header.Get("x-api-key")
|
||||||
|
gotVersion = r.Header.Get("anthropic-version")
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(anthResp))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
gw := newMessagesTestGateway(t, srv.URL, "")
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "claude-test",
|
||||||
|
"max_tokens": 100,
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||||
|
}
|
||||||
|
req := newMessagesRequest(t, body, "x-api-key", "sk-alice")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
gw.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec.Body.String() != anthResp {
|
||||||
|
t.Errorf("body = %q, want verbatim upstream body %q (no translation)", rec.Body.String(), anthResp)
|
||||||
|
}
|
||||||
|
if gotPath != anthropicMessagesPath {
|
||||||
|
t.Errorf("upstream path = %q, want %q", gotPath, anthropicMessagesPath)
|
||||||
|
}
|
||||||
|
if gotAPIKey != "sk-ant-up" { // #nosec G101 -- test fixture literal, not a real credential
|
||||||
|
t.Errorf("upstream x-api-key = %q, want sk-ant-up", gotAPIKey)
|
||||||
|
}
|
||||||
|
if gotVersion != anthropicAPIVersion {
|
||||||
|
t.Errorf("upstream anthropic-version = %q, want %q", gotVersion, anthropicAPIVersion)
|
||||||
|
}
|
||||||
|
if gotBody["model"] != "claude-test" {
|
||||||
|
t.Errorf("upstream model = %v, want claude-test (resolved upstream id)", gotBody["model"])
|
||||||
|
}
|
||||||
|
if _, hasAlias := gotBody[gatewayAliasKey]; hasAlias {
|
||||||
|
t.Error("upstream request carries gatewayAliasKey; must never reach an upstream provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
|
||||||
|
if !ok || tokIn != 12 {
|
||||||
|
t.Errorf("user tokin/day counter = %d (ok=%v), want 12", tokIn, ok)
|
||||||
|
}
|
||||||
|
tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now())
|
||||||
|
if !ok || tokOut != 6 {
|
||||||
|
t.Errorf("user tokout/day counter = %d (ok=%v), want 6", tokOut, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMessages_OpenAIProvider_TranslatesBothDirections proves a
|
||||||
|
// /v1/messages request resolved to an openai-type provider is translated
|
||||||
|
// to OpenAI's chat-completion shape going out (Anthropic's top-level
|
||||||
|
// "system" field folded into a role:"system" message) and the upstream's
|
||||||
|
// OpenAI-shaped response translated back to Anthropic's Messages shape
|
||||||
|
// coming back, with usage accounted from the translated numbers.
|
||||||
|
func TestHandleMessages_OpenAIProvider_TranslatesBothDirections(t *testing.T) {
|
||||||
|
const oaiResp = `{"id":"chatcmpl-99","object":"chat.completion","model":"gpt-real","choices":[{"index":0,"message":{"role":"assistant","content":"hi there"},"finish_reason":"stop"}],"usage":{"prompt_tokens":8,"completion_tokens":4}}`
|
||||||
|
|
||||||
|
var gotReq map[string]any
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotReq)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(oaiResp))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
gw := newMessagesTestGateway(t, "", srv.URL)
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"model": "gpt-test",
|
||||||
|
"max_tokens": 100,
|
||||||
|
"system": "You are terse.",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||||
|
}
|
||||||
|
req := newMessagesRequest(t, body, "x-api-key", "sk-alice")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
gw.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if gotPath != "/v1/chat/completions" {
|
||||||
|
t.Errorf("upstream path = %q, want /v1/chat/completions", gotPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := gotReq["messages"].([]any)
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("upstream messages = %v, want 2 (system + user)", gotReq["messages"])
|
||||||
|
}
|
||||||
|
first, _ := msgs[0].(map[string]any)
|
||||||
|
if first["role"] != "system" || first["content"] != "You are terse." {
|
||||||
|
t.Errorf("upstream messages[0] = %v, want the system message translated into OpenAI's shape", first)
|
||||||
|
}
|
||||||
|
if _, hasSystem := gotReq["system"]; hasSystem {
|
||||||
|
t.Error("upstream request still carries Anthropic's own \"system\" field; want it folded into messages[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode client response: %v", err)
|
||||||
|
}
|
||||||
|
if got["type"] != "message" || got["role"] != "assistant" {
|
||||||
|
t.Errorf("response type/role = %v/%v, want message/assistant (Anthropic shape)", got["type"], got["role"])
|
||||||
|
}
|
||||||
|
content, _ := got["content"].([]any)
|
||||||
|
if len(content) != 1 {
|
||||||
|
t.Fatalf("response content = %v, want one text block", got["content"])
|
||||||
|
}
|
||||||
|
block, _ := content[0].(map[string]any)
|
||||||
|
if block["type"] != "text" || block["text"] != "hi there" {
|
||||||
|
t.Errorf("response content[0] = %v, want {type: text, text: \"hi there\"}", block)
|
||||||
|
}
|
||||||
|
if got["model"] != "gpt-test" {
|
||||||
|
t.Errorf("response model = %v, want gpt-test (the client's own requested alias echoed back)", got["model"])
|
||||||
|
}
|
||||||
|
if got["stop_reason"] != "end_turn" {
|
||||||
|
t.Errorf("response stop_reason = %v, want end_turn", got["stop_reason"])
|
||||||
|
}
|
||||||
|
|
||||||
|
usageMap, _ := got["usage"].(map[string]any)
|
||||||
|
if usageMap["input_tokens"] != float64(8) || usageMap["output_tokens"] != float64(4) {
|
||||||
|
t.Errorf("response usage = %v, want input_tokens=8 output_tokens=4", usageMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
|
||||||
|
if !ok || tokIn != 8 {
|
||||||
|
t.Errorf("user tokin/day counter = %d (ok=%v), want 8", tokIn, ok)
|
||||||
|
}
|
||||||
|
tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now())
|
||||||
|
if !ok || tokOut != 4 {
|
||||||
|
t.Errorf("user tokout/day counter = %d (ok=%v), want 4", tokOut, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMessages_Auth table-drives the three credential cases the
|
||||||
|
// brief calls out explicitly: x-api-key authenticates, Authorization:
|
||||||
|
// Bearer authenticates, and a bad key gets a 401 in ANTHROPIC error
|
||||||
|
// shape ({"type":"error","error":{"type":...}}), not OpenAI's
|
||||||
|
// ({"error":{...}}).
|
||||||
|
func TestHandleMessages_Auth(t *testing.T) {
|
||||||
|
const anthResp = `{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(anthResp))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
authHeader string
|
||||||
|
authValue string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"x-api-key header authenticates", "x-api-key", "sk-alice", http.StatusOK},
|
||||||
|
{"Authorization Bearer header authenticates", "Authorization", "Bearer sk-alice", http.StatusOK},
|
||||||
|
{"bad key is rejected in Anthropic error shape", "x-api-key", "sk-not-a-real-key", http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
gw := newMessagesTestGateway(t, srv.URL, "")
|
||||||
|
body := map[string]any{"model": "claude-test", "max_tokens": 10, "messages": []any{map[string]any{"role": "user", "content": "hi"}}}
|
||||||
|
req := newMessagesRequest(t, body, tc.authHeader, tc.authValue)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
gw.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, tc.wantStatus, rec.Body.String())
|
||||||
|
}
|
||||||
|
if tc.wantStatus != http.StatusUnauthorized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode error body: %v", err)
|
||||||
|
}
|
||||||
|
if got["type"] != "error" {
|
||||||
|
t.Errorf(`response["type"] = %v, want "error" (Anthropic's envelope shape, not OpenAI's flat one)`, got["type"])
|
||||||
|
}
|
||||||
|
errObj, _ := got["error"].(map[string]any)
|
||||||
|
if errObj["type"] != "authentication_error" {
|
||||||
|
t.Errorf("error.type = %v, want authentication_error", errObj["type"])
|
||||||
|
}
|
||||||
|
if _, hasTopLevelCode := got["code"]; hasTopLevelCode {
|
||||||
|
t.Error(`response carries a top-level "code" field — that is OpenAI's writeOAIError shape, not Anthropic's`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMessages_StreamingRejected proves "stream": true is rejected
|
||||||
|
// cleanly with a typed, Anthropic-shaped error before any upstream call
|
||||||
|
// is made — never silently ignored, never answered non-streamed.
|
||||||
|
func TestHandleMessages_StreamingRejected(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()
|
||||||
|
|
||||||
|
gw := newMessagesTestGateway(t, srv.URL, "")
|
||||||
|
body := map[string]any{"model": "claude-test", "max_tokens": 10, "stream": true, "messages": []any{map[string]any{"role": "user", "content": "hi"}}}
|
||||||
|
req := newMessagesRequest(t, body, "x-api-key", "sk-alice")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
gw.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotImplemented {
|
||||||
|
t.Fatalf("status = %d, want 501, body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("upstream was called; streaming must be rejected before any upstream request")
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode error body: %v", err)
|
||||||
|
}
|
||||||
|
if got["type"] != "error" {
|
||||||
|
t.Errorf(`response["type"] = %v, want "error"`, got["type"])
|
||||||
|
}
|
||||||
|
errObj, _ := got["error"].(map[string]any)
|
||||||
|
msg, _ := errObj["message"].(string)
|
||||||
|
if !strings.Contains(msg, "stream") {
|
||||||
|
t.Errorf("error message = %q, want it to mention streaming is not supported", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMessages_RateLimited_Returns429 proves this route enforces
|
||||||
|
// the caller's group/user requestsPerMinute limit exactly like
|
||||||
|
// routes_unified.go's runUnified: a second request past the limit is
|
||||||
|
// refused before any upstream call, carries a Retry-After header, and
|
||||||
|
// only the first, successful request's usage is accounted.
|
||||||
|
func TestHandleMessages_RateLimited_Returns429(t *testing.T) {
|
||||||
|
upstreamCalls := 0
|
||||||
|
const anthResp = `{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
upstreamCalls++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(anthResp))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{"anthropic": {Type: providerTypeAnthropic, BaseURL: srv.URL, APIKey: "sk-ant-up", Models: []string{"claude-test"}}} // #nosec G101 -- test fixture literal, not a real credential
|
||||||
|
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
||||||
|
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{RequestsPerMinute: 1}}}}
|
||||||
|
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||||
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
gw, ok := h.(*Gateway)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("handler is not *Gateway")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]any{"model": "claude-test", "max_tokens": 10, "messages": []any{map[string]any{"role": "user", "content": "hi"}}}
|
||||||
|
|
||||||
|
req1 := newMessagesRequest(t, body, "x-api-key", "sk-alice")
|
||||||
|
rec1 := httptest.NewRecorder()
|
||||||
|
gw.ServeHTTP(rec1, req1)
|
||||||
|
if rec1.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first request status = %d, want 200, body=%s", rec1.Code, rec1.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
req2 := newMessagesRequest(t, body, "x-api-key", "sk-alice")
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
gw.ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("second request status = %d, want 429, body=%s", rec2.Code, rec2.Body.String())
|
||||||
|
}
|
||||||
|
if rec2.Header().Get("Retry-After") == "" {
|
||||||
|
t.Error("429 response missing Retry-After header")
|
||||||
|
}
|
||||||
|
if upstreamCalls != 1 {
|
||||||
|
t.Errorf("upstream called %d times, want 1 (second request refused before any upstream call)", upstreamCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
|
||||||
|
if !ok || tokIn != 1 {
|
||||||
|
t.Errorf("user tokin/day counter = %d (ok=%v), want 1 (only the first, successful request accounted)", tokIn, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -824,3 +824,408 @@ func (st *anthropicStreamState) translate(ev sseEvent) ([][]byte, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Anthropic -> OpenAI request translation (routes_messages.go) ---
|
||||||
|
//
|
||||||
|
// The functions below are the mirror of anthropicRequestFromOpenAI/
|
||||||
|
// openAIResponseFromAnthropic above: routes_messages.go's /v1/messages
|
||||||
|
// route receives an Anthropic-shaped request and, when it resolves to a
|
||||||
|
// non-anthropic-type provider, must translate it to OpenAI's shape going
|
||||||
|
// out and translate that provider's OpenAI-shaped response back to
|
||||||
|
// Anthropic's shape coming back — the opposite direction from every
|
||||||
|
// function above this comment, which exist to let an OpenAI-shaped
|
||||||
|
// /v1/chat/completions request reach an anthropic-type provider. Neither
|
||||||
|
// direction reuses the other's helpers beyond the truly shape-agnostic
|
||||||
|
// ones (isTruthy, toFloat64/toInt64, systemTextFromContentParts): the two
|
||||||
|
// wire formats are similar but not inverses of each other field-for-field
|
||||||
|
// (Anthropic's tool_use.input is a decoded JSON value, OpenAI's
|
||||||
|
// tool_calls[].function.arguments is that same value re-encoded as a
|
||||||
|
// string, for instance), so each direction earns its own small mapping
|
||||||
|
// functions rather than one trying to serve both.
|
||||||
|
|
||||||
|
// anthropicSystemToText maps Anthropic's "system" request field — a plain
|
||||||
|
// string, or (newer API versions) an array of {"type":"text","text":...}
|
||||||
|
// content blocks — to plain text. Reuses systemTextFromContentParts
|
||||||
|
// (above) for the array form, since Anthropic's system content-block
|
||||||
|
// shape and OpenAI's system-message content-array shape are identical.
|
||||||
|
// Any other type (absent, or already-invalid JSON) returns "", nil —
|
||||||
|
// openAIRequestFromAnthropic only emits a system message when this
|
||||||
|
// returns non-empty text.
|
||||||
|
func anthropicSystemToText(sys any) (string, error) {
|
||||||
|
switch s := sys.(type) {
|
||||||
|
case string:
|
||||||
|
return s, nil
|
||||||
|
case []any:
|
||||||
|
return systemTextFromContentParts(s)
|
||||||
|
default:
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAIContentPartFromAnthropicImage maps one Anthropic {"type":"image",
|
||||||
|
// "source":{"type":"base64","media_type":...,"data":...}} content block to
|
||||||
|
// OpenAI's {"type":"image_url","image_url":{"url":"data:...;base64,..."}},
|
||||||
|
// the mirror of anthropicImageSourceFromDataURI above. Anthropic's image
|
||||||
|
// source "type" is currently always "base64" (a URL-sourced image source
|
||||||
|
// is a separate, newer Anthropic feature this translator does not yet
|
||||||
|
// support) — any other value is a *translateError.
|
||||||
|
func openAIContentPartFromAnthropicImage(bm map[string]any) (map[string]any, error) {
|
||||||
|
src, _ := bm["source"].(map[string]any)
|
||||||
|
srcType, _ := src["type"].(string)
|
||||||
|
if srcType != "base64" {
|
||||||
|
return nil, &translateError{msg: fmt.Sprintf("image source type %q is not supported for this provider", srcType)}
|
||||||
|
}
|
||||||
|
mediaType, _ := src["media_type"].(string)
|
||||||
|
data, _ := src["data"].(string)
|
||||||
|
url := "data:" + mediaType + ";base64," + data
|
||||||
|
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAIToolCallFromAnthropic maps one Anthropic {"type":"tool_use","id",
|
||||||
|
// "name","input"} content block to an OpenAI tool_calls[] entry
|
||||||
|
// ({"id","type":"function","function":{"name","arguments"}}), the mirror
|
||||||
|
// of anthropicToolUseBlockFromOpenAI above: input, already a decoded Go
|
||||||
|
// value (this translator's caller decoded the whole request body into
|
||||||
|
// map[string]any), is re-marshaled to a compact JSON string for OpenAI's
|
||||||
|
// "arguments" field.
|
||||||
|
func openAIToolCallFromAnthropic(bm map[string]any) (map[string]any, error) {
|
||||||
|
id, _ := bm["id"].(string)
|
||||||
|
name, _ := bm["name"].(string)
|
||||||
|
input := bm["input"]
|
||||||
|
if input == nil {
|
||||||
|
input = map[string]any{}
|
||||||
|
}
|
||||||
|
argsBytes, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: marshal tool_use input: %w", errUpstream, err)
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"arguments": string(argsBytes),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAIToolMessageFromAnthropic maps one Anthropic {"type":"tool_result",
|
||||||
|
// "tool_use_id","content"} content block to an OpenAI role:"tool" message
|
||||||
|
// ({"role":"tool","tool_call_id","content"}), the mirror of
|
||||||
|
// anthropicToolResultBlock above.
|
||||||
|
func openAIToolMessageFromAnthropic(bm map[string]any) map[string]any {
|
||||||
|
toolUseID, _ := bm["tool_use_id"].(string)
|
||||||
|
return map[string]any{"role": "tool", "tool_call_id": toolUseID, "content": bm["content"]}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAIMessagesFromAnthropic maps one Anthropic message ({"role",
|
||||||
|
// "content"}, content either a plain string or a content-block array) to
|
||||||
|
// one or more OpenAI messages: a plain string passes through unchanged; a
|
||||||
|
// block array splits into at most one {role, content, tool_calls?}
|
||||||
|
// message carrying every text/image/tool_use block, followed by one
|
||||||
|
// role:"tool" message per tool_result block (Anthropic allows a single
|
||||||
|
// user turn to carry several tool_result blocks answering a prior
|
||||||
|
// assistant turn's several tool_use calls — OpenAI's shape represents
|
||||||
|
// each as its own separate message, the mirror of
|
||||||
|
// anthropicRequestFromOpenAI's pendingToolResults accumulation above,
|
||||||
|
// which does the same collapse in the opposite direction).
|
||||||
|
func openAIMessagesFromAnthropic(role string, content any) ([]any, error) {
|
||||||
|
if s, ok := content.(string); ok {
|
||||||
|
return []any{map[string]any{"role": role, "content": s}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
blocksRaw, ok := content.([]any)
|
||||||
|
if !ok {
|
||||||
|
// Absent or unrecognized content shape: an empty message rather
|
||||||
|
// than an error — mirrors anthropicMessageFromOpenAI's own
|
||||||
|
// tolerance of a missing "content" field above.
|
||||||
|
return []any{map[string]any{"role": role, "content": ""}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentParts []any
|
||||||
|
var toolCalls []any
|
||||||
|
var toolResults []any
|
||||||
|
for _, b := range blocksRaw {
|
||||||
|
bm, ok := b.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch bt, _ := bm["type"].(string); bt {
|
||||||
|
case "text":
|
||||||
|
text, _ := bm["text"].(string)
|
||||||
|
contentParts = append(contentParts, map[string]any{"type": "text", "text": text})
|
||||||
|
case "image":
|
||||||
|
part, err := openAIContentPartFromAnthropicImage(bm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
contentParts = append(contentParts, part)
|
||||||
|
case "tool_use":
|
||||||
|
tc, err := openAIToolCallFromAnthropic(bm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
toolCalls = append(toolCalls, tc)
|
||||||
|
case "tool_result":
|
||||||
|
toolResults = append(toolResults, openAIToolMessageFromAnthropic(bm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []any
|
||||||
|
if len(contentParts) > 0 || len(toolCalls) > 0 {
|
||||||
|
m := map[string]any{"role": role}
|
||||||
|
if len(contentParts) > 0 {
|
||||||
|
m["content"] = contentParts
|
||||||
|
} else {
|
||||||
|
m["content"] = nil
|
||||||
|
}
|
||||||
|
if len(toolCalls) > 0 {
|
||||||
|
m["tool_calls"] = toolCalls
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
out = append(out, toolResults...)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAIToolsFromAnthropic maps Anthropic's tools[]{name, description,
|
||||||
|
// input_schema} to OpenAI's tools[].function{name, description,
|
||||||
|
// parameters}, the mirror of anthropicToolsFromOpenAI above.
|
||||||
|
func openAIToolsFromAnthropic(toolsRaw []any) []any {
|
||||||
|
out := make([]any, 0, len(toolsRaw))
|
||||||
|
for _, t := range toolsRaw {
|
||||||
|
tm, ok := t.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fn := map[string]any{"name": tm["name"], "parameters": tm["input_schema"]}
|
||||||
|
if desc, ok := tm["description"]; ok {
|
||||||
|
fn["description"] = desc
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{"type": "function", "function": fn})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAIToolChoiceFromAnthropic maps Anthropic's tool_choice
|
||||||
|
// ({"type":"auto"|"any"|"tool","name":...}) to OpenAI's tool_choice, the
|
||||||
|
// mirror of anthropicToolChoiceFromOpenAI above. Anthropic has no
|
||||||
|
// equivalent of OpenAI's "none" (a tool_choice this translator's forward
|
||||||
|
// direction can only produce by omitting the field, per that function's
|
||||||
|
// own doc comment), so there is no case here that ever needs to signal
|
||||||
|
// "drop tools" the way that direction's dropTools return does.
|
||||||
|
func openAIToolChoiceFromAnthropic(tc any) any {
|
||||||
|
tm, ok := tc.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch t, _ := tm["type"].(string); t {
|
||||||
|
case "auto":
|
||||||
|
return "auto"
|
||||||
|
case "any":
|
||||||
|
return "required"
|
||||||
|
case "tool":
|
||||||
|
name, _ := tm["name"].(string)
|
||||||
|
return map[string]any{"type": "function", "function": map[string]any{"name": name}}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAIRequestFromAnthropic maps an Anthropic Messages API request body
|
||||||
|
// (already decoded into map[string]any by the messages route's shared
|
||||||
|
// admission/decode path) to an OpenAI chat-completion request body, the
|
||||||
|
// mirror of anthropicRequestFromOpenAI above. It returns a
|
||||||
|
// *translateError — never wrapped, matching this file's established
|
||||||
|
// convention — for a content block this translator cannot map (an
|
||||||
|
// unsupported image source type).
|
||||||
|
func openAIRequestFromAnthropic(req map[string]any) (map[string]any, error) {
|
||||||
|
out := map[string]any{}
|
||||||
|
if model, ok := req["model"].(string); ok {
|
||||||
|
out["model"] = model
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs := make([]any, 0, 1)
|
||||||
|
if sys, ok := req["system"]; ok {
|
||||||
|
text, err := anthropicSystemToText(sys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
msgs = append(msgs, map[string]any{"role": "system", "content": text})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msgsRaw, _ := req["messages"].([]any)
|
||||||
|
for _, m := range msgsRaw {
|
||||||
|
msg, ok := m.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role, _ := msg["role"].(string)
|
||||||
|
converted, err := openAIMessagesFromAnthropic(role, msg["content"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msgs = append(msgs, converted...)
|
||||||
|
}
|
||||||
|
out["messages"] = msgs
|
||||||
|
|
||||||
|
if v, ok := req["max_tokens"]; ok {
|
||||||
|
if n, ok2 := toInt64(v); ok2 {
|
||||||
|
out["max_tokens"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := req["temperature"]; ok {
|
||||||
|
out["temperature"] = v
|
||||||
|
}
|
||||||
|
if v, ok := req["top_p"]; ok {
|
||||||
|
out["top_p"] = v
|
||||||
|
}
|
||||||
|
if seqs, ok := req["stop_sequences"].([]any); ok && len(seqs) > 0 {
|
||||||
|
out["stop"] = seqs
|
||||||
|
}
|
||||||
|
if v, ok := req["stream"]; ok {
|
||||||
|
out["stream"] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if toolsRaw, ok := req["tools"].([]any); ok {
|
||||||
|
out["tools"] = openAIToolsFromAnthropic(toolsRaw)
|
||||||
|
}
|
||||||
|
if tc, ok := req["tool_choice"]; ok {
|
||||||
|
if choice := openAIToolChoiceFromAnthropic(tc); choice != nil {
|
||||||
|
out["tool_choice"] = choice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta, ok := req["metadata"].(map[string]any); ok {
|
||||||
|
if uid, ok := meta["user_id"]; ok {
|
||||||
|
out["user"] = uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openAIChatResponseBody is the subset of a non-streaming OpenAI
|
||||||
|
// chat.completion response this translator reads, the mirror of
|
||||||
|
// anthropicResponseBody above. Message.Content is a pointer so a
|
||||||
|
// tool-calls-only response (content: null) is told apart from one
|
||||||
|
// carrying empty text — mirroring openAIResponseFromAnthropic's own
|
||||||
|
// len(textParts) > 0 check in the other direction.
|
||||||
|
type openAIChatResponseBody struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Choices []struct {
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
Message struct {
|
||||||
|
Content *string `json:"content"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
} `json:"function"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage chatStreamUsage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicToolInputFromOpenAI decodes an OpenAI tool_calls[].function.
|
||||||
|
// arguments JSON string into a generic Go value for Anthropic's
|
||||||
|
// tool_use.input field, the mirror of reMarshalToolInput above (which
|
||||||
|
// goes the other way: a decoded value re-marshaled to a string). An empty
|
||||||
|
// arguments string decodes to an empty object, matching a tool call with
|
||||||
|
// no arguments.
|
||||||
|
func anthropicToolInputFromOpenAI(argsStr string) (any, error) {
|
||||||
|
input := any(map[string]any{})
|
||||||
|
if argsStr != "" {
|
||||||
|
if err := json.Unmarshal([]byte(argsStr), &input); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: decode tool_calls[].function.arguments: %w", errUpstream, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicStopReasonFromOpenAI maps an OpenAI finish_reason to an
|
||||||
|
// Anthropic stop_reason, the mirror of anthropicFinishReason above:
|
||||||
|
// length -> max_tokens, tool_calls -> tool_use, everything else
|
||||||
|
// (including "stop") -> end_turn. OpenAI's finish_reason carries no
|
||||||
|
// equivalent of Anthropic's "stop_sequence" (which stop string matched),
|
||||||
|
// so this translator can never populate the response's stop_sequence
|
||||||
|
// field — the same loss of fidelity anthropicFinishReason already
|
||||||
|
// accepts by collapsing end_turn and stop_sequence into one OpenAI value.
|
||||||
|
func anthropicStopReasonFromOpenAI(finishReason string) string {
|
||||||
|
switch finishReason {
|
||||||
|
case "length":
|
||||||
|
return "max_tokens"
|
||||||
|
case "tool_calls":
|
||||||
|
return "tool_use"
|
||||||
|
default:
|
||||||
|
return "end_turn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicMessageIDFromOpenAI derives an Anthropic-shaped message id
|
||||||
|
// from an OpenAI chat.completion response id, stripping
|
||||||
|
// chatCompletionIDPrefix ("chatcmpl-") when present and prefixing
|
||||||
|
// "msg_" — Anthropic's own id convention — the mirror of
|
||||||
|
// openAIResponseFromAnthropic's chatCompletionIDPrefix+resp.ID above.
|
||||||
|
func anthropicMessageIDFromOpenAI(id string) string {
|
||||||
|
return "msg_" + strings.TrimPrefix(id, chatCompletionIDPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicResponseFromOpenAI maps a non-streaming OpenAI chat.completion
|
||||||
|
// response body to an Anthropic Messages API response body, the mirror of
|
||||||
|
// openAIResponseFromAnthropic above. model is supplied by the caller
|
||||||
|
// (routes_messages.go's callTranslatedMessages) — the client's original
|
||||||
|
// requested alias, echoed back into the response's "model" field the same
|
||||||
|
// way every translating adapter's gatewayAliasKey mechanism already does
|
||||||
|
// for the outbound direction (routes_unified.go's gatewayAliasKey doc
|
||||||
|
// comment).
|
||||||
|
func anthropicResponseFromOpenAI(body []byte, model string) (map[string]any, error) {
|
||||||
|
var resp openAIChatResponseBody
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: decode openai response: %w", errUpstream, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var content []any
|
||||||
|
stopReason := "end_turn"
|
||||||
|
if len(resp.Choices) > 0 {
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
if choice.Message.Content != nil && *choice.Message.Content != "" {
|
||||||
|
content = append(content, map[string]any{"type": "text", "text": *choice.Message.Content})
|
||||||
|
}
|
||||||
|
for _, tc := range choice.Message.ToolCalls {
|
||||||
|
input, err := anthropicToolInputFromOpenAI(tc.Function.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
content = append(content, map[string]any{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tc.ID,
|
||||||
|
"name": tc.Function.Name,
|
||||||
|
"input": input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stopReason = anthropicStopReasonFromOpenAI(choice.FinishReason)
|
||||||
|
}
|
||||||
|
if content == nil {
|
||||||
|
content = []any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]any{
|
||||||
|
"id": anthropicMessageIDFromOpenAI(resp.ID),
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": model,
|
||||||
|
"content": content,
|
||||||
|
"stop_reason": stopReason,
|
||||||
|
"stop_sequence": nil,
|
||||||
|
"usage": map[string]any{
|
||||||
|
"input_tokens": resp.Usage.PromptTokens,
|
||||||
|
"output_tokens": resp.Usage.CompletionTokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -507,3 +507,145 @@ func TestToFloat64(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- openAIRequestFromAnthropic / anthropicResponseFromOpenAI: the
|
||||||
|
// reverse-direction translators routes_messages.go uses (/v1/messages
|
||||||
|
// resolved to a non-anthropic-type provider) ---
|
||||||
|
|
||||||
|
// TestOpenAIRequestFromAnthropic_ToolResultBecomesToolMessage proves an
|
||||||
|
// assistant tool_use block becomes an OpenAI tool_calls[] entry (with its
|
||||||
|
// decoded "input" re-marshaled to a JSON string, matching OpenAI's
|
||||||
|
// arguments field) and a following user tool_result block becomes its
|
||||||
|
// own separate role:"tool" message — the mirror of
|
||||||
|
// anthropicRequestFromOpenAI's pendingToolResults collapse in the other
|
||||||
|
// direction.
|
||||||
|
func TestOpenAIRequestFromAnthropic_ToolResultBecomesToolMessage(t *testing.T) {
|
||||||
|
req := map[string]any{
|
||||||
|
"model": "claude-x",
|
||||||
|
"messages": []any{
|
||||||
|
map[string]any{"role": "assistant", "content": []any{
|
||||||
|
map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{"city": "London"}},
|
||||||
|
}},
|
||||||
|
map[string]any{"role": "user", "content": []any{
|
||||||
|
map[string]any{"type": "tool_result", "tool_use_id": "call_1", "content": "sunny"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := openAIRequestFromAnthropic(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
||||||
|
}
|
||||||
|
msgs, _ := out["messages"].([]any)
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("messages = %v, want 2 (assistant tool_calls + tool result)", msgs)
|
||||||
|
}
|
||||||
|
assistant, _ := msgs[0].(map[string]any)
|
||||||
|
toolCalls, _ := assistant["tool_calls"].([]any)
|
||||||
|
if len(toolCalls) != 1 {
|
||||||
|
t.Fatalf("assistant tool_calls = %v, want 1 entry", assistant["tool_calls"])
|
||||||
|
}
|
||||||
|
tc, _ := toolCalls[0].(map[string]any)
|
||||||
|
fn, _ := tc["function"].(map[string]any)
|
||||||
|
assert.Equal(t, "get_weather", fn["name"])
|
||||||
|
assert.JSONEq(t, `{"city":"London"}`, fn["arguments"].(string))
|
||||||
|
|
||||||
|
toolMsg, _ := msgs[1].(map[string]any)
|
||||||
|
assert.Equal(t, "tool", toolMsg["role"])
|
||||||
|
assert.Equal(t, "call_1", toolMsg["tool_call_id"])
|
||||||
|
assert.Equal(t, "sunny", toolMsg["content"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenAIRequestFromAnthropic_SystemStringFoldedIntoMessages proves a
|
||||||
|
// plain-string Anthropic "system" field becomes a role:"system" message
|
||||||
|
// prepended ahead of the rest, and the Anthropic-only top-level "system"
|
||||||
|
// key never survives into the OpenAI-shaped output.
|
||||||
|
func TestOpenAIRequestFromAnthropic_SystemStringFoldedIntoMessages(t *testing.T) {
|
||||||
|
req := map[string]any{
|
||||||
|
"model": "claude-x",
|
||||||
|
"system": "Be terse.",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := openAIRequestFromAnthropic(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("openAIRequestFromAnthropic: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := out["system"]; ok {
|
||||||
|
t.Error(`out carries a "system" key; Anthropic's system field has no OpenAI equivalent and must be folded into messages[]`)
|
||||||
|
}
|
||||||
|
msgs, _ := out["messages"].([]any)
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("messages = %v, want 2 (system + user)", msgs)
|
||||||
|
}
|
||||||
|
sysMsg, _ := msgs[0].(map[string]any)
|
||||||
|
assert.Equal(t, "system", sysMsg["role"])
|
||||||
|
assert.Equal(t, "Be terse.", sysMsg["content"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthropicResponseFromOpenAI_ToolCallsBecomeToolUseBlocks proves an
|
||||||
|
// OpenAI tool_calls[] entry becomes an Anthropic tool_use content block
|
||||||
|
// with its arguments STRING decoded back to a structured "input" value,
|
||||||
|
// stop_reason maps tool_calls -> tool_use, and the caller-supplied model
|
||||||
|
// (the client's own requested alias) is echoed into the response.
|
||||||
|
func TestAnthropicResponseFromOpenAI_ToolCallsBecomeToolUseBlocks(t *testing.T) {
|
||||||
|
const body = `{"id":"chatcmpl-77","choices":[{"index":0,"message":{"content":null,"tool_calls":[{"id":"call_9","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":2}}`
|
||||||
|
|
||||||
|
out, err := anthropicResponseFromOpenAI([]byte(body), "aliased/model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anthropicResponseFromOpenAI: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, "aliased/model", out["model"])
|
||||||
|
assert.Equal(t, "tool_use", out["stop_reason"])
|
||||||
|
|
||||||
|
content, _ := out["content"].([]any)
|
||||||
|
if len(content) != 1 {
|
||||||
|
t.Fatalf("content = %v, want one tool_use block", out["content"])
|
||||||
|
}
|
||||||
|
block, _ := content[0].(map[string]any)
|
||||||
|
assert.Equal(t, "tool_use", block["type"])
|
||||||
|
assert.Equal(t, "get_weather", block["name"])
|
||||||
|
assert.Equal(t, "call_9", block["id"])
|
||||||
|
input, _ := block["input"].(map[string]any)
|
||||||
|
assert.Equal(t, "Paris", input["city"])
|
||||||
|
|
||||||
|
usageMap, _ := out["usage"].(map[string]any)
|
||||||
|
assert.Equal(t, int64(3), usageMap["input_tokens"])
|
||||||
|
assert.Equal(t, int64(2), usageMap["output_tokens"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthropicResponseFromOpenAI_PlainTextResponse proves the common
|
||||||
|
// case: a plain-text OpenAI response becomes a single Anthropic text
|
||||||
|
// content block with stop_reason "stop" -> "end_turn".
|
||||||
|
func TestAnthropicResponseFromOpenAI_PlainTextResponse(t *testing.T) {
|
||||||
|
const body = `{"id":"chatcmpl-1","choices":[{"index":0,"message":{"content":"hi there"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}`
|
||||||
|
|
||||||
|
out, err := anthropicResponseFromOpenAI([]byte(body), "claude-x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anthropicResponseFromOpenAI: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, "message", out["type"])
|
||||||
|
assert.Equal(t, "assistant", out["role"])
|
||||||
|
assert.Equal(t, "end_turn", out["stop_reason"])
|
||||||
|
content, _ := out["content"].([]any)
|
||||||
|
if len(content) != 1 {
|
||||||
|
t.Fatalf("content = %v, want one text block", out["content"])
|
||||||
|
}
|
||||||
|
block, _ := content[0].(map[string]any)
|
||||||
|
assert.Equal(t, "text", block["type"])
|
||||||
|
assert.Equal(t, "hi there", block["text"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenAIContentPartFromAnthropicImage_RejectsNonBase64Source proves an
|
||||||
|
// Anthropic image block whose source is not base64-encoded (a URL-sourced
|
||||||
|
// image, which this translator does not support) returns a
|
||||||
|
// *translateError rather than silently dropping or mistranslating it.
|
||||||
|
func TestOpenAIContentPartFromAnthropicImage_RejectsNonBase64Source(t *testing.T) {
|
||||||
|
_, err := openAIContentPartFromAnthropicImage(map[string]any{"source": map[string]any{"type": "url", "url": "https://example.com/x.png"}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want an error for a non-base64 image source, got nil")
|
||||||
|
}
|
||||||
|
if _, ok := err.(*translateError); !ok {
|
||||||
|
t.Errorf("err = %T, want *translateError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user