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:
2026-08-22 22:03:48 +01:00
parent 9dc67dedbb
commit d47dd06a46
5 changed files with 1338 additions and 0 deletions
+19
View File
@@ -785,6 +785,25 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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) {
u, grp, ok := g.auth.identify(r)
g.logAuthEvent(ok, authEventUserName(u), r)