Files
BlackBean/pigo/internal/provider/providers.go
T
2026-08-14 23:41:57 +08:00

776 lines
29 KiB
Go

// This file implements the first concrete Providers (US-013): Bedrock,
// OpenRouter, and Ollama. Each satisfies the Provider interface by building an
// *http.Request and delegating to the shared transport (StreamRequest) with a
// provider-appropriate Decoder — no bespoke HTTP/SSE handling per provider.
//
// Two backing driver shapes cover all three:
//
// - openAICompatDriver — POSTs to {baseURL}/chat/completions in the OpenAI
// Chat Completions wire format, decoded by OpenAIDecoder. OpenRouter and
// Ollama are instances of it; it is the generic OpenAI-compatible layer,
// reusable for any gateway (Groq, together, local servers, …).
// - anthropicCompatDriver — POSTs the Anthropic Messages wire format, decoded
// by AnthropicDecoder. Bedrock rides this: Anthropic-on-Bedrock speaks the
// Messages API, so the decoder is reused wholesale.
//
// Failures follow the dual failure model (FR-13): only the earliest "cannot
// build the stream" case (missing key, bad request construction) is a returned
// error; every runtime failure rides the stream as a terminal error event,
// which StreamRequest already guarantees.
//
// Security (US-012 / US-026): API keys are referenced by provider name in any
// error; secret values are never logged or embedded in error text.
package provider
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
)
// providerBaseURLs holds the default endpoint per built-in provider. A base URL
// is a transport concern (the decoders are URL-agnostic), so overriding it —
// e.g. pointing Ollama at a remote host — needs no decoder change.
const (
openRouterBaseURL = "https://openrouter.ai/api/v1"
ollamaBaseURL = "http://localhost:11434/v1"
// nvidiaBaseURL is NVIDIA's hosted NIM endpoint. It speaks the OpenAI
// Chat Completions wire format, so it rides openAICompatDriver unchanged.
nvidiaBaseURL = "https://integrate.api.nvidia.com/v1"
// bedrockBaseURL is a placeholder default; real Bedrock endpoints are
// region-specific (bedrock-runtime.<region>.amazonaws.com) and supplied at
// construction. It is exported as a field so callers set the resolved URL.
bedrockBaseURL = "https://bedrock-runtime.us-east-1.amazonaws.com"
// anthropicBaseURL is the public Anthropic Messages API endpoint, the default
// for a --protocol=anthropic provider when no --base-url is given.
anthropicBaseURL = "https://api.anthropic.com/v1"
// anthropicAPIVersion is the required anthropic-version header value sent with
// every direct-Anthropic request.
anthropicAPIVersion = "2023-06-01"
)
// ---------------------------------------------------------------------------
// OpenAI-compatible driver (OpenRouter, Ollama, and any OpenAI-compatible API).
// ---------------------------------------------------------------------------
// openAICompatDriver is the shared backing for every OpenAI-compatible
// provider. It holds the provider identity, endpoint, model catalog, and the
// auth scheme; StreamCompletion builds the chat-completions request and hands
// it to the transport with a fresh OpenAIDecoder.
type openAICompatDriver struct {
name string
baseURL string
models []Model
// requiresAuth reports whether an Authorization: Bearer header is sent.
// Ollama (local) needs none; OpenRouter does.
requiresAuth bool
// extraHeaders are attached to every request (e.g. OpenRouter attribution).
extraHeaders map[string]string
}
func (d *openAICompatDriver) Name() string { return d.name }
func (d *openAICompatDriver) Models() []Model { return d.models }
// StreamCompletion builds the OpenAI Chat Completions request and streams it.
func (d *openAICompatDriver) StreamCompletion(ctx context.Context, req CompletionRequest) (*AssistantMessageEventStream, error) {
if d.requiresAuth && strings.TrimSpace(req.Config.APIKey) == "" {
// Early "cannot build the stream": reference the provider, never a value.
return nil, fmt.Errorf("%s: missing API key", d.name)
}
if err := checkImageSupport(d.name, req.Model, d.models, req.Context.Messages); err != nil {
return nil, err
}
body, err := encodeOpenAIRequest(req)
if err != nil {
return nil, fmt.Errorf("%s: build request body: %w", d.name, err)
}
newReq := func(ctx context.Context) (*http.Request, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, d.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
if d.requiresAuth {
httpReq.Header.Set("Authorization", "Bearer "+req.Config.APIKey)
}
for k, v := range d.extraHeaders {
httpReq.Header.Set(k, v)
}
return httpReq, nil
}
return StreamRequest(ctx, TransportConfig{NewRequest: newReq, Decoder: NewOpenAIDecoder()})
}
// encodeOpenAIRequest serializes a CompletionRequest into an OpenAI Chat
// Completions JSON body with streaming enabled and usage requested.
func encodeOpenAIRequest(req CompletionRequest) ([]byte, error) {
msgs := make([]map[string]any, 0, len(req.Context.Messages)+1)
if sp := req.Context.SystemPrompt; sp != "" {
msgs = append(msgs, map[string]any{"role": "system", "content": sp})
}
for _, m := range req.Context.Messages {
msgs = append(msgs, encodeOpenAIMessage(m)...)
}
body := map[string]any{
"model": req.Model,
"messages": msgs,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
// Reasoning effort: when a thinking level is requested, forward it as the
// OpenAI `reasoning_effort` field. Reasoning models (o-series, DeepSeek-R1,
// GLM-thinking, …) read this to open their reasoning channel; omitting it
// leaves them at their default and effectively disables extended reasoning.
if effort := openAIReasoningEffort(req.Config.ThinkingLevel); effort != "" {
body["reasoning_effort"] = effort
}
if tools := encodeOpenAITools(req.Context.Tools); len(tools) > 0 {
body["tools"] = tools
}
return json.Marshal(body)
}
// openAIReasoningEffort maps the unified ThinkingLevel onto the OpenAI
// `reasoning_effort` wire value. "off"/"" yields "" (field omitted, default
// behavior preserved). OpenAI accepts minimal|low|medium|high; xhigh maps to
// high (the strongest supported value).
func openAIReasoningEffort(level agentcore.ThinkingLevel) string {
switch level {
case agentcore.ThinkingMinimal:
return "minimal"
case agentcore.ThinkingLow:
return "low"
case agentcore.ThinkingMedium:
return "medium"
case agentcore.ThinkingHigh, agentcore.ThinkingXHigh, agentcore.ThinkingMax:
return "high"
default: // off or unset
return ""
}
}
// encodeOpenAIMessage maps one pigo message onto the OpenAI wire shape. An
// assistant message may expand to content + tool_calls in a single entry; a
// tool result becomes a role:"tool" entry keyed by tool_call_id.
func encodeOpenAIMessage(m agentcore.Message) []map[string]any {
switch msg := m.(type) {
case agentcore.UserMessage:
return []map[string]any{{"role": "user", "content": openAIUserContent(msg.Content)}}
case agentcore.CompactionMessage:
// A compaction checkpoint stands in for compacted history as user text.
u := msg.AsUserMessage()
return []map[string]any{{"role": "user", "content": openAIUserContent(u.Content)}}
case agentcore.AssistantMessage:
entry := map[string]any{"role": "assistant"}
text := agentcore.ContentToText(msg.Content)
var toolCalls []map[string]any
for _, c := range msg.Content {
if tc, ok := c.(agentcore.ToolCallContent); ok {
toolCalls = append(toolCalls, map[string]any{
"id": tc.ID,
"type": "function",
"function": map[string]any{
"name": tc.Name,
"arguments": string(tc.Arguments),
},
})
}
}
if len(toolCalls) > 0 {
entry["tool_calls"] = toolCalls
// With tool calls present, send content as JSON null when there is no
// accompanying text: an empty string trips strict gateways (e.g. vLLM)
// that expect null | non-empty for an assistant tool-call turn.
if text == "" {
entry["content"] = nil
} else {
entry["content"] = text
}
} else {
entry["content"] = text
}
return []map[string]any{entry}
case agentcore.ToolResultMessage:
return []map[string]any{{
"role": "tool",
"tool_call_id": msg.ToolCallID,
"content": agentcore.ContentToText(msg.Content),
}}
default:
return nil
}
}
// openAIUserContent shapes a user content list for the OpenAI wire. When there
// are no images it collapses to a plain string (the common case, and what most
// OpenAI-compatible gateways expect). When images are present it emits the
// multimodal array form: text parts plus image_url parts carrying a base64 data
// URI (data:<mime>;base64,<data>).
func openAIUserContent(content agentcore.ContentList) any {
hasImage := false
for _, c := range content {
if _, ok := c.(agentcore.ImageContent); ok {
hasImage = true
break
}
}
if !hasImage {
return agentcore.ContentToText(content)
}
parts := make([]map[string]any, 0, len(content))
for _, c := range content {
switch b := c.(type) {
case agentcore.TextContent:
if b.Text == "" {
continue
}
parts = append(parts, map[string]any{"type": "text", "text": b.Text})
case agentcore.ImageContent:
parts = append(parts, map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": fmt.Sprintf("data:%s;base64,%s", b.MimeType, b.Data),
},
})
}
}
return parts
}
// encodeOpenAITools maps AgentTools onto the OpenAI function-tool schema.
func encodeOpenAITools(tools []agentcore.AgentTool) []map[string]any {
if len(tools) == 0 {
return nil
}
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
params := json.RawMessage(t.Schema())
if len(params) == 0 {
params = json.RawMessage("{}")
}
out = append(out, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name(),
"description": t.Description(),
"parameters": params,
},
})
}
return out
}
// ---------------------------------------------------------------------------
// Anthropic-compatible driver (Bedrock).
// ---------------------------------------------------------------------------
// anthropicCompatDriver backs Anthropic-wire providers that are not the direct
// Anthropic API — Bedrock being the case here. It POSTs the Messages wire
// format and decodes with AnthropicDecoder.
type anthropicCompatDriver struct {
name string
baseURL string
models []Model
// path is the endpoint path appended to baseURL (Bedrock's invoke path
// embeds the model id, so it is derived per request).
pathFor func(model string) string
// authHeader sets provider auth on the request (never logs the value).
authHeader func(req *http.Request, apiKey string)
}
func (d *anthropicCompatDriver) Name() string { return d.name }
func (d *anthropicCompatDriver) Models() []Model { return d.models }
// StreamCompletion builds the Anthropic Messages request and streams it,
// decoding with AnthropicDecoder.
func (d *anthropicCompatDriver) StreamCompletion(ctx context.Context, req CompletionRequest) (*AssistantMessageEventStream, error) {
if strings.TrimSpace(req.Config.APIKey) == "" {
return nil, fmt.Errorf("%s: missing API key", d.name)
}
if err := checkImageSupport(d.name, req.Model, d.models, req.Context.Messages); err != nil {
return nil, err
}
body, err := encodeAnthropicRequest(req, d.models)
if err != nil {
return nil, fmt.Errorf("%s: build request body: %w", d.name, err)
}
path := "/messages"
if d.pathFor != nil {
path = d.pathFor(req.Model)
}
newReq := func(ctx context.Context) (*http.Request, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, d.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
if d.authHeader != nil {
d.authHeader(httpReq, req.Config.APIKey)
}
return httpReq, nil
}
return StreamRequest(ctx, TransportConfig{NewRequest: newReq, Decoder: NewAnthropicDecoder()})
}
// encodeAnthropicRequest serializes a CompletionRequest into an Anthropic
// Messages JSON body with streaming enabled. The system prompt is a top-level
// field; tool results and tool calls follow the Messages content-block shape.
func encodeAnthropicRequest(req CompletionRequest, models []Model) ([]byte, error) {
msgs := make([]map[string]any, 0, len(req.Context.Messages))
all := req.Context.Messages
for i := 0; i < len(all); i++ {
m := all[i]
// Anthropic requires every tool_use in the preceding assistant message to
// be answered by tool_result blocks in a single immediately-following user
// message — splitting them across consecutive user turns yields the API
// error "tool_use ids were found without tool_result blocks immediately
// after". Coalesce runs of consecutive tool results into one turn.
if tm, ok := m.(agentcore.ToolResultMessage); ok {
blocks := []map[string]any{{
"type": "tool_result",
"tool_use_id": tm.ToolCallID,
"content": agentcore.ContentToText(tm.Content),
}}
for i+1 < len(all) {
next, ok := all[i+1].(agentcore.ToolResultMessage)
if !ok {
break
}
i++
blocks = append(blocks, map[string]any{
"type": "tool_result",
"tool_use_id": next.ToolCallID,
"content": agentcore.ContentToText(next.Content),
})
}
msgs = append(msgs, map[string]any{"role": "user", "content": blocks})
continue
}
if enc := encodeAnthropicMessage(m); enc != nil {
msgs = append(msgs, enc)
}
}
body := map[string]any{
"model": req.Model,
"messages": msgs,
"stream": true,
}
if sp := req.Context.SystemPrompt; sp != "" {
body["system"] = sp
}
maxTok := maxOutputTokensFor(req)
if maxTok <= 0 {
// Anthropic requires max_tokens. Prefer the model's declared cap; fall
// back to a coding-friendly default (4096 was too low and caused
// truncation/retry loops on longer edits).
maxTok = anthropicDefaultMaxTokens(req.Model, models)
}
// Extended thinking: when a thinking level is requested, enable the Anthropic
// thinking block with a budget derived from the level. Omitted for off/unset
// so non-thinking requests keep their prior shape.
if budget := anthropicThinkingBudget(req.Config.ThinkingLevel); budget > 0 {
// Anthropic counts thinking tokens toward max_tokens and requires
// budget_tokens < max_tokens (else a 400). Guarantee headroom for the
// visible reply by lifting max_tokens above the budget when the caller's
// cap is too low to fit both the reasoning and a real answer.
if minTok := budget + anthropicResponseHeadroom; maxTok < minTok {
maxTok = minTok
}
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
}
body["max_tokens"] = maxTok
if tools := encodeAnthropicTools(req.Context.Tools); len(tools) > 0 {
body["tools"] = tools
}
return json.Marshal(body)
}
// anthropicResponseHeadroom is the token margin reserved for the visible reply
// on top of the thinking budget, so max_tokens always exceeds budget_tokens (an
// Anthropic hard requirement) with room left for a real answer.
const anthropicResponseHeadroom = 4096
// anthropicDefaultMaxTokens picks the max_tokens fallback when no explicit hint
// is given: the model's declared MaxOutputTokens if present in the driver's
// model catalog, otherwise 8192 (a coding-friendly default that avoids
// premature truncation while staying within common model caps).
func anthropicDefaultMaxTokens(model string, models []Model) int {
for _, m := range models {
if m.ID == model && m.MaxOutputTokens > 0 {
return m.MaxOutputTokens
}
}
return 8192
}
// anthropicThinkingBudget maps a unified ThinkingLevel onto an Anthropic
// thinking budget_tokens value. off/"" yields 0 (thinking block omitted).
func anthropicThinkingBudget(level agentcore.ThinkingLevel) int {
switch level {
case agentcore.ThinkingMinimal:
return 1024
case agentcore.ThinkingLow:
return 2048
case agentcore.ThinkingMedium:
return 8192
case agentcore.ThinkingHigh:
return 16384
case agentcore.ThinkingXHigh:
return 32768
case agentcore.ThinkingMax:
return 65536
default: // off or unset
return 0
}
}
// encodeAnthropicMessage maps one pigo message onto the Anthropic Messages
// wire shape. Assistant tool calls become tool_use blocks; tool results become
// a user message carrying a tool_result block (Anthropic's convention).
func encodeAnthropicMessage(m agentcore.Message) map[string]any {
switch msg := m.(type) {
case agentcore.UserMessage:
return map[string]any{"role": "user", "content": anthropicUserContent(msg.Content)}
case agentcore.CompactionMessage:
u := msg.AsUserMessage()
return map[string]any{"role": "user", "content": anthropicUserContent(u.Content)}
case agentcore.AssistantMessage:
var blocks []map[string]any
// Thinking blocks must precede tool_use in the same assistant turn:
// Anthropic extended-thinking requires the prior thinking block (and its
// signature) to be echoed back verbatim on tool-use turns, or the API
// rejects/degrades the request. Emit them first.
for _, c := range msg.Content {
if t, ok := c.(agentcore.ThinkingContent); ok {
if t.Redacted {
blocks = append(blocks, map[string]any{
"type": "redacted_thinking", "data": t.ThinkingSignature,
})
continue
}
if t.Thinking == "" {
continue
}
block := map[string]any{"type": "thinking", "thinking": t.Thinking}
if t.ThinkingSignature != "" {
block["signature"] = t.ThinkingSignature
}
blocks = append(blocks, block)
}
}
for _, c := range msg.Content {
switch b := c.(type) {
case agentcore.TextContent:
if b.Text == "" {
continue
}
blocks = append(blocks, map[string]any{"type": "text", "text": b.Text})
case agentcore.ToolCallContent:
var input any
_ = json.Unmarshal(b.Arguments, &input)
blocks = append(blocks, map[string]any{
"type": "tool_use", "id": b.ID, "name": b.Name, "input": input,
})
}
}
if len(blocks) == 0 {
// No usable content: emit a single space rather than an empty text
// block ("text must be non-empty" is rejected by strict endpoints).
blocks = []map[string]any{{"type": "text", "text": " "}}
}
return map[string]any{"role": "assistant", "content": blocks}
case agentcore.ToolResultMessage:
return map[string]any{"role": "user", "content": []map[string]any{{
"type": "tool_result",
"tool_use_id": msg.ToolCallID,
"content": agentcore.ContentToText(msg.Content),
}}}
default:
return nil
}
}
// anthropicUserContent shapes a user content list for the Anthropic Messages
// wire. When there are no images it collapses to a plain string. When images
// are present it emits the content-block array form: text blocks plus image
// blocks with a base64 source ({"type":"image","source":{"type":"base64",
// "media_type":<mime>,"data":<b64>}}).
func anthropicUserContent(content agentcore.ContentList) any {
hasImage := false
for _, c := range content {
if _, ok := c.(agentcore.ImageContent); ok {
hasImage = true
break
}
}
if !hasImage {
return agentcore.ContentToText(content)
}
blocks := make([]map[string]any, 0, len(content))
for _, c := range content {
switch b := c.(type) {
case agentcore.TextContent:
if b.Text == "" {
continue
}
blocks = append(blocks, map[string]any{"type": "text", "text": b.Text})
case agentcore.ImageContent:
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": b.MimeType,
"data": b.Data,
},
})
}
}
return blocks
}
// contextHasImage reports whether any message in the context carries an image
// content block, so a driver can reject image input on a non-multimodal model.
func contextHasImage(msgs []agentcore.Message) bool {
for _, m := range msgs {
var content agentcore.ContentList
switch msg := m.(type) {
case agentcore.UserMessage:
content = msg.Content
case agentcore.CompactionMessage:
content = msg.AsUserMessage().Content
default:
continue
}
for _, c := range content {
if _, ok := c.(agentcore.ImageContent); ok {
return true
}
}
}
return false
}
// checkImageSupport returns a clear error when the request carries image input
// but the named model (looked up in models) does not declare SupportsImages.
// A model absent from the catalog is treated permissively (unknown capability),
// deferring to the provider's own validation. This turns the silent drop of
// image blocks on a text-only model into an actionable message.
func checkImageSupport(providerName, model string, models []Model, msgs []agentcore.Message) error {
if !contextHasImage(msgs) {
return nil
}
for _, m := range models {
if m.ID == model {
if !m.SupportsImages {
return fmt.Errorf("%s: model %q does not support image input", providerName, model)
}
return nil
}
}
return nil
}
// encodeAnthropicTools maps AgentTools onto the Anthropic tool schema.
func encodeAnthropicTools(tools []agentcore.AgentTool) []map[string]any {
if len(tools) == 0 {
return nil
}
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
schema := json.RawMessage(t.Schema())
if len(schema) == 0 {
schema = json.RawMessage("{}")
}
out = append(out, map[string]any{
"name": t.Name(),
"description": t.Description(),
"input_schema": schema,
})
}
return out
}
// maxOutputTokensFor pulls a max-output hint from the request Extra map, if any,
// so callers can bound Anthropic responses without a registry lookup here.
func maxOutputTokensFor(req CompletionRequest) int {
if req.Config.Extra == nil {
return 0
}
switch v := req.Config.Extra["max_tokens"].(type) {
case int:
return v
case float64:
return int(v)
}
return 0
}
// ---------------------------------------------------------------------------
// Constructors.
// ---------------------------------------------------------------------------
// openAICompatPreset captures the per-gateway differences among the
// OpenAI-compatible providers: everything the constructors used to repeat
// (provider name, default endpoint, whether auth is required, and any extra
// headers). Collapsing the near-identical constructors onto this table means
// "add a gateway" is a one-line entry plus a thin exported wrapper.
type openAICompatPreset struct {
name string
defaultURL string // "" ⇒ no default; baseURL must be supplied by the caller
requiresAuth bool
extraHeaders map[string]string
}
// newOpenAICompat builds an OpenAI-compatible driver from a preset, falling back
// to the preset's default endpoint when baseURL is empty.
func newOpenAICompat(p openAICompatPreset, baseURL string, models []Model) Provider {
if baseURL == "" {
baseURL = p.defaultURL
}
return &openAICompatDriver{
name: p.name,
baseURL: baseURL,
models: models,
requiresAuth: p.requiresAuth,
extraHeaders: p.extraHeaders,
}
}
// NewOpenRouterProvider builds the OpenRouter provider — the reference
// OpenAI-compatible gateway. baseURL defaults to the public endpoint when empty.
func NewOpenRouterProvider(baseURL string, models []Model) Provider {
return newOpenAICompat(openAICompatPreset{
name: "openrouter",
defaultURL: openRouterBaseURL,
requiresAuth: true,
extraHeaders: map[string]string{
// OpenRouter attribution headers (optional but recommended).
"HTTP-Referer": "https://github.com/smallnest/pigo",
"X-Title": "pigo",
},
}, baseURL, models)
}
// NewOllamaProvider builds the Ollama provider (local, OpenAI-compatible, no
// auth). baseURL defaults to the local daemon when empty.
func NewOllamaProvider(baseURL string, models []Model) Provider {
return newOpenAICompat(openAICompatPreset{
name: "ollama",
defaultURL: ollamaBaseURL,
requiresAuth: false,
}, baseURL, models)
}
// NewNvidiaProvider builds the NVIDIA provider (hosted NIM, OpenAI-compatible,
// Bearer auth). baseURL defaults to the public integrate endpoint when empty.
// The API key is resolved by the "nvidia" provider name (NVIDIA_API_KEY);
// secret values are never logged.
func NewNvidiaProvider(baseURL string, models []Model) Provider {
return newOpenAICompat(openAICompatPreset{
name: "nvidia",
defaultURL: nvidiaBaseURL,
requiresAuth: true,
}, baseURL, models)
}
// NewOpenAICompatibleProvider builds a generic OpenAI-compatible provider for an
// arbitrary gateway reached by baseURL (Bearer auth). It is the target of an
// explicit --protocol=openai selection: unlike the preset constructors it has no
// default endpoint (baseURL must be supplied) and carries the neutral provider
// name "openai", so an API key resolves from OPENAI_API_KEY (or the --api-key
// override bound to that name). Secret values are never logged.
func NewOpenAICompatibleProvider(baseURL string, models []Model) Provider {
return newOpenAICompat(openAICompatPreset{
name: "openai",
defaultURL: "", // no default: caller must supply the endpoint
requiresAuth: true,
}, baseURL, models)
}
// newAnthropicCompat builds an Anthropic-Messages driver, falling back to
// defaultURL when baseURL is empty. It is the shared body of the two
// Anthropic-wire constructors, which differ only in name, default endpoint, and
// auth header.
func newAnthropicCompat(name, defaultURL, baseURL string, models []Model, authHeader func(*http.Request, string)) Provider {
if baseURL == "" {
baseURL = defaultURL
}
return &anthropicCompatDriver{
name: name,
baseURL: baseURL,
models: models,
authHeader: authHeader,
}
}
// anthropicAuthHeaderFor returns the auth-header setter for an Anthropic-Messages
// provider given its registry AuthScheme (spec.AuthScheme). The two shapes seen
// among anthropic-protocol providers are:
//
// - AuthBearer → Authorization: Bearer <key>. Used by anthropic-protocol
// gateways that authenticate with a plain bearer token on their /anthropic
// endpoint.
// - AuthXAPIKey → x-api-key: <key> plus the required anthropic-version
// header. This is the direct-Anthropic convention and, per pi's behavior,
// also what MiniMax (minimax / minimax-cn) uses on its /anthropic endpoint.
//
// Any other scheme (e.g. AuthAWS for Bedrock, AuthSpecial) falls back to the
// x-api-key convention so a generic anthropic-protocol provider does not crash;
// bespoke auth for those is layered by a later node. The returned func never
// logs the secret value.
func anthropicAuthHeaderFor(authScheme string) func(*http.Request, string) {
if authScheme == AuthBearer {
return func(req *http.Request, apiKey string) {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
}
return func(req *http.Request, apiKey string) {
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", anthropicAPIVersion)
}
}
// NewAnthropicProvider builds a provider that speaks the Anthropic Messages wire
// format directly (POST {baseURL}/messages), the target of an explicit
// --protocol=anthropic selection. baseURL defaults to the public Anthropic API
// when empty. Auth uses the Anthropic conventions: an x-api-key header plus the
// required anthropic-version header. The API key resolves by the "anthropic"
// provider name (ANTHROPIC_API_KEY / CLAUDE_API_KEY, or the --api-key override);
// secret values are never logged.
func NewAnthropicProvider(baseURL string, models []Model) Provider {
return newAnthropicCompat("anthropic", anthropicBaseURL, baseURL, models,
anthropicAuthHeaderFor(AuthXAPIKey))
}
// NewAnthropicProtocolProvider builds a named Anthropic-Messages provider whose
// auth header follows the given registry AuthScheme. It is the target of an
// explicit --provider selection for any anthropic-protocol built-in (anthropic,
// minimax, minimax-cn, and — routed generically for now — bedrock/
// cloudflare-ai-gateway): the driver identity is the provider's own name (so
// errors reference it), baseURL is the already-resolved endpoint (spec default
// or override), and authScheme selects the header shape (see
// anthropicAuthHeaderFor). Secret values are never logged.
func NewAnthropicProtocolProvider(name, baseURL, authScheme string, models []Model) Provider {
return newAnthropicCompat(name, anthropicBaseURL, baseURL, models,
anthropicAuthHeaderFor(authScheme))
}
// NewBedrockProvider builds the Bedrock provider, reusing the Anthropic Messages
// decoder (Anthropic-on-Bedrock speaks the Messages wire format). baseURL
// defaults to a us-east-1 runtime endpoint when empty; real deployments pass
// the region-specific URL. Auth is a Bearer token (Bedrock API keys); SigV4
// signing, when required, is layered by the caller's HTTP client.
func NewBedrockProvider(baseURL string, models []Model) Provider {
return newAnthropicCompat("bedrock", bedrockBaseURL, baseURL, models,
func(req *http.Request, apiKey string) {
req.Header.Set("Authorization", "Bearer "+apiKey)
})
}