feat(model): 双协议 LLM Provider(AnthropicChNativeAdapter /v1/messages + tool_use.input→Args 直接映射 + 流式重组)+ Provider 感知工厂/路由 + LLM_PROVIDER/anthropic 配置(OpenAI 兼容保留)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
Aska committed 2026-09-06 09:41:06 +08:00
1 parent f9650185f1
commit 3632e04733
11 files changed
+1452 -18

No files matched your search

+9 -1
View File
@@ -30,12 +30,20 @@ DB_SQLITE_PATH=./data/cyrene.db
# PostgreSQL + pgvector 为可选增强;未配置时走 SQLite。相关向量能力在 M4 接入。 # PostgreSQL + pgvector 为可选增强;未配置时走 SQLite。相关向量能力在 M4 接入。
# DB_PG_URL=postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable # DB_PG_URL=postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable
# ---- LLM 多供应商 ---- # ---- LLM 多供应商(LLM_PROVIDER 显式声明协议:openai / anthropic,默认 openai) ----
# Provider = openai:OpenAI 兼容 /chat/completions(含 OpenAI 兼容网关,也可经它承载 Anthropic 模型,但失去原生 tool input 对象优势)
LLM_PROVIDER=openai
LLM_BASE_URL=https://api.openai.com/v1 LLM_BASE_URL=https://api.openai.com/v1
LLM_API_KEY=sk-xxxxx LLM_API_KEY=sk-xxxxx
LLM_MODEL=gpt-4o LLM_MODEL=gpt-4o
LLM_FALLBACK_MODEL=gpt-4o-mini LLM_FALLBACK_MODEL=gpt-4o-mini
# Provider = anthropic:Anthropic Messages /v1/messages(tool_use.input 为结构化对象,工具循环更稳)
# ANTHROPIC_BASE_URL=https://api.anthropic.com
# ANTHROPIC_API_KEY=sk-ant-xxxxx
# ANTHROPIC_MODEL=claude-sonnet-4-20250514
# ANTHROPIC_FALLBACK_MODEL=claude-haiku-3-5
# ---- 记忆嵌入(可选) ---- # ---- 记忆嵌入(可选) ----
# 不配置时使用内置 n-gram / FTS 向量。配置后必须真实走语义路径(M4 接入)。 # 不配置时使用内置 n-gram / FTS 向量。配置后必须真实走语义路径(M4 接入)。
# EMBEDDING_API_URL=http://localhost:11434/v1 # EMBEDDING_API_URL=http://localhost:11434/v1
+2 -2
View File
@@ -246,12 +246,12 @@ func (a *app) wireMemory(ctx context.Context) error {
// wireRuntime builds the model adapter, tool dispatcher, guardrails, the agent // wireRuntime builds the model adapter, tool dispatcher, guardrails, the agent
// and its loop, tracing, persistence and the orchestration scheduler. // and its loop, tracing, persistence and the orchestration scheduler.
func (a *app) wireRuntime() error { func (a *app) wireRuntime() error {
adapter, aerr := model.NewOpenAIChatChain(a.cfg.LLM) adapter, aerr := model.NewChatChain(a.cfg.LLM)
if aerr != nil { if aerr != nil {
a.log.Warn("llm adapter unavailable", "error", aerr) a.log.Warn("llm adapter unavailable", "error", aerr)
adapter = nil adapter = nil
} }
router := model.NewRouter(a.cfg.LLM.Model) router := model.NewRouter(model.DefaultModelForProvider(a.cfg.LLM))
dispatcher, derr := tool.New(a.caps, tool.WithDefaultTimeout(defaultToolTimeout)) dispatcher, derr := tool.New(a.caps, tool.WithDefaultTimeout(defaultToolTimeout))
if derr != nil { if derr != nil {
+16 -1
View File
@@ -54,12 +54,27 @@ type DBConfig struct {
PGURL string `env:"DB_PG_URL"` PGURL string `env:"DB_PG_URL"`
} }
// LLMConfig holds the multi-supplier LLM adapter settings. // LLMConfig holds the multi-supplier LLM adapter settings. Provider selects the
// wire protocol; every provider has its own independent base/key/model/fallback
// so multiple vendors can coexist.
type LLMConfig struct { type LLMConfig struct {
// Provider declares which protocol to use: "openai" (OpenAI-compatible
// /chat/completions) or "anthropic" (Anthropic Messages /v1/messages).
Provider string `env:"LLM_PROVIDER" default:"openai"`
// OpenAI-compatible provider (default). Kept flat for backward compatibility
// with the historic LLM_BASE_URL/API_KEY/MODEL/FALLBACK_MODEL env names.
BaseURL string `env:"LLM_BASE_URL"` BaseURL string `env:"LLM_BASE_URL"`
APIKey string `env:"LLM_API_KEY"` APIKey string `env:"LLM_API_KEY"`
Model string `env:"LLM_MODEL"` Model string `env:"LLM_MODEL"`
Fallback string `env:"LLM_FALLBACK_MODEL"` Fallback string `env:"LLM_FALLBACK_MODEL"`
// Anthropic native provider, used when Provider=anthropic. Independent
// config block so each vendor is a self-contained config.
AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL"`
AnthropicAPIKey string `env:"ANTHROPIC_API_KEY"`
AnthropicModel string `env:"ANTHROPIC_MODEL"`
AnthropicFallback string `env:"ANTHROPIC_FALLBACK_MODEL"`
} }
// EmbeddingConfig holds the optional semantic embedding API settings. When // EmbeddingConfig holds the optional semantic embedding API settings. When
+445
View File
@@ -0,0 +1,445 @@
package model
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Anthropic Messages (/v1/messages) wire protocol constants.
const (
// anthropicAPIVersion is the Messages API version sent on every request.
anthropicAPIVersion = "2023-06-01"
// anthropicDefaultMaxTokens is the default max_tokens sent when
// Request.MaxTokens is zero. Anthropic requires max_tokens to be present and
// positive, so a sensible default is applied rather than omitting it.
anthropicDefaultMaxTokens = 1024
// anthropicDataPrefix marks a payload embedded as a data: URI.
anthropicDataPrefix = "data:"
)
// anthropicRequest is the body sent to /v1/messages. Messages carry Anthropic's
// content-block model and the system prompt lives at the request root.
type anthropicRequest struct {
Model string `json:"model"`
System string `json:"system,omitempty"`
Messages []anthropicMessage `json:"messages"`
Tools []anthropicTool `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream,omitempty"`
}
// anthropicMessage is one conversation turn. Content is an array of content
// blocks (text / image for now; tool_result blocks are added by M2).
type anthropicMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
// anthropicTool is the wire form of a tool schema.
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema any `json:"input_schema"`
}
// anthropicResponse is the JSON body returned by /v1/messages.
type anthropicResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Content []anthropicBlock `json:"content"`
Usage *anthropicUsage `json:"usage,omitempty"`
}
// anthropicBlock is a single content block in a response. For tool_use blocks the
// Input field is a fully decoded object (never a JSON string).
type anthropicBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input map[string]any `json:"input,omitempty"`
}
// anthropicUsage reports token consumption. Anthropic reports input_tokens and
// output_tokens rather than a single total.
type anthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// AnthropicChatAdapter calls an Anthropic Messages (/v1/messages) endpoint. It is
// constructed from a config.LLMConfig and implements Adapter for both
// non-streaming and token-level SSE streaming, speaking the provider's native
// content-block protocol so tool_use.input arrives as a structured object.
type AnthropicChatAdapter struct {
name string
baseURL string
apiKey string
model string
client *http.Client
recorder Recorder
}
// AnthropicOption configures an AnthropicChatAdapter.
type AnthropicOption func(*AnthropicChatAdapter)
// WithAnthropicHTTPClient overrides the default HTTP client (used to inject mocks
// or a tuned transport in tests and production).
func WithAnthropicHTTPClient(c *http.Client) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.client = c }
}
// WithAnthropicRecorder wires a per-call Recorder for CallRecord accounting.
func WithAnthropicRecorder(r Recorder) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.recorder = r }
}
// WithAnthropicModel overrides the adapter's default model name.
func WithAnthropicModel(m string) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.model = m }
}
// NewAnthropicChat builds an Anthropic adapter from a supplier config. baseURL is
// trimmed of any trailing slash before the /v1/messages path is appended.
func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicChatAdapter {
a := &AnthropicChatAdapter{
name: "anthropic",
baseURL: strings.TrimRight(cfg.AnthropicBaseURL, "/"),
apiKey: cfg.AnthropicAPIKey,
model: cfg.AnthropicModel,
client: &http.Client{
Transport: http.DefaultTransport,
Timeout: defaultHTTPTimeout,
},
}
for _, o := range opts {
o(a)
}
return a
}
// Name returns the adapter identifier.
func (a *AnthropicChatAdapter) Name() string { return a.name }
// Chat performs a non-streaming completion.
func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
start := time.Now()
if a.baseURL == "" {
return Response{}, ErrUpstream
}
ctx, cancel := withCallTimeout(ctx, opts)
defer cancel()
model, err := a.resolveModel(req)
if err != nil {
return Response{}, err
}
req.Model = model
body, err := a.buildRequest(req, false)
if err != nil {
return Response{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
if err != nil {
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
}
resp, err := a.doRequest(httpReq, false)
if err != nil {
return Response{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Response{}, a.upstreamError(resp)
}
var wire anthropicResponse
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
return Response{}, fmt.Errorf("model: %w: decode response: %v", ErrUpstream, err)
}
out := responseFromAnthropicWire(&wire)
a.record(req, start, out.TotalTokens)
return out, nil
}
// ChatStream streams content-block deltas via onChunk and returns the aggregated
// response with usage when the provider reports it.
func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onChunk func(delta string)) (Response, error) {
start := time.Now()
if a.baseURL == "" {
return Response{}, ErrUpstream
}
model, err := a.resolveModel(req)
if err != nil {
return Response{}, err
}
req.Model = model
body, err := a.buildRequest(req, true)
if err != nil {
return Response{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
if err != nil {
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
}
resp, err := a.doRequest(httpReq, true)
if err != nil {
return Response{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Response{}, a.upstreamError(resp)
}
out, err := scanAnthropicSSE(resp.Body, onChunk)
if err != nil {
return Response{}, err
}
out.Model = model
a.record(req, start, out.TotalTokens)
return out, nil
}
// resolveModel returns the effective model name.
func (a *AnthropicChatAdapter) resolveModel(req Request) (string, error) {
if req.Model != "" {
return req.Model, nil
}
if a.model != "" {
return a.model, nil
}
return "", ErrNoModel
}
// endpoint returns the full /v1/messages URL.
func (a *AnthropicChatAdapter) endpoint() string {
return a.baseURL + "/v1/messages"
}
func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, error) {
system, msgs, err := buildAnthropicMessages(req.Messages)
if err != nil {
return nil, err
}
tokenLimit := req.MaxTokens
if tokenLimit == 0 {
tokenLimit = anthropicDefaultMaxTokens
}
wire := anthropicRequest{
Model: req.Model,
System: system,
Messages: msgs,
Tools: buildAnthropicTools(req.Tools),
ToolChoice: req.ToolChoice,
Temperature: req.Temperature,
MaxTokens: tokenLimit,
Stream: stream,
}
buf, err := json.Marshal(wire)
if err != nil {
return nil, fmt.Errorf("model: %w: marshal request: %v", ErrBadRequest, err)
}
return buf, nil
}
func (a *AnthropicChatAdapter) doRequest(httpReq *http.Request, stream bool) (*http.Response, error) {
if a.apiKey != "" {
httpReq.Header.Set("x-api-key", a.apiKey)
}
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
httpReq.Header.Set("Content-Type", "application/json")
if stream {
httpReq.Header.Set("Accept", "text/event-stream")
} else {
httpReq.Header.Set("Accept", "application/json")
}
resp, err := a.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, err)
}
return resp, nil
}
func (a *AnthropicChatAdapter) upstreamError(resp *http.Response) error {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyCap))
return fmt.Errorf("model: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
}
func (a *AnthropicChatAdapter) record(req Request, start time.Time, tokens int) {
if a.recorder == nil {
return
}
a.recorder(CallRecord{
Model: req.Model,
Purpose: req.Purpose,
Tokens: tokens,
TookMs: time.Since(start).Milliseconds(),
})
}
// buildAnthropicMessages splits a normalized message list into a top-level system
// string and the remaining messages in Anthropic's content-block form. All
// RoleSystem messages are concatenated into a single system prompt (Anthropic
// carries it at the request root, not in messages); messages with no content
// parts are dropped because Anthropic rejects empty content.
func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) {
var sys strings.Builder
out := make([]anthropicMessage, 0, len(msgs))
for _, m := range msgs {
if m.Role == RoleSystem {
if t := messageText(m); t != "" {
if sys.Len() > 0 {
sys.WriteString("\n")
}
sys.WriteString(t)
}
continue
}
blocks, err := encodeAnthropicContent(m.Content)
if err != nil {
return "", nil, err
}
if len(blocks) == 0 {
continue
}
out = append(out, anthropicMessage{Role: anthropicRole(m.Role), Content: blocks})
}
return sys.String(), out, nil
}
// messageText concatenates the text parts of a message (used for system prompts).
func messageText(m Message) string {
var b strings.Builder
for _, part := range m.Content {
if part.Type == ContentTypeText {
b.WriteString(part.Text)
}
}
return b.String()
}
// anthropicRole maps a normalized Role to the Anthropic role vocabulary. system
// messages are extracted to the top-level system field and never reach here.
func anthropicRole(r Role) string {
switch r {
case RoleAssistant:
return "assistant"
default:
// user, tool and any future roles all land on the user turn; tool_result
// blocks (M2) belong to the user role in Anthropic.
return "user"
}
}
// encodeAnthropicContent turns normalized content parts into Anthropic content
// blocks.
func encodeAnthropicContent(parts []Content) ([]any, error) {
blocks := make([]any, 0, len(parts))
for _, p := range parts {
switch p.Type {
case ContentTypeText:
blocks = append(blocks, map[string]any{"type": "text", "text": p.Text})
case ContentTypeImage:
mediaType, data, err := parseAnthropicImageData(p.ImageURL)
if err != nil {
return nil, err
}
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": mediaType,
"data": data,
},
})
default:
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
}
}
return blocks, nil
}
// parseAnthropicImageData extracts the media type and base64 payload from a data:
// URI. Anthropic requires a base64 image source and cannot reference a remote
// URL, so any other URI form is rejected as a bad request.
func parseAnthropicImageData(uri string) (string, string, error) {
if !strings.HasPrefix(uri, anthropicDataPrefix) {
return "", "", fmt.Errorf("model: %w: anthropic image requires a data: URI, got %q", ErrBadRequest, uri)
}
rest := strings.TrimPrefix(uri, anthropicDataPrefix)
comma := strings.Index(rest, ",")
if comma < 0 {
return "", "", fmt.Errorf("model: %w: malformed data: URI %q", ErrBadRequest, uri)
}
meta := rest[:comma]
data := rest[comma+1:]
mediaType := meta
if idx := strings.Index(meta, ";"); idx >= 0 {
mediaType = meta[:idx]
}
if mediaType == "" {
return "", "", fmt.Errorf("model: %w: data: URI missing media type in %q", ErrBadRequest, uri)
}
return mediaType, data, nil
}
// buildAnthropicTools converts ToolSpec schemas into Anthropic tools.
func buildAnthropicTools(tools []ToolSpec) []anthropicTool {
if len(tools) == 0 {
return nil
}
out := make([]anthropicTool, 0, len(tools))
for _, t := range tools {
out = append(out, anthropicTool{
Name: t.Name,
Description: t.Description,
InputSchema: t.Parameters,
})
}
return out
}
// responseFromAnthropicWire converts a non-streaming /v1/messages response into a
// normalized Response. Each text block contributes to the assistant content; each
// tool_use block becomes a ToolCall whose Args is taken directly from the decoded
// input object.
func responseFromAnthropicWire(wire *anthropicResponse) Response {
out := Response{Model: wire.Model}
var b strings.Builder
var toolCalls []ToolCall
for _, blk := range wire.Content {
switch blk.Type {
case "text":
b.WriteString(blk.Text)
case "tool_use":
toolCalls = append(toolCalls, ToolCall{Name: blk.Name, Args: blk.Input})
}
}
out.Content = b.String()
out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: out.Content}}}
out.ToolCalls = toolCalls
if u := wire.Usage; u != nil {
out.PromptTokens = u.InputTokens
out.CompletionTokens = u.OutputTokens
out.TotalTokens = u.InputTokens + u.OutputTokens
}
return out
}
// AnthropicToolResult builds the content block used to feed a tool result back to
// Anthropic (role: user, first content element). toolUseID must equal the id of a
// previous tool_use block; isError marks an execution failure.
func AnthropicToolResult(toolUseID, content string, isError bool) map[string]any {
return map[string]any{
"type": "tool_result",
"tool_use_id": toolUseID,
"content": content,
"is_error": isError,
}
}
+189
View File
@@ -0,0 +1,189 @@
package model
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strings"
)
// anthropicStreamEvent is one SSE payload from /v1/messages with stream: true.
// Anthropic carries the event kind both in the SSE `event:` field and inside the
// JSON `type` field, so the parser keys off the JSON payload type.
type anthropicStreamEvent struct {
Type string `json:"type"`
Message *anthropicStartBlock `json:"message,omitempty"`
Index *int `json:"index,omitempty"`
Content *anthropicBlock `json:"content_block,omitempty"`
Delta *anthropicDelta `json:"delta,omitempty"`
Usage *anthropicUsage `json:"usage,omitempty"`
Error *anthropicStreamError `json:"error,omitempty"`
}
// anthropicStartBlock is the payload of a message_start event.
type anthropicStartBlock struct {
Model string `json:"model"`
Usage *anthropicUsage `json:"usage,omitempty"`
}
// anthropicDelta is the incremental payload inside a content_block_delta event.
type anthropicDelta struct {
Type string `json:"type"` // text_delta | input_json_delta
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
}
// anthropicStreamError is the payload of an error event.
type anthropicStreamError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// streamBlock tracks the partial state of one content block during streaming.
type streamBlock struct {
index int
typ string
id string
name string
text strings.Builder
input strings.Builder
initialArgs map[string]any
}
// scanAnthropicSSE incrementally decodes Anthropic's content-block stream. Text
// deltas are forwarded to onChunk; tool_use input_json_delta fragments are
// accumulated per block and re-assembled into a structured Args map when the block
// stops. The stream must terminate with a message_stop event, otherwise ErrStream
// is returned.
func scanAnthropicSSE(r io.Reader, onChunk func(string)) (Response, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, sseBufInit), maxSSELineBytes)
agg := Response{}
blocks := map[int]*streamBlock{}
var order []int
var text strings.Builder
sawMessageStop := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, sseDataPrefix) {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, sseDataPrefix))
if data == "" {
continue
}
var evt anthropicStreamEvent
if err := json.Unmarshal([]byte(data), &evt); err != nil {
return Response{}, fmt.Errorf("model: %w: decode stream chunk: %v", ErrUpstream, err)
}
if err := applyAnthropicEvent(&agg, blocks, &order, &text, evt, onChunk); err != nil {
return Response{}, err
}
if evt.Type == "message_stop" {
sawMessageStop = true
}
}
if err := scanner.Err(); err != nil {
return Response{}, fmt.Errorf("model: %w: read stream: %v", ErrUpstream, err)
}
if !sawMessageStop {
return Response{}, ErrStream
}
finalizeAnthropicStream(&agg, blocks, order, text.String())
return agg, nil
}
// applyAnthropicEvent folds a single stream event into the running aggregation.
func applyAnthropicEvent(
agg *Response,
blocks map[int]*streamBlock,
order *[]int,
text *strings.Builder,
evt anthropicStreamEvent,
onChunk func(string),
) error {
switch evt.Type {
case "message_start":
if evt.Message != nil {
agg.Model = evt.Message.Model
if evt.Message.Usage != nil {
agg.PromptTokens = evt.Message.Usage.InputTokens
}
}
case "content_block_start":
if evt.Index == nil || evt.Content == nil {
return nil
}
b := &streamBlock{
index: *evt.Index,
typ: evt.Content.Type,
id: evt.Content.ID,
name: evt.Content.Name,
initialArgs: evt.Content.Input,
}
if evt.Content.Type == "text" && evt.Content.Text != "" {
b.text.WriteString(evt.Content.Text)
text.WriteString(evt.Content.Text)
if onChunk != nil {
onChunk(evt.Content.Text)
}
}
if _, ok := blocks[*evt.Index]; !ok {
*order = append(*order, *evt.Index)
}
blocks[*evt.Index] = b
case "content_block_delta":
if evt.Index == nil || evt.Delta == nil {
return nil
}
b := blocks[*evt.Index]
if b == nil {
return nil
}
switch evt.Delta.Type {
case "text_delta":
b.text.WriteString(evt.Delta.Text)
text.WriteString(evt.Delta.Text)
if onChunk != nil {
onChunk(evt.Delta.Text)
}
case "input_json_delta":
b.input.WriteString(evt.Delta.PartialJSON)
}
case "message_delta":
if evt.Usage != nil {
agg.CompletionTokens = evt.Usage.OutputTokens
}
case "error":
if evt.Error != nil {
return fmt.Errorf("model: %w: anthropic stream error: %s: %s", ErrUpstream, evt.Error.Type, evt.Error.Message)
}
}
return nil
}
// finalizeAnthropicStream assembles the aggregated Response from the accumulated
// blocks: concatenated text plus any tool_use ToolCalls derived per block.
func finalizeAnthropicStream(agg *Response, blocks map[int]*streamBlock, order []int, content string) {
agg.Content = content
agg.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: content}}}
agg.TotalTokens = agg.PromptTokens + agg.CompletionTokens
for _, idx := range order {
b := blocks[idx]
if b == nil || b.typ != "tool_use" {
continue
}
args := b.initialArgs
if b.input.Len() > 0 {
decoded := map[string]any{}
if err := json.Unmarshal([]byte(b.input.String()), &decoded); err == nil {
args = decoded
} else {
args = map[string]any{}
}
}
agg.ToolCalls = append(agg.ToolCalls, ToolCall{Name: b.name, Args: args})
}
}
+442
View File
@@ -0,0 +1,442 @@
package model
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// anthropicCaptured records what an AnthropicChatAdapter sent to a stub server.
type anthropicCaptured struct {
method string
path string
apiKey string
anthropicVersion string
contentType string
accept string
body map[string]any
}
func newAnthropicServer(t *testing.T, status int, contentType, body string, ch chan<- anthropicCaptured) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var reqBody map[string]any
if ch != nil {
_ = json.NewDecoder(r.Body).Decode(&reqBody)
}
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.WriteHeader(status)
if body != "" {
_, _ = io.WriteString(w, body)
}
if ch != nil {
ch <- anthropicCaptured{
method: r.Method,
path: r.URL.Path,
apiKey: r.Header.Get("x-api-key"),
anthropicVersion: r.Header.Get("anthropic-version"),
contentType: r.Header.Get("Content-Type"),
accept: r.Header.Get("Accept"),
body: reqBody,
}
}
}))
t.Cleanup(srv.Close)
return srv
}
const anthropicTextResponse = `{
"id":"msg_1","model":"claude-test","role":"assistant",
"content":[{"type":"text","text":"Hello!"}],
"usage":{"input_tokens":5,"output_tokens":2}
}`
func TestAnthropicChat_Chat_NonStreamingText(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
recs := make([]CallRecord, 0, 1)
temp := 1.0
ad := NewAnthropicChat(config.LLMConfig{
AnthropicBaseURL: srv.URL, AnthropicAPIKey: "ak-test", AnthropicModel: "claude-test",
}, WithAnthropicRecorder(func(cr CallRecord) { recs = append(recs, cr) }))
resp, err := ad.Chat(context.Background(), Request{
Messages: []Message{TextMessage(RoleUser, "hi")},
Model: "claude-test",
Temperature: &temp,
MaxTokens: 64,
Purpose: "chat",
}, Options{})
if err != nil {
t.Fatalf("Chat error: %v", err)
}
got := <-ch
if got.method != http.MethodPost {
t.Errorf("method = %s, want POST", got.method)
}
if got.path != "/v1/messages" {
t.Errorf("path = %s, want /v1/messages", got.path)
}
if got.apiKey != "ak-test" {
t.Errorf("x-api-key = %q, want ak-test", got.apiKey)
}
if got.anthropicVersion != "2023-06-01" {
t.Errorf("anthropic-version = %q, want 2023-06-01", got.anthropicVersion)
}
if got.contentType != "application/json" {
t.Errorf("content-type = %s, want application/json", got.contentType)
}
if got.accept != "application/json" {
t.Errorf("accept = %s, want application/json", got.accept)
}
if got.body["model"] != "claude-test" {
t.Errorf("body.model = %v, want claude-test", got.body["model"])
}
if got.body["max_tokens"] != float64(64) {
t.Errorf("body.max_tokens = %v, want 64", got.body["max_tokens"])
}
if got.body["temperature"] != 1.0 {
t.Errorf("body.temperature = %v, want 1.0", got.body["temperature"])
}
if got.body["stream"] != nil && got.body["stream"] != false {
t.Errorf("body.stream = %v, want omitted(false)", got.body["stream"])
}
msgs, _ := got.body["messages"].([]any)
if len(msgs) != 1 {
t.Fatalf("body.messages = %v, want 1 message", got.body["messages"])
}
if resp.Model != "claude-test" {
t.Errorf("resp.Model = %q, want claude-test", resp.Model)
}
if resp.Content != "Hello!" {
t.Errorf("resp.Content = %q, want Hello!", resp.Content)
}
if resp.TotalTokens != 7 || resp.PromptTokens != 5 || resp.CompletionTokens != 2 {
t.Errorf("resp usage = (%d,%d,%d), want (5,2,7)", resp.PromptTokens, resp.CompletionTokens, resp.TotalTokens)
}
if resp.Message.Role != RoleAssistant || len(resp.Message.Content) != 1 || resp.Message.Content[0].Text != "Hello!" {
t.Errorf("resp.Message = %+v", resp.Message)
}
if len(recs) != 1 {
t.Fatalf("records = %d, want 1", len(recs))
}
if recs[0].Model != "claude-test" || recs[0].Purpose != "chat" || recs[0].Tokens != 7 || recs[0].TookMs < 0 {
t.Errorf("record = %+v, want model=claude-test purpose=chat tokens=7 took_ms>=0", recs[0])
}
}
func TestAnthropicChat_Chat_ToolUseInputObject(t *testing.T) {
t.Parallel()
const resp = `{
"id":"msg_2","model":"claude-test",
"content":[
{"type":"text","text":"Let me check."},
{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"location":"Paris","limit":3}}
],
"usage":{"input_tokens":10,"output_tokens":12}
}`
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "application/json", resp, ch)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
req := Request{
Messages: []Message{TextMessage(RoleUser, "hi")},
Tools: []ToolSpec{{Name: "lookup", Description: "look up", Parameters: map[string]any{"type": "object"}}},
}
out, err := ad.Chat(context.Background(), req, Options{})
if err != nil {
t.Fatalf("Chat error: %v", err)
}
got := <-ch
tools, ok := got.body["tools"].([]any)
if !ok || len(tools) != 1 {
t.Fatalf("body.tools = %v, want 1 entry", got.body["tools"])
}
tool0, _ := tools[0].(map[string]any)
if tool0["name"] != "lookup" {
t.Errorf("tool name = %v, want lookup", tool0["name"])
}
if _, ok = tool0["input_schema"]; !ok {
t.Errorf("tool input_schema missing from %v", tool0)
}
if out.Content != "Let me check." {
t.Errorf("out.Content = %q, want Let me check.", out.Content)
}
if len(out.ToolCalls) != 1 {
t.Fatalf("out.ToolCalls = %+v, want 1", out.ToolCalls)
}
tc := out.ToolCalls[0]
if tc.Name != "lookup" {
t.Errorf("tool call name = %q, want lookup", tc.Name)
}
if tc.Args == nil {
t.Fatalf("tool call args = nil, want structured object")
}
if tc.Args["location"] != "Paris" {
t.Errorf("tool call args.location = %v, want Paris (structured, not JSON string)", tc.Args["location"])
}
if tc.Args["limit"] != float64(3) {
t.Errorf("tool call args.limit = %v, want 3", tc.Args["limit"])
}
}
func TestAnthropicChat_Chat_SystemAndToolChoice(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
req := Request{
Messages: []Message{
TextMessage(RoleSystem, "Be concise."),
TextMessage(RoleUser, "hi"),
},
ToolChoice: map[string]any{"type": "auto"},
}
if _, err := ad.Chat(context.Background(), req, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
got := <-ch
if got.body["system"] != "Be concise." {
t.Errorf("body.system = %v, want Be concise.", got.body["system"])
}
msgs, _ := got.body["messages"].([]any)
if len(msgs) != 1 {
t.Fatalf("body.messages = %v, want 1 message (system extracted)", got.body["messages"])
}
if _, ok := got.body["tool_choice"].(map[string]any); !ok {
t.Errorf("body.tool_choice = %v, want an object", got.body["tool_choice"])
}
}
func TestAnthropicChat_Chat_ImageContent(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
req := Request{Messages: []Message{ImageMessage(RoleUser, "what is this", "data:image/png;base64,abc")}}
if _, err := ad.Chat(context.Background(), req, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
got := <-ch
msgs, _ := got.body["messages"].([]any)
part0, _ := msgs[0].(map[string]any)
content, _ := part0["content"].([]any)
if len(content) != 2 {
t.Fatalf("content parts = %d, want 2 (text+image)", len(content))
}
found := false
for _, c := range content {
cp, _ := c.(map[string]any)
if cp["type"] != "image" {
continue
}
found = true
src, _ := cp["source"].(map[string]any)
if src["type"] != "base64" {
t.Errorf("image source type = %v, want base64", src["type"])
}
if src["media_type"] != "image/png" {
t.Errorf("image media_type = %v, want image/png", src["media_type"])
}
if src["data"] != "abc" {
t.Errorf("image data = %v, want abc", src["data"])
}
}
if !found {
t.Fatalf("no image block found in %v", content)
}
}
func TestAnthropicChat_Chat_UpstreamError(t *testing.T) {
t.Parallel()
srv := newAnthropicServer(t, http.StatusInternalServerError, "application/json", `{"error":"boom"}`, nil)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "m"})
_, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
if err == nil {
t.Fatal("want error, got nil")
}
if !errors.Is(err, ErrUpstream) {
t.Fatalf("err = %v, want ErrUpstream", err)
}
}
func TestAnthropicChat_Chat_NoBaseURL(t *testing.T) {
t.Parallel()
ad := NewAnthropicChat(config.LLMConfig{AnthropicModel: "m"})
if _, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); !errors.Is(err, ErrUpstream) {
t.Fatalf("err = %v, want ErrUpstream", err)
}
}
func TestAnthropicChat_Chat_NoModel(t *testing.T) {
t.Parallel()
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, nil)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL})
if _, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); !errors.Is(err, ErrNoModel) {
t.Fatalf("err = %v, want ErrNoModel", err)
}
}
func TestAnthropicChat_ChatStream_Text(t *testing.T) {
t.Parallel()
sse := strings.Join([]string{
`data: {"type":"message_start","message":{"model":"claude-test","usage":{"input_tokens":5}}}`,
``,
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
``,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}`,
``,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"lo"}}`,
``,
`data: {"type":"content_block_stop","index":0}`,
``,
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`,
``,
`data: {"type":"message_stop"}`,
``,
}, "\n")
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "text/event-stream", sse, ch)
recs := make([]CallRecord, 0, 1)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"},
WithAnthropicRecorder(func(cr CallRecord) { recs = append(recs, cr) }))
var deltas []string
resp, err := ad.ChatStream(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}, Model: "claude-test", Purpose: "chat"},
func(d string) { deltas = append(deltas, d) })
if err != nil {
t.Fatalf("ChatStream error: %v", err)
}
got := <-ch
if got.accept != "text/event-stream" {
t.Errorf("accept = %s, want text/event-stream", got.accept)
}
if got.body["stream"] != true {
t.Errorf("body.stream = %v, want true", got.body["stream"])
}
if resp.Content != "Hello" {
t.Errorf("resp.Content = %q, want Hello", resp.Content)
}
if resp.Model != "claude-test" {
t.Errorf("resp.Model = %q, want claude-test", resp.Model)
}
if resp.TotalTokens != 7 {
t.Errorf("resp.TotalTokens = %d, want 7", resp.TotalTokens)
}
if !reflect.DeepEqual(deltas, []string{"Hel", "lo"}) {
t.Errorf("deltas = %v, want [Hel lo]", deltas)
}
if len(recs) != 1 || recs[0].Tokens != 7 || recs[0].Purpose != "chat" {
t.Errorf("records = %+v, want 1 record tokens=7 purpose=chat", recs)
}
}
func TestAnthropicChat_ChatStream_ToolUseReassembly(t *testing.T) {
t.Parallel()
sse := strings.Join([]string{
`data: {"type":"message_start","message":{"model":"claude-test","usage":{"input_tokens":10}}}`,
``,
`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}}`,
``,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"location\":"}}`,
``,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"Paris\"}"}}`,
``,
`data: {"type":"content_block_stop","index":0}`,
``,
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":8}}`,
``,
`data: {"type":"message_stop"}`,
``,
}, "\n")
srv := newAnthropicServer(t, http.StatusOK, "text/event-stream", sse, nil)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
var deltas []string
resp, err := ad.ChatStream(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}, Model: "claude-test"},
func(d string) { deltas = append(deltas, d) })
if err != nil {
t.Fatalf("ChatStream error: %v", err)
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("resp.ToolCalls = %+v, want 1", resp.ToolCalls)
}
tc := resp.ToolCalls[0]
if tc.Name != "lookup" {
t.Errorf("tool call name = %q, want lookup", tc.Name)
}
if tc.Args["location"] != "Paris" {
t.Errorf("tool call args.location = %v, want Paris (reassembled from partial_json)", tc.Args["location"])
}
if resp.Content != "" {
t.Errorf("resp.Content = %q, want empty (tool-only response)", resp.Content)
}
if resp.TotalTokens != 18 {
t.Errorf("resp.TotalTokens = %d, want 18", resp.TotalTokens)
}
if len(deltas) != 0 {
t.Errorf("deltas = %v, want none (only tool_use)", deltas)
}
}
func TestAnthropicChat_ChatStream_NoMessageStop(t *testing.T) {
t.Parallel()
sse := strings.Join([]string{
`data: {"type":"message_start","message":{"model":"claude-test","usage":{"input_tokens":5}}}`,
``,
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
``,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}`,
``,
}, "\n")
srv := newAnthropicServer(t, http.StatusOK, "text/event-stream", sse, nil)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
if _, err := ad.ChatStream(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}, Model: "claude-test"}, nil); !errors.Is(err, ErrStream) {
t.Fatalf("err = %v, want ErrStream", err)
}
}
func TestAnthropicChat_ToolResult(t *testing.T) {
t.Parallel()
blk := AnthropicToolResult("toolu_1", `{"ok":true}`, true)
if blk["type"] != "tool_result" {
t.Errorf("type = %v, want tool_result", blk["type"])
}
if blk["tool_use_id"] != "toolu_1" {
t.Errorf("tool_use_id = %v, want toolu_1", blk["tool_use_id"])
}
if blk["content"] != `{"ok":true}` {
t.Errorf("content = %v, want the raw result", blk["content"])
}
if blk["is_error"] != true {
t.Errorf("is_error = %v, want true", blk["is_error"])
}
}
+87
View File
@@ -0,0 +1,87 @@
package model
import (
"errors"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Provider names accepted by the provider-aware factory. These are the only two
// protocols the factory understands today; adding a vendor means adding a case
// here plus a matching adapter, not touching the rest of the package.
const (
// ProviderOpenAI selects the OpenAI-compatible /chat/completions protocol.
ProviderOpenAI = "openai"
// ProviderAnthropic selects the Anthropic Messages /v1/messages protocol.
ProviderAnthropic = "anthropic"
)
// ErrUnknownProvider is returned by the provider-aware factory when the
// configured provider name is not recognized.
var ErrUnknownProvider = errors.New("model: unknown provider")
// NewChatAdapter builds a single provider adapter selected by cfg.Provider. The
// empty provider defaults to openai for backward compatibility. An unrecognized
// provider returns ErrUnknownProvider.
func NewChatAdapter(cfg config.LLMConfig) (Adapter, error) {
switch cfg.Provider {
case ProviderOpenAI, "":
return NewOpenAIChat(cfg), nil
case ProviderAnthropic:
return NewAnthropicChat(cfg), nil
default:
return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider)
}
}
// NewChatChain builds a fallback Chain for the configured provider: the primary
// model first, then the provider's fallback model when set and distinct. Each
// step is served by an adapter of the selected protocol. The empty provider
// defaults to openai. An unrecognized provider returns ErrUnknownProvider.
func NewChatChain(cfg config.LLMConfig) (*Chain, error) {
switch cfg.Provider {
case ProviderOpenAI, "":
return newOpenAIChain(cfg)
case ProviderAnthropic:
return newAnthropicChain(cfg)
default:
return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider)
}
}
// newOpenAIChain builds a fallback chain from the OpenAI-compatible config block.
// Each step is an OpenAIChatAdapter against the configured endpoint.
func newOpenAIChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) {
if cfg.BaseURL == "" || cfg.Model == "" {
return nil, ErrNoModel
}
steps := []Step{{Model: cfg.Model, Adapter: NewOpenAIChat(cfg, opts...)}}
if cfg.Fallback != "" && cfg.Fallback != cfg.Model {
steps = append(steps, Step{Model: cfg.Fallback, Adapter: NewOpenAIChat(cfg, opts...)})
}
return NewChain(steps...), nil
}
// newAnthropicChain builds a fallback chain from the Anthropic config block. Each
// step is an AnthropicChatAdapter against the configured /v1/messages endpoint.
func newAnthropicChain(cfg config.LLMConfig) (*Chain, error) {
if cfg.AnthropicBaseURL == "" || cfg.AnthropicModel == "" {
return nil, ErrNoModel
}
steps := []Step{{Model: cfg.AnthropicModel, Adapter: NewAnthropicChat(cfg)}}
if cfg.AnthropicFallback != "" && cfg.AnthropicFallback != cfg.AnthropicModel {
steps = append(steps, Step{Model: cfg.AnthropicFallback, Adapter: NewAnthropicChat(cfg)})
}
return NewChain(steps...), nil
}
// DefaultModelForProvider returns the primary model name for the configured
// provider: the flat OpenAI Model for "openai", the Anthropic model for
// "anthropic". Used by callers (e.g. cmd/cyrene) to seed a Router's default.
func DefaultModelForProvider(cfg config.LLMConfig) string {
if cfg.Provider == ProviderAnthropic {
return cfg.AnthropicModel
}
return cfg.Model
}
+199
View File
@@ -0,0 +1,199 @@
package model
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
func TestNewChatAdapter_Provider(t *testing.T) {
t.Parallel()
cases := []struct {
name string
provider string
wantType any
wantErr bool
}{
{"openai", ProviderOpenAI, (*OpenAIChatAdapter)(nil), false},
{"empty defaults to openai", "", (*OpenAIChatAdapter)(nil), false},
{"anthropic", ProviderAnthropic, (*AnthropicChatAdapter)(nil), false},
{"unknown", "gemini", nil, true},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
ad, err := NewChatAdapter(config.LLMConfig{Provider: c.provider, Model: "m", AnthropicModel: "m"})
if c.wantErr {
if !errors.Is(err, ErrUnknownProvider) {
t.Fatalf("err = %v, want ErrUnknownProvider", err)
}
if ad != nil {
t.Fatalf("adapter = %v, want nil on error", ad)
}
return
}
if err != nil {
t.Fatalf("NewChatAdapter error: %v", err)
}
switch c.wantType.(type) {
case *OpenAIChatAdapter:
if _, ok := ad.(*OpenAIChatAdapter); !ok {
t.Fatalf("adapter = %T, want *OpenAIChatAdapter", ad)
}
case *AnthropicChatAdapter:
if _, ok := ad.(*AnthropicChatAdapter); !ok {
t.Fatalf("adapter = %T, want *AnthropicChatAdapter", ad)
}
}
})
}
}
func TestNewChatChain_Provider(t *testing.T) {
t.Parallel()
t.Run("openai primary and fallback", func(t *testing.T) {
t.Parallel()
chain, err := NewChatChain(config.LLMConfig{Provider: ProviderOpenAI, BaseURL: "http://x", Model: "m", Fallback: "fb"})
if err != nil {
t.Fatalf("NewChatChain error: %v", err)
}
if len(chain.steps) != 2 {
t.Fatalf("steps = %d, want 2", len(chain.steps))
}
if chain.steps[0].Model != "m" || chain.steps[1].Model != "fb" {
t.Errorf("step models = [%s %s], want [m fb]", chain.steps[0].Model, chain.steps[1].Model)
}
if _, ok := chain.steps[0].Adapter.(*OpenAIChatAdapter); !ok {
t.Errorf("step[0] adapter = %T, want *OpenAIChatAdapter", chain.steps[0].Adapter)
}
})
t.Run("anthropic primary and fallback", func(t *testing.T) {
t.Parallel()
chain, err := NewChatChain(config.LLMConfig{
Provider: ProviderAnthropic,
AnthropicBaseURL: "http://x",
AnthropicModel: "claude-main",
AnthropicFallback: "claude-fb",
})
if err != nil {
t.Fatalf("NewChatChain error: %v", err)
}
if len(chain.steps) != 2 {
t.Fatalf("steps = %d, want 2", len(chain.steps))
}
if chain.steps[0].Model != "claude-main" || chain.steps[1].Model != "claude-fb" {
t.Errorf("step models = [%s %s], want [claude-main claude-fb]", chain.steps[0].Model, chain.steps[1].Model)
}
if _, ok := chain.steps[0].Adapter.(*AnthropicChatAdapter); !ok {
t.Errorf("step[0] adapter = %T, want *AnthropicChatAdapter", chain.steps[0].Adapter)
}
})
t.Run("missing model returns ErrNoModel", func(t *testing.T) {
t.Parallel()
if _, err := NewChatChain(config.LLMConfig{Provider: ProviderOpenAI, BaseURL: "http://x"}); !errors.Is(err, ErrNoModel) {
t.Fatalf("openai err = %v, want ErrNoModel", err)
}
if _, err := NewChatChain(config.LLMConfig{Provider: ProviderAnthropic, AnthropicBaseURL: "http://x"}); !errors.Is(err, ErrNoModel) {
t.Fatalf("anthropic err = %v, want ErrNoModel", err)
}
})
t.Run("unknown provider returns ErrUnknownProvider", func(t *testing.T) {
t.Parallel()
if _, err := NewChatChain(config.LLMConfig{Provider: "gemini"}); !errors.Is(err, ErrUnknownProvider) {
t.Fatalf("err = %v, want ErrUnknownProvider", err)
}
})
}
func TestNewOpenAIChatChain_StillWorks(t *testing.T) {
t.Parallel()
chain, err := NewOpenAIChatChain(config.LLMConfig{BaseURL: "http://x", Model: "m", Fallback: "fb"})
if err != nil {
t.Fatalf("NewOpenAIChatChain error: %v", err)
}
if len(chain.steps) != 2 {
t.Fatalf("steps = %d, want 2", len(chain.steps))
}
if _, ok := chain.steps[0].Adapter.(*OpenAIChatAdapter); !ok {
t.Errorf("step[0] adapter = %T, want *OpenAIChatAdapter", chain.steps[0].Adapter)
}
}
func TestDefaultModelForProvider(t *testing.T) {
t.Parallel()
if got := DefaultModelForProvider(config.LLMConfig{Provider: ProviderOpenAI, Model: "gpt"}); got != "gpt" {
t.Errorf("openai = %q, want gpt", got)
}
if got := DefaultModelForProvider(config.LLMConfig{Provider: ProviderAnthropic, AnthropicModel: "claude"}); got != "claude" {
t.Errorf("anthropic = %q, want claude", got)
}
}
func TestRouter_RouteProvider(t *testing.T) {
t.Parallel()
r := NewRouter("default",
WithDefaultProvider(ProviderAnthropic),
WithRoleProvider(Role("engineer"), ProviderOpenAI),
)
cases := []struct {
role Role
want string
}{
{Role("engineer"), ProviderOpenAI},
{Role("user"), ProviderAnthropic},
{Role("assistant"), ProviderAnthropic},
}
for _, c := range cases {
c := c
t.Run(string(c.role), func(t *testing.T) {
t.Parallel()
if got := r.RouteProvider(c.role); got != c.want {
t.Fatalf("RouteProvider(%q) = %q, want %q", c.role, got, c.want)
}
})
}
}
// TestChain_AnthropicFallback exercises the fallback chain against two Anthropic
// adapters: the primary step fails and the backup step succeeds, proving the
// adapter composes cleanly inside a Chain.
func TestChain_AnthropicFallback(t *testing.T) {
t.Parallel()
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"error":"down"}`)
}))
t.Cleanup(primary.Close)
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"id":"msg_b","model":"backup","content":[{"type":"text","text":"Hello!"}],"usage":{"input_tokens":5,"output_tokens":2}}`)
}))
t.Cleanup(backup.Close)
chain := NewChain(
Step{Model: "main", Adapter: NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: primary.URL, AnthropicModel: "main"})},
Step{Model: "backup", Adapter: NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: backup.URL, AnthropicModel: "backup"})},
)
resp, err := chain.Chat(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}, Purpose: "chat"}, Options{})
if err != nil {
t.Fatalf("Chain.Chat error: %v", err)
}
if resp.Content != "Hello!" {
t.Errorf("resp.Content = %q, want Hello!", resp.Content)
}
if resp.Model != "backup" {
t.Errorf("resp.Model = %q, want backup", resp.Model)
}
}
+3 -9
View File
@@ -29,16 +29,10 @@ func NewChain(steps ...Step) *Chain {
// NewOpenAIChatChain builds a fallback chain from a single supplier config: the // NewOpenAIChatChain builds a fallback chain from a single supplier config: the
// primary model first, then cfg.Fallback (when set and distinct), both served // primary model first, then cfg.Fallback (when set and distinct), both served
// by an OpenAIChatAdapter against the same OpenAI-compatible endpoint. It // by an OpenAIChatAdapter against the same OpenAI-compatible endpoint. It
// returns ErrNoModel when no base URL or primary model is configured. // delegates to the provider-aware builder so the OpenAI path and the factory stay
// in sync. It returns ErrNoModel when no base URL or primary model is configured.
func NewOpenAIChatChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) { func NewOpenAIChatChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) {
if cfg.BaseURL == "" || cfg.Model == "" { return newOpenAIChain(cfg, opts...)
return nil, ErrNoModel
}
steps := []Step{{Model: cfg.Model, Adapter: NewOpenAIChat(cfg, opts...)}}
if cfg.Fallback != "" && cfg.Fallback != cfg.Model {
steps = append(steps, Step{Model: cfg.Fallback, Adapter: NewOpenAIChat(cfg, opts...)})
}
return NewChain(steps...), nil
} }
// Name returns the chain identifier. // Name returns the chain identifier.
+20
View File
@@ -87,6 +87,18 @@ type ToolSpec struct {
Parameters any Parameters any
} }
// ToolCall is a single assistant-originated tool invocation parsed from a
// provider's tool_use content block. Args is the structured argument object
// (never a serialized JSON string), so a tool loop can bind it directly without
// re-parsing.
type ToolCall struct {
// Name is the tool being invoked.
Name string
// Args are the tool arguments, decoded from the provider's structured
// tool_use.input object. It is never a JSON-encoded string.
Args map[string]any
}
// Request is a completion request, normalized across providers. // Request is a completion request, normalized across providers.
type Request struct { type Request struct {
// Model overrides the adapter's configured default model. Empty means the // Model overrides the adapter's configured default model. Empty means the
@@ -101,6 +113,11 @@ type Request struct {
Purpose string Purpose string
// Tools is the interface-level placeholder for M2 function calling. // Tools is the interface-level placeholder for M2 function calling.
Tools []ToolSpec Tools []ToolSpec
// ToolChoice optionally directs how the provider selects a tool. It is an
// opaque provider-agnostic directive (e.g. {"type":"auto"} or {"type":"tool",
// "name":"x"}); empty means the provider default. Passed through verbatim to
// protocols that support it (Anthropic's tool_choice).
ToolChoice any
} }
// Response is a normalized completion result. // Response is a normalized completion result.
@@ -110,6 +127,9 @@ type Response struct {
Content string Content string
// Message is the normalized assistant message. // Message is the normalized assistant message.
Message Message Message Message
// ToolCalls carries any tool_use blocks the assistant emitted. Empty when the
// response is pure text.
ToolCalls []ToolCall
// PromptTokens, CompletionTokens and TotalTokens report provider usage when // PromptTokens, CompletionTokens and TotalTokens report provider usage when
// available; zero when the provider does not return it. // available; zero when the provider does not return it.
PromptTokens int PromptTokens int
+40 -5
View File
@@ -2,11 +2,16 @@ package model
// Router maps roles to model names. An unknown role resolves to the configured // Router maps roles to model names. An unknown role resolves to the configured
// default model. When a request carries image content and a vision model is // default model. When a request carries image content and a vision model is
// configured, the request routes to the vision model. // configured, the request routes to the vision model. A Router also carries a
// provider per role (defaulting to the global provider) so a multi-vendor
// deployment can pair a role's model with the correct protocol; the provider
// selection is surfaced via RouteProvider.
type Router struct { type Router struct {
defaultModel string defaultModel string
byRole map[Role]string byRole map[Role]string
visionModel string visionModel string
defaultProvider string
providerByRole map[Role]string
} }
// RouterOption configures a Router. // RouterOption configures a Router.
@@ -22,10 +27,28 @@ func WithVisionModel(model string) RouterOption {
return func(r *Router) { r.visionModel = model } return func(r *Router) { r.visionModel = model }
} }
// WithDefaultProvider sets the provider returned by RouteProvider for roles with
// no explicit binding. It defaults to openai.
func WithDefaultProvider(provider string) RouterOption {
return func(r *Router) { r.defaultProvider = provider }
}
// WithRoleProvider binds a provider to a role, so a multi-vendor deployment can
// route a role to a different vendor's protocol (e.g. one role on anthropic, one
// on openai). Pair it with WithRoleModel for the role's model name.
func WithRoleProvider(role Role, provider string) RouterOption {
return func(r *Router) { r.providerByRole[role] = provider }
}
// NewRouter builds a Router with a default model applied to any role that has // NewRouter builds a Router with a default model applied to any role that has
// no explicit binding. // no explicit binding.
func NewRouter(defaultModel string, opts ...RouterOption) *Router { func NewRouter(defaultModel string, opts ...RouterOption) *Router {
r := &Router{defaultModel: defaultModel, byRole: map[Role]string{}} r := &Router{
defaultModel: defaultModel,
byRole: map[Role]string{},
defaultProvider: ProviderOpenAI,
providerByRole: map[Role]string{},
}
for _, o := range opts { for _, o := range opts {
o(r) o(r)
} }
@@ -40,6 +63,18 @@ func (r *Router) Route(role Role) string {
return r.defaultModel return r.defaultModel
} }
// RouteProvider returns the provider for a role, falling back to the router's
// default provider. It is the routing hook for a multi-vendor deployment: each
// role can carry its own provider+model pairing, and roles without a binding fall
// back to the global provider. The runtime/loop is expected to build a per-role
// adapter or chain from this decision (the provider-aware factory is the builder).
func (r *Router) RouteProvider(role Role) string {
if p, ok := r.providerByRole[role]; ok && p != "" {
return p
}
return r.defaultProvider
}
// RouteRequest picks the model for a request and reports whether the request // RouteRequest picks the model for a request and reports whether the request
// needs a vision model. When the request carries image content and a vision // needs a vision model. When the request carries image content and a vision
// model is configured, the vision model is returned with needsVision set to // model is configured, the vision model is returned with needsVision set to