Files
Cyrene_v2/internal/runtime/model/anthropic.go
T
AskaEth 3dc1232bc2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
2026-10-04 16:27:54 +08:00

435 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package model
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 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,而不是单一总量。
type anthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// 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
// 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 }
}
// 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(req, start, out.TotalTokens)
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, 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(req, start, out.TotalTokens)
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, 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(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, 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(),
})
}
// 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
if u := wire.Usage; u != nil {
out.PromptTokens = u.InputTokens
out.CompletionTokens = u.OutputTokens
out.TotalTokens = u.InputTokens + u.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,
}
}