docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 只翻注释/文档字符串:`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
This commit is contained in:
AskaEth committed 2026-10-04 16:27:54 +08:00
1 parent f5cf117482
commit 3dc1232bc2
54 files changed
+1923 -1923

No files matched your search

+22 -22
View File
@@ -5,40 +5,40 @@ import (
"errors"
)
// Errors returned by the model package. Each is wrapped with %w on the way out
// so callers can test for a category while keeping the underlying cause.
// model 包返回的错误。向外传递时一律用 %w 包装,
// 以便调用方既能按类别判定,又能保留底层原因。
var (
// ErrNoModel is returned when no model name can be resolved.
// 无法解析出任何模型名时返回 ErrNoModel。
ErrNoModel = errors.New("model: no model configured")
// ErrNoSteps is returned when a fallback chain has no steps.
// 降级链没有任何步骤时返回 ErrNoSteps。
ErrNoSteps = errors.New("model: empty fallback chain")
// ErrUpstream is returned when the provider request fails (network, status,
// decode) or every fallback step fails.
// 提供商请求失败(网络、状态码、解码)或降级链的
// 每一步都失败时,返回 ErrUpstream。
ErrUpstream = errors.New("model: upstream request failed")
// ErrBadRequest is returned when the request cannot be encoded for the wire.
// 请求无法编码为线格式时返回 ErrBadRequest。
ErrBadRequest = errors.New("model: invalid request")
// ErrStream is returned when a stream ends before completion.
// 流在完成前就结束时返回 ErrStream。
ErrStream = errors.New("model: stream closed before completion")
)
// Recorder receives a CallRecord after a successfully completed LLM call. It is
// invoked by adapters so the runtime (M9 monitor/frontend) can collect usage
// without coupling to a storage backend.
// Recorder 在一次成功完成的 LLM 调用之后接收 CallRecord。
// 它由各适配器调用,好让运行时(M9 监控/前端)收集用量,
// 而不必与任何存储后端耦合。
type Recorder func(CallRecord)
// Adapter abstracts a single LLM provider. Implementations are vendor-specific
// but speak the normalized Request/Response vocabulary; a fallback Chain or a
// Router can compose them without knowing which provider is behind each one.
// Adapter 抽象单个 LLM 提供商。各实现面向具体厂商,
// 但都说归一化的 Request/Response 词汇;降级 Chain 或
// Router 都能组合它们,无需知道每个背后是哪家提供商。
type Adapter interface {
// Name returns a stable identifier for the provider/adapter.
// Name 返回提供商/适配器的稳定标识。
Name() string
// Chat performs a non-streaming completion and returns the full response.
// Chat 执行一次非流式补全并返回完整响应。
Chat(ctx context.Context, req Request, opts Options) (Response, error)
// ChatStream streams structured events via onEvent and returns the aggregated
// response (with usage when the provider reports it). onEvent may be nil to
// discard events while still aggregating. Every provider-supported segment is
// bracketed (text_start/delta/end, tool_call_start/delta/end), so the consumer
// can render each independently; segments adapters cannot yet distinguish are
// delivered as text_delta events.
// ChatStream 通过 onEvent 流出结构化事件,并返回聚合后的
// 响应(提供商上报用量时一并带上)。onEvent 可为 nil,
// 此时仍照常聚合、只是丢弃事件。提供商支持的每个片段
// 都被成对包裹(text_start/delta/end、tool_call_start/delta/end),
// 消费者因此可以独立渲染各片段;适配器尚无法区分的片段
// 以 text_delta 事件投递。
ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error)
}
+84 -84
View File
@@ -15,41 +15,41 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// Anthropic Messages (/v1/messages) wire protocol constants.
// Anthropic Messages(/v1/messages)线协议常量。
const (
// anthropicAPIVersion is the Messages API version sent on every request.
// anthropicAPIVersion 是每次请求都要发送的 Messages API 版本。
anthropicAPIVersion = "2023-06-01"
// anthropicDefaultMaxTokens is the default max_tokens sent when
// Request.MaxTokens is zero. Anthropic requires max_tokens to be present and
// positive, so a sensible default is applied rather than omitting it.
// max_tokens is shared by thinking + text, so it must leave room for both.
// anthropicDefaultMaxTokens 是 Request.MaxTokens 为零时发送的默认
// max_tokens。Anthropic 要求 max_tokens 必须存在且为正,
// 因此这里给出合理默认值而不是省略它。
// max_tokens 由思考与正文共享,必须给两者都留出空间。
anthropicDefaultMaxTokens = 8192
// anthropicDataPrefix marks a payload embedded as a data: URI.
// anthropicDataPrefix 标记以 data: URI 内嵌的负载。
anthropicDataPrefix = "data:"
// anthropicThinkingTypeEnabled is the wire value of an enabled extended-
// thinking directive.
// anthropicThinkingTypeEnabled 是「开启扩展思考」指令的
// 线取值。
anthropicThinkingTypeEnabled = "enabled"
)
// anthropicThinking is the wire form of the extended-thinking directive. It is
// omitted entirely when the caller did not ask for thinking (or asked for it to
// be off), because Anthropic treats a missing field as "no thinking". DeepSeek's
// /anthropic endpoint instead defaults to enabled, so an explicit off is sent as
// {"type":"disabled"} with no budget (design/04 §3.2 fact 2).
// 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 is the Anthropic-format reasoning strength block. Only
// DeepSeek's /anthropic endpoint documents it (and only the effort field), so it
// is never sent to the Anthropic endpoint (design/04 §3.2 fact 6).
// anthropicOutputConfig 是 Anthropic 格式的推理强度块。只有
// DeepSeek 的 /anthropic 端点记录了它(且只有 effort 字段),所以
// 它永远不会发给 Anthropic 端点(design/04 §3.2 事实 6)。
type anthropicOutputConfig struct {
Effort string `json:"effort"`
}
// anthropicRequest is the body sent to /v1/messages. Messages carry Anthropic's
// content-block model and the system prompt lives at the request root.
// anthropicRequest 是发给 /v1/messages 的请求体。Messages 采用 Anthropic
// 的内容块模型,system 提示词位于请求根。
type anthropicRequest struct {
Model string `json:"model"`
System string `json:"system,omitempty"`
@@ -63,21 +63,21 @@ type anthropicRequest struct {
Stream bool `json:"stream,omitempty"`
}
// anthropicMessage is one conversation turn. Content is an array of content
// blocks (text / image for now; tool_result blocks are added by M2).
// anthropicMessage 是一个会话回合。Content 是内容块数组
// (目前为文本 / 图片;tool_result 块由 M2 补上)。
type anthropicMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
// anthropicTool is the wire form of a tool schema.
// anthropicTool 是工具 schema 的线格式。
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema any `json:"input_schema"`
}
// anthropicResponse is the JSON body returned by /v1/messages.
// anthropicResponse 是 /v1/messages 返回的 JSON 响应体。
type anthropicResponse struct {
ID string `json:"id"`
Model string `json:"model"`
@@ -86,8 +86,8 @@ type anthropicResponse struct {
Usage *anthropicUsage `json:"usage,omitempty"`
}
// anthropicBlock is a single content block in a response. For tool_use blocks the
// Input field is a fully decoded object (never a JSON string).
// anthropicBlock 是响应里的单个内容块。对 tool_use 块而言,
// Input 字段是已完全解码的对象(绝不是 JSON 字符串)。
type anthropicBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
@@ -96,17 +96,17 @@ type anthropicBlock struct {
Input map[string]any `json:"input,omitempty"`
}
// anthropicUsage reports token consumption. Anthropic reports input_tokens and
// output_tokens rather than a single total.
// anthropicUsage 上报 token 消耗。Anthropic 上报的是 input_tokens
// 与 output_tokens,而不是单一总量。
type anthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// AnthropicChatAdapter calls an Anthropic Messages (/v1/messages) endpoint. It is
// constructed from a config.LLMConfig and implements Adapter for both
// non-streaming and token-level SSE streaming, speaking the provider's native
// content-block protocol so tool_use.input arrives as a structured object.
// AnthropicChatAdapter 调用 Anthropic Messages(/v1/messages)端点。
// 它由 config.LLMConfig 构建,同时实现非流式与 token 级 SSE 流式的
// Adapter,直接说提供商的原生内容块协议,
// 因此 tool_use.input 以结构化对象到达。
type AnthropicChatAdapter struct {
name string
baseURL string
@@ -114,34 +114,34 @@ type AnthropicChatAdapter struct {
model string
client *http.Client
recorder Recorder
// log reports reasoning adjustments and ignored directives. It is never nil:
// a discard logger tagged with this adapter's source is installed when the
// caller supplies none, so a provider decision is always traceable.
// log 上报推理调整与被忽略的指令。它永不为 nil:
// 调用方未提供时,会装入一个带有本适配器来源标记的丢弃式日志器,
// 因此提供商的每个决定都可追溯。
log *slog.Logger
}
// AnthropicOption configures an AnthropicChatAdapter.
// AnthropicOption 用于配置 AnthropicChatAdapter。
type AnthropicOption func(*AnthropicChatAdapter)
// WithAnthropicHTTPClient overrides the default HTTP client (used to inject mocks
// or a tuned transport in tests and production).
// WithAnthropicHTTPClient 覆盖默认 HTTP 客户端(用于在测试与生产
// 中注入 mock 或调优过的 transport)。
func WithAnthropicHTTPClient(c *http.Client) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.client = c }
}
// WithAnthropicRecorder wires a per-call Recorder for CallRecord accounting.
// WithAnthropicRecorder 装入按调用计账的 Recorder,用于 CallRecord 统计。
func WithAnthropicRecorder(r Recorder) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.recorder = r }
}
// WithAnthropicModel overrides the adapter's default model name.
// WithAnthropicModel 覆盖适配器的默认模型名。
func WithAnthropicModel(m string) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.model = m }
}
// WithAnthropicLogger attaches the logger used to report reasoning adjustments
// (budget raised to fit max_tokens, ignored directives). A nil logger is ignored
// so the discard default stays in place.
// WithAnthropicLogger 挂上用于上报推理调整的日志器
// (为容纳 max_tokens 而抬高的预算、被忽略的指令)。nil 日志器会被忽略,
// 从而保留丢弃式的默认值。
func WithAnthropicLogger(l *slog.Logger) AnthropicOption {
return func(a *AnthropicChatAdapter) {
if l != nil {
@@ -150,8 +150,8 @@ func WithAnthropicLogger(l *slog.Logger) AnthropicOption {
}
}
// NewAnthropicChat builds an Anthropic adapter from a supplier config. baseURL is
// trimmed of any trailing slash before the /v1/messages path is appended.
// NewAnthropicChat 由供应商配置构建 Anthropic 适配器。baseURL 会先
// 去掉末尾斜杠,再拼接 /v1/messages 路径。
func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicChatAdapter {
a := &AnthropicChatAdapter{
name: "anthropic",
@@ -165,9 +165,9 @@ func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicC
log: discardLogger("runtime/model/anthropic"),
}
if kind := strings.TrimSpace(cfg.ReasoningKind); kind != "" && kind != runtimeconfig.ReasoningKindAuto {
// The anthropic protocol defines its own thinking shape, so an
// openai-shape declaration is inert here. Report it rather than letting a
// configured option be silently ineffective (裁决 03.4.2: 禁止静默无效).
// 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)
}
@@ -177,14 +177,14 @@ func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicC
return a
}
// Name returns the adapter identifier.
// Name 返回适配器标识。
func (a *AnthropicChatAdapter) Name() string { return a.name }
// Chat performs a non-streaming completion. A response cut off by the token
// budget with no visible output (stop_reason max_tokens and neither text nor a
// tool_use block) is retried once with a doubled budget and, if that still
// yields nothing, replaced by a non-empty placeholder so the caller never sees a
// silent empty reply.
// 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 == "" {
@@ -210,8 +210,8 @@ func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Optio
return out, nil
}
// chatOnce performs a single non-streaming /v1/messages call and normalizes the
// wire body into a Response.
// chatOnce 执行一次非流式 /v1/messages 调用,并把线格式
// 响应体归一化为 Response。
func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Response, error) {
body, err := a.buildRequest(req, false)
if err != nil {
@@ -236,13 +236,13 @@ func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Respo
return responseFromAnthropicWire(&wire), nil
}
// ChatStream streams structured events via onEvent and returns the aggregated
// response with usage when the provider reports it. Text blocks are delivered as
// text_start/text_delta/text_end events; tool_use blocks are delivered as
// tool_call_start/tool_call_delta/tool_call_end so the frontend can render each
// segment independently. A budget-truncated empty stream is retried once with a
// doubled budget (thinking from the retry is suppressed to avoid a duplicate
// block) and, if still empty, closed with a non-empty placeholder text segment.
// 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 == "" {
@@ -267,8 +267,8 @@ func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEv
return out, nil
}
// streamOnce performs a single streaming /v1/messages call and aggregates its
// SSE events into a Response.
// 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 {
@@ -289,7 +289,7 @@ func (a *AnthropicChatAdapter) streamOnce(ctx context.Context, req Request, onEv
return scanAnthropicSSE(resp.Body, onEvent)
}
// resolveModel returns the effective model name.
// resolveModel 返回实际生效的模型名。
func (a *AnthropicChatAdapter) resolveModel(req Request) (string, error) {
if req.Model != "" {
return req.Model, nil
@@ -300,7 +300,7 @@ func (a *AnthropicChatAdapter) resolveModel(req Request) (string, error) {
return "", ErrNoModel
}
// endpoint returns the full /v1/messages URL.
// endpoint 返回完整的 /v1/messages URL。
func (a *AnthropicChatAdapter) endpoint() string {
return a.baseURL + "/v1/messages"
}
@@ -314,16 +314,16 @@ func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, e
var thinking *anthropicThinking
var output *anthropicOutputConfig
if deepSeekAnthropicEndpoint(req.Model, a.baseURL) {
// A DeepSeek /anthropic endpoint ignores budget_tokens, so max_tokens is
// deliberately not raised here: raising it would misreport the budget the
// provider actually applies. Strength goes through output_config.effort
// instead (design/04 §3.2 fact 6).
// 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 {
// Extended thinking shares max_tokens with the visible answer, so on the
// Anthropic endpoint the budget is resolved together with the limit it
// requires (design/04 §3.2 red line).
// 扩展思考与可见答案共享 max_tokens,所以在 Anthropic 端点上,
// 预算是与它所需要的限额一起解出的
// (design/04 §3.2 红线)。
tokenLimit, thinking = anthropicThinkingFor(req.Thinking, tokenLimit, a.log)
a.reportAnthropicEffortIgnored(req)
}
@@ -346,10 +346,10 @@ func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, e
return buf, nil
}
// reportAnthropicEffortIgnored reports a reasoning_effort on an endpoint that has
// no field for it: the Anthropic Messages protocol carries thinking strength as
// budget_tokens. It only reports — inventing a budget would change the caller's
// intent.
// reportAnthropicEffortIgnored 上报落在没有对应字段的端点上的
// reasoning_effort:Anthropic Messages 协议用 budget_tokens
// 承载思考强度。它只上报——凭空编一个预算会改变调用方的
// 意图。
func (a *AnthropicChatAdapter) reportAnthropicEffortIgnored(req Request) {
if strings.TrimSpace(req.ReasoningEffort) == "" {
return
@@ -393,10 +393,10 @@ func (a *AnthropicChatAdapter) record(req Request, start time.Time, tokens int)
})
}
// responseFromAnthropicWire converts a non-streaming /v1/messages response into a
// normalized Response. Each text block contributes to the assistant content; each
// tool_use block becomes a ToolCall whose Args is taken directly from the decoded
// input object.
// responseFromAnthropicWire 把非流式 /v1/messages 响应转换为
// 归一化的 Response。每个文本块汇入助手内容;
// 每个 tool_use 块变成一个 ToolCall,其 Args 直接取自已解码的
// input 对象。
func responseFromAnthropicWire(wire *anthropicResponse) Response {
out := Response{Model: wire.Model}
var b strings.Builder
@@ -421,9 +421,9 @@ func responseFromAnthropicWire(wire *anthropicResponse) Response {
return out
}
// AnthropicToolResult builds the content block used to feed a tool result back to
// Anthropic (role: user, first content element). toolUseID must equal the id of a
// previous tool_use block; isError marks an execution failure.
// 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",
+10 -10
View File
@@ -10,11 +10,11 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking guards the fix
// for thinking models whose reasoning shares the max_tokens budget with the
// visible answer: the zero-value default must leave room for both. It also
// asserts an explicit Request.MaxTokens is never overridden by that default
// (the existing 64-token case in anthropic_test.go is the compatibility proof).
// TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking 守护思考
// 模型的修复:其推理与可见答案共享 max_tokens 预算时,
// 零值默认必须给两者都留出空间。它还断言显式的
// Request.MaxTokens 绝不会被该默认覆盖
// (anthropic_test.go 里既有的 64-token 用例就是兼容性证明)。
func TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -33,11 +33,11 @@ func TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking(t *testing.T)
}
}
// TestAnthropicChat_Chat_LongThinkingThenShortText asserts a response whose
// thinking block is far longer than its text block still surfaces the text and
// never leaks the thinking into Content (the thinking-is-not-the-answer red
// line). This is the shape a thinking model produces when the budget is large
// enough, versus the truncation the old 1024 cap caused.
// TestAnthropicChat_Chat_LongThinkingThenShortText 断言思考块
// 远长于文本块的响应仍会浮出文本,且
// 绝不把思考泄漏进 Content(思考不是答案的
// 红线)。这就是预算足够大时思考模型产出的形状,
// 与旧 1024 上限造成的截断形成对照。
func TestAnthropicChat_Chat_LongThinkingThenShortText(t *testing.T) {
t.Parallel()
thinking := strings.Repeat("推理", 1500) // 3000 runes, far longer than the answer
@@ -9,10 +9,10 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestDeepSeekAnthropicEndpoint_Classification pins the endpoint detection: a
// DeepSeek /anthropic URL must be recognized even when the configured model name
// is not a DeepSeek one (the service silently maps unknown names onto
// deepseek-flash, so the name is not a reliable signal there).
// TestDeepSeekAnthropicEndpoint_Classification 钉住端点检测:
// 即便配置的模型名不是 DeepSeek 的,也必须认出
// DeepSeek /anthropic URL(该服务会把未知名称静默映射到
// deepseek-flash,所以那里模型名不是可靠信号)。
func TestDeepSeekAnthropicEndpoint_Classification(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -33,18 +33,18 @@ func TestDeepSeekAnthropicEndpoint_Classification(t *testing.T) {
}
}
// newDeepSeekAnthropicAdapter builds an Anthropic-protocol adapter whose model
// name identifies DeepSeek, so the DeepSeek /anthropic branch is exercised while
// the request still goes to a local stub.
// newDeepSeekAnthropicAdapter 构建一个 Anthropic 协议适配器,其模型
// 名标识 DeepSeek,从而走到 DeepSeek /anthropic 分支,
// 同时请求仍发往本地 stub。
func newDeepSeekAnthropicAdapter(t *testing.T, ch chan<- anthropicCaptured, opts ...AnthropicOption) *AnthropicChatAdapter {
t.Helper()
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
return NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: deepSeekModel}, opts...)
}
// TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens asserts the max_tokens raise
// stays on the Anthropic endpoint: DeepSeek's /anthropic ignores budget_tokens, so
// raising the limit there would misreport the budget the provider applies.
// TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens 断言抬高 max_tokens
// 只留在 Anthropic 端点:DeepSeek 的 /anthropic 忽略 budget_tokens,所以在
// 那里抬高限额会错误反映提供商采用的预算。
func TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -79,9 +79,9 @@ func TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens(t *testing.T) {
}
}
// TestAnthropicOfficial_BudgetStillRaisesMaxTokens is the counterpart guard: on
// the Anthropic endpoint the design red line ("budget must not eat the answer")
// still holds.
// TestAnthropicOfficial_BudgetStillRaisesMaxTokens 是对位守护:在
// Anthropic 端点上,设计红线(「预算不得吃光答案」)
// 依然成立。
func TestAnthropicOfficial_BudgetStillRaisesMaxTokens(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -110,9 +110,9 @@ func TestAnthropicOfficial_BudgetStillRaisesMaxTokens(t *testing.T) {
}
}
// TestAnthropicDeepSeek_OutputConfigEffort asserts the DeepSeek /anthropic
// strength path exists: the effort becomes output_config.effort, and the aliases
// are normalized to the tiers the service applies.
// TestAnthropicDeepSeek_OutputConfigEffort 断言 DeepSeek /anthropic 的
// 强度路径存在:effort 变成 output_config.effort,别名
// 被归一化为服务采用的档位。
func TestAnthropicDeepSeek_OutputConfigEffort(t *testing.T) {
t.Parallel()
cases := []struct{ sent, want string }{
@@ -148,8 +148,8 @@ func TestAnthropicDeepSeek_OutputConfigEffort(t *testing.T) {
}
}
// TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped asserts an unrecognized
// effort is dropped and reported rather than approximated.
// TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped 断言无法识别的
// effort 会被丢弃并上报,而不是做近似处理。
func TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -178,10 +178,10 @@ func TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped(t *testing.T) {
}
}
// TestAnthropicDeepSeek_EffortNoneDisablesThinking pins the translation of the
// one effort value output_config cannot carry: DeepSeek's Anthropic format has no
// effort "none", so the off intent goes through the thinking switch — mapping it
// onto a tier would turn thinking off into thinking on.
// TestAnthropicDeepSeek_EffortNoneDisablesThinking 钉住 output_config
// 无法承载的那一个 effort 取值的翻译:DeepSeek 的 Anthropic 格式
// 没有 "none" 档,所以关闭意图经 thinking 开关转达——把它映射
// 到某个档位会把「关闭思考」变成「开启思考」。
func TestAnthropicDeepSeek_EffortNoneDisablesThinking(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -208,9 +208,9 @@ func TestAnthropicDeepSeek_EffortNoneDisablesThinking(t *testing.T) {
}
}
// TestAnthropicDeepSeek_ThinkingSwitch asserts an explicit switch is carried as
// the Anthropic-format thinking block, and that a disabled one omits the budget
// (DeepSeek ignores it anyway).
// TestAnthropicDeepSeek_ThinkingSwitch 断言显式开关以
// Anthropic 格式的 thinking 块携带,且 disabled 时不带预算
// (反正 DeepSeek 会忽略它)。
func TestAnthropicDeepSeek_ThinkingSwitch(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -253,8 +253,8 @@ func TestAnthropicDeepSeek_ThinkingSwitch(t *testing.T) {
}
}
// TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled keeps the "off wins"
// rule on the Anthropic path too: output_config.effort must not re-open thinking.
// TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled 在 Anthropic 路径
// 上也守住「关闭优先」规则:output_config.effort 不得重新打开思考。
func TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -281,9 +281,9 @@ func TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled(t *testing.T) {
}
}
// TestAnthropicOfficial_ReasoningEffortReportedIgnored asserts a reasoning_effort
// aimed at the Anthropic endpoint is reported instead of silently disappearing —
// the Messages protocol has no such field.
// TestAnthropicOfficial_ReasoningEffortReportedIgnored 断言落在
// Anthropic 端点上的 reasoning_effort 会被上报,而不是静默消失——
// Messages 协议没有这个字段。
func TestAnthropicOfficial_ReasoningEffortReportedIgnored(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -313,9 +313,9 @@ func TestAnthropicOfficial_ReasoningEffortReportedIgnored(t *testing.T) {
}
}
// TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns covers the DeepSeek 400
// trap on the Anthropic path: the adapter warns and still sends the directive —
// it never closes thinking on its own.
// TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns 覆盖 Anthropic 路径
// 上的 DeepSeek 400 陷阱:适配器会警告但仍发送该指令——
// 它绝不自行关闭思考。
func TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
+28 -28
View File
@@ -5,11 +5,11 @@ import (
"strings"
)
// buildAnthropicMessages splits a normalized message list into a top-level system
// string and the remaining messages in Anthropic's content-block form. All
// RoleSystem messages are concatenated into a single system prompt (Anthropic
// carries it at the request root, not in messages); messages with no content
// parts are dropped because Anthropic rejects empty content.
// buildAnthropicMessages 把归一化消息列表拆成顶层的 system
// 字符串与其余按 Anthropic 内容块形式表示的消息。所有
// RoleSystem 消息拼接成单个 system 提示词(Anthropic
// 把它放在请求根而非 messages 里);没有内容片段的消息
// 会被丢弃,因为 Anthropic 拒绝空内容。
func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) {
var sys strings.Builder
out := make([]anthropicMessage, 0, len(msgs))
@@ -35,9 +35,9 @@ func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error)
return sys.String(), out, nil
}
// messageText concatenates the text-bearing parts of a message (system prompts
// and tool-result bodies). Tool-result parts carry their body in Text, so a
// RoleTool turn's content serializes correctly for OpenAI's tool message.
// messageText 拼接消息中带文本的片段(system 提示词
// 与工具结果正文)。工具结果片段把正文放在 Text 里,
// 因此 RoleTool 回合的内容能正确序列化为 OpenAI 的 tool message。
func messageText(m Message) string {
var b strings.Builder
for _, part := range m.Content {
@@ -48,23 +48,23 @@ func messageText(m Message) string {
return b.String()
}
// anthropicRole maps a normalized Role to the Anthropic role vocabulary. system
// messages are extracted to the top-level system field and never reach here.
// anthropicRole 把归一化的 Role 映射到 Anthropic 的角色词汇。
// system 消息已被抽取到顶层 system 字段,不会走到这里。
func anthropicRole(r Role) string {
switch r {
case RoleAssistant:
return "assistant"
default:
// user, tool and any future roles all land on the user turn; tool_result
// blocks (M2) belong to the user role in Anthropic.
// user、tool 以及未来任何角色都落在 user 回合上;
// tool_result 块(M2)在 Anthropic 里属于 user 角色。
return "user"
}
}
// encodeAnthropicMessage turns a normalized message into Anthropic content
// blocks. A RoleTool turn is ALWAYS rendered as native tool_result blocks (never
// a user plain-text turn), because Anthropic requires a tool result to arrive as
// a tool_result block carrying the tool_use_id and the is_error flag.
// encodeAnthropicMessage 把归一化消息转成 Anthropic 内容块。
// RoleTool 回合永远渲染为原生 tool_result 块
// (绝不是 user 纯文本回合),因为 Anthropic 要求工具结果以
// 携带 tool_use_id 与 is_error 标志的 tool_result 块形式到达。
func encodeAnthropicMessage(m Message) ([]any, error) {
if m.Role == RoleTool {
return encodeAnthropicToolResult(m)
@@ -72,11 +72,11 @@ func encodeAnthropicMessage(m Message) ([]any, error) {
return encodeAnthropicContent(m.Content)
}
// encodeAnthropicToolResult renders a RoleTool turn as tool_result blocks. Each
// ContentTypeToolResult part becomes one block; legacy text parts are wrapped as
// tool_result blocks too so the turn never degrades into a plain user message.
// The tool_use_id falls back to the message-level ToolCallID when a part does not
// carry its own.
// encodeAnthropicToolResult 把 RoleTool 回合渲染为 tool_result 块。
// 每个 ContentTypeToolResult 片段变成一个块;遗留的文本片段也会
// 被包成 tool_result 块,使该回合永远不会退化成普通 user 消息。
// 当片段自身没有携带 tool_use_id 时,回退到消息级的
// ToolCallID。
func encodeAnthropicToolResult(m Message) ([]any, error) {
blocks := make([]any, 0, len(m.Content))
for _, p := range m.Content {
@@ -95,9 +95,9 @@ func encodeAnthropicToolResult(m Message) ([]any, error) {
return blocks, nil
}
// encodeAnthropicContent turns normalized content parts into Anthropic content
// blocks. Text / image / tool_use (assistant) parts are handled here; tool_result
// parts are rendered at the message level by encodeAnthropicToolResult.
// encodeAnthropicContent 把归一化内容片段转成 Anthropic 内容块。
// 文本 / 图片 / tool_use(助手)片段在此处理;tool_result
// 片段由 encodeAnthropicToolResult 在消息级渲染。
func encodeAnthropicContent(parts []Content) ([]any, error) {
blocks := make([]any, 0, len(parts))
for _, p := range parts {
@@ -137,9 +137,9 @@ func encodeAnthropicContent(parts []Content) ([]any, error) {
return blocks, nil
}
// parseAnthropicImageData extracts the media type and base64 payload from a data:
// URI. Anthropic requires a base64 image source and cannot reference a remote
// URL, so any other URI form is rejected as a bad request.
// parseAnthropicImageData 从 data: URI 中提取媒体类型与 base64
// 负载。Anthropic 要求 base64 图片源、无法引用远程
// URL,因此其它任何 URI 形式都按错误请求拒绝。
func parseAnthropicImageData(uri string) (string, string, error) {
if !strings.HasPrefix(uri, anthropicDataPrefix) {
return "", "", fmt.Errorf("model: %w: anthropic image requires a data: URI, got %q", ErrBadRequest, uri)
@@ -161,7 +161,7 @@ func parseAnthropicImageData(uri string) (string, string, error) {
return mediaType, data, nil
}
// buildAnthropicTools converts ToolSpec schemas into Anthropic tools.
// buildAnthropicTools 把 ToolSpec schema 转换为 Anthropic 工具。
func buildAnthropicTools(tools []ToolSpec) []anthropicTool {
if len(tools) == 0 {
return nil
+32 -32
View File
@@ -5,27 +5,27 @@ import (
"strings"
)
// Anthropic extended-thinking protocol bounds (design/04 §3.2).
// Anthropic 扩展思考协议的边界(design/04 §3.2)。
const (
// anthropicMinThinkingBudget is the smallest budget_tokens the Messages API
// accepts for thinking.type=enabled; anything below it is rejected with a
// 400, so a lower non-zero budget is raised and reported rather than sent.
// anthropicMinThinkingBudget 是 Messages API 在 thinking.type=enabled 时
// 接受的最小 budget_tokens;低于它会被 400 拒绝,
// 因此更低但非零的预算会被抬高并上报,而不是照发。
anthropicMinThinkingBudget = 1024
// anthropicDefaultThinkingBudget is used when thinking is switched on without
// an explicit budget.
// anthropicDefaultThinkingBudget 在开启思考但未给
// 显式预算时使用。
anthropicDefaultThinkingBudget = 4096
// anthropicThinkingBodyReserve is the body room kept on top of the thinking
// budget: max_tokens is shared by thinking and the visible answer, so the
// budget must never be allowed to consume it all (design/04 §3.2 red line:
// anthropicThinkingBodyReserve 是在思考预算之上留给正文的空间:
// max_tokens 由思考与可见答案共享,所以
// 预算绝不允许把它全部吃光(design/04 §3.2 红线:
// "预算与正文不得互相吃光").
anthropicThinkingBodyReserve = 2048
)
// deepSeekAnthropicEndpoint reports whether an Anthropic-protocol request is
// really served by DeepSeek's /anthropic endpoint. The base URL is checked first
// because the endpoint — not the model name — decides whether budget_tokens is
// honoured, and a DeepSeek endpoint is commonly configured with a non-DeepSeek
// model name (the service silently maps unknown names onto deepseek-flash).
// deepSeekAnthropicEndpoint 报告一次 Anthropic 协议请求是否
// 真的由 DeepSeek 的 /anthropic 端点服务。先检查 base URL,
// 因为是端点——而不是模型名——决定 budget_tokens 是否
// 被尊重,而 DeepSeek 端点常常配着一个非 DeepSeek 的
// 模型名(该服务会把未知名称静默映射到 deepseek-flash)。
func deepSeekAnthropicEndpoint(model, baseURL string) bool {
if strings.Contains(strings.ToLower(baseURL), "deepseek") {
return true
@@ -33,16 +33,16 @@ func deepSeekAnthropicEndpoint(model, baseURL string) bool {
return reasoningVendorFor(model, baseURL) == vendorDeepSeek
}
// anthropicThinkingFor resolves the extended-thinking directive together with the
// max_tokens it requires. It returns the (possibly raised) max_tokens and the
// wire block; a nil block means "do not send thinking" (no directive, or an
// explicit off). Because max_tokens is shared by thinking and the visible answer,
// the returned budget always leaves anthropicThinkingBodyReserve for the body,
// and a request whose budget would eat the answer raises max_tokens instead — the
// adjustment is logged so it is never a silent rewrite.
// anthropicThinkingFor 解出扩展思考指令以及它所需的
// max_tokens。它返回(可能已被抬高的)max_tokens 与
// 线格式块;块为 nil 表示「不发送思考」(无指令,或
// 显式关闭)。由于 max_tokens 由思考与可见答案共享,
// 返回的预算总会为正文留下 anthropicThinkingBodyReserve,
// 而预算会吃光答案的请求则改为抬高 max_tokens——该
// 调整会被记录,因此绝不是一次静默改写。
//
// This is the Anthropic endpoint's rule only. A DeepSeek /anthropic endpoint
// ignores budget_tokens, so it must not go through here (design/04 §3.2 fact 6).
// 这条规则只适用于 Anthropic 端点。DeepSeek 的 /anthropic 端点
// 会忽略 budget_tokens,所以它绝不能走这里(design/04 §3.2 事实 6)。
func anthropicThinkingFor(th *Thinking, maxTokens int, log *slog.Logger) (int, *anthropicThinking) {
if th == nil || (th.Enable != nil && !*th.Enable) {
return maxTokens, nil
@@ -67,12 +67,12 @@ func anthropicThinkingFor(th *Thinking, maxTokens int, log *slog.Logger) (int, *
return maxTokens, &anthropicThinking{Type: anthropicThinkingTypeEnabled, BudgetTokens: budget}
}
// anthropicDeepSeekReasoning resolves the reasoning knobs for a DeepSeek
// /anthropic request. Two differences from the Anthropic endpoint drive it: that
// endpoint ignores budget_tokens, so max_tokens is left untouched (raising it
// would misreport the budget the provider actually applies), and strength is
// controlled by output_config.effort, which has no "none" value — there the "off"
// intent is carried by the thinking switch itself (design/04 §3.2 facts 2 and 6).
// anthropicDeepSeekReasoning 解出 DeepSeek /anthropic 请求的
// 推理旋钮。与 Anthropic 端点的两点差异决定了它:该
// 端点忽略 budget_tokens,所以 max_tokens 保持不动(抬高它
// 会错误反映提供商实际采用的预算),而强度由
// output_config.effort 控制,它没有 "none" 取值——在那里「关闭」
// 意图由 thinking 开关自身承载(design/04 §3.2 事实 2 与 6)。
func anthropicDeepSeekReasoning(log *slog.Logger, model string, th *Thinking, effort string) (*anthropicThinking, *anthropicOutputConfig) {
var block *anthropicThinking
if th != nil {
@@ -118,9 +118,9 @@ func anthropicDeepSeekReasoning(log *slog.Logger, model string, th *Thinking, ef
return block, &anthropicOutputConfig{Effort: tier}
}
// anthropicThinkingOn reports the effective thinking state of a resolved DeepSeek
// /anthropic request: an omitted switch leaves the provider default in place, and
// DeepSeek's default is enabled.
// anthropicThinkingOn 报告一个已解出的 DeepSeek /anthropic 请求的
// 实际思考状态:省略开关会保留提供商默认值,而
// DeepSeek 的默认是开启。
func anthropicThinkingOn(th *anthropicThinking) bool {
return th == nil || th.Type != thinkingTypeDisabled
}
+20 -20
View File
@@ -8,9 +8,9 @@ import (
"strings"
)
// anthropicStreamEvent is one SSE payload from /v1/messages with stream: true.
// Anthropic carries the event kind both in the SSE `event:` field and inside the
// JSON `type` field, so the parser keys off the JSON payload type.
// anthropicStreamEvent 是 /v1/messages(stream: true)的一个 SSE 负载。
// Anthropic 既在 SSE 的 `event:` 字段里、也在 JSON 的 `type` 字段里
// 携带事件种类,所以解析器按 JSON 负载的 type 取值。
type anthropicStreamEvent struct {
Type string `json:"type"`
Message *anthropicStartBlock `json:"message,omitempty"`
@@ -21,30 +21,30 @@ type anthropicStreamEvent struct {
Error *anthropicStreamError `json:"error,omitempty"`
}
// anthropicStartBlock is the payload of a message_start event.
// anthropicStartBlock 是 message_start 事件的负载。
type anthropicStartBlock struct {
Model string `json:"model"`
Usage *anthropicUsage `json:"usage,omitempty"`
}
// anthropicDelta is the incremental payload inside a content_block_delta event.
// The message_delta event reuses the same field name for its terminal
// stop_reason (end_turn / max_tokens / tool_use), so it is decoded here too.
// anthropicDelta 是 content_block_delta 事件里的增量负载。
// Anthropic 把 message_delta 事件的终止 stop_reason 复用了同一字段名
// (end_turn / max_tokens / tool_use),所以这里也一并解码。
type anthropicDelta struct {
Type string `json:"type"` // text_delta | input_json_delta | thinking_delta
Type string `json:"type"` // 取值:text_delta | input_json_delta | thinking_delta
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
Thinking string `json:"thinking,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
}
// anthropicStreamError is the payload of an error event.
// anthropicStreamError 是 error 事件的负载。
type anthropicStreamError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// streamBlock tracks the partial state of one content block during streaming.
// streamBlock 跟踪流式过程中单个内容块的部分状态。
type streamBlock struct {
index int
typ string
@@ -55,13 +55,13 @@ type streamBlock struct {
initialArgs map[string]any
}
// scanAnthropicSSE incrementally decodes Anthropic's content-block stream. Text
// deltas are forwarded as text_delta events (segment bracketed by
// text_start/text_end); tool_use input_json_delta fragments are forwarded as
// tool_call_delta events (segment bracketed by tool_call_start/tool_call_end)
// and accumulated per block so they re-assemble into a structured Args map when
// the block stops. The stream must terminate with a message_stop event,
// otherwise ErrStream is returned.
// scanAnthropicSSE 增量解码 Anthropic 的内容块流。文本
// 增量以 text_delta 事件转发(片段由
// text_start/text_end 包起来);tool_use 的 input_json_delta 片段以
// tool_call_delta 事件转发(片段由 tool_call_start/tool_call_end 包起来),
// 并按块累积,以便块结束时重新拼装成结构化的 Args map。
// 流必须以 message_stop 事件终止,
// 否则返回 ErrStream。
func scanAnthropicSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, sseBufInit), maxSSELineBytes)
@@ -101,7 +101,7 @@ func scanAnthropicSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error)
return agg, nil
}
// applyAnthropicEvent folds a single stream event into the running aggregation.
// applyAnthropicEvent 把单个流事件折叠进正在运行的聚合状态。
func applyAnthropicEvent(
agg *Response,
blocks map[int]*streamBlock,
@@ -218,8 +218,8 @@ func applyAnthropicEvent(
return nil
}
// finalizeAnthropicStream assembles the aggregated Response from the accumulated
// blocks: concatenated text plus any tool_use ToolCalls derived per block.
// finalizeAnthropicStream 由累积的内容块拼装出聚合后的 Response:
// 拼接的文本,加上按块导出的 tool_use ToolCall。
func finalizeAnthropicStream(agg *Response, blocks map[int]*streamBlock, order []int, content string) {
agg.Content = content
agg.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: content}}}
+9 -9
View File
@@ -14,7 +14,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// anthropicCaptured records what an AnthropicChatAdapter sent to a stub server.
// anthropicCaptured 记录 AnthropicChatAdapter 发往 stub 服务器的内容。
type anthropicCaptured struct {
method string
path string
@@ -510,8 +510,8 @@ func TestAnthropicChat_ToolResult(t *testing.T) {
}
}
// anthropicBlockContent asserts a message renders a single Anthropic content block
// and returns it as a map for inspection.
// anthropicBlockContent 断言一条消息渲染出单个 Anthropic 内容块,
// 并把它作为 map 返回以供检查。
func anthropicBlockContent(t *testing.T, msg anthropicMessage) (map[string]any, bool) {
t.Helper()
blocks, ok := msg.Content.([]any)
@@ -540,7 +540,7 @@ func TestAnthropicChat_Build_ToolResultBlock(t *testing.T) {
t.Fatalf("messages = %d, want 3", len(out))
}
// The assistant turn must carry the native tool_use block, not plain text.
// 助手回合必须携带原生 tool_use 块,而不是纯文本。
if out[1].Role != "assistant" {
t.Errorf("assistant role = %q, want assistant", out[1].Role)
}
@@ -555,8 +555,8 @@ func TestAnthropicChat_Build_ToolResultBlock(t *testing.T) {
t.Errorf("tool_use input = %#v, want {city:Paris}", use["input"])
}
// The tool-result turn must be a user turn with a native tool_result block
// (the audit's core fix: never a user plain-text turn).
// 工具结果回合必须是带原生 tool_result 块的 user
// 回合(审计的核心修复:绝不是 user 纯文本回合)。
if out[2].Role != "user" {
t.Errorf("tool-result role = %q, want user", out[2].Role)
}
@@ -580,9 +580,9 @@ func TestAnthropicChat_Build_ToolResultBlock(t *testing.T) {
func TestAnthropicChat_Build_ToolResultLegacyText(t *testing.T) {
t.Parallel()
// The loop's current form: a RoleTool message carrying a plain text part and
// Message.ToolCallID. The protocol layer must still render a native tool_result
// block (not a user plain-text turn).
// loop 当前的形态:一个携带纯文本片段与
// Message.ToolCallID 的 RoleTool 消息。协议层仍必须渲染原生
// tool_result 块(而不是 user 纯文本回合)。
m := Message{Role: RoleTool, ToolCallID: "toolu_9",
Content: []Content{{Type: ContentTypeText, Text: "tool weather result: 22"}}}
_, out, err := buildAnthropicMessages([]Message{m})
+17 -17
View File
@@ -13,8 +13,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// newFallbackDispatchServer serves two chain steps from one endpoint: the
// "main" model fails so the chain moves on, every other model succeeds.
// newFallbackDispatchServer 由一个端点服务两个链步骤:
// "main" 模型失败以便链继续前进,其它所有模型成功。
func newFallbackDispatchServer(t *testing.T, ch chan<- capturedRequest) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -42,8 +42,8 @@ func chatOnce(t *testing.T, chain *Chain) {
}
}
// TestChain_Chat_GroupBudgetAppliesToPrimary asserts the active group's own
// maxTokens is applied to a request that carries no explicit budget.
// TestChain_Chat_GroupBudgetAppliesToPrimary 断言生效组自己的
// maxTokens 会作用于未携带显式预算的请求。
func TestChain_Chat_GroupBudgetAppliesToPrimary(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -57,8 +57,8 @@ func TestChain_Chat_GroupBudgetAppliesToPrimary(t *testing.T) {
}
}
// TestChain_Chat_ExplicitRequestWinsOverGroupBudget asserts an explicit
// Request.MaxTokens is never overridden (the small review/judge budgets).
// TestChain_Chat_ExplicitRequestWinsOverGroupBudget 断言显式的
// Request.MaxTokens 绝不会被覆盖(用于小额的评审/裁判预算)。
func TestChain_Chat_ExplicitRequestWinsOverGroupBudget(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -75,8 +75,8 @@ func TestChain_Chat_ExplicitRequestWinsOverGroupBudget(t *testing.T) {
}
}
// TestChain_Chat_FallbackEndAppliesEnvBudget asserts the last-resort budget only
// reaches the final fallback step, never the primary.
// TestChain_Chat_FallbackEndAppliesEnvBudget 断言兜底预算只
// 到达最后的降级步骤,绝不到达主步骤。
func TestChain_Chat_FallbackEndAppliesEnvBudget(t *testing.T) {
t.Parallel()
primaryCh := make(chan capturedRequest, 1)
@@ -97,8 +97,8 @@ func TestChain_Chat_FallbackEndAppliesEnvBudget(t *testing.T) {
}
}
// TestChain_Chat_GroupBudgetWinsOverFallbackEnv asserts a group maxTokens beats
// the last-resort env budget even on the final fallback step.
// TestChain_Chat_GroupBudgetWinsOverFallbackEnv 断言组的 maxTokens 胜过
// 兜底 env 预算,即便在最后的降级步骤上。
func TestChain_Chat_GroupBudgetWinsOverFallbackEnv(t *testing.T) {
t.Parallel()
primaryCh := make(chan capturedRequest, 1)
@@ -116,9 +116,9 @@ func TestChain_Chat_GroupBudgetWinsOverFallbackEnv(t *testing.T) {
}
}
// TestChain_Chat_SingleStepIgnoresFallbackEnv asserts a chain with no fallback
// model (one step = the primary) never gets the last-resort budget, so the
// adapter default is preserved.
// TestChain_Chat_SingleStepIgnoresFallbackEnv 断言没有降级
// 模型(单步 = 主模型)的链绝不会拿到兜底预算,因此
// 适配器默认值得以保留。
func TestChain_Chat_SingleStepIgnoresFallbackEnv(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -132,8 +132,8 @@ func TestChain_Chat_SingleStepIgnoresFallbackEnv(t *testing.T) {
}
}
// TestRuntimeLLM_GroupMaxTokensEndToEnd proves the models.json maxTokens field
// reaches the provider request body through the real runtime stack.
// TestRuntimeLLM_GroupMaxTokensEndToEnd 证明 models.json 的 maxTokens 字段
// 经真实运行时栈到达提供商请求体。
func TestRuntimeLLM_GroupMaxTokensEndToEnd(t *testing.T) {
ch := make(chan capturedRequest, 1)
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
@@ -156,8 +156,8 @@ func TestRuntimeLLM_GroupMaxTokensEndToEnd(t *testing.T) {
}
}
// TestRuntimeLLM_FallbackEndAppliesEnvBudget proves LLM_FALLBACK_MAX_TOKENS is
// wired from config through buildSnapshot into the final fallback step.
// TestRuntimeLLM_FallbackEndAppliesEnvBudget 证明 LLM_FALLBACK_MAX_TOKENS
// 由配置经 buildSnapshot 接线到最后的降级步骤。
func TestRuntimeLLM_FallbackEndAppliesEnvBudget(t *testing.T) {
ch := make(chan capturedRequest, 2)
srv := newFallbackDispatchServer(t, ch)
@@ -9,21 +9,21 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// newDeepSeekAdapter builds an OpenAI-compatible adapter pointed at a stub server
// but classified as DeepSeek by its model name, so the DeepSeek mapping is
// exercised against the real request body.
// newDeepSeekAdapter 构建一个 OpenAI 兼容适配器,指向 stub 服务器
// 但按其模型名归类为 DeepSeek,从而让 DeepSeek 映射
// 针对真实请求体被验证。
func newDeepSeekAdapter(t *testing.T, ch chan<- capturedRequest, opts ...OpenAIOption) *OpenAIChatAdapter {
t.Helper()
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
return NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: deepSeekModel}, opts...)
}
// deepSeekModel is the current DeepSeek model name (the old deepseek-chat /
// deepseek-reasoner names were discontinued on 2026-07-24).
// deepSeekModel 是当前的 DeepSeek 模型名(旧的 deepseek-chat /
// deepseek-reasoner 名称已于 2026-07-24 停用)。
const deepSeekModel = "deepseek-flash"
// deepSeekThinkingType reads body.thinking.type, reporting whether the switch was
// sent at all.
// deepSeekThinkingType 读取 body.thinking.type,报告该开关是否
// 被发出。
func deepSeekThinkingType(body map[string]any) (string, bool) {
block, ok := body["thinking"].(map[string]any)
if !ok {
@@ -33,12 +33,12 @@ func deepSeekThinkingType(body map[string]any) (string, bool) {
return value, true
}
// TestOpenAIChat_DeepSeekReasoningEffortMatrix pins the official value set end to
// end at the request body: none/low/high/max pass through untouched and the four
// compatibility aliases are normalized to the tier DeepSeek applies
// (minimal→low, medium/xhigh→high, ultra→max). The old behavior — grading "none"
// down to "low" — turned thinking off into thinking on, so "none" must arrive as
// "none".
// TestOpenAIChat_DeepSeekReasoningEffortMatrix 钉住官方取值集合,端到
// 端落到请求体上:none/low/high/max 原样通过,四个
// 兼容别名被归一化为 DeepSeek 采用的档位
// (minimal→low、medium/xhigh→high、ultra→max)。旧行为——把 "none"
// 降级到 "low"——把关闭思考变成了开启思考,所以 "none" 必须
// 以 "none" 到达。
func TestOpenAIChat_DeepSeekReasoningEffortMatrix(t *testing.T) {
t.Parallel()
cases := []struct{ sent, want string }{
@@ -74,9 +74,9 @@ func TestOpenAIChat_DeepSeekReasoningEffortMatrix(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped asserts an unrecognized
// value is dropped and reported — never replaced by a nearby tier, which is how
// the original "round down" logic inverted the caller's intent.
// TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped 断言无法识别的
// 取值会被丢弃并上报——绝不会被替换成邻近档位,正是那套
// 「向下取整」逻辑颠倒了调用方的意图。
func TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -105,10 +105,10 @@ func TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekThinkingSwitch asserts the DeepSeek thinking switch is
// really sent: an explicit off must reach the wire (DeepSeek defaults to enabled,
// so staying silent would leave thinking ON), and nil must not be turned into
// either value.
// TestOpenAIChat_DeepSeekThinkingSwitch 断言 DeepSeek 思考开关
// 真的被发出:显式关闭必须到达线上(DeepSeek 默认开启,
// 所以保持沉默会让思考保持开启),而 nil 不得被变成
// 任何一个取值。
func TestOpenAIChat_DeepSeekThinkingSwitch(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -147,9 +147,9 @@ func TestOpenAIChat_DeepSeekThinkingSwitch(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch asserts the
// official reasoning_effort "none" path is used verbatim when no thinking switch
// was requested — that is the documented way to close thinking.
// TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch 断言在未
// 请求 thinking 开关时,官方 reasoning_effort "none" 路径被原样
// 使用——那是关闭思考的记录在案的做法。
func TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -171,9 +171,9 @@ func TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch(t *testing.
}
}
// TestOpenAIChat_DeepSeekThinkingOffBeatsEffort is the conflict rule documented in
// resolveOpenAIReasoning: the provider states no precedence, so the off side wins
// and the dropped tier is reported — never silently applied.
// TestOpenAIChat_DeepSeekThinkingOffBeatsEffort 是 resolveOpenAIReasoning 里
// 记录的冲突规则:提供商未说明优先级,所以关闭一侧获胜,
// 被丢弃的档位会被上报——绝不静默套用。
func TestOpenAIChat_DeepSeekThinkingOffBeatsEffort(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -200,9 +200,9 @@ func TestOpenAIChat_DeepSeekThinkingOffBeatsEffort(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled covers the opposite
// conflict: reasoning_effort "none" and thinking enabled cannot both be honoured,
// so the off side wins and the request carries exactly one switch.
// TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled 覆盖相反的
// 冲突:reasoning_effort "none" 与 thinking 开启无法同时被
// 尊重,所以关闭一侧获胜,请求只携带一个开关。
func TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -229,9 +229,9 @@ func TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored asserts a token budget
// (an Anthropic-style directive) is reported as unmappable on the DeepSeek
// chat-completions endpoint instead of opening or closing the switch by guess.
// TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored 断言 token 预算
// (Anthropic 风格的指令)在 DeepSeek chat-completions 端点上
// 会被上报为无法映射,而不是靠猜去开或关开关。
func TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -257,10 +257,10 @@ func TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns covers the DeepSeek
// 400 trap: a forcing tool_choice while thinking is on is rejected, and thinking
// is on by default. The adapter only warns — it must never close thinking by
// itself.
// TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns 覆盖 DeepSeek
// 400 陷阱:思考开启时强制型 tool_choice 会被拒绝,而思考
// 默认就是开启。适配器只警告——它绝不能自行
// 关闭思考。
func TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns(t *testing.T) {
t.Parallel()
cases := []struct {
+43 -43
View File
@@ -9,23 +9,23 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Provider names accepted by the provider-aware factory. These are the only two
// protocols the factory understands today; adding a vendor means adding a case
// here plus a matching adapter, not touching the rest of the package.
// 提供商感知工厂接受的提供商名。这是工厂目前理解的仅有的
// 两种协议;新增厂商意味着在这里加一个 case
// 再加一个配套适配器,而不必改动包内其它部分。
const (
// ProviderOpenAI selects the OpenAI-compatible /chat/completions protocol.
// ProviderOpenAI 选择 OpenAI 兼容的 /chat/completions 协议。
ProviderOpenAI = "openai"
// ProviderAnthropic selects the Anthropic Messages /v1/messages protocol.
// ProviderAnthropic 选择 Anthropic Messages 的 /v1/messages 协议。
ProviderAnthropic = "anthropic"
)
// ErrUnknownProvider is returned by the provider-aware factory when the
// configured provider name is not recognized.
// 配置的提供商名无法识别时,由提供商感知工厂返回
// ErrUnknownProvider。
var ErrUnknownProvider = errors.New("model: unknown provider")
// NewChatAdapter builds a single provider adapter selected by cfg.Provider. The
// empty provider defaults to openai for backward compatibility. An unrecognized
// provider returns ErrUnknownProvider.
// NewChatAdapter 按 cfg.Provider 构建单个提供商适配器。
// 提供商为空时为向后兼容默认用 openai。无法识别的
// 提供商返回 ErrUnknownProvider。
func NewChatAdapter(cfg config.LLMConfig) (Adapter, error) {
switch cfg.Provider {
case ProviderOpenAI, "":
@@ -37,25 +37,25 @@ func NewChatAdapter(cfg config.LLMConfig) (Adapter, error) {
}
}
// NewChatChain builds a fallback Chain for the configured provider: the primary
// model first, then the provider's fallback model when set and distinct. Each
// step is served by an adapter of the selected protocol. The empty provider
// defaults to openai. An unrecognized provider returns ErrUnknownProvider.
// NewChatChain 为配置的提供商构建降级 Chain:先是主
// 模型,再是提供商已设置且不同的降级模型。每一步
// 都由所选协议的适配器服务。提供商为空
// 时默认用 openai。无法识别的提供商返回 ErrUnknownProvider。
func NewChatChain(cfg config.LLMConfig) (*Chain, error) {
return NewChatChainWithLog(cfg, nil)
}
// NewChatChainWithLog is NewChatChain with a logger attached to every adapter it
// builds, so a provider-level reasoning decision (a thinking budget raised to fit
// max_tokens, a reasoning_effort value downgraded or ignored) is reported with
// its module source. A nil logger leaves the adapters' discard default in place.
// NewChatChainWithLog 是 NewChatChain,并为它构建的每个适配器
// 挂上日志器,好让提供商层面的推理决策(为容纳
// max_tokens 而抬高的思考预算、被降级或忽略的 reasoning_effort 值)
// 带着模块来源上报。nil 日志器则保留适配器的丢弃式默认值。
//
// It is also the only builder that appends the optional local third rung
// (裁决 03.4.3, primary → fallback → local), because that rung may speak a
// different protocol than the active group and therefore needs the raw logger to
// build its own adapter. It is appended only when the local rung is enabled AND
// fully configured; otherwise the chain keeps its two-level shape and the reason
// is reported.
// 它也是唯一会追加可选本地第三级(local rung)的构建器
// (裁决 03.4.3,primary → fallback → local),因为那一级可能说
// 与生效组不同的协议,因此需要原始日志器来
// 构建自己的适配器。只有当本地级已启用且完全
// 配置好时才会追加;否则链保持两级形状,并
// 上报原因。
func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) {
var chain *Chain
var err error
@@ -76,16 +76,16 @@ func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error)
return chain, nil
}
// localChainStep builds the local third degradation rung as a chain step. It
// returns ok=false (and, when the rung was asked for but is incomplete, a WARN
// naming the missing field) when the rung is off, unconfigured or its provider is
// unknown — a half-configured step would only ever fail, which is worse than not
// having it.
// localChainStep 把本地第三级降级构造成一个链步骤。
// 当该级关闭、未配置或其提供商未知时,返回 ok=false
// (若该级被要求但配置不全,则附一条点出缺失字段的 WARN)
// ——一个半配置的步骤只会失败,那还不如没有
// 它。
func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) {
local := cfg.Local
// Configured() already requires Enabled, so the rung is off by default and a
// disabled rung can never become a chain step. The warning is emitted only
// when the rung was actually asked for but is incomplete.
// Configured() 本身已要求 Enabled,所以该级默认关闭,
// 被禁用的级永远不会成为链步骤。只有当该级确实被
// 要求、但配置不全时才发出该警告。
if !local.Configured() {
if local.Enabled {
warn(log, "local model rung enabled but not configured; skipping it",
@@ -112,8 +112,8 @@ func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) {
}
}
// newOpenAIChain builds a fallback chain from the OpenAI-compatible config block.
// Each step is an OpenAIChatAdapter against the configured endpoint.
// newOpenAIChain 由 OpenAI 兼容配置块构建降级链。
// 每一步都是针对该端点的 OpenAIChatAdapter。
func newOpenAIChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) {
if cfg.BaseURL == "" || cfg.Model == "" {
return nil, ErrNoModel
@@ -125,8 +125,8 @@ func newOpenAIChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error)
return NewChain(steps...), nil
}
// newAnthropicChain builds a fallback chain from the Anthropic config block. Each
// step is an AnthropicChatAdapter against the configured /v1/messages endpoint.
// newAnthropicChain 由 Anthropic 配置块构建降级链。每一步
// 都是针对该 /v1/messages 端点的 AnthropicChatAdapter。
func newAnthropicChain(cfg config.LLMConfig, opts ...AnthropicOption) (*Chain, error) {
if cfg.AnthropicBaseURL == "" || cfg.AnthropicModel == "" {
return nil, ErrNoModel
@@ -138,9 +138,9 @@ func newAnthropicChain(cfg config.LLMConfig, opts ...AnthropicOption) (*Chain, e
return NewChain(steps...), nil
}
// DefaultModelForProvider returns the primary model name for the configured
// provider: the flat OpenAI Model for "openai", the Anthropic model for
// "anthropic". Used by callers (e.g. cmd/cyrene) to seed a Router's default.
// DefaultModelForProvider 返回配置提供商的主模型名:
// "openai" 取扁平配置里的 OpenAI Model,"anthropic" 取 Anthropic 模型。
// 调用方(如 cmd/cyrene)用它播种 Router 的默认值。
func DefaultModelForProvider(cfg config.LLMConfig) string {
if cfg.Provider == ProviderAnthropic {
return cfg.AnthropicModel
@@ -148,10 +148,10 @@ func DefaultModelForProvider(cfg config.LLMConfig) string {
return cfg.Model
}
// warn reports through the injected logger, falling back to a discard logger that
// still carries the module source so an unsourced record can never be emitted
// (project rule §10). It exists because a chain may be built without a logger
// (NewChatChain) while a skipped rung must still be reported.
// warn 通过注入的日志器上报,降级到一个仍带有模块来源的丢弃式
// 日志器,因此绝不会产生无来源日志
// (项目铁律 §10)。它之所以存在,是因为链可能在没有日志器
// 的情况下构建(NewChatChain),而被跳过的级仍须上报。
func warn(log *slog.Logger, msg string, args ...any) {
if log == nil {
log = discardLogger("model")
+3 -3
View File
@@ -163,9 +163,9 @@ func TestRouter_RouteProvider(t *testing.T) {
}
}
// TestChain_AnthropicFallback exercises the fallback chain against two Anthropic
// adapters: the primary step fails and the backup step succeeds, proving the
// adapter composes cleanly inside a Chain.
// TestChain_AnthropicFallback 用两个 Anthropic 适配器验证
// 降级链:主步骤失败、备用步骤成功,证明该
// 适配器能在 Chain 内干净地组合。
func TestChain_AnthropicFallback(t *testing.T) {
t.Parallel()
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+39 -39
View File
@@ -7,48 +7,48 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Step binds a model name to an Adapter for one entry in a fallback chain. An
// empty Model keeps the incoming Request.Model.
// Step 把模型名与 Adapter 绑定成降级链里的一项。
// Model 为空时沿用传入 Request.Model。
type Step struct {
Model string
Adapter Adapter
}
// Chain is an Adapter that tries its steps in order and returns the first
// successful response. It implements Adapter, so it can be used anywhere a
// single provider is expected.
// Chain 是一个按顺序尝试各步骤、返回第一个
// 成功响应的 Adapter。它实现了 Adapter,因此任何期望单个
// 提供商的地方都能使用它。
type Chain struct {
steps []Step
// groupMaxTokens is the active model group's own budget (models.json
// maxTokens). When > 0 it applies to every step of the group's chain and
// wins over fallbackMaxTokens.
// groupMaxTokens 是生效模型组自己的预算(models.json
// maxTokens)。大于 0 时作用于该组链的每一步,
// 并优先于 fallbackMaxTokens。
groupMaxTokens int
// fallbackMaxTokens is the last-resort budget (LLM_FALLBACK_MAX_TOKENS). It
// only applies to the final step of a chain that actually has a fallback.
// fallbackMaxTokens 是最后的兜底预算(LLM_FALLBACK_MAX_TOKENS)。
// 它只作用于确实有降级步骤的链的最后一个步骤。
fallbackMaxTokens int
}
// NewChain builds a fallback chain from the given steps.
// NewChain 由给定步骤构建降级链。
func NewChain(steps ...Step) *Chain {
return &Chain{steps: steps}
}
// WithTokenBudget sets the chain's token budgets: the active group's own budget
// and the last-resort fallback budget. It must be called before the chain is
// published to concurrent callers (the runtime builds a fresh chain per
// snapshot and swaps it in atomically), so the fields are never mutated in
// flight.
// WithTokenBudget 设置链的 token 预算:生效组自己的预算
// 与最后的兜底预算。必须在链被发布给并发调用方之前调用
// (运行时为每个快照构建一条新链并原子换入),
// 快照并原子换入),所以这些字段绝不会在运行中
// 被改写。
func (c *Chain) WithTokenBudget(groupMaxTokens, fallbackMaxTokens int) *Chain {
c.groupMaxTokens = groupMaxTokens
c.fallbackMaxTokens = fallbackMaxTokens
return c
}
// stepModel picks the model name for step idx of the chain. The primary step
// prefers the request's routed model — the per-persona / per-request decision the
// Router produced — over the group's configured model, so a routing decision is
// never silently discarded. Every fallback step pins its own configured model, so
// a degradation never re-sends the primary model name to a different endpoint
// stepModel 选出链上第 idx 步的模型名。主步骤
// 优先用请求中被路由的模型——即 Router 产出的按角色/按请求
// 决策——而不是组里配置的模型,这样一个路由决策
// 绝不会被静默丢弃。每个降级步骤都钉住自己配置的模型,
// 因此一次降级绝不会把主模型名重发给另一个端点。
// (裁决 03.4.1: per-persona 路由必须真的落到请求上).
func stepModel(req Request, idx int, configured string) string {
if idx == 0 && req.Model != "" {
@@ -57,12 +57,12 @@ func stepModel(req Request, idx int, configured string) string {
return configured
}
// applyBudget resolves the effective MaxTokens for step idx, honoring the
// precedence: an explicit Request.MaxTokens > the group's MaxTokens > the
// last-resort LLM_FALLBACK_MAX_TOKENS > the adapter default. The last-resort
// budget only applies when the chain really has a fallback step (len > 1) and
// only to that final step; a single-step chain is a primary model, not a
// fallback, so its adapter default is left untouched.
// applyBudget 解出第 idx 步实际生效的 MaxTokens,遵循
// 优先级:显式的 Request.MaxTokens > 组自己的 MaxTokens >
// 兜底的 LLM_FALLBACK_MAX_TOKENS > 适配器默认值。兜底
// 预算只在链确实有降级步骤(len > 1)时生效,
// 且只作用于最后那一步;单步链就是主模型、并非
// 降级,所以它的适配器默认值保持不动。
func (c *Chain) applyBudget(req Request, idx int) Request {
if req.MaxTokens != 0 {
return req
@@ -77,20 +77,20 @@ func (c *Chain) applyBudget(req Request, idx int) Request {
return req
}
// NewOpenAIChatChain builds a fallback chain from a single supplier config: the
// primary model first, then cfg.Fallback (when set and distinct), both served
// by an OpenAIChatAdapter against the same OpenAI-compatible endpoint. It
// delegates to the provider-aware builder so the OpenAI path and the factory stay
// in sync. It returns ErrNoModel when no base URL or primary model is configured.
// NewOpenAIChatChain 由单份供应商配置构建降级链:先
// 主模型,再 cfg.Fallback(已设置且不同时),两者都由
// 同一个 OpenAI 兼容端点上的 OpenAIChatAdapter 服务。它
// 委托给提供商感知的构建器,使 OpenAI 路径与工厂保持
// 一致。未配置 base URL 或主模型时返回 ErrNoModel。
func NewOpenAIChatChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) {
return newOpenAIChain(cfg, opts...)
}
// Name returns the chain identifier.
// Name 返回链标识。
func (c *Chain) Name() string { return "fallback" }
// Chat tries each step in order and returns the first success. If every step
// fails, the last error is returned wrapped as ErrUpstream.
// Chat 按顺序尝试每一步并返回第一个成功的结果。若每一步
// 都失败,则把最后一个错误包成 ErrUpstream 返回。
func (c *Chain) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
if len(c.steps) == 0 {
return Response{}, ErrNoSteps
@@ -110,10 +110,10 @@ func (c *Chain) Chat(ctx context.Context, req Request, opts Options) (Response,
return Response{}, fmt.Errorf("model: %w: %v", ErrUpstream, lastErr)
}
// ChatStream tries each step in order. If a step fails before delivering any
// event, the chain moves to the next step; once a step has delivered output it
// is committed, so a mid-stream failure surfaces as an error rather than mixing
// content across providers.
// ChatStream 按顺序尝试每一步。若某步在交付任何事件之前
// 就失败,链会移到下一步;一旦某步已交付输出,
// 它就被视为已提交,因此流中途的失败会作为错误浮出,而不会把
// 不同提供商的内容混在一起。
func (c *Chain) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
if len(c.steps) == 0 {
return Response{}, ErrNoSteps
+19 -19
View File
@@ -13,10 +13,10 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// recordingServer is an OpenAI-compatible /chat/completions endpoint that records
// the model name of every request and either fails or succeeds. It backs the
// local-rung tests (裁决 03.4.3): the point of the rung is that a request reaches
// the local endpoint with the local model name, and never when the rung is off.
// recordingServer 是一个 OpenAI 兼容的 /chat/completions 端点,记录
// 每个请求的模型名,并或失败或成功。它支撑
// 本地级测试(裁决 03.4.3):该级的意义在于请求以本地模型名
// 到达本地端点,而该级关闭时绝不如此。
type recordingServer struct {
mu sync.Mutex
models []string
@@ -51,9 +51,9 @@ func newRecordingServer(t *testing.T, fail bool) (*httptest.Server, *recordingSe
return srv, rec
}
// TestNewChatChainWithLog_LocalRungAppended verifies the third degradation level
// (primary → fallback → local) only joins the chain when it is enabled AND fully
// configured.
// TestNewChatChainWithLog_LocalRungAppended 验证第三级降级
// (primary → fallback → local)只在启用且完全
// 配置好时才加入链。
func TestNewChatChainWithLog_LocalRungAppended(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -88,9 +88,9 @@ func TestNewChatChainWithLog_LocalRungAppended(t *testing.T) {
}
}
// TestChain_Chat_LocalRungServesAfterFallback verifies the local rung really takes
// over the request after the primary and fallback steps fail, with the local model
// name on the wire and the local endpoint's own base URL/API key (not the group's).
// TestChain_Chat_LocalRungServesAfterFallback 验证本地级在主
// 步骤与降级步骤都失败后真的接管请求,且线上用
// 本地模型名与本地端点自己的 base URL/API key(而非组的)。
func TestChain_Chat_LocalRungServesAfterFallback(t *testing.T) {
t.Parallel()
mainSrv, _ := newRecordingServer(t, true)
@@ -117,9 +117,9 @@ func TestChain_Chat_LocalRungServesAfterFallback(t *testing.T) {
}
}
// TestChain_Chat_LocalRungOffNeverCalled pins the default-off red line: with the
// rung disabled the local endpoint must never be touched, even when every other
// step fails.
// TestChain_Chat_LocalRungOffNeverCalled 钉住默认关闭的红线:该级
// 禁用时绝不能触碰本地端点,即便其它所有
// 步骤都失败。
func TestChain_Chat_LocalRungOffNeverCalled(t *testing.T) {
t.Parallel()
mainSrv, _ := newRecordingServer(t, true)
@@ -141,14 +141,14 @@ func TestChain_Chat_LocalRungOffNeverCalled(t *testing.T) {
}
}
// TestChain_Chat_PrimaryStepKeepsRoutedModel pins that the chain does not discard
// the router's decision: the primary step sends the request's routed model, and
// the fallback step still pins its own configured model (裁决 03.4.1).
// TestChain_Chat_PrimaryStepKeepsRoutedModel 钉住链不会丢弃
// 路由器的决策:主步骤发送请求中被路由的模型,而
// 降级步骤仍钉住自己配置的模型(裁决 03.4.1)。
func TestChain_Chat_PrimaryStepKeepsRoutedModel(t *testing.T) {
t.Parallel()
// One endpoint serves both steps (the factory builds every step against the
// group's base URL): it fails for anything but the fallback model, so the
// recorded model names show which model each step actually sent.
// 一个端点同时服务两个步骤(工厂让每一步都针对
// 组的 base URL 构建):除降级模型外它都失败,所以
// 记录下来的模型名显示了每一步实际发送了哪个模型。
rec := &recordingServer{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
+149 -149
View File
@@ -1,102 +1,102 @@
// Package model provides a vendor-agnostic LLM adapter layer for Cyrene v2.
// model 包为 Cyrene v2 提供与厂商无关的 LLM 适配层。
//
// It defines a normalized Message/Request/Response vocabulary, a single
// Adapter interface implemented by OpenAIChatAdapter (which speaks the
// OpenAI-compatible /chat/completions protocol used by OpenAI, DashScope/百炼,
// local gateways and proxies), a per-role Router, an automatic fallback Chain,
// multimodal (vision) detection, and per-call CallRecord accounting.
// 它定义了归一化的 Message/Request/Response 词汇、单个
// 由 OpenAIChatAdapter 实现的 Adapter 接口(该适配器说
// OpenAI、DashScope/百炼等使用的 OpenAI 兼容 /chat/completions 协议、
// 本地网关与代理),还有按角色的 Router、自动降级 Chain、
// 多模态(视觉)检测与按调用的 CallRecord 计账。
//
// The full tool loop (schema registration, argument binding, result re-feed) is
// deferred to M2; this package only carries interface-level placeholders
// (ToolSpec) so the wire contract is ready without executing any loop.
// 完整的工具循环(schema 注册、参数绑定、结果回喂)
// 推迟到 M2;本包只携带接口级占位
// (ToolSpec),让线格式契约先就绪,而不执行任何循环。
package model
import "time"
// Role identifies who authored a message. The values are vendor-agnostic and
// translated to each provider's own role vocabulary by the adapter.
// Role 标识消息的作者。取值与厂商无关,
// 由适配器翻译成各提供商自己的角色词汇。
type Role string
const (
// RoleSystem is a system/instruction message.
// RoleSystem 是系统/指令消息。
RoleSystem Role = "system"
// RoleUser is an end-user message.
// RoleUser 是终端用户消息。
RoleUser Role = "user"
// RoleAssistant is an assistant (model) message.
// RoleAssistant 是助手(模型)消息。
RoleAssistant Role = "assistant"
// RoleTool is a tool-result message (reserved for the M2 tool loop).
// RoleTool 是工具结果消息(保留给 M2 工具循环)。
RoleTool Role = "tool"
)
// ContentType describes the kind of payload inside a Content part.
// ContentType 描述 Content 片段内负载的种类。
type ContentType string
const (
// ContentTypeText is a plain-text content part.
// ContentTypeText 是纯文本内容片段。
ContentTypeText ContentType = "text"
// ContentTypeImage is an image content part.
// ContentTypeImage 是图片内容片段。
ContentTypeImage ContentType = "image_url"
// ContentTypeToolCall is an assistant-originated tool invocation part
// (Anthropic tool_use / OpenAI tool_calls), carried on a RoleAssistant turn.
// ContentTypeToolCall 是助手发起的工具调用片段
// (Anthropic tool_use / OpenAI tool_calls),挂在 RoleAssistant 回合上。
ContentTypeToolCall ContentType = "tool_call"
// ContentTypeToolResult is a tool execution result part, carried on a RoleTool
// turn and encoded natively (Anthropic tool_result / OpenAI tool message).
// ContentTypeToolResult 是工具执行结果片段,挂在 RoleTool
// 回合上并按原生方式编码(Anthropic tool_result / OpenAI tool message)。
ContentTypeToolResult ContentType = "tool_result"
)
// Content is a single, possibly multimodal, part of a message.
// Content 是消息的单个片段,可能是多模态的一部分。
type Content struct {
// Type distinguishes a text part from an image / tool part.
// Type 区分文本片段与图片 / 工具片段。
Type ContentType
// Text is the text payload when Type == ContentTypeText, or the result body
// when Type == ContentTypeToolResult.
// Text 是 Type == ContentTypeText 时的文本负载,
// 或 Type == ContentTypeToolResult 时的结果正文。
Text string
// ImageURL is the image source when Type == ContentTypeImage. It may be a
// data: URI or an http(s):// URL.
// ImageURL 是 Type == ContentTypeImage 时的图片来源。它可以是
// data: URI 或 http(s):// URL。
ImageURL string
// ToolCallID correlates the part to a provider tool_use/tool_call id: the call
// id for ContentTypeToolCall, or the tool_use_id being answered for
// ContentTypeToolResult.
// ToolCallID 把片段关联到提供商的 tool_use/tool_call id:
// ContentTypeToolCall 对应调用 id,
// ContentTypeToolResult 对应被应答的 tool_use_id。
ToolCallID string
// ToolCallName is the invoked tool name when Type == ContentTypeToolCall.
// ToolCallName 是 Type == ContentTypeToolCall 时被调用的工具名。
ToolCallName string
// ToolArgs is the structured argument object when Type == ContentTypeToolCall.
// It is never a JSON-encoded string.
// ToolArgs 是 Type == ContentTypeToolCall 时的结构化参数对象。
// 它绝不是 JSON 编码的字符串。
ToolArgs map[string]any
// IsError marks a ContentTypeToolResult part as an execution failure so the
// provider's tool_result.is_error / error flag is set natively.
// IsError 把 ContentTypeToolResult 片段标记为执行失败,
// 以便原生设置提供商的 tool_result.is_error / error 标志。
IsError bool
}
// Message is a single conversation turn, normalized across providers.
// Message 是单个会话回合,已跨提供商归一化。
type Message struct {
Role Role
// Content carries one or more parts. A text-only message uses a single
// ContentTypeText part.
// Content 携带一个或多个片段。仅文本的消息使用单个
// ContentTypeText 片段。
Content []Content
// SenderName is the human-readable display name of the message's sender
// (design/16 §4.6). It is transport metadata used by the context-assembly
// layer to label third-party messages for the model (e.g. "[第三方 · 昵称]"),
// never a substitute for the strongly typed sender identity carried on the
// channel layer. Empty means the sender is unlabeled (the owner, or an
// unknown/unresolved sender), and callers may leave it unset.
// SenderName 是消息发送者可读的显示名
// (design/16 §4.6)。它是上下文组装层用来给模型
// 标注第三方消息(如 "[第三方 · 昵称]")的传输元数据,
// 绝不是 channel 层强类型发送者身份的替代品。
// 为空表示发送者未标注(主人,或未识别/未解析的发送者),
// 调用方可以留着不设置。
SenderName string
// ToolCallID correlates a RoleTool turn to the provider tool_use/tool_call id
// it answers, and ToolCallName is the invoked tool name carried as a
// convenience for callers that render the result. Both are set by the tool
// loop when it feeds a result back, and consumed by the adapters to emit
// native tool_result / tool-message blocks.
// ToolCallID 把 RoleTool 回合关联到它所应答的提供商
// tool_use/tool_call id,而 ToolCallName 是同时携带的
// 被调用工具名,方便渲染结果的调用方使用。两者都由工具
// 循环在回喂结果时设置,并被适配器消费以发出
// 原生 tool_result / tool-message 块。
ToolCallID string
ToolCallName string
}
// TextMessage builds a text-only message.
// TextMessage 构建仅文本的消息。
func TextMessage(role Role, text string) Message {
return Message{Role: role, Content: []Content{{Type: ContentTypeText, Text: text}}}
}
// ImageMessage builds a message carrying optional text plus an image. imageURL
// may be a data: URI or an http(s):// URL. An empty text omits the text part.
// ImageMessage 构建携带可选文本加一张图片的消息。imageURL
// 可以是 data: URI 或 http(s):// URL。文本为空则省略文本片段。
func ImageMessage(role Role, text, imageURL string) Message {
parts := make([]Content, 0, 2)
if text != "" {
@@ -106,17 +106,17 @@ func ImageMessage(role Role, text, imageURL string) Message {
return Message{Role: role, Content: parts}
}
// ToolResultMessage builds a RoleTool turn carrying the native result of one tool
// call. toolUseID must equal the provider id of the tool_use block this result
// answers; isError marks an execution failure; text is the result body.
// 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 builds a RoleAssistant turn advertising the tool calls
// the model requested, so a subsequent RoleTool turn can be correlated to them by
// the provider (OpenAI tool_call_id / Anthropic tool_use_id).
// 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 {
@@ -125,148 +125,148 @@ func AssistantToolCallMessage(calls []ToolCall) Message {
return Message{Role: RoleAssistant, Content: parts}
}
// ToolSpec is an interface-level placeholder for a tool/function schema. The
// full tool loop (registration, argument binding, result re-feed) is M2; here
// it only lets a request advertise tools to the provider in the wire contract.
// ToolSpec 是工具/函数 schema 的接口级占位。完整
// 工具循环(注册、参数绑定、结果回喂)属于 M2;这里
// 只让请求能在线格式契约里向提供商声明工具。
type ToolSpec struct {
Name string
Description string
// Parameters is the JSON Schema for the tool arguments. Populated from M2.
// Parameters 是工具参数的 JSON Schema。M2 起填入。
Parameters any
}
// ToolCall is a single assistant-originated tool invocation parsed from a
// provider's tool_use content block. Args is the structured argument object
// (never a serialized JSON string), so a tool loop can bind it directly without
// re-parsing.
// ToolCall 是从提供商的 tool_use 内容块解析出的单个
// 助手发起的工具调用。Args 是结构化参数对象
// (绝不是序列化后的 JSON 字符串),因此工具循环可以直接绑定它,
// 无需重新解析。
type ToolCall struct {
// ID is the provider tool_use/tool_call id, so a tool loop can correlate the
// resulting RoleTool message to the exact call (Anthropic tool_use_id / OpenAI
// tool_call_id). Empty when the provider emitted no id.
// ID 是提供商的 tool_use/tool_call id,工具循环因此能把
// 产生的 RoleTool 消息关联到确切的那次调用(Anthropic tool_use_id /
// OpenAI tool_call_id)。提供商未发出 id 时为空。
ID string
// Name is the tool being invoked.
// Name 是被调用的工具。
Name string
// Args are the tool arguments, decoded from the provider's structured
// tool_use.input object. It is never a JSON-encoded string.
// Args 是工具参数,由提供商的结构化
// tool_use.input 对象解码而来。它绝不是 JSON 编码的字符串。
Args map[string]any
}
// Request is a completion request, normalized across providers.
// Request 是一次补全请求,已跨提供商归一化。
type Request struct {
// Model overrides the adapter's configured default model. Empty means the
// adapter default (or the chain step model) applies.
// Model 覆盖适配器已配置的默认模型。为空表示
// 使用适配器默认值(或链步骤模型)。
Model string
Messages []Message
// Temperature is the sampling temperature. nil means the provider default.
// Temperature 是采样温度。nil 表示提供商默认值。
//
// DeepSeek notes (design/04 §3.2 facts 3 and 4): temperature takes no effect
// while thinking is on. frequency_penalty and presence_penalty are deprecated
// at the API level — "传入该参数将不会产生任何效果" — in every mode, which is
// why Request has no field for them. top_p is deliberately not exposed
// either: it only applies while thinking is on and is clamped to >= 0.95
// there, is always 1.0 otherwise, so sending a configured value would be
// silently ineffective on one side of the switch and a hard floor on the
// other.
// DeepSeek 说明(design/04 §3.2 事实 3 与 4):思考开启期间
// temperature 不生效。frequency_penalty 与 presence_penalty 已被弃用
// ——「传入该参数将不会产生任何效果」——在任何模式下都是如此,
// 这就是 Request 没有对应字段的原因。top_p 同样被刻意不暴露:
// 它只在思考开启时生效,且在那里被夹到 >= 0.95,
// 其它情况下恒为 1.0,所以发送一个配置好的值
// 会在开关的一侧静默失效,
// 在另一侧则成为硬性下限。
Temperature *float64
// MaxTokens caps the generated tokens. Zero means the provider default.
// MaxTokens 限制生成的 token 数。为零表示提供商默认值。
MaxTokens int
// Purpose labels the call for CallRecord accounting (e.g. "chat").
// Purpose 为该次调用打标签,用于 CallRecord 计账(如 "chat")。
Purpose string
// PersonaID is the persona this turn answers as — the persona-level routing
// key of 裁决 03.4.1, as opposed to the LLM message role. RuntimeLLM reads it
// to pick the persona's provider chain (models.json personaProviders, i.e. a
// cross-protocol per-persona route). Empty means "no persona binding": the
// active group's default chain serves the request, so every pre-existing
// caller behaves exactly as before.
// PersonaID 是本回合以哪个角色作答——即
// 裁决 03.4.1 所说的按角色路由的「角色」,与 LLM 消息角色不同。RuntimeLLM
// 读它来挑出该角色的提供商链(models.json 的 personaProviders,即
// 跨协议的按角色路由)。为空表示「无角色绑定」:
// 由生效组的默认链服务该请求,因此所有既有
// 调用方的行为与之前完全一致。
//
// Why a request field and not a context key: the decision happens at the
// adapter boundary that already carries the Request, and the value is
// per-call metadata of the same kind as Model/Purpose. The persona lives in
// the loop's private session-env context key, which the model package cannot
// read (model must not import loop), so a ctx carrier would have needed the
// same write anyway — with the decision hidden behind an invisible key and
// hard to assert across packages.
// 为什么是请求字段而不是 context 键:该决策发生在
// 本就携带 Request 的适配器边界上,而且这个值是
// 与 Model/Purpose 同类的按调用元数据。角色存在于
// loop 私有的会话环境 context 键里,而 model 包读不到
// 它(model 不得 import loop),所以用 ctx 载体反正也得
// 做同样的写入——只是决策被藏在一个看不见的键后面,
// 且难以跨包断言。
PersonaID string
// Tools is the interface-level placeholder for M2 function calling.
// Tools 是 M2 函数调用的接口级占位。
Tools []ToolSpec
// ToolChoice optionally directs how the provider selects a tool. It is an
// opaque provider-agnostic directive (e.g. {"type":"auto"} or {"type":"tool",
// "name":"x"}); empty means the provider default. Passed through verbatim to
// protocols that support it (Anthropic's tool_choice).
// ToolChoice 可选地指示提供商如何选择工具。它是一个
// 提供商无关的不透明指令(如 {"type":"auto"} 或 {"type":"tool",
// "name":"x"});为空表示提供商默认值。会原样传给
// 支持它的协议(Anthropic 的 tool_choice)。
//
// DeepSeek red line (design/04 §3.2 fact 5): with thinking on — its default —
// a forcing directive ("required" / "any" / a named tool) is rejected with a
// 400, so thinking must be switched off explicitly first. The adapters only
// WARN about that combination; they never switch thinking off on their own.
// DeepSeek 红线(design/04 §3.2 事实 5):思考开启时——即其默认——
// 强制型指令("required" / "any" / 具名工具)会被 400
// 拒绝,因此必须先把思考显式关掉。适配器只对这种组合
// 发 WARN;它们绝不会自行关掉思考。
ToolChoice any
// Thinking controls the provider's reasoning budget/switch (design/04 §3.2).
// nil means "do not intervene" and uses the provider default. A provider
// whose protocol has no mapping for it logs a WARN rather than silently
// ignoring it.
// Thinking 控制提供商的推理预算/开关(design/04 §3.2)。
// nil 表示「不干预」,使用提供商默认值。协议中
// 没有对应映射的提供商会记一条 WARN,而不是静默
// 忽略它。
//
// "Do not intervene" is not the same as "off": DeepSeek's thinking defaults to
// enabled, so Enable=false is what closes it there (sent as
// {"thinking":{"type":"disabled"}}).
// 「不干预」不等于「关闭」:DeepSeek 的思考默认
// 开启,所以在那里要靠 Enable=false 来关(发送为
// {"thinking":{"type":"disabled"}})。
Thinking *Thinking
// ReasoningEffort is the OpenAI-compatible reasoning strength knob
// (reasoning_effort). Empty means "do not intervene". The value is validated
// against the detected provider's whitelist; an unrecognized value is dropped
// with a WARN and never replaced by a different tier (design/04 §3.2 fact 1).
// DeepSeek additionally documents compatibility aliases (minimal→low,
// medium/xhigh→high, ultra→max), which are normalized to the tier the service
// applies.
// ReasoningEffort 是 OpenAI 兼容的推理强度旋钮
// (reasoning_effort)。为空表示「不干预」。该值会针对
// 已识别提供商的白名单校验;无法识别的值会带着 WARN
// 被丢弃,绝不会被换成别的档位(design/04 §3.2 事实 1)。
// DeepSeek 另外记录了兼容别名(minimal→low、
// medium/xhigh→high、ultra→max),它们会被归一化为服务
// 实际采用的档位。
ReasoningEffort string
}
// Thinking carries the reasoning knobs a caller wants applied (design/04 §3.2).
// Providers map the fields they actually support and report the rest; it only
// controls budget/switch, never the thought content (design/01 §2.1 red line).
// Thinking 携带调用方希望生效的推理旋钮(design/04 §3.2)。
// 提供商映射它们真正支持的字段、并上报其余字段;它只
// 控制预算/开关,绝不控制思考内容(design/01 §2.1 红线)。
type Thinking struct {
// BudgetTokens is the Anthropic thinking.budget_tokens target (the official
// semantic is a target, not a hard cap). Zero selects the adapter default.
// BudgetTokens 是 Anthropic thinking.budget_tokens 的目标值(官方
// 语义是目标,不是硬上限)。为零则选用适配器默认值。
BudgetTokens int
// Enable is an explicit on/off switch for providers that expose a boolean
// thinking flag. nil = no explicit switch.
// Enable 是显式的开/关开关,供暴露布尔
// thinking 标志的提供商使用。nil = 无显式开关。
Enable *bool
}
// Response is a normalized completion result.
// Response 是归一化的补全结果。
type Response struct {
// Model is the model that produced the response.
// Model 是产出该响应的模型。
Model string
Content string
// Message is the normalized assistant message.
// Message 是归一化的助手消息。
Message Message
// ToolCalls carries any tool_use blocks the assistant emitted. Empty when the
// response is pure text.
// ToolCalls 携带助手发出的任何 tool_use 块。响应是纯文本时为
// 空。
ToolCalls []ToolCall
// StopReason is the provider's raw stop/finish reason (Anthropic stop_reason:
// end_turn / max_tokens / tool_use; OpenAI finish_reason: stop / length /
// tool_calls), so callers can distinguish a token-budget truncation from a
// normal end. Empty when the provider reports none.
// StopReason 是提供商原始的停止/结束原因(Anthropic stop_reason:
// end_turn / max_tokens / tool_use;OpenAI finish_reason:stop / length /
// tool_calls),因此调用方能区分 token 预算截断与
// 正常结束。提供商未上报时为空。
StopReason string
// PromptTokens, CompletionTokens and TotalTokens report provider usage when
// available; zero when the provider does not return it.
// PromptTokens、CompletionTokens 与 TotalTokens 在可用时上报
// 提供商用量;提供商未返回时为零。
PromptTokens int
CompletionTokens int
TotalTokens int
}
// CallRecord describes a single successfully completed LLM call, for the M9
// monitor/frontend. A failed attempt emits no record.
// CallRecord 描述一次成功完成的 LLM 调用,供 M9
// 监控/前端使用。失败的尝试不产生记录。
type CallRecord struct {
// Model is the model that served the request.
// Model 是服务该请求的模型。
Model string
// Purpose labels what the call was for.
// Purpose 标注该次调用的用途。
Purpose string
// Tokens is the total token consumption reported by the provider.
// Tokens 是提供商上报的总 token 消耗。
Tokens int
// TookMs is the end-to-end call latency in milliseconds.
// TookMs 是端到端调用延迟,单位毫秒。
TookMs int64
}
// Options carries per-call knobs layered on top of the Request.
// Options 携带叠加在 Request 之上的按调用旋钮。
type Options struct {
// Timeout bounds a single HTTP call. Zero uses the adapter client timeout.
// Timeout 约束单次 HTTP 调用。为零则用适配器客户端的超时。
Timeout time.Duration
}
+38 -38
View File
@@ -14,25 +14,25 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Wire constraints for the OpenAI-compatible /chat/completions protocol.
// OpenAI 兼容 /chat/completions 协议的线格式约束。
const (
// sseDataPrefix marks an SSE event line carrying a JSON payload.
// sseDataPrefix 标记携带 JSON 负载的 SSE 事件行。
sseDataPrefix = "data: "
// sseDone is the terminal SSE sentinel.
// sseDone 是终止用的 SSE 哨兵。
sseDone = "[DONE]"
// sseBufInit is the initial bufio.Scanner read buffer size.
// sseBufInit 是 bufio.Scanner 的初始读缓冲大小。
sseBufInit = 64 * 1024
// maxSSELineBytes caps a single SSE event line.
// maxSSELineBytes 限制单条 SSE 事件行的长度。
maxSSELineBytes = 1024 * 1024
// errBodyCap bounds how much of an error body is read for the message.
// errBodyCap 限制为取出消息而读取的错误响应体大小。
errBodyCap = 512
// defaultHTTPTimeout bounds a single provider call when Options.Timeout is 0.
// defaultHTTPTimeout 在 Options.Timeout 为 0 时约束单次提供商调用。
defaultHTTPTimeout = 180 * time.Second
)
// OpenAIChatAdapter calls an OpenAI-compatible /chat/completions endpoint. It is
// constructed from a config.LLMConfig and implements Adapter for both
// non-streaming and token-level SSE streaming.
// OpenAIChatAdapter 调用 OpenAI 兼容的 /chat/completions 端点。它由
// config.LLMConfig 构建,同时实现非流式与 token 级 SSE 流式的
// Adapter。
type OpenAIChatAdapter struct {
name string
baseURL string
@@ -40,39 +40,39 @@ type OpenAIChatAdapter struct {
model string
client *http.Client
recorder Recorder
// reasoningKind is this endpoint's configured reasoning-switch shape
// (models.json group reasoning.kind / LLM_REASONING_KIND, 裁决 03.4.2/08.4.1).
// Empty means "auto": infer the vendor from the model name/base URL and send
// nothing when the shape is ambiguous.
// reasoningKind 是本端点已配置的推理开关形状
// (models.json 组的 reasoning.kind / LLM_REASONING_KIND,裁决 03.4.2/08.4.1)。
// 为空表示 "auto":按模型名/base URL 推断厂商,
// 形状含糊时什么都不发。
reasoningKind string
// log reports reasoning_effort validation and ignored thinking directives. It
// is never nil: a discard logger tagged with this adapter's source is
// installed when the caller supplies none, so a decision is always traceable.
// log 上报 reasoning_effort 校验与被忽略的思考指令。它
// 永不为 nil:调用方未提供时,会装入一个带有本适配器来源
// 标记的丢弃式日志器,因此每个决定都可追溯。
log *slog.Logger
}
// OpenAIOption configures an OpenAIChatAdapter.
// OpenAIOption 用于配置 OpenAIChatAdapter。
type OpenAIOption func(*OpenAIChatAdapter)
// WithHTTPClient overrides the default HTTP client (used to inject mocks or a
// tuned transport in tests and production).
// WithHTTPClient 覆盖默认 HTTP 客户端(用于在测试与生产中
// 注入 mock 或调优过的 transport)。
func WithHTTPClient(c *http.Client) OpenAIOption {
return func(a *OpenAIChatAdapter) { a.client = c }
}
// WithRecorder wires a per-call Recorder for CallRecord accounting.
// WithRecorder 装入按调用计账的 Recorder,用于 CallRecord 统计。
func WithRecorder(r Recorder) OpenAIOption {
return func(a *OpenAIChatAdapter) { a.recorder = r }
}
// WithModel overrides the adapter's default model name.
// WithModel 覆盖适配器的默认模型名。
func WithModel(m string) OpenAIOption {
return func(a *OpenAIChatAdapter) { a.model = m }
}
// WithOpenAILogger attaches the logger used to report reasoning_effort
// validation and ignored thinking directives. A nil logger is ignored so the
// discard default stays in place.
// WithOpenAILogger 挂上用于上报 reasoning_effort 校验与
// 被忽略思考指令的日志器。nil 日志器会被忽略,
// 从而保留丢弃式的默认值。
func WithOpenAILogger(l *slog.Logger) OpenAIOption {
return func(a *OpenAIChatAdapter) {
if l != nil {
@@ -81,12 +81,12 @@ func WithOpenAILogger(l *slog.Logger) OpenAIOption {
}
}
// NewOpenAIChat builds an adapter from a supplier config. baseURL is trimmed of
// any trailing slash before the /chat/completions path is appended. The
// endpoint's configured reasoning shape (cfg.ReasoningKind) travels with the
// adapter so every chain built from the same config — the group's chain, its
// per-persona chain and the local rung — resolves its reasoning switches the
// same way.
// NewOpenAIChat 由供应商配置构建适配器。baseURL 会先去掉
// 末尾斜杠,再拼接 /chat/completions 路径。
// 端点已配置的推理形状(cfg.ReasoningKind)随
// 适配器一起走,因此由同一份配置构建的每条链——组链、
// 它的按角色链以及本地级——都以
// 相同方式解出自己的推理开关。
func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapter {
a := &OpenAIChatAdapter{
name: "openai",
@@ -106,10 +106,10 @@ func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapte
return a
}
// Name returns the adapter identifier.
// Name 返回适配器标识。
func (a *OpenAIChatAdapter) Name() string { return a.name }
// Chat performs a non-streaming completion.
// Chat 执行一次非流式补全。
func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
start := time.Now()
if a.baseURL == "" {
@@ -147,8 +147,8 @@ func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options)
return out, nil
}
// ChatStream streams structured events via onEvent and returns the aggregated
// response with usage when the provider reports it in the final chunk.
// ChatStream 通过 onEvent 流出结构化事件,并在提供商于
// 末块上报用量时返回带用量的聚合响应。
func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
start := time.Now()
if a.baseURL == "" {
@@ -184,7 +184,7 @@ func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent
return out, nil
}
// withCallTimeout wraps ctx with opts.Timeout when it is positive.
// withCallTimeout 在 opts.Timeout 为正时用该超时包裹 ctx。
func withCallTimeout(parent context.Context, opts Options) (context.Context, context.CancelFunc) {
if opts.Timeout > 0 {
return context.WithTimeout(parent, opts.Timeout)
@@ -192,7 +192,7 @@ func withCallTimeout(parent context.Context, opts Options) (context.Context, con
return parent, func() {}
}
// resolveModel returns the effective model name.
// resolveModel 返回实际生效的模型名。
func (a *OpenAIChatAdapter) resolveModel(req Request) (string, error) {
if req.Model != "" {
return req.Model, nil
@@ -203,7 +203,7 @@ func (a *OpenAIChatAdapter) resolveModel(req Request) (string, error) {
return "", ErrNoModel
}
// endpoint returns the full /chat/completions URL.
// endpoint 返回完整的 /chat/completions URL。
func (a *OpenAIChatAdapter) endpoint() string {
return a.baseURL + "/chat/completions"
}
+48 -48
View File
@@ -5,8 +5,8 @@ import (
"fmt"
)
// openAIChatRequest is the JSON body sent to /chat/completions. Field names
// match the provider protocol so the struct marshals directly.
// openAIChatRequest 是发给 /chat/completions 的 JSON 请求体。字段名
// 与提供商协议一致,因此该结构体可以直接序列化。
type openAIChatRequest struct {
Model string `json:"model"`
Messages []openAIMessage `json:"messages"`
@@ -15,49 +15,49 @@ type openAIChatRequest struct {
Stream bool `json:"stream,omitempty"`
StreamOptions *openAIStreamOpts `json:"stream_options,omitempty"`
Tools []openAITool `json:"tools,omitempty"`
// Thinking is DeepSeek's top-level thinking switch. It is sent only for
// DeepSeek and only from an explicit switch (or an explicit off that must be
// honoured): leaving it out keeps the provider default, which is ENABLED, so
// an omitted field is not a way to switch thinking off (design/04 §3.2
// fact 2).
// Thinking 是 DeepSeek 的顶层思考开关。它只对
// DeepSeek 发送,且只在有显式开关(或必须被
// 尊重的显式关闭)时发送:省略它会保留提供商默认值,而该默认值
// 是「开启」,所以省略字段并不是关闭思考的办法(design/04 §3.2
// 事实 2)。
Thinking *openAIThinking `json:"thinking,omitempty"`
// ReasoningEffort is the OpenAI-compatible reasoning strength knob. It is set
// only after validation against the detected vendor's whitelist (design/04
// §3.2); an unrecognized value is dropped, never turned into a different tier.
// ReasoningEffort 是 OpenAI 兼容的推理强度旋钮。它只在
// 对已识别厂商的白名单校验通过后才设置(design/04
// §3.2);无法识别的值会被丢弃,绝不会被换成别的档位。
ReasoningEffort string `json:"reasoning_effort,omitempty"`
// ChatTemplateKwargs carries the vLLM-style thinking switch
// (chat_template_kwargs.enable_thinking, design/04 §3.2 Qwen3 行). It is sent
// only to an endpoint whose provider declared reasoning.kind=vllm.
// ChatTemplateKwargs 承载 vLLM 风格的思考开关
// (chat_template_kwargs.enable_thinking,design/04 §3.2 Qwen3 行)。它只
// 发送给提供商声明了 reasoning.kind=vllm 的端点。
ChatTemplateKwargs *openAIChatTemplateKwargs `json:"chat_template_kwargs,omitempty"`
// EnableThinking carries the DashScope compatible-mode thinking switch (the
// top-level enable_thinking). It is sent only to an endpoint whose provider
// declared reasoning.kind=dashscope — the shape is never inferred, because a
// wrong shape 400s the request.
// EnableThinking 承载 DashScope 兼容模式的思考开关(
// 顶层 enable_thinking)。它只发送给提供商
// 声明了 reasoning.kind=dashscope 的端点——该形状永不推断,因为
// 形状错了会让请求 400。
EnableThinking *bool `json:"enable_thinking,omitempty"`
}
// openAIThinking is the wire form of DeepSeek's thinking switch: enabled or
// disabled. It is only ever sent to a DeepSeek endpoint.
// openAIThinking 是 DeepSeek 思考开关的线格式:enabled 或
// disabled。它只会发给 DeepSeek 端点。
type openAIThinking struct {
Type string `json:"type"`
}
// openAIChatTemplateKwargs is the wire form of the vLLM (Qwen3) thinking switch:
// the boolean lives nested under chat_template_kwargs.
// openAIChatTemplateKwargs 是 vLLM(Qwen3)思考开关的线格式:
// 该布尔值嵌在 chat_template_kwargs 之下。
type openAIChatTemplateKwargs struct {
EnableThinking *bool `json:"enable_thinking,omitempty"`
}
// openAIStreamOpts asks the provider to include usage in the final stream chunk.
// openAIStreamOpts 请求提供商在最后一个流块里带上用量。
type openAIStreamOpts struct {
IncludeUsage bool `json:"include_usage"`
}
// openAIMessage is one request message in the wire body. Content is either a
// plain string (text-only) or an array of {type,text|image_url} parts. Tool
// messages carry ToolCallID (required by OpenAI); assistant messages that
// requested functions carry ToolCalls so the following tool messages can be
// correlated.
// openAIMessage 是线格式请求体里的一条消息。Content 要么是
// 纯字符串(仅文本),要么是 {type,text|image_url} 片段数组。Tool
// 消息携带 ToolCallID(OpenAI 要求);请求过函数的助手消息
// 携带 ToolCalls,以便随后的 tool 消息能够
// 被关联。
type openAIMessage struct {
Role string `json:"role"`
Content any `json:"content"`
@@ -65,28 +65,28 @@ type openAIMessage struct {
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
}
// openAIToolCall is the wire form of one assistant tool_call in a request. OpenAI
// requires each subsequent tool message to reference the id back to these calls.
// openAIToolCall 是请求里单个助手 tool_call 的线格式。OpenAI
// 要求每条后续 tool 消息都把这些调用的 id 引用回来。
type openAIToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function openAIToolCallFn `json:"function"`
}
// openAITool is the wire form of a function tool schema (M2 placeholder).
// openAITool 是函数工具 schema 的线格式(M2 占位)。
type openAITool struct {
Type string `json:"type"`
Function openAIToolFn `json:"function"`
}
// openAIToolFn carries the function definition.
// openAIToolFn 承载函数定义。
type openAIToolFn struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters any `json:"parameters,omitempty"`
}
// openAIToolCallFn is the function part of a streaming tool-call fragment.
// openAIToolCallFn 是流式工具调用片段的函数部分。
type openAIToolCallFn struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
@@ -97,12 +97,12 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro
if err != nil {
return nil, err
}
// The vendor behind an OpenAI-compatible endpoint decides which
// reasoning_effort tiers are legal and whether it has a thinking switch;
// classify it from the resolved model (and the base URL as a fallback) before
// touching the request. The endpoint's configured shape (models.json
// reasoning.kind, 裁决 03.4.2/08.4.1) wins over that classification, because
// the same wire protocol carries different switch shapes (vLLM vs DashScope).
// OpenAI 兼容端点背后的厂商决定哪些
// reasoning_effort 档位合法、以及它是否有思考开关;
// 在改动请求之前,先按已解析出的模型(并以 base URL 作降级)
// 分类。端点已声明的形状(models.json
// 的 reasoning.kind,裁决 03.4.2/08.4.1)优先于该分类,因为
// 同一线协议承载着不同的开关形状(vLLM 对 DashScope)。
vendor := reasoningVendorFor(req.Model, a.baseURL)
plan := resolveOpenAIReasoning(a.log, a.reasoningKind, vendor, req.Model, req.Thinking, req.ReasoningEffort)
reportForcedToolChoiceWithThinking(a.log, plan.provider, req.Model, req.ToolChoice, plan.thinkingOn())
@@ -140,10 +140,10 @@ func buildMessages(msgs []Message) ([]openAIMessage, error) {
return out, nil
}
// encodeOpenAIMessage renders one normalized message in OpenAI wire form. A
// RoleTool turn becomes a tool message carrying tool_call_id (required by OpenAI,
// and the audit's gap); an assistant turn that advertised tool calls carries the
// corresponding tool_calls array so the provider can correlate the tool messages.
// encodeOpenAIMessage 把一条归一化消息渲染成 OpenAI 线格式。
// RoleTool 回合变成携带 tool_call_id 的 tool 消息(OpenAI 要求,
// 也是审计指出的缺口);声明过工具调用的助手回合携带
// 对应的 tool_calls 数组,好让提供商能关联 tool 消息。
func encodeOpenAIMessage(m Message) (openAIMessage, error) {
if m.Role == RoleTool {
return openAIMessage{
@@ -159,8 +159,8 @@ func encodeOpenAIMessage(m Message) (openAIMessage, error) {
return openAIMessage{Role: string(m.Role), Content: content, ToolCalls: encodeToolCalls(m.Content)}, nil
}
// toolMessageCallID resolves the tool_call_id for a RoleTool turn: the
// message-level id, else the id carried on the first tool-result part.
// toolMessageCallID 解出 RoleTool 回合的 tool_call_id:
// 先是消息级 id,否则取第一个工具结果片段上的 id。
func toolMessageCallID(m Message) string {
if m.ToolCallID != "" {
return m.ToolCallID
@@ -173,8 +173,8 @@ func toolMessageCallID(m Message) string {
return ""
}
// encodeToolCalls builds the assistant tool_calls array from a turn's tool-call
// parts. Arguments are serialized to JSON because OpenAI expects a string.
// encodeToolCalls 由回合的工具调用片段构建助手的 tool_calls
// 数组。参数序列化为 JSON,因为 OpenAI 期望字符串。
func encodeToolCalls(parts []Content) []openAIToolCall {
calls := make([]openAIToolCall, 0, len(parts))
for _, p := range parts {
@@ -211,8 +211,8 @@ func encodeContent(parts []Content) (any, error) {
"image_url": map[string]any{"url": p.ImageURL},
})
case ContentTypeToolCall, ContentTypeToolResult:
// Tool-call and tool-result parts are encoded at the message level
// (tool_calls array / tool_call_id), never as text-image content.
// 工具调用与工具结果片段在消息级编码
// (tool_calls 数组 / tool_call_id),绝不作为文本-图片内容。
continue
default:
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
+12 -12
View File
@@ -4,8 +4,8 @@ import (
"encoding/json"
)
// openAIChatResponse is the JSON body returned by /chat/completions, for both
// the non-streaming payload and each SSE stream chunk.
// openAIChatResponse 是 /chat/completions 返回的 JSON 体,既用于
// 非流式负载,也用于每个 SSE 流块。
type openAIChatResponse struct {
ID string `json:"id"`
Model string `json:"model"`
@@ -13,7 +13,7 @@ type openAIChatResponse struct {
Usage *openAIUsage `json:"usage,omitempty"`
}
// openAIChoice is one candidate completion.
// openAIChoice 是一个候选补全。
type openAIChoice struct {
Index int `json:"index"`
Message openAIResponseMessage `json:"message"`
@@ -21,16 +21,16 @@ type openAIChoice struct {
FinishReason string `json:"finish_reason,omitempty"`
}
// openAIResponseMessage is the assistant message inside a non-streaming choice.
// openAIResponseMessage 是非流式 choice 里的助手消息。
type openAIResponseMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
}
// openAIDelta is the incremental payload inside a stream chunk. Reasoning content
// (OpenAI o-series / DeepSeek / DashScope) arrives in ReasoningContent; streaming
// function calls arrive in ToolCalls, accumulated per fragment index.
// openAIDelta 是流块里的增量负载。推理内容
// (OpenAI o 系 / DeepSeek / DashScope)到达 ReasoningContent;流式
// 函数调用到达 ToolCalls,按片段索引累积。
type openAIDelta struct {
Role string `json:"role"`
Content string `json:"content"`
@@ -38,17 +38,17 @@ type openAIDelta struct {
ToolCalls []openAIToolCallDelta `json:"tool_calls"`
}
// openAIToolCallDelta is one streaming function-call fragment. OpenAI emits a
// sequence of these sharing the same Index: the ID and Function.Name usually
// arrive once (in the first fragment) while Function.Arguments streams across
// fragments.
// openAIToolCallDelta 是一个流式函数调用片段。OpenAI 会发出一串
// 共享同一 Index 的此类片段:ID 与 Function.Name 通常
// 只在第一个片段里出现一次,而 Function.Arguments 跨
// 多个片段流式到达。
type openAIToolCallDelta struct {
Index int `json:"index"`
ID string `json:"id"`
Function openAIToolCallFn `json:"function"`
}
// openAIUsage reports token consumption.
// openAIUsage 上报 token 消耗。
type openAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
+43 -43
View File
@@ -8,9 +8,9 @@ import (
"strings"
)
// openAIToolAcc accumulates the partial state of one streaming tool call (keyed
// by the provider's tool_calls[].index) so its arguments re-assemble across the
// delta fragments. started guards a single ToolCallStart emission per block.
// openAIToolAcc 累积单个流式工具调用的部分状态(按
// 提供商的 tool_calls[].index 作键),以便其参数跨
// 增量片段重新拼装。started 保证每块只发出一次 ToolCallStart。
type openAIToolAcc struct {
id string
name string
@@ -18,8 +18,8 @@ type openAIToolAcc struct {
started bool
}
// idOrName resolves the block identifier used to correlate start/delta/end: the
// provider tool-call id when present, else the function name.
// idOrName 解出用于关联 start/delta/end 的块标识:
// 有提供商工具调用 id 时用它,否则用函数名。
func (a *openAIToolAcc) idOrName() string {
if a.id != "" {
return a.id
@@ -27,12 +27,12 @@ func (a *openAIToolAcc) idOrName() string {
return a.name
}
// openAIStreamState is the running aggregation of one streamed
// /chat/completions response: the assembled Response, the bracketing flags of the
// currently open segment, and the per-index tool-call accumulators. It is the
// OpenAI counterpart of anthropic's streamBlock bookkeeping, so both adapters
// share one parse → apply → finalize shape. emit is the nil-tolerant event sink
// (a nil onEvent simply drops every event).
// openAIStreamState 是一次流式 /chat/completions 响应的
// 运行中聚合状态:拼装好的 Response、当前打开片段的
// 包裹标志,以及按索引的工具调用累加器。它是
// Anthropic 的 streamBlock 记账的对位实现,因此两个适配器
// 共用同一套「解析 → 应用 → 收尾」形态。emit 是容忍 nil 的事件槽
// (onEvent 为 nil 时只是丢弃每个事件)。
type openAIStreamState struct {
agg Response
text strings.Builder
@@ -43,13 +43,13 @@ type openAIStreamState struct {
onEvent func(StreamEvent)
}
// scanSSE incrementally decodes stream chunks, accumulates the full body text
// with a strings.Builder (never reading the whole body at once) and captures
// usage from the final chunk. It splits each delta into its segment and brackets
// them: reasoning_content as thinking_start/delta/end, tool_calls fragments as
// tool_call_start/delta/end, and body text as text_start/delta/end. The
// accumulated tool-call arguments re-assemble across fragments into a structured
// Args map, and are attached to the returned Response alongside the text.
// scanSSE 增量解码流块,用 strings.Builder 累积完整正文
// 文本(绝不一次性读完整个响应体),并从最后一个块
// 捕获用量。它把每个增量拆到其片段并按对包裹:
// reasoning_content 作为 thinking_start/delta/end,tool_calls 片段作为
// tool_call_start/delta/end,正文文本作为 text_start/delta/end。
// 累积的工具调用参数会跨片段重新拼装成结构化的
// Args map,并与文本一起挂到返回的 Response 上。
func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, sseBufInit), maxSSELineBytes)
@@ -76,16 +76,16 @@ func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
return finalizeOpenAIStream(st), nil
}
// emit forwards one structured event, tolerating a nil sink.
// emit 转发一个结构化事件,容忍 nil 事件槽。
func (st *openAIStreamState) emit(ev StreamEvent) {
if st.onEvent != nil {
st.onEvent(ev)
}
}
// applyChunk folds a single decoded stream chunk into the running aggregation:
// usage and stop reason are recorded even on a choice-less terminal chunk, and
// the segment delta (if any) is routed to its handler.
// applyChunk 把单个已解码的流块折叠进运行中的聚合状态:
// 用量与停止原因即使在无 choice 的终止块上也会被记录,
// 片段增量(若有)则被路由到其处理器。
func (st *openAIStreamState) applyChunk(chunk openAIChatResponse) {
if u := chunk.Usage; u != nil {
st.agg.PromptTokens = u.PromptTokens
@@ -104,9 +104,9 @@ func (st *openAIStreamState) applyChunk(chunk openAIChatResponse) {
st.applyDelta(chunk.Choices[0].Delta)
}
// applyDelta routes one delta fragment to its segment handler. The precedence is
// the provider's: a reasoning fragment wins over a tool-call fragment, which wins
// over body text, so a delta never contributes to two segments at once.
// applyDelta 把一个增量片段路由到其片段处理器。优先级
// 取自提供商:推理片段压过工具调用片段,后者又压过
// 正文文本,因此一个增量绝不会同时进入两个片段。
func (st *openAIStreamState) applyDelta(delta *openAIDelta) {
switch {
case delta.ReasoningContent != "":
@@ -122,8 +122,8 @@ func (st *openAIStreamState) applyDelta(delta *openAIDelta) {
}
}
// applyThinking forwards one reasoning fragment, opening the thinking segment on
// the first fragment only.
// applyThinking 转发一个推理片段,只在第一个
// 片段上打开 thinking 片段。
func (st *openAIStreamState) applyThinking(fragment string) {
if !st.thinkingStarted {
st.thinkingStarted = true
@@ -132,8 +132,8 @@ func (st *openAIStreamState) applyThinking(fragment string) {
st.emit(StreamEvent{Kind: StreamKindThinkingDelta, Delta: fragment})
}
// endThinking closes the open thinking segment (no-op when none is open), so a
// following tool-call or text segment starts a fresh one.
// endThinking 关闭已打开的 thinking 片段(未打开时是空操作),
// 这样随后的工具调用或文本片段会开一个新的。
func (st *openAIStreamState) endThinking() {
if !st.thinkingStarted {
return
@@ -142,9 +142,9 @@ func (st *openAIStreamState) endThinking() {
st.emit(StreamEvent{Kind: StreamKindThinkingEnd})
}
// applyToolCall folds one streaming tool-call fragment into its per-index
// accumulator, opening the block's start event once its id/name is known and
// forwarding the argument fragments as they arrive.
// applyToolCall 把一个流式工具调用片段折叠进它按索引的
// 累加器,在 id/name 已知时打开该块的 start 事件,
// 并随着参数片段到达而转发它们。
func (st *openAIStreamState) applyToolCall(tc openAIToolCallDelta) {
acc := st.toolCalls[tc.Index]
if acc == nil {
@@ -169,8 +169,8 @@ func (st *openAIStreamState) applyToolCall(tc openAIToolCallDelta) {
st.emit(StreamEvent{Kind: StreamKindToolCallDelta, Index: tc.Index, ID: acc.idOrName(), Delta: tc.Function.Arguments})
}
// applyText appends one body-text fragment, opening the text segment on the first
// fragment only.
// applyText 追加一个正文文本片段,只在第一个
// 片段上打开文本片段。
func (st *openAIStreamState) applyText(fragment string) {
st.text.WriteString(fragment)
if !st.textStarted {
@@ -180,9 +180,9 @@ func (st *openAIStreamState) applyText(fragment string) {
st.emit(StreamEvent{Kind: StreamKindTextDelta, Delta: fragment})
}
// closeSegments emits the end event of every segment still open at stream
// termination. OpenAI has no per-block stop event, so each started tool_call
// block is closed here.
// closeSegments 在流终止时发出每个仍打开片段的结束
// 事件。OpenAI 没有按块的停止事件,所以每个已开始的 tool_call
// 块都在此关闭。
func (st *openAIStreamState) closeSegments() {
if st.thinkingStarted {
st.emit(StreamEvent{Kind: StreamKindThinkingEnd})
@@ -199,9 +199,9 @@ func (st *openAIStreamState) closeSegments() {
}
}
// assembleToolCalls decodes each accumulated tool-call argument buffer into its
// structured Args map, in provider order. Unparseable fragments degrade to an
// empty map (the call is still surfaced).
// assembleToolCalls 按提供商顺序把每个累积的工具调用参数缓冲
// 解码成结构化的 Args map。无法解析的片段降级为
// 空 map(该调用仍会浮出)。
func (st *openAIStreamState) assembleToolCalls() {
for _, idx := range st.toolOrder {
acc := st.toolCalls[idx]
@@ -218,9 +218,9 @@ func (st *openAIStreamState) assembleToolCalls() {
}
}
// finalizeOpenAIStream closes the open segments and assembles the aggregated
// Response: the concatenated body text (reasoning never leaks into it) plus the
// re-assembled tool calls.
// finalizeOpenAIStream 关闭打开的片段并拼装聚合后的
// Response:拼接的正文文本(推理永不泄漏进去)加上
// 重新拼装的工具调用。
func finalizeOpenAIStream(st *openAIStreamState) Response {
st.closeSegments()
st.assembleToolCalls()
+6 -6
View File
@@ -23,7 +23,7 @@ const okCompletion = `{
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}
}`
// capturedRequest records what an OpenAIChatAdapter sent to a stub server.
// capturedRequest 记录 OpenAIChatAdapter 发往 stub 服务器的内容。
type capturedRequest struct {
method string
path string
@@ -374,8 +374,8 @@ func TestOpenAIChat_Chat_ToolRoundTrip(t *testing.T) {
t.Fatalf("body.messages = %v, want 3 messages", got.body["messages"])
}
// The assistant turn must carry tool_calls (so the tool message can be
// correlated by the provider) — this was previously dropped entirely.
// 助手回合必须携带 tool_calls(好让 tool 消息能被
// 提供商关联)——这一点此前被完全丢弃。
assistant, _ := msgs[1].(map[string]any)
if assistant["role"] != "assistant" {
t.Errorf("assistant role = %v, want assistant", assistant["role"])
@@ -393,7 +393,7 @@ func TestOpenAIChat_Chat_ToolRoundTrip(t *testing.T) {
t.Errorf("function = %#v, want name=weather arguments={\"city\":\"Paris\"}", fn)
}
// The tool message must carry tool_call_id (the audit's gap) and the result.
// tool 消息必须携带 tool_call_id(审计指出的缺口)与结果。
tool, _ := msgs[2].(map[string]any)
if tool["role"] != "tool" {
t.Errorf("tool role = %v, want tool", tool["role"])
@@ -408,8 +408,8 @@ func TestOpenAIChat_Chat_ToolRoundTrip(t *testing.T) {
func TestOpenAIChat_Build_ToolMessageLegacy(t *testing.T) {
t.Parallel()
// The loop's current form: a RoleTool message carrying a plain text part and
// Message.ToolCallID. buildMessages must emit a tool message with tool_call_id.
// loop 当前的形态:一个携带纯文本片段与
// Message.ToolCallID 的 RoleTool 消息。buildMessages 必须发出带 tool_call_id 的 tool 消息。
m := Message{Role: RoleTool, ToolCallID: "call_9",
Content: []Content{{Type: ContentTypeText, Text: "tool weather result: 22"}}}
msgs, err := buildMessages([]Message{m})
+19 -19
View File
@@ -89,10 +89,10 @@ func TestRuntimeLLM_PersonaProviderSelectsTheOtherProtocol(t *testing.T) {
}
}
// TestRuntimeLLM_PersonaProviderAppliesToStreamingToo asserts the streaming path
// (the main chat path: loop.streamStep → adapter.ChatStream) resolves the same
// persona chain as the non-streaming one — a persona route that only worked on
// one of the two paths would be a silent half-fix.
// TestRuntimeLLM_PersonaProviderAppliesToStreamingToo 断言流式路径
// (主聊天路径:loop.streamStep → adapter.ChatStream)解析出与非流式
// 相同的那条 persona 链——只在两条路径之一生效的 persona
// 路由只能算一次静默的半修。
func TestRuntimeLLM_PersonaProviderAppliesToStreamingToo(t *testing.T) {
t.Parallel()
anthropicCh := make(chan anthropicCaptured, 1)
@@ -123,9 +123,9 @@ func TestRuntimeLLM_PersonaProviderAppliesToStreamingToo(t *testing.T) {
}
}
// TestPersonaStack_StampsPersonaOnEveryRequest pins the carrier that lets a
// per-persona agent wire the route without the loop handing over its ctx: the
// scoped adapter view stamps PersonaID and routes the model by that persona.
// TestPersonaStack_StampsPersonaOnEveryRequest 钉住那个载体:它让
// 按 persona 的 agent 能在 loop 不交出自身 ctx 的情况下接好路由,
// 即作用域化的适配器视图打上 PersonaID 并按该 persona 路由模型。
func TestPersonaStack_StampsPersonaOnEveryRequest(t *testing.T) {
t.Parallel()
f := newPersonaProviderFixture(t, nil)
@@ -151,8 +151,8 @@ func TestPersonaStack_StampsPersonaOnEveryRequest(t *testing.T) {
}
}
// TestPersonaStack_BlankPersonaKeepsDefaultRoute asserts an unbound view is a
// pass-through: an empty persona must not turn into a binding of its own.
// TestPersonaStack_BlankPersonaKeepsDefaultRoute 断言未绑定的视图是
// 直通:空的 persona 不得变成它自己的一个绑定。
func TestPersonaStack_BlankPersonaKeepsDefaultRoute(t *testing.T) {
t.Parallel()
f := newPersonaProviderFixture(t, nil)
@@ -177,10 +177,10 @@ func TestPersonaStack_BlankPersonaKeepsDefaultRoute(t *testing.T) {
}
}
// TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain covers the
// half-declared config: a personaProviders entry alone says nothing about which
// model to send, so switching protocol would push the group's default model name
// to another vendor's endpoint. The default chain is kept and the reason logged.
// TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain 覆盖
// 半声明的配置:只有一条 personaProviders 条目并不能说明该发
// 哪个模型,所以切换协议会把组的默认模型名推到
// 另一厂商的端点。此时保留默认链并记录原因。
func TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain(t *testing.T) {
t.Parallel()
f := newPersonaProviderFixture(t, func(g *runtimeconfig.Group) {
@@ -200,9 +200,9 @@ func TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain(t *testing.T
}
}
// TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain covers a declared
// protocol that models.json has no group for: the persona keeps the default chain
// instead of failing the turn, and the skip is reported.
// TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain 覆盖已声明
// 但 models.json 没有对应组的协议:该 persona 保留默认链
// 而不是让回合失败,并上报这次跳过。
func TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain(t *testing.T) {
t.Parallel()
openaiCh := make(chan capturedRequest, 1)
@@ -231,9 +231,9 @@ func TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain(t *testing.T)
}
}
// TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain asserts a persona
// naming the active group's own protocol builds no second chain: the default one
// already speaks it, and the per-persona model name reaches it via the router.
// TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain 断言声明了
// 生效组自身协议的 persona 不会构建第二条链:默认链
// 本就说那个协议,按 persona 的模型名经路由器到达它。
func TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain(t *testing.T) {
t.Parallel()
f := newPersonaProviderFixture(t, func(g *runtimeconfig.Group) {
+14 -14
View File
@@ -6,9 +6,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// TestRouter_RouteRequestForPersona pins 裁决 03.4.1: the "role" of per-role
// routing is the persona a turn answers as, and a persona without a binding keeps
// the message-role behavior.
// TestRouter_RouteRequestForPersona 钉住裁决 03.4.1:按角色路由里的
// 「角色」是本回合作答所依据的 persona,而未绑定的 persona 保持
// 消息角色行为。
func TestRouter_RouteRequestForPersona(t *testing.T) {
t.Parallel()
r := NewRouter("default",
@@ -39,10 +39,10 @@ func TestRouter_RouteRequestForPersona(t *testing.T) {
}
}
// TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch pins two edges: a
// blank persona/model cannot create a binding, and a persona-routed image request
// is not silently re-routed to a vision model (that decision belongs to
// vision.Router, 裁决 14.4.1/14.4.5).
// TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch 钉住两个边界:空
// persona/模型无法创建绑定,而按 persona 路由的图像请求
// 不会被静默改道到视觉模型(那个决策属于
// vision.Router,裁决 14.4.1/14.4.5)。
func TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch(t *testing.T) {
t.Parallel()
r := NewRouter("default",
@@ -63,9 +63,9 @@ func TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch(t *testing.T) {
}
}
// TestRuntimeLLM_PersonaRoutesFromModelsJSON pins the carrier: a per-persona
// binding written on the models.json group reaches the router through the hot
// snapshot, and a reload picks up a changed binding (裁决 03.4.1 载体 = models.json)。
// TestRuntimeLLM_PersonaRoutesFromModelsJSON 钉住载体:写在
// models.json 组上的按 persona 绑定经热
// 快照到达路由器,且一次重载会拾取改过的绑定(裁决 03.4.1 载体 = models.json)。
func TestRuntimeLLM_PersonaRoutesFromModelsJSON(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{{
ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model",
@@ -96,10 +96,10 @@ func TestRuntimeLLM_PersonaRoutesFromModelsJSON(t *testing.T) {
}
}
// TestRuntimeLLM_NoVisionPreemptionOnImage pins that the runtime snapshot no longer
// carries the env vision model: a configured LLM_VISION_MODEL must not switch an
// image-bearing request to another model, because image routing is owned by
// vision.Router (裁决 14.4.1/14.4.5).
// TestRuntimeLLM_NoVisionPreemptionOnImage 钉住运行时快照不再
// 携带 env 视觉模型:已配置的 LLM_VISION_MODEL 不得把
// 携带图片的请求切到另一个模型,因为图像路由归
// vision.Router 所有(裁决 14.4.1/14.4.5)。
func TestRuntimeLLM_NoVisionPreemptionOnImage(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{{
ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model",
+59 -59
View File
@@ -8,32 +8,32 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// discardLogger returns a logger that drops every record but still carries the
// adapter's module source, so an adapter built without an injected logger can
// never emit an unsourced record.
// discardLogger 返回一个丢弃所有记录的日志器,但仍带有
// 适配器的模块来源,因此未注入日志器而构建的适配器
// 绝不会产生无来源记录。
func discardLogger(source string) *slog.Logger {
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), source)
}
// Reasoning wire values shared by the OpenAI-compatible and Anthropic adapters
// (design/04 §3.2, checked against the provider documents on 2026-10-03).
// OpenAI 兼容与 Anthropic 适配器共用的推理线取值
// (design/04 §3.2,已于 2026-10-03 对照各提供商文档核对)。
const (
// thinkingTypeEnabled and thinkingTypeDisabled are the values of the
// {"thinking":{"type":...}} switch. DeepSeek's default is enabled, so omitting
// the field leaves thinking ON — "do not intervene" is not a way to switch it
// off; only an explicit disabled (or reasoning_effort "none") is.
// thinkingTypeEnabled 与 thinkingTypeDisabled 是
// {"thinking":{"type":...}} 开关的取值。DeepSeek 默认开启,所以省略
// 该字段会让思考保持开启——「不干预」不是关闭它的办法;
// 只有显式 disabled(或 reasoning_effort "none")才是。
thinkingTypeEnabled = "enabled"
thinkingTypeDisabled = "disabled"
// effortNone is the reasoning_effort value that switches thinking off. It is a
// switch, not a strength tier, so it must never be replaced by a tier: mapping
// it onto "low" would invert the caller's intent (thinking off → thinking on).
// effortNone 是关闭思考的 reasoning_effort 取值。它是
// 开关而非强度档位,所以绝不能被替换成某个档位:把它
// 映射到 "low" 会颠倒调用方的意图(关闭思考 → 开启思考)。
effortNone = "none"
)
// reasoningVendor identifies the vendor behind an OpenAI-compatible endpoint.
// The wire protocol is the same for all of them, but the accepted
// reasoning_effort values are not, so a value must be checked against the right
// whitelist instead of being forwarded blindly.
// reasoningVendor 标识 OpenAI 兼容端点背后的厂商。
// 它们的线协议相同,但可接受的
// reasoning_effort 取值不同,所以必须按正确的
// 白名单校验,而不是盲目转发。
type reasoningVendor string
const (
@@ -43,10 +43,10 @@ const (
vendorUnknown reasoningVendor = "unknown"
)
// reasoningEffortAliases is DeepSeek's documented compatibility mapping: the
// service accepts these aliases and applies the mapped tier. The mapping is
// applied on our side too, so the request body always carries the tier the
// provider documents as the effective one (design/04 §3.2 fact 1).
// reasoningEffortAliases 是 DeepSeek 记录的兼容映射:
// 服务接受这些别名并采用映射后的档位。我们在自己这一侧
// 也做同一映射,因此请求体总是携带提供商
// 记录为有效值的那一档(design/04 §3.2 事实 1)。
var reasoningEffortAliases = map[string]string{
"minimal": "low",
"medium": "high",
@@ -54,20 +54,20 @@ var reasoningEffortAliases = map[string]string{
"ultra": "max",
}
// reasoningEffortAllowed is the per-vendor set of accepted reasoning_effort
// values. A vendor absent from this table has no reasoning_effort support at all,
// which is reported rather than guessed.
// reasoningEffortAllowed 是按厂商划分的可接受 reasoning_effort
// 取值集合。不在本表里的厂商完全没有 reasoning_effort 支持,
// 这一点会被上报,而不是靠猜。
//
// There is deliberately no "round down to the nearest tier" fallback: an
// unsupported value is dropped and reported instead, because silently changing it
// can invert the caller's intent — the original bug mapped DeepSeek's "none"
// (thinking off) onto "low" (thinking on) exactly that way.
// 这里刻意没有「向下取到最近档位」的降级:不支持的取值
// 会被丢弃并上报,因为静默改写它可能会颠倒调用方的
// 意图——早先的 bug 正是这样把 DeepSeek 的 "none"(关闭思考)
// 映射到了 "low"(开启思考)。
var reasoningEffortAllowed = map[reasoningVendor]map[string]bool{
vendorOpenAI: tierSet("none", "minimal", "low", "medium", "high", "xhigh", "max"),
vendorDeepSeek: tierSet("none", "low", "high", "max"),
}
// tierSet builds a membership set from the given tiers.
// tierSet 由给定档位构建一个成员集合。
func tierSet(tiers ...string) map[string]bool {
set := make(map[string]bool, len(tiers))
for _, t := range tiers {
@@ -76,12 +76,12 @@ func tierSet(tiers ...string) map[string]bool {
return set
}
// reasoningVendorFor classifies the vendor of an OpenAI-compatible request from
// its model name, falling back to the base URL when the name does not identify
// one. An unrecognized pair yields vendorUnknown so the caller reports "not
// supported" instead of assuming OpenAI semantics. The Anthropic adapter uses the
// same classification to tell a DeepSeek /anthropic endpoint (which ignores
// budget_tokens) from the Anthropic endpoint.
// reasoningVendorFor 按模型名分类 OpenAI 兼容请求的厂商,
// 模型名无法识别出厂商时退回到 base URL。
// 无法识别的组合得到 vendorUnknown,好让调用方上报「不
// 支持」而不是假定 OpenAI 语义。Anthropic 适配器用
// 同一套分类来区分 DeepSeek 的 /anthropic 端点(它忽略
// budget_tokens)与 Anthropic 端点。
func reasoningVendorFor(model, baseURL string) reasoningVendor {
m := strings.ToLower(strings.TrimSpace(model))
switch {
@@ -105,7 +105,7 @@ func reasoningVendorFor(model, baseURL string) reasoningVendor {
return vendorUnknown
}
// thinkingSwitchValue maps an explicit on/off switch onto its wire value.
// thinkingSwitchValue 把显式开/关开关映射到它的线取值。
func thinkingSwitchValue(enable bool) string {
if enable {
return thinkingTypeEnabled
@@ -113,10 +113,10 @@ func thinkingSwitchValue(enable bool) string {
return thinkingTypeDisabled
}
// normalizeDeepSeekEffort maps an effort onto the tier DeepSeek applies and
// reports whether the value is one the service documents: the documented set
// (none/low/high/max) passes through, the compatibility aliases map to their
// official tier.
// normalizeDeepSeekEffort 把 effort 映射到 DeepSeek 实际采用的档位,
// 并报告该值是否为服务记录的取值:记录在案的集合
// (none/low/high/max)原样通过,兼容别名映射到其
// 官方档位。
func normalizeDeepSeekEffort(effort string) (string, bool) {
if tier, ok := reasoningEffortAliases[effort]; ok {
return tier, true
@@ -127,16 +127,16 @@ func normalizeDeepSeekEffort(effort string) (string, bool) {
return "", false
}
// resolveReasoningEffort validates effort against the vendor's whitelist and
// returns the value to send; "" drops the field. An unrecognized value is never
// turned into a different tier — it is dropped and reported with provider, field,
// value and model, because changing it can invert the caller's intent. A DeepSeek
// compatibility alias is normalized to the tier the service applies and reported.
// resolveReasoningEffort 针对厂商白名单校验 effort 并
// 返回要发送的值;"" 表示丢弃该字段。无法识别的值绝不会
// 被换成别的档位——它会被丢弃,并把 provider、field、
// value 与 model 一并上报,因为改动它会颠倒调用方的意图。DeepSeek
// 的兼容别名会被归一化为服务采用的档位并上报。
//
// vendor must be a vendor with a reasoning_effort whitelist (DeepSeek / OpenAI):
// the shapes that carry no such field (vLLM / DashScope) and the endpoints whose
// shape is unknown drop the effort themselves and report it, so their callers
// never reach this function (see reasoning_shape.go, 裁决 03.4.2).
// vendor 必须是带 reasoning_effort 白名单的厂商(DeepSeek / OpenAI):
// 不带该字段的形状(vLLM / DashScope)以及形状未知的
// 端点会自行丢弃 effort 并上报,因此它们的调用方
// 永远不会走到本函数(见 reasoning_shape.go,裁决 03.4.2)。
func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, model string) string {
effort = strings.TrimSpace(effort)
if effort == "" {
@@ -159,8 +159,8 @@ func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, mo
return ""
}
// toolChoiceForces reports whether a tool_choice directive obliges the model to
// call a tool ("required", Anthropic's "any", or a named tool). auto/none do not.
// toolChoiceForces 报告 tool_choice 指令是否强制模型
// 调用工具("required"、Anthropic 的 "any",或具名工具)。auto/none 不会。
func toolChoiceForces(toolChoice any) bool {
switch v := toolChoice.(type) {
case nil:
@@ -183,7 +183,7 @@ func toolChoiceForces(toolChoice any) bool {
return false
}
// forcedToolChoiceType reports whether a tool_choice "type" value forces a call.
// forcedToolChoiceType 报告 tool_choice 的 "type" 取值是否强制调用。
func forcedToolChoiceType(value any) bool {
t, _ := value.(string)
switch strings.ToLower(strings.TrimSpace(t)) {
@@ -193,14 +193,14 @@ func forcedToolChoiceType(value any) bool {
return false
}
// reportForcedToolChoiceWithThinking warns about the DeepSeek red line that a
// forced tool call — tool_choice "required"/"any"/a named tool — and thinking are
// not compatible: while thinking is on (its default) the API rejects the forcing
// directive with a 400, so the caller must switch thinking off explicitly first
// (design/04 §3.2 fact 5). It only reports: resolution must never switch thinking
// off on its own. On the OpenAI-compatible adapter the directive is not on the
// wire yet (M2), so the warning is prospective there — it still records that the
// requested combination can never be honoured.
// reportForcedToolChoiceWithThinking 就 DeepSeek 红线报警:
// 强制型工具调用——tool_choice "required"/"any"/具名工具——与思考
// 不兼容:思考开启(其默认)时 API 会用 400 拒绝该强制
// 指令,所以调用方必须先把思考显式关掉
// (design/04 §3.2 事实 5)。它只上报:解算过程绝不能自行
// 关掉思考。在 OpenAI 兼容适配器上该指令尚未上线
// (M2),所以那里只是前瞻性警告——它仍然记录了
// 所请求的组合永远无法被满足。
func reportForcedToolChoiceWithThinking(log *slog.Logger, provider, model string, toolChoice any, thinkingOn bool) {
if !thinkingOn || !toolChoiceForces(toolChoice) {
return
+12 -12
View File
@@ -110,8 +110,8 @@ func TestOpenAIChat_DeclaredShapeSendsItsOwnSwitch(t *testing.T) {
}
}
// TestOpenAIChat_DeclaredShapeEnablesThinking pins the on direction as well, so the
// switch cannot be hard-wired off by a refactor.
// TestOpenAIChat_DeclaredShapeEnablesThinking 也钉住「开启」方向,
// 以免开关被一次重构硬接成关闭。
func TestOpenAIChat_DeclaredShapeEnablesThinking(t *testing.T) {
t.Parallel()
for _, tc := range declaredShapeCases {
@@ -226,9 +226,9 @@ func TestOpenAIChat_ReasoningKindOpenAIKeepsOpenAITiers(t *testing.T) {
}
}
// TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch asserts the effort-only
// shape does not silently swallow a boolean thinking switch: it reports it, and
// points at the documented way to disable reasoning.
// TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch 断言「仅 effort」
// 形状不会静默吞掉布尔 thinking 开关:它会报告该情况,并
// 指出文档记录在案的关闭推理的做法。
func TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -256,10 +256,10 @@ func TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch(t *testing.T) {
}
}
// TestOpenAIChat_UnknownReasoningKindSendsNothing asserts a shape this build does
// not implement is reported and sends nothing — even when the model name would
// otherwise look like a vendor we do understand. Guessing here is exactly the
// "fake adaptation" the ruling forbids.
// TestOpenAIChat_UnknownReasoningKindSendsNothing 断言本次构建
// 未实现的形状会被上报、且什么都不发——即便模型名
// 看上去像我们确实理解的厂商也一样。在这里靠猜正是
// 裁决所禁止的「假适配」。
func TestOpenAIChat_UnknownReasoningKindSendsNothing(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -288,9 +288,9 @@ func TestOpenAIChat_UnknownReasoningKindSendsNothing(t *testing.T) {
}
}
// TestOpenAIChat_AutoQwenShapeStaysConservative pins the un-declared default: a
// Qwen endpoint may be vLLM or DashScope, whose field shapes differ, so auto mode
// sends nothing and asks for an explicit declaration instead of guessing one.
// TestOpenAIChat_AutoQwenShapeStaysConservative 钉住未声明的默认:
// Qwen 端点可能是 vLLM 或 DashScope,两者字段形状不同,所以 auto 模式
// 什么都不发,并改为要求显式声明,而不是替它猜一个。
func TestOpenAIChat_AutoQwenShapeStaysConservative(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
+36 -36
View File
@@ -11,20 +11,20 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// captureLog returns a logger writing text records into a buffer, plus the
// buffer, so a test can assert the WARN an adapter emits.
// captureLog 返回一个把文本记录写进缓冲的日志器,外加那个
// 缓冲,好让测试能断言适配器发出的 WARN。
func captureLog() (*slog.Logger, *bytes.Buffer) {
buf := &bytes.Buffer{}
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
}
// boolPtr is a convenience for the tri-state Thinking.Enable.
// boolPtr 是三态 Thinking.Enable 的便利构造。
func boolPtr(v bool) *bool { return &v }
// TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer is the design/04 §3.2
// red line: a budget large enough to eat max_tokens must not be sent as-is —
// the adapter raises max_tokens so thinking and the visible answer both fit, and
// it logs the adjustment (never a silent rewrite).
// TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer 就是 design/04 §3.2
// 的红线:大到能吃光 max_tokens 的预算不得照原样发送——
// 适配器会抬高 max_tokens,让思考与可见答案都放得下,
// 并记录这次调整(绝不是一次静默改写)。
func TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -35,8 +35,8 @@ func TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) {
WithAnthropicLogger(log))
const budget = 4096
// max_tokens equals the budget: without the guard the whole budget would be
// consumed by thinking and the answer would come back empty.
// max_tokens 恰好等于预算:没有这道守护,整个预算会被
// 思考吃光,答案将空着返回。
if _, err := ad.Chat(context.Background(), Request{
Model: "claude-test",
Messages: []Message{TextMessage(RoleUser, "hi")},
@@ -69,9 +69,9 @@ func TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) {
}
}
// TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged pins the non-saturated
// case: when the caller's max_tokens already leaves room, it is sent untouched
// and the budget stays strictly below it.
// TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged 钉住未饱和
// 的情形:调用方的 max_tokens 本已留出空间时,它原样发送,
// 而预算严格低于它。
func TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -104,8 +104,8 @@ func TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged(t *testing.T) {
}
}
// TestAnthropicChat_ThinkingDisabledOmitsField asserts the explicit off switch
// sends no thinking directive at all (Anthropic treats a missing field as off).
// TestAnthropicChat_ThinkingDisabledOmitsField 断言显式关闭开关
// 完全不发送 thinking 指令(Anthropic 把字段缺失视为关闭)。
func TestAnthropicChat_ThinkingDisabledOmitsField(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -124,8 +124,8 @@ func TestAnthropicChat_ThinkingDisabledOmitsField(t *testing.T) {
}
}
// TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum asserts a budget below
// the API minimum is raised (sending it as-is would 400) and reported.
// TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum 断言低于
// API 最小值的预算会被抬高(照发会 400)并上报。
func TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -151,8 +151,8 @@ func TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum(t *testing.T) {
}
}
// TestOpenAIChat_ReasoningEffortPassthrough asserts a supported tier on a known
// provider is forwarded verbatim, with no warning.
// TestOpenAIChat_ReasoningEffortPassthrough 断言已知提供商上的
// 受支持档位被原样转发,且不发出警告。
func TestOpenAIChat_ReasoningEffortPassthrough(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -175,10 +175,10 @@ func TestOpenAIChat_ReasoningEffortPassthrough(t *testing.T) {
}
}
// TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek asserts a DeepSeek
// compatibility alias is normalized to the tier the service applies (medium →
// high per the official mapping table) and reported, never graded down to a
// weaker tier of our own choosing.
// TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek 断言 DeepSeek 的
// 兼容别名被归一化为服务采用的档位(按官方映射表 medium →
// high)并上报,绝不按我们自己挑的
// 更弱档位降级。
func TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -204,9 +204,9 @@ func TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek(t *testing.T) {
}
}
// TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider asserts an
// unrecognized provider/model leaves the request untouched and reports the
// ignored field (design/04 §3.2: never silently ineffective).
// TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider 断言无法识别的
// 提供商/模型会让请求保持不动,并上报被
// 忽略的字段(design/04 §3.2:绝不静默失效)。
func TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -233,10 +233,10 @@ func TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider(t *testing.T) {
}
}
// TestOpenAIChat_QwenEnableThinkingReportedNotForwarded documents the deliberate
// choice for the Qwen3 hard switch: the OpenAI-compatible protocol has no
// portable wire shape for enable_thinking, so this version reports it and leaves
// the request byte-identical instead of guessing a shape that could 400.
// TestOpenAIChat_QwenEnableThinkingReportedNotForwarded 记录了针对 Qwen3
// 硬开关的刻意选择:OpenAI 兼容协议没有 enable_thinking 的
// 可移植线格式,所以本版本上报它,并让请求
// 逐字节保持原样,而不是去猜一个可能导致 400 的形状。
func TestOpenAIChat_QwenEnableThinkingReportedNotForwarded(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -277,9 +277,9 @@ func TestOpenAIChat_QwenEnableThinkingReportedNotForwarded(t *testing.T) {
}
}
// TestOpenAIChat_ThinkingBudgetReportedIgnored asserts a token budget (an
// Anthropic-style directive) is reported as unmappable on an OpenAI-compatible
// endpoint rather than silently dropped.
// TestOpenAIChat_ThinkingBudgetReportedIgnored 断言 token 预算
// (Anthropic 风格的指令)在 OpenAI 兼容端点上会被上报为
// 无法映射,而不是被静默丢弃。
func TestOpenAIChat_ThinkingBudgetReportedIgnored(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -305,8 +305,8 @@ func TestOpenAIChat_ThinkingBudgetReportedIgnored(t *testing.T) {
}
}
// TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer runs the same
// red-line check on the streaming path, which builds its own request body.
// TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer 在流式
// 路径上跑同一条红线检查,该路径会自建请求体。
func TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -337,8 +337,8 @@ func TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T)
}
}
// TestReasoningVendorFor_Classification locks the vendor detection the whitelist
// depends on, including the fallback to the base URL.
// TestReasoningVendorFor_Classification 锁住白名单所依赖的厂商
// 检测,包括回退到 base URL 的那条路径。
func TestReasoningVendorFor_Classification(t *testing.T) {
t.Parallel()
+66 -66
View File
@@ -2,22 +2,22 @@ package model
import "strings"
// Router maps roles to model names. An unknown role resolves to the configured
// default model. A Router also carries a provider per role (defaulting to the
// global provider) so a multi-vendor deployment can pair a role's model with the
// correct protocol; the provider selection is surfaced via RouteProvider.
// Router 把角色映射到模型名。未知角色解析为已配置的
// 默认模型。Router 还按角色携带一个提供商(默认用
// 全局提供商),好让多厂商部署能把角色的模型与
// 正确协议配对;提供商选择通过 RouteProvider 暴露。
//
// Per 裁决 03.4.1 the "role" of per-role routing is the **persona** a turn
// answers as, not the LLM message role: a persona binding wins over the
// message-role binding, and a persona without a binding keeps the previous
// behavior. The binding carrier is the active models.json group
// (runtimeconfig.Group.PersonaModels / PersonaProviders).
// 按裁决 03.4.1,按角色路由里的「角色」是本回合以之
// 作答的 **persona**,而不是 LLM 消息角色:persona 绑定优先于
// 消息角色绑定,而未绑定的 persona 保持原有
// 行为。绑定的载体是生效的 models.json 组
// (runtimeconfig.Group.PersonaModels / PersonaProviders)。
//
// Image routing is deliberately NOT this router's job any more (裁决
// 14.4.1/14.4.5): the single authority for layered image routing is
// runtime/multimodal/vision.Router, which the loop calls with the main model's
// real image-input capability. A "has image → switch to the vision model" rule
// here used to bypass that decision.
// 图像路由刻意不再是本 Router 的职责(裁决
// 14.4.1/14.4.5):分层图像路由的唯一权威是
// runtime/multimodal/vision.Router,由 loop 带着主模型真实的
// 图像输入能力去调用它。这里曾有的「带图 → 切到视觉模型」规则
// 绕过了那个决策。
type Router struct {
defaultModel string
byRole map[Role]string
@@ -28,25 +28,25 @@ type Router struct {
personaProvider map[string]string
}
// RouterOption configures a Router.
// RouterOption 用于配置 Router。
type RouterOption func(*Router)
// WithRoleModel binds a model to a role.
// WithRoleModel 把模型绑定到角色。
func WithRoleModel(role Role, model string) RouterOption {
return func(r *Router) { r.byRole[role] = model }
}
// WithVisionModel configures the dedicated vision model. It is kept for
// backwards compatibility (tests and callers that route a pure image request by
// model name) but production no longer sets it: image routing belongs to
// vision.Router (裁决 14.4.1/14.4.5).
// WithVisionModel 配置专用视觉模型。它保留下来是为了
// 向后兼容(测试与按模型名路由纯图像请求的调用方),
// 但生产路径不再设置它:图像路由属于
// vision.Router(裁决 14.4.1/14.4.5)。
func WithVisionModel(model string) RouterOption {
return func(r *Router) { r.visionModel = model }
}
// WithPersonaModel binds a model to a persona (裁决 03.4.1). A blank persona or
// model is ignored so a partially configured group cannot create a binding that
// routes a persona to an empty model.
// WithPersonaModel 把模型绑定到 persona(裁决 03.4.1)。空的 persona 或
// 模型会被忽略,因此半配置的组无法造出一个把 persona 路由到
// 空模型的绑定。
func WithPersonaModel(persona, model string) RouterOption {
return func(r *Router) {
if p := trimKey(persona); p != "" && model != "" {
@@ -55,9 +55,9 @@ func WithPersonaModel(persona, model string) RouterOption {
}
}
// WithPersonaProvider binds a vendor protocol to a persona, so one persona can
// answer through a different vendor than the group default (裁决 03.4.1). Pair it
// with WithPersonaModel.
// WithPersonaProvider 把厂商协议绑定到 persona,这样一个 persona 可以
// 走与组默认不同的厂商作答(裁决 03.4.1)。请与
// WithPersonaModel 配对使用。
func WithPersonaProvider(persona, provider string) RouterOption {
return func(r *Router) {
if p := trimKey(persona); p != "" && provider != "" {
@@ -66,21 +66,21 @@ func WithPersonaProvider(persona, provider string) RouterOption {
}
}
// WithDefaultProvider sets the provider returned by RouteProvider for roles with
// no explicit binding. It defaults to openai.
// WithDefaultProvider 设置 RouteProvider 对没有显式绑定的角色
// 返回的提供商。它默认是 openai。
func WithDefaultProvider(provider string) RouterOption {
return func(r *Router) { r.defaultProvider = provider }
}
// WithRoleProvider binds a provider to a role, so a multi-vendor deployment can
// route a role to a different vendor's protocol (e.g. one role on anthropic, one
// on openai). Pair it with WithRoleModel for the role's model name.
// WithRoleProvider 把提供商绑定到角色,好让多厂商部署
// 能把角色路由到另一厂商的协议(例如一个角色走 anthropic、一个
// 走 openai)。请与 WithRoleModel 配对以给出该角色的模型名。
func WithRoleProvider(role Role, provider string) RouterOption {
return func(r *Router) { r.providerByRole[role] = provider }
}
// NewRouter builds a Router with a default model applied to any role that has
// no explicit binding.
// NewRouter 构建 Router,并对任何没有显式绑定的角色
// 套用默认模型。
func NewRouter(defaultModel string, opts ...RouterOption) *Router {
r := &Router{
defaultModel: defaultModel,
@@ -96,11 +96,11 @@ func NewRouter(defaultModel string, opts ...RouterOption) *Router {
return r
}
// trimKey normalizes a persona id or provider name before it is stored, so
// " cyrene" and "cyrene" cannot become two different bindings.
// trimKey 在存入之前归一化 persona id 或提供商名,因此
// " cyrene" 与 "cyrene" 不会变成两个不同的绑定。
func trimKey(k string) string { return strings.TrimSpace(k) }
// Route returns the model for a role, falling back to the default model.
// Route 返回角色的模型,回退到默认模型。
func (r *Router) Route(role Role) string {
if m, ok := r.byRole[role]; ok && m != "" {
return m
@@ -108,11 +108,11 @@ func (r *Router) Route(role Role) string {
return r.defaultModel
}
// RouteProvider returns the provider for a role, falling back to the router's
// default provider. It is the routing hook for a multi-vendor deployment: each
// role can carry its own provider+model pairing, and roles without a binding fall
// back to the global provider. The runtime/loop is expected to build a per-role
// adapter or chain from this decision (the provider-aware factory is the builder).
// RouteProvider 返回角色的提供商,回退到 Router 的
// 默认提供商。它是多厂商部署的路由钩子:每个
// 角色可以携带自己的提供商+模型配对,而未绑定的角色回
// 退到全局提供商。runtime/loop 应据该决策构建按角色的
// 适配器或链(提供商感知工厂就是构建器)。
func (r *Router) RouteProvider(role Role) string {
if p, ok := r.providerByRole[role]; ok && p != "" {
return p
@@ -120,16 +120,16 @@ func (r *Router) RouteProvider(role Role) string {
return r.defaultProvider
}
// RouteRequest picks the model for a request and reports whether the request
// carries image content. When the request carries image content and a vision
// model is configured, the vision model is returned with needsVision set to
// true. Otherwise the role of the last message drives routing (user by
// default).
// RouteRequest 为请求挑出模型,并报告该请求
// 是否携带图像内容。当请求携带图像内容且已配置
// 视觉模型时,返回视觉模型并把 needsVision 置为
// true。否则由最后一条消息的角色驱动路由(默认
// user)。
//
// Deprecated in the production path: the loop routes by persona via
// RouteRequestForPersona, and image routing belongs to vision.Router (裁决
// 14.4.1/14.4.5). This method stays because it is the ModelRouter contract the
// agent package and the existing tests are built on.
// 在生产路径中已弃用:loop 改经
// RouteRequestForPersona 按 persona 路由,图像路由属于 vision.Router(裁决
// 14.4.1/14.4.5)。本方法保留是因为 agent 包与
// 既有测试都建立在 ModelRouter 契约之上。
func (r *Router) RouteRequest(req Request) (model string, needsVision bool) {
role := RoleUser
if n := len(req.Messages); n > 0 {
@@ -142,18 +142,18 @@ func (r *Router) RouteRequest(req Request) (model string, needsVision bool) {
return r.Route(role), needsVision
}
// PersonaRouter is the optional persona-aware routing extension of ModelRouter
// (裁决 03.4.1). The loop detects it on the agent's router and prefers it over
// RouteRequest, so a per-persona model/provider binding actually reaches the
// request. It is a separate interface so a router that only knows message roles
// keeps working unchanged.
// PersonaRouter 是 ModelRouter 可选的 persona 感知路由扩展
// (裁决 03.4.1)。loop 会在 agent 的 router 上探测它,并优先于
// RouteRequest 使用,从而让按 persona 的模型/提供商绑定真正到达
// 请求。它单列为一个接口,好让只知道消息角色的 router
// 保持可用、不被改动。
type PersonaRouter interface {
RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool)
}
// RouteForPersona returns the model bound to persona, falling back to the
// router's default model. Unlike Route (message role) this is the persona-level
// binding: 裁决 03.4.1 defines the "role" of per-role routing as the persona.
// RouteForPersona 返回绑定到 persona 的模型,回退到
// Router 的默认模型。与 Route(消息角色)不同,这是 persona 级
// 绑定:裁决 03.4.1 把按角色路由的「角色」定义为 persona。
func (r *Router) RouteForPersona(persona string) string {
if m, ok := r.personaModel[trimKey(persona)]; ok && m != "" {
return m
@@ -161,8 +161,8 @@ func (r *Router) RouteForPersona(persona string) string {
return r.defaultModel
}
// RouteProviderForPersona returns the provider bound to persona, falling back to
// the router's default provider (itself the active group's protocol).
// RouteProviderForPersona 返回绑定到 persona 的提供商,回退到
// Router 的默认提供商(其本身即生效组的协议)。
func (r *Router) RouteProviderForPersona(persona string) string {
if p, ok := r.personaProvider[trimKey(persona)]; ok && p != "" {
return p
@@ -170,11 +170,11 @@ func (r *Router) RouteProviderForPersona(persona string) string {
return r.defaultProvider
}
// RouteRequestForPersona routes a request as the given persona: a persona
// binding wins over the message-role decision, and a persona without a binding
// falls back to RouteRequest so nothing changes for the unconfigured case. It
// reports whether the request carries an image, but never switches the model to
// the vision model — that decision belongs to vision.Router (裁决 14.4.1/14.4.5).
// RouteRequestForPersona 以给定 persona 路由请求:persona
// 绑定优先于消息角色决策,而没有绑定的 persona
// 回退到 RouteRequest,因此未配置的情形毫无变化。它
// 报告请求是否携带图片,但绝不会把模型切到
// 视觉模型——那个决策属于 vision.Router(裁决 14.4.1/14.4.5)。
func (r *Router) RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool) {
needsVision = HasImage(req.Messages)
if m, ok := r.personaModel[trimKey(personaID)]; ok && m != "" {
@@ -187,7 +187,7 @@ func (r *Router) RouteRequestForPersona(personaID string, req Request) (model st
return r.Route(role), needsVision
}
// HasImage reports whether any message carries an image content part.
// HasImage 报告是否有任何消息携带图片内容片段。
func HasImage(msgs []Message) bool {
for _, m := range msgs {
for _, part := range m.Content {
+95 -95
View File
@@ -12,45 +12,45 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// ModelRouter is the routing contract the agent (and loop) depend on. *Router
// implements it; RuntimeLLM implements it too, so a hot-swappable provider can
// serve as both the adapter and the router without a separate router reference
// to keep in sync.
// ModelRouter 是 agent(与 loop)依赖的路由契约。*Router
// 实现它;RuntimeLLM 也实现它,因此一个可热切换的提供商
// 可以同时充当适配器与路由器,无需另存一份 router 引用
// 去保持同步。
type ModelRouter interface {
RouteRequest(req Request) (model string, needsVision bool)
}
// runtimeChainSnapshot is an immutable, self-contained LLM stack snapshot. It is
// stored and swapped atomically: a request that already captured a snapshot
// keeps using that exact chain+router for its whole call, so a reload never
// interrupts an in-flight request.
// runtimeChainSnapshot 是不可变、自包含的 LLM 栈快照。它
// 被原子存储与换入:已经捕获某个快照的请求
// 整次调用都用那条确切的链+路由器,所以一次重载绝不会
// 打断进行中的请求。
type runtimeChainSnapshot struct {
chain *Chain
router *Router
group runtimeconfig.Group
groups []runtimeconfig.Group
// providerChains holds one chain per *other* protocol declared by the active
// group's personaProviders (裁决 03.4.1 的 per-persona provider:跨协议换
// adapter). A persona whose provider binding resolves to one of these keys is
// served by that chain; every other request keeps the default chain above.
// providerChains 为生效组的 personaProviders 声明的每个
// *其它* 协议各存一条链(裁决 03.4.1 的 per-persona provider:跨协议换
// adapter)。提供商绑定解析到这些键之一的 persona 由
// 那条链服务;其余所有请求仍走上面的默认链。
providerChains map[string]*Chain
}
// RuntimeLLM is the hot-swappable LLM provider stack for the runtime. It
// implements Adapter (delegating to the current chain) and ModelRouter
// (delegating to the current router). A panel save reloads models.json, rebuilds
// a fresh chain+router and atomically swaps it in: new calls use the new stack
// while calls already in flight finish on the old one.
// RuntimeLLM 是运行时的可热切换 LLM 提供商栈。它
// 实现 Adapter(委托给当前链)与 ModelRouter
// (委托给当前路由器)。面板保存会重载 models.json、重建
// 一份新的链+路由器并原子换入:新调用使用新栈,
// 而进行中的调用在旧栈上跑完。
type RuntimeLLM struct {
path string
env config.LLMConfig
log *slog.Logger
current atomic.Value // *runtimeChainSnapshot
current atomic.Value // 类型:*runtimeChainSnapshot
}
// NewRuntimeLLM builds the provider stack from models.json at path, falling back
// to env. It loads the initial snapshot so the runtime is usable immediately.
// log may be nil (a discard logger is used).
// NewRuntimeLLM 由 path 处的 models.json 构建提供商栈,并回退
// 到 env。它会加载初始快照,因此运行时立即可用。
// log 可为 nil(此时使用丢弃式日志器)。
func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *RuntimeLLM {
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -60,11 +60,11 @@ func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *Runtime
return m
}
// Path returns the models.json path this manager reads and reloads from.
// Path 返回本管理器读取并重载的 models.json 路径。
func (m *RuntimeLLM) Path() string { return m.path }
// Groups returns a copy of the current loaded groups; the active group (the one
// a new request uses by default) is index 0.
// Groups 返回当前已加载组的一份拷贝;生效组(新请求
// 默认使用的那个)是下标 0。
func (m *RuntimeLLM) Groups() []runtimeconfig.Group {
src := m.load().groups
out := make([]runtimeconfig.Group, len(src))
@@ -72,18 +72,18 @@ func (m *RuntimeLLM) Groups() []runtimeconfig.Group {
return out
}
// Group returns the active group (index 0).
// Group 返回生效组(下标 0)。
func (m *RuntimeLLM) Group() runtimeconfig.Group { return m.load().group }
// Current returns the current chain and router. The returned chain stays valid
// for calls already in flight; new calls pick up the latest swap.
// Current 返回当前链与路由器。返回的链对进行中的
// 调用仍然有效;新调用会拿到最新的换入结果。
func (m *RuntimeLLM) Current() (*Chain, *Router) {
snap := m.load()
return snap.chain, snap.router
}
// Reload re-reads models.json (falling back to env) and atomically rebuilds the
// in-use chain+router. In-flight calls keep the previous snapshot.
// Reload 重新读取 models.json(回退到 env)并原子重建
// 在用的链+路由器。进行中的调用保持原快照。
func (m *RuntimeLLM) Reload() error {
groups, warn := runtimeconfig.LoadModels(m.path, m.env)
if warn != nil {
@@ -92,19 +92,19 @@ func (m *RuntimeLLM) Reload() error {
return m.swap(groups)
}
// Provider returns the active group's provider protocol.
// Provider 返回生效组的提供商协议。
func (m *RuntimeLLM) Provider() string { return m.load().group.Provider }
// Model returns the active group's primary model name.
// Model 返回生效组的主模型名。
func (m *RuntimeLLM) Model() string { return m.load().group.Model }
// Name implements Adapter, returning the active group id.
// Name 实现 Adapter,返回生效组 id。
func (m *RuntimeLLM) Name() string { return m.load().group.ID }
// Chat implements Adapter, delegating to the chain the request's persona
// resolves to. A request that began before a reload completes on the snapshot it
// captured. When no model is configured (nil chain) it returns ErrNoModel rather
// than panicking.
// Chat 实现 Adapter,委托给请求的 persona 所解析到的
// 链。在重载完成前开始的请求,会在它已捕获的快照上
// 跑完。未配置模型(链为 nil)时返回 ErrNoModel,而不是
// panic。
func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
chain := m.chainFor(req.PersonaID)
if chain == nil {
@@ -113,7 +113,7 @@ func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Respo
return chain.Chat(ctx, req, opts)
}
// ChatStream implements Adapter.
// ChatStream 实现 Adapter。
func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
chain := m.chainFor(req.PersonaID)
if chain == nil {
@@ -122,20 +122,20 @@ func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(S
return chain.ChatStream(ctx, req, onEvent)
}
// chainFor resolves the chain that serves this persona (裁决 03.4.1 的
// per-persona provider). It consumes Router.RouteProviderForPersona — the
// provider decision the router already made for this persona — and only switches
// when BOTH hold:
// chainFor 解出服务该 persona 的链(裁决 03.4.1 的
// per-persona provider)。它消费 Router.RouteProviderForPersona ——
// 路由器已为该 persona 做出的提供商决策 —— 且只在
// 二者同时成立时才切换:
//
// - the persona has its own model binding (an entry in personaProviders alone
// would send the group's default model name to another vendor's endpoint),
// - and a chain exists for that provider (a declared protocol with no matching
// group in models.json is reported when the snapshot is built, and the
// persona then keeps the default chain rather than failing the turn).
// - 该 persona 有自己的模型绑定(只写 personaProviders 一项
// 会把组的默认模型名发到另一厂商的端点),
// - 且该提供商存在一条链(声明了协议但 models.json 里没有
// 对应组的情况会在构建快照时上报,
// 该 persona 随后保留默认链,而不是让回合失败)。
//
// Everything else — no persona, no binding, same protocol as the active group —
// returns the default chain, so a deployment without per-persona providers
// behaves exactly as before.
// 其余情形 —— 无 persona、无绑定、与生效组同协议 ——
// 返回默认链,因此没有按 persona 提供商的部署
// 行为与之前完全一致。
func (m *RuntimeLLM) chainFor(persona string) *Chain {
snap := m.load()
if p := trimKey(persona); p != "" && snap.router != nil {
@@ -148,7 +148,7 @@ func (m *RuntimeLLM) chainFor(persona string) *Chain {
return snap.chain
}
// RouteRequest implements ModelRouter, delegating to the current router.
// RouteRequest 实现 ModelRouter,委托给当前路由器。
func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) {
r := m.load().router
if r == nil {
@@ -157,9 +157,9 @@ func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) {
return r.RouteRequest(req)
}
// RouteRequestForPersona implements PersonaRouter (裁决 03.4.1): the loop routes
// a turn as the persona it answers as, and a per-persona binding in the active
// models.json group reaches the request through here.
// RouteRequestForPersona 实现 PersonaRouter(裁决 03.4.1):loop 以
// 本回合作答所依据的 persona 来路由,生效 models.json 组里的
// 按 persona 绑定经由这里到达请求。
func (m *RuntimeLLM) RouteRequestForPersona(personaID string, req Request) (string, bool) {
r := m.load().router
if r == nil {
@@ -168,8 +168,8 @@ func (m *RuntimeLLM) RouteRequestForPersona(personaID string, req Request) (stri
return r.RouteRequestForPersona(personaID, req)
}
// loadInitial builds the first snapshot; a build failure leaves a nil chain so
// calls report a clear no-model error instead of panicking.
// loadInitial 构建首个快照;构建失败会留下 nil 链,
// 使调用报告清晰的「无模型」错误而不是 panic。
func (m *RuntimeLLM) loadInitial() {
groups, warn := runtimeconfig.LoadModels(m.path, m.env)
if warn != nil {
@@ -178,8 +178,8 @@ func (m *RuntimeLLM) loadInitial() {
_ = m.swap(groups)
}
// swap rebuilds and atomically stores a snapshot for groups. A blank slice falls
// back to the env-synthesized group so there is always at least one.
// swap 为 groups 重建快照并原子存储。空切片会回
// 退到 env 合成的组,因此始终至少有一个组。
func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error {
if len(groups) == 0 {
groups = runtimeconfig.GroupFromEnv(m.env)
@@ -192,8 +192,8 @@ func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error {
return nil
}
// load returns the current snapshot, or an empty (nil chain) one when the
// manager was never initialized.
// load 返回当前快照;管理器从未初始化时返回一个空
// (nil 链)快照。
func (m *RuntimeLLM) load() *runtimeChainSnapshot {
if v := m.current.Load(); v != nil {
return v.(*runtimeChainSnapshot)
@@ -201,18 +201,18 @@ func (m *RuntimeLLM) load() *runtimeChainSnapshot {
return &runtimeChainSnapshot{}
}
// buildSnapshot turns the active group (index 0) into a chain+router. A group
// without a base URL/model yields a nil chain (no model configured), surfaced as
// ErrNoModel by the adapter methods.
// buildSnapshot 把生效组(下标 0)变成链+路由器。没有
// base URL/模型的组会产出 nil 链(未配置模型),由适配器
// 方法表现为 ErrNoModel。
//
// The env-local rung is copied onto the group's flat config because a models.json
// group does not carry it: without that copy the third degradation level
// (裁决 03.4.3) would silently disappear as soon as models.json exists.
// env 本地级会被复制到组的扁平配置上,因为 models.json
// 的组不携带它:没有这次复制,第三级降级
// (裁决 03.4.3)只要 models.json 一存在就会静默消失。
//
// The router is seeded with the active group's per-persona bindings
// (裁决 03.4.1). It is deliberately NOT given the env vision model: image routing
// is owned by vision.Router, and a "has image → switch model" rule here bypassed
// that decision (裁决 14.4.1/14.4.5).
// 路由器以生效组的按 persona 绑定播种
// (裁决 03.4.1)。它刻意不给 env 的视觉模型:图像路由
// 归 vision.Router 所有,而这里曾有的「带图 → 切换模型」规则绕过了
// 那个决策(裁决 14.4.1/14.4.5)。
func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) (*runtimeChainSnapshot, error) {
active := groups[0]
cfg := active.ToLLMConfig()
@@ -222,9 +222,9 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog
return nil, err
}
if chain != nil {
// Budgets are resolved inside the chain: the active group's own
// maxTokens (models.json) wins, and env.FallbackMaxTokens only caps the
// last step of a chain that really has a fallback (design/04 §3.1).
// 预算在链内部解出:生效组自己的
// maxTokens(models.json)优先,env.FallbackMaxTokens 只约束
// 确实有降级步骤的链的最后一步(design/04 §3.1)。
chain = chain.WithTokenBudget(active.MaxTokens, env.FallbackMaxTokens)
}
router := NewRouter(DefaultModelForProvider(cfg), personaRouterOptions(active, cfg)...)
@@ -237,17 +237,17 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog
}, nil
}
// personaProviderChains builds one chain per protocol that the active group's
// personaProviders declares and that models.json actually provides a group for
// (裁决 03.4.1 的 per-persona provider:跨协议换 adapter). The persona's own
// model name is pinned as the chain's primary model, so a caller that stamps the
// persona without a routed model name still reaches the right endpoint.
// personaProviderChains 为生效组 personaProviders 声明的、且
// models.json 确实提供了对应组的每种协议各构建一条链
// (裁决 03.4.1 的 per-persona provider:跨协议换 adapter)。persona 自己的
// 模型名被钉为该链的主模型,因此即便调用方打上的
// persona 没带被路由的模型名,也仍能到达正确端点。
//
// A persona that names the same protocol as the active group needs no chain of
// its own: the default chain already speaks that protocol, and the per-persona
// model name reaches it through the router. A personaProviders entry whose
// protocol has no group is reported and skipped — a half-built chain could only
// fail, and the persona keeps the default chain instead.
// 与生效组同协议的 persona 不需要自己
// 的链:默认链本就说那个协议,而按 persona 的
// 模型名经路由器到达它。personaProviders 中协议
// 没有对应组的条目会被上报并跳过——半构建的链只会
// 失败,该 persona 改为保留默认链。
func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) map[string]*Chain {
if len(active.PersonaProviders) == 0 {
return nil
@@ -283,12 +283,12 @@ func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Gr
return out
}
// personaChain builds and budgets the chain serving one persona through another
// vendor's group: the group's flat config with the persona's own model name as
// the primary model, plus the shared local last rung. The reasoning shape
// (reasoning.kind) travels with the group's config, so a persona routed to
// another vendor keeps that vendor's switch shape. It reports the reason and
// returns nil when the group cannot serve a chain.
// personaChain 构建并通过另一厂商的组服务某个 persona 的链,并
// 为其定预算:组的扁平配置,persona 自己的模型名作为
// 主模型,加上共享的本地最后一级。推理形状
// (reasoning.kind)随组的配置一起走,因此被路由到
// 另一厂商的 persona 仍保持该厂商的开关形状。它会报告原因,
// 并在该组无法服务一条链时返回 nil。
func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger) *Chain {
cfg := g.ToLLMConfig()
if cfg.Provider == ProviderAnthropic {
@@ -310,10 +310,10 @@ func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log
return chain.WithTokenBudget(g.MaxTokens, env.FallbackMaxTokens)
}
// groupForProvider returns the first group speaking the given protocol. The group
// order in models.json is the operator's choice, so "first match" is a stable,
// documentable rule rather than a silent pick — a duplicate protocol is reported
// by the builder when it matters.
// groupForProvider 返回第一条说指定协议的组。models.json 里
// 组的顺序由运维者决定,所以「第一个匹配」是一条稳定、
// 可记录的规则,而不是一次静默挑选——重复协议会在
// 要紧的时候由构建器上报。
func groupForProvider(groups []runtimeconfig.Group, provider string) (runtimeconfig.Group, bool) {
for _, g := range groups {
if g.Provider == provider {
@@ -323,9 +323,9 @@ func groupForProvider(groups []runtimeconfig.Group, provider string) (runtimecon
return runtimeconfig.Group{}, false
}
// personaRouterOptions turns the active group's per-persona model/provider
// bindings into router options (裁决 03.4.1). It is a helper so buildSnapshot
// stays inside the 80-line function budget.
// personaRouterOptions 把生效组的按 persona 模型/提供商
// 绑定转换成 router 选项(裁决 03.4.1)。它是一个辅助函数,好让 buildSnapshot
// 留在 80 行函数预算之内。
func personaRouterOptions(active runtimeconfig.Group, cfg config.LLMConfig) []RouterOption {
opts := []RouterOption{WithDefaultProvider(cfg.Provider)}
for persona, model := range active.PersonaModels {
+7 -7
View File
@@ -11,7 +11,7 @@ import (
)
func rtEnv() config.LLMConfig {
// Env fallback matches "openai default" (provider not anthropic).
// env 降级符合「openai 默认」(提供商不是 anthropic)。
return config.LLMConfig{
Provider: ProviderOpenAI,
BaseURL: "https://api.openai.com/v1",
@@ -30,7 +30,7 @@ func writeRTFile(t *testing.T, groups []runtimeconfig.Group) string {
}
func TestRuntimeLLM_EnvFallback(t *testing.T) {
// Missing file → env fallback provider/model.
// 文件缺失 → env 降级的提供商/模型。
m := NewRuntimeLLM(filepath.Join(t.TempDir(), "nope.json"), rtEnv(), nil)
if m.Provider() != ProviderOpenAI || m.Model() != "gpt-4o" {
t.Fatalf("fallback Provider=%q Model=%q, want openai/gpt-4o", m.Provider(), m.Model())
@@ -40,7 +40,7 @@ func TestRuntimeLLM_EnvFallback(t *testing.T) {
t.Fatal("router is nil after env fallback")
}
if chain == nil {
// No real endpoint is fine; the adapter should report ErrNoModel, not panic.
// 没有真实端点没关系;适配器应报 ErrNoModel,而不是 panic。
if _, err := m.Chat(context.Background(), Request{Messages: nil}, Options{}); err != ErrNoModel {
t.Fatalf("Chat on empty config err = %v, want ErrNoModel", err)
}
@@ -68,7 +68,7 @@ func TestRuntimeLLM_ReloadReflectsNewProviderModel(t *testing.T) {
if m.Model() != "gpt-4o" {
t.Fatalf("before reload Model = %q, want gpt-4o", m.Model())
}
// Rewrite the file with an anthropic provider/model, then reload.
// 用 anthropic 提供商/模型重写该文件,然后重载。
if err := runtimeconfig.SaveModels(path, []runtimeconfig.Group{
{ID: "claude", Provider: ProviderAnthropic, BaseURL: "https://api.anthropic.com", APIKey: "sk-ant", Model: "claude-sonnet-4"},
}); err != nil {
@@ -129,15 +129,15 @@ func TestRuntimeLLM_ConcurrentReadDuringReloadNoPanic(t *testing.T) {
return
}
if chain == nil {
// A nil chain means the config had no base/model; our test file has one,
// so a swap must always produce a chain. Guard anyway.
// nil 链意味着配置里没有 base/model;我们的测试文件里有,
// 所以一次换入必须总能产出链。仍然加一道守护。
t.Error("chain is nil during concurrent read")
return
}
}
}()
}
// Reload a number of times while the readers spin.
// 在读方自旋期间重载若干次。
for i := 0; i < 200; i++ {
if err := m.Reload(); err != nil {
t.Fatalf("reload %d: %v", i, err)
+68 -68
View File
@@ -1,59 +1,59 @@
package model
// StreamKind enumerates the kinds of structured events produced while a
// completion streams. It mirrors OpenClaw's AssistantMessageEvent segments
// (text / thinking / tool_call) plus a tool-execution progress event, so the
// frontend can render each segment into its own surface (body, collapsed
// thinking block, tool card) rather than accumulating a single opaque token
// stream.
// StreamKind 枚举补全流式过程中产生的结构化事件种类。
// 它对应 OpenClaw 的 AssistantMessageEvent 片段
// (文本 / 思考 / tool_call)外加一个工具执行进度事件,
// 因此前端可以把每个片段渲染到自己的界面元素(正文、折叠
// 思考块、工具卡片),而不是累积成一条不透明的 token
// 流。
type StreamKind string
const (
// StreamKindTextStart opens the body-text segment. The single text segment
// of a response is bracketed by TextStart/TextEnd; deltas fill it.
// StreamKindTextStart 打开正文文本片段。一次响应的单个文本
// 片段由 TextStart/TextEnd 包起来;增量填充其中。
StreamKindTextStart StreamKind = "text_start"
// StreamKindTextDelta is one body-text token fragment.
// StreamKindTextDelta 是一个正文文本 token 片段。
StreamKindTextDelta StreamKind = "text_delta"
// StreamKindTextEnd closes the body-text segment.
// StreamKindTextEnd 关闭正文文本片段。
StreamKindTextEnd StreamKind = "text_end"
// StreamKindThinkingStart opens a chain-of-thought segment (frontend
// collapsed block). Reserved: adapters that report reasoning emit it.
// StreamKindThinkingStart 打开一个思维链片段(前端
// 折叠块)。保留:上报推理的适配器会发出它。
StreamKindThinkingStart StreamKind = "thinking_start"
// StreamKindThinkingDelta is one reasoning token fragment.
// StreamKindThinkingDelta 是一个推理 token 片段。
StreamKindThinkingDelta StreamKind = "thinking_delta"
// StreamKindThinkingEnd closes a reasoning segment.
// StreamKindThinkingEnd 关闭一个推理片段。
StreamKindThinkingEnd StreamKind = "thinking_end"
// StreamKindToolCallStart opens a tool invocation's streaming form.
// StreamKindToolCallStart 打开一次工具调用的流式形态。
StreamKindToolCallStart StreamKind = "tool_call_start"
// StreamKindToolCallDelta is a fragment of the tool arguments (e.g. the
// incremental input_json of an Anthropic tool_use block).
// StreamKindToolCallDelta 是工具参数的一个片段(例如
// Anthropic tool_use 块增量式的 input_json)。
StreamKindToolCallDelta StreamKind = "tool_call_delta"
// StreamKindToolCallEnd closes a tool invocation block.
// StreamKindToolCallEnd 关闭一个工具调用块。
StreamKindToolCallEnd StreamKind = "tool_call_end"
// StreamKindToolExecutionStart opens the execution of a tool the model
// requested (design/04 §1.1: entering execution). It is emitted by the tool
// loop, not the model adapter; Phase carries the running label.
// StreamKindToolExecutionStart 打开模型所请求工具的
// 执行(design/04 §1.1:进入执行)。它由工具循环
// 发出,而不是模型适配器;Phase 携带 running 标签。
StreamKindToolExecutionStart StreamKind = "tool_execution_start"
// StreamKindToolExecutionUpdate reports intermediate progress or partial
// output while a requested tool runs (design/04 §1.1). Multiple frames per
// execution are allowed; the wire mapping is defined, and a producer appears
// once tools can report mid-execution progress.
// StreamKindToolExecutionUpdate 报告所请求工具运行期间的
// 中间进度或部分输出(design/04 §1.1)。每次执行允许
// 多帧;线格式映射已定义,等工具能上报执行中进度时
// 就会有生产者。
StreamKindToolExecutionUpdate StreamKind = "tool_execution_update"
// StreamKindToolExecutionEnd closes a tool execution with its terminal
// phase (done or error). It is emitted by the tool loop after Invoke
// returns.
// StreamKindToolExecutionEnd 以终态阶段(done 或 error)
// 关闭一次工具执行。它由工具循环在 Invoke
// 返回后发出。
StreamKindToolExecutionEnd StreamKind = "tool_execution_end"
// StreamKindTurnStart opens an assistant turn at the loop's run boundary
// (design/04 §1.1 turn.start). It carries no payload: the frame shell's
// session/turn identity brackets the turn on the client.
// StreamKindTurnStart 在 loop 的运行边界打开一个助手回合
// (design/04 §1.1 turn.start)。它不携带负载:帧外壳的
// 会话/回合标识在客户端一侧把该回合包起来。
StreamKindTurnStart StreamKind = "turn_start"
// StreamKindTurnEnd closes an assistant turn at the loop's run boundary,
// however the run settled (final answer, guardrail stop, terminated tool
// loop, exhausted rounds or an error).
// StreamKindTurnEnd 在 loop 的运行边界关闭一个助手回合,
// 无论运行如何收场(最终答复、护栏停止、被终止的工具
// 循环、轮次耗尽或错误)。
StreamKindTurnEnd StreamKind = "turn_end"
)
@@ -69,48 +69,48 @@ const (
ToolPhaseError = "error"
)
// StreamEvent is a single structured event in a streamed completion. It replaces
// the former plain text delta as the unit delivered by Adapter.ChatStream, so a
// consumer can distinguish body text from tool calls (and, in time, reasoning
// segments) and track each by Index/ID.
// StreamEvent 是流式补全里的单个结构化事件。它取代
// 原先的纯文本增量,成为 Adapter.ChatStream 投递的单位,
// 因此消费者能区分正文文本、工具调用(以及将来还有推理
// 片段),并按 Index/ID 跟踪各自。
type StreamEvent struct {
// Kind classifies the event (see StreamKind).
// Kind 对事件分类(见 StreamKind)。
Kind StreamKind
// Delta carries the incremental payload: body text for TextDelta, reasoning
// text for ThinkingDelta, or a partial_argument fragment for ToolCallDelta.
// Tool-execution events never pack their phase or tool name in here —
// those live in the structured Phase/Tool fields.
// Delta 携带增量负载:TextDelta 时为正文文本,ThinkingDelta
// 时为推理文本,ToolCallDelta 时为 partial_argument 片段。
// 工具执行事件绝不把阶段或工具名塞进这里——
// 那些住在结构化的 Phase/Tool 字段里。
Delta string
// Index is the zero-based content-block index the event belongs to, when the
// provider reports one. The frontend uses it to correlate a segment's
// start/delta/end events.
// Index 是事件所属内容块的零基索引(提供商上报时)。
// 前端用它关联一个片段的
// start/delta/end 事件。
Index int
// ID is the provider block/index identifier for a tool call (e.g. Anthropic's
// tool_use block id). Empty for text segments. Tool-execution events reuse it
// so the client can correlate an execution with its tool_call.* frames.
// ID 是某次工具调用的提供商块/索引标识(例如 Anthropic 的
// tool_use 块 id)。文本片段为空。工具执行事件复用它,
// 好让客户端能把一次执行与它的 tool_call.* 帧关联起来。
ID string
// Tool is the invoked tool's name on a tool_execution.* event. Empty on
// every other kind.
// Tool 是 tool_execution.* 事件上被调用工具的名字。
// 其它所有种类为空。
Tool string
// Phase is the fine-grained execution label (ToolPhase*) on a
// tool_execution.* event: running on start/update, done or error on end.
// Empty on every other kind.
// Phase 是 tool_execution.* 事件上的细粒度执行标签
// (ToolPhase*):start/update 时为 running,end 时为 done 或 error。
// 其它所有种类为空。
Phase string
// StreamID identifies the single model stream (one ChatStream call) a frame
// belongs to. The loop stamps it at the stream boundary so every frame of
// the same streamed message shares it, while a multi-step turn's later
// model calls get fresh ids; consumers distinguish it from TurnID, which
// stays constant across the whole turn.
// StreamID 标识某一帧所属的那一次模型流(一次 ChatStream 调用)。
// loop 在流边界打上它,使同一条流式消息的每一帧
// 共享它,而多步回合后续的模型调用会拿到新 id;
// 消费者据此把它与 TurnID 区分开——后者
// 在整个回合中保持不变。
StreamID string
// SessionID is the conversation the streamed event belongs to. It is stamped
// by the loop at the turn boundary (loop.streamStep), which reads the session
// the Run was started for; a model adapter, running below the loop and with no
// notion of a session, leaves it zero. A consumer that routes the event (the
// WS hub) reads it to target the right conversation.
// SessionID 是该流式事件所属的会话。它由 loop 在回合
// 边界打上(loop.streamStep),那里读取 Run 启动时所用的
// 会话;模型适配器跑在 loop 之下、没有
// 会话概念,所以留零值。路由该事件的消费者(WS hub)
// 读它来定位正确的会话。
SessionID string
// TurnID is the assistant turn the event belongs to. Like SessionID it is
// stamped by the loop, which reads the turn identity the caller attached with
// loop.WithTurnID; it is zero when no turn id was attached, so a consumer must
// treat both fields as best-effort correlation rather than a hard guarantee.
// TurnID 是事件所属的助手回合。与 SessionID 一样,它由
// loop 打上,那里读取调用方用
// loop.WithTurnID 附上的回合标识;未附回合 id 时为零,所以消费者必须
// 把这两个字段当作尽力而为的关联,而不是硬保证。
TurnID string
}
+46 -46
View File
@@ -1,27 +1,27 @@
package model
// Provider stop/finish reasons that mean the token budget cut the generation
// off: Anthropic reports stop_reason "max_tokens", OpenAI-compatible providers
// report finish_reason "length". Both are normalized into Response.StopReason
// verbatim so a trace keeps the provider's own vocabulary.
// 表示 token 预算把生成切断的提供商停止/结束原因:
// Anthropic 上报 stop_reason "max_tokens",OpenAI 兼容提供商
// 上报 finish_reason "length"。两者都被原样归一化进 Response.StopReason,
// 因此链路追踪保留提供商自己的词汇。
const (
stopReasonMaxTokens = "max_tokens"
stopReasonLength = "length"
)
// Truncation-recovery bounds. At most one retry with a doubled budget is
// allowed; the cap keeps a pathological budget from growing without bound. The
// hint is the non-empty placeholder returned when even the retry produced no
// visible text, so a truncated turn never surfaces as a silent empty reply.
// It never carries thinking: reasoning is not the answer.
// 截断恢复的边界。最多允许一次带翻倍预算的重试;
// 上限防止病态预算无界增长。hint 是连重试
// 也没产出可见文本时返回的非空占位,
// 因此被截断的回合绝不会以静默空回复的形式浮出。
// 它绝不携带思考:推理不是答案。
const (
truncationRetryCap = 32768
truncationEmptyHint = "(思考过长,未能生成回复)"
)
// effectiveMaxTokens resolves the budget an attempt actually sends: the explicit
// Request.MaxTokens, else the adapter default. def may be zero when the protocol
// has no adapter default, in which case the provider default applies.
// effectiveMaxTokens 解出一次尝试实际发送的预算:显式的
// Request.MaxTokens,否则用适配器默认值。当协议没有适配器
// 默认值时 def 可以为零,此时适用提供商默认值。
func effectiveMaxTokens(n, def int) int {
if n > 0 {
return n
@@ -29,10 +29,10 @@ func effectiveMaxTokens(n, def int) int {
return def
}
// retryMaxTokens doubles the effective budget for the one-shot truncation retry,
// capped at truncationRetryCap. It returns the effective budget unchanged when
// there is nothing to double (both n and def are zero, i.e. the provider default
// applies), letting the caller skip a retry that would send an identical request.
// retryMaxTokens 为一次性的截断重试翻倍实际生效预算,
// 上限为 truncationRetryCap。当没有什么可翻倍时(n 与 def 都是零,
// 即适用提供商默认值),它原样返回实际生效预算,从而让
// 调用方跳过那次会发出完全相同请求的重试。
func retryMaxTokens(n, def int) int {
eff := effectiveMaxTokens(n, def)
if eff <= 0 {
@@ -44,31 +44,31 @@ func retryMaxTokens(n, def int) int {
return truncationRetryCap
}
// isTruncatedStopReason reports whether a stop reason means the token budget cut
// the generation off.
// isTruncatedStopReason 报告某个停止原因是否意味着 token 预算
// 切断了生成。
func isTruncatedStopReason(reason string) bool {
return reason == stopReasonMaxTokens || reason == stopReasonLength
}
// isEmptyBody reports whether a response carries neither visible text nor tool
// calls. A tool-only round is NOT empty: its empty body is normal and belongs to
// the tool loop.
// isEmptyBody 报告响应是否既无可见文本也无工具
// 调用。仅工具的一轮并不是空:它的空正文是正常的、属于
// 工具循环。
func isEmptyBody(r Response) bool {
return r.Content == "" && len(r.ToolCalls) == 0
}
// isTruncatedEmpty reports whether an attempt was cut off by the token budget
// before producing any visible output (no text, no tool call). Only this shape
// triggers the truncation retry/fallback; a normal tool_use round, whose body is
// legitimately empty, does not.
// isTruncatedEmpty 报告某次尝试是否在产出任何可见输出之前
// 就被 token 预算切断(无文本、无工具调用)。只有这种形状
// 才触发截断重试/兜底;正常的 tool_use 回合,其正文
// 合法地为空,不会触发。
func isTruncatedEmpty(r Response) bool {
return isEmptyBody(r) && isTruncatedStopReason(r.StopReason)
}
// withEmptyBodyHint fills a non-empty placeholder body when a response has
// neither text nor tool calls, so a truncated turn never surfaces as a silent
// empty reply. A response that already carries text or tool calls is returned
// unchanged.
// withEmptyBodyHint 在响应既无文本也无工具调用时填入一段
// 非空占位正文,因此被截断的回合绝不会以静默
// 空回复的形式浮出。已携带文本或工具调用的响应
// 原样返回。
func withEmptyBodyHint(r Response) Response {
if !isEmptyBody(r) {
return r
@@ -78,12 +78,12 @@ func withEmptyBodyHint(r Response) Response {
return r
}
// callWithTruncationRetry runs a non-streaming attempt, retrying once with a
// doubled token budget when the first attempt was cut off with no visible
// output, then returning a non-empty placeholder when the retry still produced
// nothing. attempt receives the budget to send. An upstream error on the first
// attempt is returned unchanged (no retry); a failed retry falls back to the
// placeholder rather than surfacing an empty reply.
// callWithTruncationRetry 执行一次非流式尝试,当第一次尝试
// 在无可见输出下被切断时,用翻倍 token 预算重试一次,
// 随后在重试仍无产出时返回非空占位。attempt 接收要发送
// 的预算。第一次尝试的上游错误会被原样返回(不重试);
// 重试失败则退回占位,
// 而不是浮出一个空回复。
func callWithTruncationRetry(maxTokens, def int, attempt func(limit int) (Response, error)) (Response, error) {
out, err := attempt(maxTokens)
if err != nil {
@@ -100,13 +100,13 @@ func callWithTruncationRetry(maxTokens, def int, attempt func(limit int) (Respon
return withEmptyBodyHint(out), nil
}
// callStreamWithTruncationRetry is the streaming counterpart of
// callWithTruncationRetry. attempt receives the budget and the event callback
// for that attempt. The retry drops thinking events (the first attempt already
// streamed its reasoning, so re-streaming it would duplicate the block) while
// forwarding text/tool events live; when even the retry yields no visible
// output, a non-empty placeholder is emitted as a text segment and returned as
// the response body, so the stream never ends on a silent truncation.
// callStreamWithTruncationRetry 是 callWithTruncationRetry 的
// 流式对位实现。attempt 接收该次尝试的预算与事件回调。
// 重试会丢弃思考事件(第一次尝试已经
// 流过它的推理,再流一次会产生重复块),
// 同时实时转发文本/工具事件;当连重试也没有可见
// 输出时,会以文本片段发出非空占位并把它作为
// 响应正文返回,因此流绝不会以静默截断收尾。
func callStreamWithTruncationRetry(
maxTokens, def int,
attempt func(limit int, onEvent func(StreamEvent)) (Response, error),
@@ -131,8 +131,8 @@ func callStreamWithTruncationRetry(
return out, nil
}
// dropThinking wraps onEvent so a retry attempt does not re-emit thinking
// segments. Text and tool_call events pass through unchanged.
// dropThinking 包裹 onEvent,使重试尝试不会再次发出思考
// 片段。文本与 tool_call 事件原样通过。
func dropThinking(onEvent func(StreamEvent)) func(StreamEvent) {
if onEvent == nil {
return nil
@@ -147,8 +147,8 @@ func dropThinking(onEvent func(StreamEvent)) func(StreamEvent) {
}
}
// emitTextSegment emits a single-delta text segment so a synthesized placeholder
// reaches streaming consumers that render only message.* events.
// emitTextSegment 发出一个单增量的文本片段,使合成的占位
// 能到达只渲染 message.* 事件的流式消费者。
func emitTextSegment(onEvent func(StreamEvent), text string) {
if onEvent == nil || text == "" {
return
+27 -27
View File
@@ -14,9 +14,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// anthropicSeqServer answers successive /v1/messages calls with a fixed script
// of bodies (the last body repeats) and records every decoded request body, so a
// test can assert both the retry count and the retried max_tokens.
// anthropicSeqServer 用一份固定的响应体脚本回答连续的 /v1/messages
// 调用(最后一个响应体重复),并记录每个已解码的请求体,因此
// 测试既能断言重试次数、也能断言重试后的 max_tokens。
type anthropicSeqServer struct {
*httptest.Server
mu sync.Mutex
@@ -61,8 +61,8 @@ func (s *anthropicSeqServer) body(i int) map[string]any {
return s.bodies[i]
}
// anthropicThinkingOnlyResponse is a thinking-only, budget-truncated reply: the
// reasoning consumed the whole max_tokens budget, so no text block was emitted.
// anthropicThinkingOnlyResponse 是一份只有思考、被预算截断的回复:
// 推理吃光了整个 max_tokens 预算,所以没有文本块被发出。
const anthropicThinkingOnlyResponse = `{
"id":"msg_think","model":"claude-test",
"content":[{"type":"thinking","thinking":"SECRET-REASONING long long long"}],
@@ -84,8 +84,8 @@ const anthropicToolUseResponse = `{
"usage":{"input_tokens":5,"output_tokens":8}
}`
// anthropicTruncatedThinkingSSE is the streaming form of a thinking-only
// truncation: a thinking block, then message_delta stop_reason max_tokens.
// anthropicTruncatedThinkingSSE 是「只有思考」截断的流式形态:
// 一个 thinking 块,随后是 message_delta 的 stop_reason max_tokens。
const anthropicTruncatedThinkingSSE = `data: {"type":"message_start","message":{"model":"claude-test","usage":{"input_tokens":5}}}
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
@@ -114,9 +114,9 @@ data: {"type":"message_stop"}
`
// TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget proves the
// primary recovery: a max_tokens truncation with only a thinking block retries
// exactly once with the token budget doubled and surfaces the retry's text.
// TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget 证明
// 主恢复路径:只有 thinking 块的 max_tokens 截断会
// 恰好重试一次、token 预算翻倍,并浮出重试的文本。
func TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "application/json", anthropicThinkingOnlyResponse, anthropicEndTurnTextResponse)
@@ -149,9 +149,9 @@ func TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget(t *testing.T)
}
}
// TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint proves the
// safety net: when the retry is truncated too, the caller gets a non-empty
// placeholder instead of a silent empty reply, and the thinking never leaks.
// TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint 证明
// 兜底网:连重试也被截断时,调用方拿到非空
// 占位,而不是静默空回复,且思考永不泄漏。
func TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "application/json", anthropicThinkingOnlyResponse, anthropicThinkingOnlyResponse)
@@ -184,9 +184,9 @@ func TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint(t *test
}
}
// TestAnthropicChat_NormalStop_NoRetry proves a normal end_turn answer and a
// native tool_use round (whose body is legitimately empty) are left untouched:
// no retry, no fallback hint.
// TestAnthropicChat_NormalStop_NoRetry 证明正常的 end_turn 答复与
// 原生 tool_use 回合(其正文合法地为空)保持不动:
// 不重试、不补兜底占位。
func TestAnthropicChat_NormalStop_NoRetry(t *testing.T) {
t.Parallel()
t.Run("end_turn", func(t *testing.T) {
@@ -224,9 +224,9 @@ func TestAnthropicChat_NormalStop_NoRetry(t *testing.T) {
})
}
// TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget proves the
// streaming retry: the retry's thinking is suppressed (no duplicate block) while
// its text streams through, and the doubled budget is sent.
// TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget 证明
// 流式重试:重试产生的思考被抑制(无重复块),
// 而其文本流过,且发送的是翻倍预算。
func TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "text/event-stream", anthropicTruncatedThinkingSSE, anthropicEndTurnTextSSE)
@@ -265,10 +265,10 @@ func TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget(t *testi
}
}
// TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint proves the
// streaming safety net: when the retry is truncated too, a non-empty placeholder
// is emitted as a text segment (and returned as the body) so the frontend is not
// left with a silent truncated turn.
// TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint 证明
// 流式兜底网:连重试也被截断时,会以文本片段发出非空
// 占位(并把它作为正文返回),因此前端不会
// 停在一个静默截断的回合上。
func TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "text/event-stream", anthropicTruncatedThinkingSSE, anthropicTruncatedThinkingSSE)
@@ -301,7 +301,7 @@ func TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint(t *test
}
}
// TestTruncationBudget covers the budget arithmetic that bounds the retry.
// TestTruncationBudget 覆盖约束重试的预算算术。
func TestTruncationBudget(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -328,9 +328,9 @@ func TestTruncationBudget(t *testing.T) {
}
}
// TestOpenAIChat_StopReasonMapping asserts the OpenAI adapter fills the unified
// Response.StopReason from finish_reason on both the non-streaming and streaming
// paths, so a "length" truncation is identifiable across protocols.
// TestOpenAIChat_StopReasonMapping 断言 OpenAI 适配器在非流式与
// 流式两条路径上都由 finish_reason 填充统一的
// Response.StopReason,因此 "length" 截断在各协议间都可识别。
func TestOpenAIChat_StopReasonMapping(t *testing.T) {
t.Parallel()
t.Run("non_streaming", func(t *testing.T) {