first commit
This commit is contained in:
@@ -0,0 +1,775 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user