// model 包为 Cyrene v2 提供与厂商无关的 LLM 适配层。 // // 它定义了归一化的 Message/Request/Response 词汇、单个 // 由 OpenAIChatAdapter 实现的 Adapter 接口(该适配器说 // OpenAI、DashScope/百炼等使用的 OpenAI 兼容 /chat/completions 协议、 // 本地网关与代理),还有按角色的 Router、自动降级 Chain、 // 多模态(视觉)检测与按调用的 CallRecord 计账。 // // 完整的工具循环(schema 注册、参数绑定、结果回喂) // 推迟到 M2;本包只携带接口级占位 // (ToolSpec),让线格式契约先就绪,而不执行任何循环。 package model import "time" // Role 标识消息的作者。取值与厂商无关, // 由适配器翻译成各提供商自己的角色词汇。 type Role string const ( // RoleSystem 是系统/指令消息。 RoleSystem Role = "system" // RoleUser 是终端用户消息。 RoleUser Role = "user" // RoleAssistant 是助手(模型)消息。 RoleAssistant Role = "assistant" // RoleTool 是工具结果消息(保留给 M2 工具循环)。 RoleTool Role = "tool" ) // ContentType 描述 Content 片段内负载的种类。 type ContentType string const ( // ContentTypeText 是纯文本内容片段。 ContentTypeText ContentType = "text" // ContentTypeImage 是图片内容片段。 ContentTypeImage ContentType = "image_url" // ContentTypeToolCall 是助手发起的工具调用片段 // (Anthropic tool_use / OpenAI tool_calls),挂在 RoleAssistant 回合上。 ContentTypeToolCall ContentType = "tool_call" // ContentTypeToolResult 是工具执行结果片段,挂在 RoleTool // 回合上并按原生方式编码(Anthropic tool_result / OpenAI tool message)。 ContentTypeToolResult ContentType = "tool_result" // ContentTypeDocument 是文档内容片段(design/24 §2.2)。文档部件只允许 // 挂在 RoleUser 回合上,且**本期永不抵达编码器**:装配期会把它确定性 // 替换为抽取所得的文本部件或仅清单条目(design/24 §2.3)。 ContentTypeDocument ContentType = "document" ) // Content 是消息的单个片段,可能是多模态的一部分。 type Content struct { // Type 区分文本片段与图片 / 工具片段。 Type ContentType // Text 是 Type == ContentTypeText 时的文本负载, // 或 Type == ContentTypeToolResult 时的结果正文。 Text string // ImageURL 是 Type == ContentTypeImage 时的图片来源。它可以是 // data: URI 或 http(s):// URL。 ImageURL string // ToolCallID 把片段关联到提供商的 tool_use/tool_call id: // ContentTypeToolCall 对应调用 id, // ContentTypeToolResult 对应被应答的 tool_use_id。 ToolCallID string // ToolCallName 是 Type == ContentTypeToolCall 时被调用的工具名。 ToolCallName string // ToolArgs 是 Type == ContentTypeToolCall 时的结构化参数对象。 // 它绝不是 JSON 编码的字符串。 ToolArgs map[string]any // IsError 把 ContentTypeToolResult 片段标记为执行失败, // 以便原生设置提供商的 tool_result.is_error / error 标志。 IsError bool // DocumentName 是 Type == ContentTypeDocument 时该文档的服务端存储名 // (design/24 §2.2、§3.2)。它由服务端签发,绝不是客户端传来的原始 // 文件名——原始文件名永不参与落盘路径。 DocumentName string // DocumentMIME 是服务端**字节嗅探**所得的媒体类型(design/24 §3.2: // 嗅探是唯一裁决),绝不是客户端的声明。 DocumentMIME string // DocumentURL 是服务端签发的本地引用(design/24 §6.1 的文档读侧 // URL)。任意 http(s) URL 一律不接受:那会重开 F3 那种「服务端代取 // URL」的 SSRF 面。 DocumentURL string // DocumentSize 是该文档的字节数。 DocumentSize int64 } // Message 是单个会话回合,已跨提供商归一化。 type Message struct { Role Role // Content 携带一个或多个片段。仅文本的消息使用单个 // ContentTypeText 片段。 Content []Content // SenderName 是消息发送者可读的显示名 // (design/16 §4.6)。它是上下文组装层用来给模型 // 标注第三方消息(如 "[第三方 · 昵称]")的传输元数据, // 绝不是 channel 层强类型发送者身份的替代品。 // 为空表示发送者未标注(主人,或未识别/未解析的发送者), // 调用方可以留着不设置。 SenderName string // ToolCallID 把 RoleTool 回合关联到它所应答的提供商 // tool_use/tool_call id,而 ToolCallName 是同时携带的 // 被调用工具名,方便渲染结果的调用方使用。两者都由工具 // 循环在回喂结果时设置,并被适配器消费以发出 // 原生 tool_result / tool-message 块。 ToolCallID string ToolCallName string } // TextMessage 构建仅文本的消息。 func TextMessage(role Role, text string) Message { return Message{Role: role, Content: []Content{{Type: ContentTypeText, Text: text}}} } // ImageMessage 构建携带可选文本加一张图片的消息。imageURL // 可以是 data: URI 或 http(s):// URL。文本为空则省略文本片段。 func ImageMessage(role Role, text, imageURL string) Message { parts := make([]Content, 0, 2) if text != "" { parts = append(parts, Content{Type: ContentTypeText, Text: text}) } parts = append(parts, Content{Type: ContentTypeImage, ImageURL: imageURL}) return Message{Role: role, Content: parts} } // ToolResultMessage 构建一个 RoleTool 回合,携带某次工具调用的 // 原生结果。toolUseID 必须等于该结果所应答的 tool_use 块的 // 提供商 id;isError 标记执行失败;text 是结果正文。 func ToolResultMessage(toolUseID, text string, isError bool) Message { return Message{Role: RoleTool, ToolCallID: toolUseID, Content: []Content{{Type: ContentTypeToolResult, Text: text, ToolCallID: toolUseID, IsError: isError}}} } // AssistantToolCallMessage 构建一个 RoleAssistant 回合,声明模型 // 请求过的工具调用,好让随后一个 RoleTool 回合能被提供商 // 关联回来(OpenAI tool_call_id / Anthropic tool_use_id)。 func AssistantToolCallMessage(calls []ToolCall) Message { parts := make([]Content, 0, len(calls)) for _, c := range calls { parts = append(parts, Content{Type: ContentTypeToolCall, ToolCallID: c.ID, ToolCallName: c.Name, ToolArgs: c.Args}) } return Message{Role: RoleAssistant, Content: parts} } // ToolSpec 是工具/函数 schema 的接口级占位。完整 // 工具循环(注册、参数绑定、结果回喂)属于 M2;这里 // 只让请求能在线格式契约里向提供商声明工具。 type ToolSpec struct { Name string Description string // Parameters 是工具参数的 JSON Schema。M2 起填入。 Parameters any } // ToolCall 是从提供商的 tool_use 内容块解析出的单个 // 助手发起的工具调用。Args 是结构化参数对象 // (绝不是序列化后的 JSON 字符串),因此工具循环可以直接绑定它, // 无需重新解析。 type ToolCall struct { // ID 是提供商的 tool_use/tool_call id,工具循环因此能把 // 产生的 RoleTool 消息关联到确切的那次调用(Anthropic tool_use_id / // OpenAI tool_call_id)。提供商未发出 id 时为空。 ID string // Name 是被调用的工具。 Name string // Args 是工具参数,由提供商的结构化 // tool_use.input 对象解码而来。它绝不是 JSON 编码的字符串。 Args map[string]any } // Request 是一次补全请求,已跨提供商归一化。 type Request struct { // Model 覆盖适配器已配置的默认模型。为空表示 // 使用适配器默认值(或链步骤模型)。 Model string Messages []Message // Temperature 是采样温度。nil 表示提供商默认值。 // // DeepSeek 说明(design/04 §3.2 事实 3 与 4):思考开启期间 // temperature 不生效。frequency_penalty 与 presence_penalty 已被弃用 // ——「传入该参数将不会产生任何效果」——在任何模式下都是如此, // 这就是 Request 没有对应字段的原因。top_p 同样被刻意不暴露: // 它只在思考开启时生效,且在那里被夹到 >= 0.95, // 其它情况下恒为 1.0,所以发送一个配置好的值 // 会在开关的一侧静默失效, // 在另一侧则成为硬性下限。 Temperature *float64 // MaxTokens 限制生成的 token 数。为零表示提供商默认值。 MaxTokens int // Purpose 为该次调用打标签,用于 CallRecord 计账(如 "chat")。 Purpose string // PersonaID 是本回合以哪个角色作答——即 // 裁决 03.4.1 所说的按角色路由的「角色」,与 LLM 消息角色不同。RuntimeLLM // 读它来挑出该角色的提供商链(models.json 的 personaProviders,即 // 跨协议的按角色路由)。为空表示「无角色绑定」: // 由生效组的默认链服务该请求,因此所有既有 // 调用方的行为与之前完全一致。 // // 为什么是请求字段而不是 context 键:该决策发生在 // 本就携带 Request 的适配器边界上,而且这个值是 // 与 Model/Purpose 同类的按调用元数据。角色存在于 // loop 私有的会话环境 context 键里,而 model 包读不到 // 它(model 不得 import loop),所以用 ctx 载体反正也得 // 做同样的写入——只是决策被藏在一个看不见的键后面, // 且难以跨包断言。 PersonaID string // Tools 是 M2 函数调用的接口级占位。 Tools []ToolSpec // ToolChoice 可选地指示提供商如何选择工具。它是一个 // 提供商无关的不透明指令(如 {"type":"auto"} 或 {"type":"tool", // "name":"x"});为空表示提供商默认值。会原样传给 // 支持它的协议(Anthropic 的 tool_choice)。 // // DeepSeek 红线(design/04 §3.2 事实 5):思考开启时——即其默认—— // 强制型指令("required" / "any" / 具名工具)会被 400 // 拒绝,因此必须先把思考显式关掉。适配器只对这种组合 // 发 WARN;它们绝不会自行关掉思考。 ToolChoice any // Thinking 控制提供商的推理预算/开关(design/04 §3.2)。 // nil 表示「不干预」,使用提供商默认值。协议中 // 没有对应映射的提供商会记一条 WARN,而不是静默 // 忽略它。 // // 「不干预」不等于「关闭」:DeepSeek 的思考默认 // 开启,所以在那里要靠 Enable=false 来关(发送为 // {"thinking":{"type":"disabled"}})。 Thinking *Thinking // ReasoningEffort 是 OpenAI 兼容的推理强度旋钮 // (reasoning_effort)。为空表示「不干预」。该值会针对 // 已识别提供商的白名单校验;无法识别的值会带着 WARN // 被丢弃,绝不会被换成别的档位(design/04 §3.2 事实 1)。 // DeepSeek 另外记录了兼容别名(minimal→low、 // medium/xhigh→high、ultra→max),它们会被归一化为服务 // 实际采用的档位。 ReasoningEffort string } // Thinking 携带调用方希望生效的推理旋钮(design/04 §3.2)。 // 提供商映射它们真正支持的字段、并上报其余字段;它只 // 控制预算/开关,绝不控制思考内容(design/01 §2.1 红线)。 type Thinking struct { // BudgetTokens 是 Anthropic thinking.budget_tokens 的目标值(官方 // 语义是目标,不是硬上限)。为零则选用适配器默认值。 BudgetTokens int // Enable 是显式的开/关开关,供暴露布尔 // thinking 标志的提供商使用。nil = 无显式开关。 Enable *bool } // Response 是归一化的补全结果。 type Response struct { // Model 是产出该响应的模型。 Model string Content string // Message 是归一化的助手消息。 Message Message // ToolCalls 携带助手发出的任何 tool_use 块。响应是纯文本时为 // 空。 ToolCalls []ToolCall // StopReason 是提供商原始的停止/结束原因(Anthropic stop_reason: // end_turn / max_tokens / tool_use;OpenAI finish_reason:stop / length / // tool_calls),因此调用方能区分 token 预算截断与 // 正常结束。提供商未上报时为空。 StopReason string // PromptTokens、CompletionTokens 与 TotalTokens 在可用时上报 // 提供商用量;提供商未返回时为零。 PromptTokens int CompletionTokens int TotalTokens int // CacheHitTokens / CacheMissTokens 是**输入侧**的两档 token 数(design/04 // 附录 A.1:提供商按缓存命中/未命中两档计价,命中价约为未命中的 1/50)。 // 两种线格式都解析进这里: // // - OpenAI 兼容:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens // (DeepSeek),缺失时用 prompt_tokens_details.cached_tokens 作命中数; // - Anthropic:usage.cache_read_input_tokens 作命中数, // input_tokens + cache_creation_input_tokens 作未命中数。 // // 提供商未上报(缺字段)时为零 —— 缺字段是**容错**路径,绝不 panic、绝不 // 影响正文解析。 CacheHitTokens int CacheMissTokens int // ReasoningTokens 是完成侧里属于**思维链**的 token 数(OpenAI 兼容的 // usage.completion_tokens_details.reasoning_tokens)。Anthropic 不上报它 // (思考 token 已计入 output_tokens),因此那里恒为 0。 ReasoningTokens int } // CacheHitRate 返回输入侧的缓存命中率(hit / (hit+miss))。提供商未上报任何 // 输入侧明细、或两档都是 0 时返回 0 —— 不编造一个 100% 或 0% 之外的假值。 func (r Response) CacheHitRate() float64 { total := r.CacheHitTokens + r.CacheMissTokens if total <= 0 { return 0 } return float64(r.CacheHitTokens) / float64(total) } // CallRecord 描述一次成功完成的 LLM 调用,供 M9 // 监控/前端使用。失败的尝试不产生记录。 type CallRecord struct { // Model 是服务该请求的模型。 Model string // Purpose 标注该次调用的用途。 Purpose string // Tokens 是提供商上报的总 token 消耗。 Tokens int // TookMs 是端到端调用延迟,单位毫秒。 TookMs int64 } // Options 携带叠加在 Request 之上的按调用旋钮。 type Options struct { // Timeout 约束单次 HTTP 调用。为零则用适配器客户端的超时。 Timeout time.Duration }