Files
Cyrene_v2/internal/runtime/model/anthropic.go
T
AskaEth 66dda084cd fix(runtime): 上游错误体统一走 trace.UpstreamError,正文不进 error
M41 的「LLM / embedding / 视觉 上游错误体」一族(M38 #5 #6 #7 #8 #9 #14):
非 2xx 时把响应体截断后拼进 error,而 error 会被 chat run / loop step /
retrieve_fallback 打日志,等于把她的提问、记忆正文、要念的台词写进日志。

- model:openai/anthropic 两处 upstreamError 收口到 upstream.go 的
  providerUpstreamError;anthropic 流内 error 事件的 evt.Error.Message
  (原本无上限)整段丢弃,只保留令牌形状的 type
- embedding:readErrorBody 删除,改走 helper(input 就是她的记忆正文)
- vision:错误路径不再沿用取图的 50 MiB 上限,改为 4 KiB 有界读且不拼原文
- voice:classifyDashscopeStatus 只解 code/request_id,正文只留在内存里做
  分类判据;错误体读取同样改为 4 KiB 有界
- 测试:五个上游各一条「日志/error 里搜不到哨兵正文」用例,且能变异打红
2026-10-26 13:44:32 +08:00

466 lines
17 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"
"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,而不是单一总量;提示缓存另有两个字段
// (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, 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, 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, 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,
}
}