// Package llm 定义统一的 LLM Provider 抽象(借鉴 pigo internal/provider 的 // 统一层思路),当前支持 OpenAI 兼容与 Anthropic 两种协议,均为手写 SSE 流式。 package llm import ( "context" "strings" ) // Role 取值:system / user / assistant / tool。 type Role string const ( RoleSystem Role = "system" RoleUser Role = "user" RoleAssistant Role = "assistant" RoleTool Role = "tool" ) // ThinkingLevel 是每轮请求的思考/推理深度。注意零值是 ""(不是 ThinkingOff)。 type ThinkingLevel string const ( ThinkingOff ThinkingLevel = "off" ThinkingLow ThinkingLevel = "low" ThinkingMedium ThinkingLevel = "medium" ThinkingHigh ThinkingLevel = "high" ) // Enabled 仅对 low/medium/high 为真;off 与零值 "" 均为 false。 // 判定务必用本方法,不要写 `!= ThinkingOff`(零值不等于 off)。 func (l ThinkingLevel) Enabled() bool { return l == ThinkingLow || l == ThinkingMedium || l == ThinkingHigh } // ParseThinkingLevel 归一化输入;空值与未知值一律回退 ThinkingOff。 func ParseThinkingLevel(s string) ThinkingLevel { switch ThinkingLevel(strings.ToLower(strings.TrimSpace(s))) { case ThinkingLow: return ThinkingLow case ThinkingMedium: return ThinkingMedium case ThinkingHigh: return ThinkingHigh default: return ThinkingOff } } // IsThinkingLevel 报告 s 是否为四个合法值之一(配置校验用)。 func IsThinkingLevel(s string) bool { v := ThinkingLevel(strings.ToLower(strings.TrimSpace(s))) return v == ThinkingOff || v.Enabled() } // StreamOptions 是单轮请求的可选参数。零值 = off:不发送任何新字段。 type StreamOptions struct { Thinking ThinkingLevel // SystemDynamic 是不该进入 prompt-cache 断点的 system 后缀:计划、记忆、 // 回退分支摘要这类每轮可能变化的内容。Anthropic 把它作为第二个 system 块 // (第一个块带 cache_control);OpenAI 兼容/Responses 追加在同一条 system // 消息尾部——token 前缀缓存仍命中稳定段,同时避免部分网关不接受多条 // system 消息。 SystemDynamic string // DisableTools 保留 tools 定义、但禁止模型调用(三家的 tool_choice=none)。 // 收尾回合用它而不是把 tools 传成 nil:换掉工具数组会打碎缓存前缀。 DisableTools bool } // ToolCall 是模型发起的一次工具调用请求。 type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Input string `json:"input"` // 原始 JSON 参数字符串 } // Image 是随消息发送的图片(按文件名引用,构建请求时从缓存读取并 base64 编码; // 会话 JSONL 只存文件名,不存像素)。 type Image struct { Filename string `json:"filename"` // 缓存中的文件名 Path string `json:"path,omitempty"` // 兼容旧格式:直接路径 } // Message 是会话中的一条消息。ToolCallID 非空表示这是工具结果回填。 type Message struct { Role Role `json:"role"` Content string `json:"content"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` Images []Image `json:"images,omitempty"` // 常见于截图类工具结果 Usage *Usage `json:"usage,omitempty"` // Reasoning 是开启思考时的推理文本(展示用;Anthropic 场景还需回传,见下)。 Reasoning string `json:"reasoning,omitempty"` // ReasoningSignature 是 Anthropic thinking 块的签名:工具调用场景下, // 下一轮请求必须原样回传 thinking 块(含签名),否则 API 会拒绝。 ReasoningSignature string `json:"reasoning_signature,omitempty"` // RedactedThinking 是 Anthropic 的 redacted_thinking 数据(安全策略触发时), // 同样需要原样回传。 RedactedThinking string `json:"redacted_thinking,omitempty"` // --- 本地 trace:仅为度量与展示,绝不进入任何 API 请求报文 --- // 三家 provider 都手工白名单拼 body(oaBuildMessages / rsBuildInput / // anBuildMessages),全仓不存在 json.Marshal(Message),故这些字段不会出网; // 它们只随会话 JSONL 落盘。omitempty 保证旧消息与旧格式输出不变。 StartedAt int64 `json:"started_at,omitempty"` // 本步开始时刻(unix ms) DurationMs int64 `json:"duration_ms,omitempty"` // 本步耗时:assistant = 模型调用,tool = 工具执行 Step int `json:"step,omitempty"` // 第 N 次模型调用(tool 消息沿用其所属轮次) // Format/Model 是这一步的归因:协议格式 + 实际模型名。 // 没有它们就分不清"哪一步换了供应商/模型"——真实会话里出现过一条中途 // 切了 provider 的记录,事后只能靠 tool_call id 的前缀形态猜。 Format string `json:"format,omitempty"` Model string `json:"model,omitempty"` // Tool 是 tool 消息对应的工具名。此前只有 ToolCallID 落盘,工具名得从失败块 // 文本里 parse(retro.parseFriction 一直在这么干)。 Tool string `json:"tool,omitempty"` // Fail 是归一出的失败类别(timeout / denied / self-kill / …),空 = 成功。 // 有了它,stats 与 retro 不必再靠「[TOOL FAILED] …(kind)」字符串前缀计数—— // 那条契约的原话是"内容前缀是唯一可靠信号",格式漂一处就静默失准。 Fail string `json:"fail,omitempty"` // Exit 是命令类工具的退出码(取不到就不序列化)。 Exit int `json:"exit,omitempty"` // Truncated 表示 Content 被长度上限截过:模型看到的不是全量,UI 该标出来, // 统计也知道这条会话里有多少结果其实被削了。 Truncated bool `json:"truncated,omitempty"` // OutputLimited 是"**模型想写却写不下**":这一轮的终止原因是输出 token 上限 // (OpenAI finish_reason=length / Anthropic stop_reason=max_tokens / // Responses response.incomplete + max_output_tokens)。 // // 与 Truncated 正相反:那是我们自己削工具结果,这是模型的笔停了。不接住它的后果 // 是"讲一半就停"——agent 的收尾判定是"没有 tool_calls ⇒ 说完了",于是半截答案 // 被当成最终回复,用户只能自己打「继续」。带 tool_calls 的响应一律不算(见 completion)。 OutputLimited bool `json:"output_limited,omitempty"` // Interrupted 是"这条没写完就断了":模型调用出错或被停止时,已经流出来的半截 // 回复仍值得留下(见 agent.persistPartial),但它**不是**一个答案。 // 没有这个标记,会话末尾看起来就像已经答完了,重试入口只能回一句 // "没有可重试的消息"——而用户明明只看到一个断掉的半句。 Interrupted bool `json:"interrupted,omitempty"` // Synthetic 标记这条 user 消息是系统生成的(后台作业完成通知等),不是人打的字。 // 只用于本地展示与统计:provider 的手工白名单拼报文不会带上它;统计、rewind // 提问号与标题派生据此跳过,避免把通知算成一次人工干预。 Synthetic bool `json:"synthetic,omitempty"` } // ToolDef 向模型声明一个可调用工具。 type ToolDef struct { Name string `json:"name"` Description string `json:"description"` Parameters map[string]any `json:"parameters"` // JSON schema } // Usage 记录一次响应的 token 消耗。 // // **口径约定:InputTokens 是本次请求在模型侧占位的提示 token 总量**,缓存命中 // 与缓存写入都算在内,Cache* 字段只是明细。必须这么归一:Anthropic 的 // `input_tokens` 只报**未命中缓存**的那一段(缓存部分单列在 // cache_read_input_tokens / cache_creation_input_tokens)。照抄的后果是终端计数、 // `stats` 的 TokensIn 与任何按 token 算的成本都被低报(95% 命中时 10 万的上下文 // 记成 5 千)。注意:当前自动压缩走的是**字符估算**(见 agent/context.go), // 不依赖这几个数,所以这个口径修的是账目,不是压缩触发条件。 // OpenAI / Responses 的 prompt_tokens 本身已含缓存,取其明细即可。 type Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` // CacheReadTokens 是命中提示缓存的 token 数(Anthropic cache_read / // OpenAI prompt_tokens_details.cached_tokens)。 CacheReadTokens int `json:"cache_read_tokens,omitempty"` // CacheCreationTokens 是本次写入缓存的 token 数(Anthropic 独有,计费更贵)。 CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` // ReasoningTokens 是思考消耗(OpenAI/Responses 单列;Anthropic 混在 // output_tokens 里报不出来,留 0,不要瞎猜)。 ReasoningTokens int `json:"reasoning_tokens,omitempty"` } // StreamDelta 是流式回调收到的一段增量内容。ToolCall 是一个工具调用的参数 // 已经完整的提前通知:调用方可以在消息结束前先执行只读工具;最终 // Message.ToolCalls 仍包含完整列表,按 ID 去重即可。 type StreamDelta struct { Text string Reasoning string ToolCall *ToolCall Usage *Usage Err error } // Provider 是统一的 LLM 后端接口。 type Provider interface { // Stream 发起一轮对话,增量回调 delta 流;返回最终的 assistant 完整消息。 // opts 为零值时不改变请求(思考级别默认关闭)。 Stream(ctx context.Context, system string, msgs []Message, tools []ToolDef, opts StreamOptions, onDelta func(StreamDelta)) (Message, error) } // ModelSwitcher 是支持运行时切换模型的 Provider。 type ModelSwitcher interface { SetModel(model string) } // CurrentModeler 是能报告当前模型的 Provider。模型预设路由(含图走多模态、 // 失败回落备选)需要先知道现状才能决定切不切、以及回合后恢复到哪—— // 只读查询,与 SetModel 配对使用。 type CurrentModeler interface { CurrentModel() string } // Identifier 是 provider 自报「协议格式 + 当前模型」的能力(三家都实现)。 // Agent 用它给每条 assistant 消息盖归因戳:会话中途切了供应商或模型时, // 没有这两项就分不清哪一步是谁做的。 type Identifier interface { Identity() (format, model string) } // ModelLister 是支持枚举可用模型的 Provider(GET /models)。 type ModelLister interface { ListModels(ctx context.Context) ([]string, error) }