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:
2026-08-20 03:37:15 +01:00
parent c0f31a3bc0
commit fb7162ab71
38 changed files with 1969 additions and 11 deletions
+13 -7
View File
@@ -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
// by provider name. For each provider it resolves APIKey via resolveSecret
// (propagating that error, but treating a resolved empty key as a valid
// keyless upstream rather than an error) and defaults BaseURL by provider
// type when the config omits one, trimming any trailing slash either way.
// An unknown provider type is a config error. A configured anthropic or
// gemini provider currently errors too — Tasks 9 and 10 fill in those
// switch cases; every other case here is unaffected by that.
// (propagating that error) and defaults BaseURL by provider type when the
// config omits one, trimming any trailing slash either way. An unknown
// provider type is a config error. A resolved empty key is a valid
// keyless upstream for openai-type providers, but a constructor error for
// anthropic-type ones — newAnthropicAdapter enforces that, per ruling (c);
// 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) {
adapters := make(map[string]providerAdapter, len(cfg.Providers))
for name, pc := range cfg.Providers {
@@ -195,7 +197,11 @@ func buildAdapters(cfg *Config) (map[string]providerAdapter, error) {
case providerTypeOpenAI:
adapters[name] = newOpenAIAdapter(name, base, apiKey)
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:
return nil, fmt.Errorf("llmgateway: provider %q: type %q not yet implemented", name, pc.Type)
default: