// 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..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:;base64,). 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":,"data":}}). 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 . Used by anthropic-protocol // gateways that authenticate with a plain bearer token on their /anthropic // endpoint. // - AuthXAPIKey → x-api-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) }) }