package model import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "net/http" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // Anthropic Messages(/v1/messages)线协议常量。 const ( // anthropicAPIVersion 是每次请求都要发送的 Messages API 版本。 anthropicAPIVersion = "2023-06-01" // anthropicDefaultMaxTokens 是 Request.MaxTokens 为零时发送的默认 // max_tokens。Anthropic 要求 max_tokens 必须存在且为正, // 因此这里给出合理默认值而不是省略它。 // max_tokens 由思考与正文共享,必须给两者都留出空间。 anthropicDefaultMaxTokens = 8192 // anthropicDataPrefix 标记以 data: URI 内嵌的负载。 anthropicDataPrefix = "data:" // anthropicThinkingTypeEnabled 是「开启扩展思考」指令的 // 线取值。 anthropicThinkingTypeEnabled = "enabled" ) // anthropicThinking 是扩展思考指令的线格式。调用方未要求思考 // (或要求关闭)时整段省略,因为 Anthropic 把字段缺失视为「不思考」。 // DeepSeek 的 /anthropic 端点恰好相反、默认开启,所以显式关闭会带着 // 不带预算的 {"type":"disabled"} 发出去 // (design/04 §3.2 事实 2)。 type anthropicThinking struct { Type string `json:"type"` BudgetTokens int `json:"budget_tokens,omitempty"` } // anthropicOutputConfig 是 Anthropic 格式的推理强度块。只有 // DeepSeek 的 /anthropic 端点记录了它(且只有 effort 字段),所以 // 它永远不会发给 Anthropic 端点(design/04 §3.2 事实 6)。 type anthropicOutputConfig struct { Effort string `json:"effort"` } // anthropicRequest 是发给 /v1/messages 的请求体。Messages 采用 Anthropic // 的内容块模型,system 提示词位于请求根。 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"` Thinking *anthropicThinking `json:"thinking,omitempty"` OutputConfig *anthropicOutputConfig `json:"output_config,omitempty"` Stream bool `json:"stream,omitempty"` } // anthropicMessage 是一个会话回合。Content 是内容块数组 // (目前为文本 / 图片;tool_result 块由 M2 补上)。 type anthropicMessage struct { Role string `json:"role"` Content any `json:"content"` } // anthropicTool 是工具 schema 的线格式。 type anthropicTool struct { Name string `json:"name"` Description string `json:"description,omitempty"` InputSchema any `json:"input_schema"` } // anthropicResponse 是 /v1/messages 返回的 JSON 响应体。 type anthropicResponse struct { ID string `json:"id"` Model string `json:"model"` Content []anthropicBlock `json:"content"` StopReason string `json:"stop_reason,omitempty"` Usage *anthropicUsage `json:"usage,omitempty"` } // anthropicBlock 是响应里的单个内容块。对 tool_use 块而言, // Input 字段是已完全解码的对象(绝不是 JSON 字符串)。 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 上报 token 消耗。Anthropic 上报的是 input_tokens // 与 output_tokens,而不是单一总量;提示缓存另有两个字段 // (cache_creation_input_tokens = 本次写入缓存的输入 token, // cache_read_input_tokens = 本次从缓存读到的输入 token)。它们全都容忍缺失: // 未启用缓存的端点只会给前两个。 type anthropicUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` } // applyAnthropicUsage 把一个 usage 对象折进 Response(只填用量字段, // input_tokens 与 output_tokens 的既有口径不变)。缓存两档的对应关系: // 命中 = cache_read_input_tokens;未命中 = input_tokens + cache_creation_input_tokens // —— Anthropic 的 input_tokens **不含**缓存读到的部分,若只取它,命中率会被算成 0。 func applyAnthropicUsage(out *Response, u *anthropicUsage) { if u == nil { return } out.PromptTokens = u.InputTokens out.CompletionTokens = u.OutputTokens out.CacheHitTokens = u.CacheReadInputTokens out.CacheMissTokens = u.InputTokens + u.CacheCreationInputTokens } // AnthropicChatAdapter 调用 Anthropic Messages(/v1/messages)端点。 // 它由 config.LLMConfig 构建,同时实现非流式与 token 级 SSE 流式的 // Adapter,直接说提供商的原生内容块协议, // 因此 tool_use.input 以结构化对象到达。 type AnthropicChatAdapter struct { name string baseURL string apiKey string model string client *http.Client recorder Recorder // usageSink 是可选的用量落库出口(切片 U1)。为 nil 时只记结构化日志, // 行为逐字等于引入落库之前。 usageSink UsageSink // log 上报推理调整与被忽略的指令。它永不为 nil: // 调用方未提供时,会装入一个带有本适配器来源标记的丢弃式日志器, // 因此提供商的每个决定都可追溯。 log *slog.Logger } // AnthropicOption 用于配置 AnthropicChatAdapter。 type AnthropicOption func(*AnthropicChatAdapter) // WithAnthropicHTTPClient 覆盖默认 HTTP 客户端(用于在测试与生产 // 中注入 mock 或调优过的 transport)。 func WithAnthropicHTTPClient(c *http.Client) AnthropicOption { return func(a *AnthropicChatAdapter) { a.client = c } } // WithAnthropicRecorder 装入按调用计账的 Recorder,用于 CallRecord 统计。 func WithAnthropicRecorder(r Recorder) AnthropicOption { return func(a *AnthropicChatAdapter) { a.recorder = r } } // WithAnthropicUsageSink 装入用量落库出口(切片 U1)。nil 会被忽略,从而保留 // 「只记日志」的默认行为;实现方必须非阻塞(见 UsageSink)。 func WithAnthropicUsageSink(s UsageSink) AnthropicOption { return func(a *AnthropicChatAdapter) { a.usageSink = s } } // WithAnthropicModel 覆盖适配器的默认模型名。 func WithAnthropicModel(m string) AnthropicOption { return func(a *AnthropicChatAdapter) { a.model = m } } // WithAnthropicLogger 挂上用于上报推理调整的日志器 // (为容纳 max_tokens 而抬高的预算、被忽略的指令)。nil 日志器会被忽略, // 从而保留丢弃式的默认值。 func WithAnthropicLogger(l *slog.Logger) AnthropicOption { return func(a *AnthropicChatAdapter) { if l != nil { a.log = l } } } // NewAnthropicChat 由供应商配置构建 Anthropic 适配器。baseURL 会先 // 去掉末尾斜杠,再拼接 /v1/messages 路径。 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, }, log: discardLogger("runtime/model/anthropic"), } if kind := strings.TrimSpace(cfg.ReasoningKind); kind != "" && kind != runtimeconfig.ReasoningKindAuto { // anthropic 协议自带思考形状,因此这里声明 openai // 形状是无效的。要上报它,而不是任由一个已配置的选项 // 静默失效(裁决 03.4.2:禁止静默无效)。 a.log.Warn("reasoning.kind ignored: the anthropic protocol defines its own thinking shape", "provider", ProviderAnthropic, "field", "reasoning.kind", "kind", kind) } for _, o := range opts { o(a) } return a } // Name 返回适配器标识。 func (a *AnthropicChatAdapter) Name() string { return a.name } // Chat 执行一次非流式补全。被 token 预算截断且没有任何可见输出 // (stop_reason 为 max_tokens,且既无文本也无 tool_use 块) // 的响应,会用翻倍的预算重试一次; // 若仍无输出,则替换为非空占位文本, // 让调用方永远不会看到静默的空回复。 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 out, err := callWithTruncationRetry(req.MaxTokens, anthropicDefaultMaxTokens, func(limit int) (Response, error) { attempt := req attempt.MaxTokens = limit return a.chatOnce(ctx, attempt) }) if err != nil { return Response{}, err } a.record(ctx, req, start, out) return out, nil } // chatOnce 执行一次非流式 /v1/messages 调用,并把线格式 // 响应体归一化为 Response。 func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Response, error) { 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, trace.URLCause(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) } return responseFromAnthropicWire(&wire), nil } // ChatStream 通过 onEvent 流出结构化事件,并在提供商上报时 // 返回带用量的聚合响应。文本块以 text_start/text_delta/text_end // 事件投递;tool_use 块以 // tool_call_start/tool_call_delta/tool_call_end 投递, // 前端因此可以独立渲染各片段。被预算截断的空流会用翻倍预算重试一次 // (重试产生的思考被抑制,以免出现重复块), // 若仍为空,则用一段非空占位文本关闭。 func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (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 out, err := callStreamWithTruncationRetry(req.MaxTokens, anthropicDefaultMaxTokens, func(limit int, ev func(StreamEvent)) (Response, error) { attempt := req attempt.MaxTokens = limit return a.streamOnce(ctx, attempt, ev) }, onEvent) if err != nil { return Response{}, err } out.Model = model a.record(ctx, req, start, out) return out, nil } // streamOnce 执行一次流式 /v1/messages 调用,并把其 SSE // 事件聚合为 Response。 func (a *AnthropicChatAdapter) streamOnce(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) { 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, trace.URLCause(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) } return scanAnthropicSSE(upstreamLabel(a.name, a.baseURL), resp.Body, onEvent) } // resolveModel 返回实际生效的模型名。 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 返回完整的 /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 := effectiveMaxTokens(req.MaxTokens, anthropicDefaultMaxTokens) var thinking *anthropicThinking var output *anthropicOutputConfig if deepSeekAnthropicEndpoint(req.Model, a.baseURL) { // DeepSeek 的 /anthropic 端点忽略 budget_tokens,因此这里刻意 // 不抬高 max_tokens:抬高了就会错误地反映提供商实际采用的预算。 // 强度改走 output_config.effort // (design/04 §3.2 事实 6)。 thinking, output = anthropicDeepSeekReasoning(a.log, req.Model, req.Thinking, req.ReasoningEffort) reportForcedToolChoiceWithThinking(a.log, string(vendorDeepSeek), req.Model, req.ToolChoice, anthropicThinkingOn(thinking)) } else { // 扩展思考与可见答案共享 max_tokens,所以在 Anthropic 端点上, // 预算是与它所需要的限额一起解出的 // (design/04 §3.2 红线)。 tokenLimit, thinking = anthropicThinkingFor(req.Thinking, tokenLimit, a.log) a.reportAnthropicEffortIgnored(req) } wire := anthropicRequest{ Model: req.Model, System: system, Messages: msgs, Tools: buildAnthropicTools(req.Tools), ToolChoice: req.ToolChoice, Temperature: req.Temperature, MaxTokens: tokenLimit, Thinking: thinking, OutputConfig: output, Stream: stream, } buf, err := json.Marshal(wire) if err != nil { return nil, fmt.Errorf("model: %w: marshal request: %v", ErrBadRequest, err) } return buf, nil } // reportAnthropicEffortIgnored 上报落在没有对应字段的端点上的 // reasoning_effort:Anthropic Messages 协议用 budget_tokens // 承载思考强度。它只上报——凭空编一个预算会改变调用方的 // 意图。 func (a *AnthropicChatAdapter) reportAnthropicEffortIgnored(req Request) { if strings.TrimSpace(req.ReasoningEffort) == "" { return } a.log.Warn("reasoning_effort ignored: the Anthropic Messages protocol takes thinking.budget_tokens, not an effort tier", "provider", "anthropic", "field", "reasoning_effort", "value", req.ReasoningEffort, "model", req.Model) } 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, trace.URLCause(err)) } return resp, nil } // upstreamError 把一次非 2xx 响应交给包内唯一的上游错误收口(见 upstream.go): // 只留 status / code / type 与体积摘要,响应体正文与 error.message 不出镜。 func (a *AnthropicChatAdapter) upstreamError(resp *http.Response) error { return providerUpstreamError(a.name, a.baseURL, resp) } // record 上报一次成功调用的用量:先记一条结构化用量日志(含缓存命中率, // 所有调用点都覆盖 —— 见 usage.go),再喂给可选的 CallRecord 计账出口。 // ctx 用来取本次调用所属的会话(loop 播种,见 session_ctx.go)。 func (a *AnthropicChatAdapter) record(ctx context.Context, req Request, start time.Time, resp Response) { logUsage(ctx, a.log, a.usageSink, a.name, req, resp, time.Since(start)) if a.recorder == nil { return } a.recorder(CallRecord{ Model: req.Model, Purpose: req.Purpose, Tokens: resp.TotalTokens, TookMs: time.Since(start).Milliseconds(), }) } // responseFromAnthropicWire 把非流式 /v1/messages 响应转换为 // 归一化的 Response。每个文本块汇入助手内容; // 每个 tool_use 块变成一个 ToolCall,其 Args 直接取自已解码的 // input 对象。 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, ID: blk.ID, Args: blk.Input}) } } out.Content = b.String() out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: out.Content}}} out.ToolCalls = toolCalls out.StopReason = wire.StopReason applyAnthropicUsage(&out, wire.Usage) if wire.Usage != nil { out.TotalTokens = wire.Usage.InputTokens + wire.Usage.OutputTokens } return out } // AnthropicToolResult 构建把工具结果回喂给 Anthropic 的内容块 // (role: user,第一个内容元素)。toolUseID 必须等于此前某个 // tool_use 块的 id;isError 标记执行失败。 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, } }