docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
`chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
`rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
This commit is contained in:
1 parent
f5cf117482
commit
3dc1232bc2
54 files changed
+1923
-1923
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
// Package backthink implements the M8 autonomous background-think service
|
||||
// (DevDocs/design/12-后台思考与主动.md §0-§3).
|
||||
// backthink 包实现 M8 自主后台思考服务
|
||||
// (DevDocs/design/12-后台思考与主动.md §0-§3)。
|
||||
//
|
||||
// It runs unattended thinking passes on two triggers:
|
||||
// 它在两类触发下运行无人值守的思考轮:
|
||||
//
|
||||
// - A periodic heartbeat (config.Background.IntervalSec) that asks the injected
|
||||
// runtime to reflect and is allowed to call tools (IoT, calendar, reminders)
|
||||
// but must not emit chat-style messages by default.
|
||||
// - Asynchronous events on the in-process bus (iot.device_update,
|
||||
// reminder.trigger, system.event) that wake a thinking pass.
|
||||
// - 周期性心跳(config.Background.IntervalSec),请求注入的运行时
|
||||
// 反思,并允许调用工具(IoT、日历、提醒),
|
||||
// 但默认不得发出 chat 风格的消息。
|
||||
// - 进程内总线上的异步事件(iot.device_update、
|
||||
// reminder.trigger、system.event),它们唤醒一轮思考。
|
||||
//
|
||||
// Each pass produces a conclusion whose structured tail decides whether she
|
||||
// wants to speak at all (design/20 §2 P2), then consults Decide — the importance
|
||||
// cascade restored as the production decision path — to judge whether now is an
|
||||
// acceptable moment (event importance / time-of-day / user state such as heart
|
||||
// rate, sleep, work, busy). A critical safety event punches through work/busy
|
||||
// and quiet hours (裁决 07.4.2), and a heartbeat pass is never held by a
|
||||
// long-silent activity gap (裁决 07.4.3). When it is worth interrupting the
|
||||
// push goes to the active client first and falls back to the IM bridge. The
|
||||
// proactive-push rate gate of DevDocs/design/11 §6 governs the periodic heartbeat
|
||||
// only; an event-driven push bypasses it (owner mandate: "该发就发") subject to
|
||||
// same-event dedup (see event_dedup.go). Every pass is persisted to SQLite via
|
||||
// Store so the M9/M10 monitor panel can render the background-thinking page
|
||||
// (design/12 §4).
|
||||
// 每轮产出一段结论,其结构化尾段决定她
|
||||
// 到底想不想说话(design/20 §2 P2),随后咨询 Decide——
|
||||
// 作为生产决策路径恢复的重要度级联——来判断此刻是否
|
||||
// 合适(事件重要度 / 时段 / 用户状态,如心率、
|
||||
// 睡眠、工作、忙碌)。critical 安全事件穿透工作/忙碌
|
||||
// 与安静时段(裁决 07.4.2),而心跳轮永远不会被长期
|
||||
// 静默的活动间隔拦下(裁决 07.4.3)。当值得打扰时,
|
||||
// 推送先走活跃客户端,再降级到 IM 桥。
|
||||
// DevDocs/design/11 §6 的主动推送频率门只约束
|
||||
// 周期性心跳;事件驱动的推送绕过它(主人要求:"该发就发"),
|
||||
// 但受同一事件去重约束(见 event_dedup.go)。每轮都经
|
||||
// Store 持久化到 SQLite,好让 M9/M10 监控面板能渲染后台思考页
|
||||
// (design/12 §4)。
|
||||
//
|
||||
// The package is concurrency-safe: a mutex guards the lifecycle state, the
|
||||
// injected store/pusher/thinker are required to be concurrency-safe by contract,
|
||||
// and the internal publish path never waits on a slow consumer.
|
||||
// 本包并发安全:一把互斥锁守护生命周期状态,
|
||||
// 注入的 store/pusher/thinker 按契约要求并发安全,
|
||||
// 且内部发布路径绝不会等待慢消费者。
|
||||
package backthink
|
||||
|
||||
import (
|
||||
@@ -43,68 +43,68 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by this package. Use errors.Is to test for them; they
|
||||
// are returned directly or wrapped with %w so the underlying cause stays intact.
|
||||
// 本包返回的哨兵错误。用 errors.Is 判定;它们被直接
|
||||
// 返回或用 %w 包装,因此底层原因保持完整。
|
||||
var (
|
||||
// ErrNilDB is returned when Backthink or Store is built with a nil database.
|
||||
// 用 nil 数据库构建 Backthink 或 Store 时返回 ErrNilDB。
|
||||
ErrNilDB = errors.New("backthink: nil database handle")
|
||||
// ErrNoThinker is returned when New is given no Thinker.
|
||||
// New 未收到 Thinker 时返回 ErrNoThinker。
|
||||
ErrNoThinker = errors.New("backthink: nil thinker")
|
||||
// ErrNoLoop is returned by LoopThinker when it wraps a nil loop.
|
||||
// LoopThinker 包装了 nil loop 时由它返回 ErrNoLoop。
|
||||
ErrNoLoop = errors.New("backthink: nil loop")
|
||||
)
|
||||
|
||||
// Thinker runs one unattended thinking pass over the injected runtime. It is the
|
||||
// seam between the service and the loop/agent: the production wiring hands back a
|
||||
// LoopThinker over a *loop.Loop, while tests inject a mock. Implementations may
|
||||
// call tools (IoT, calendar, reminders) and must be concurrency-safe.
|
||||
// Thinker 在注入的运行时上跑一轮无人值守的思考。它是
|
||||
// 服务与 loop/agent 之间的接缝:生产接线交回一个基于
|
||||
// *loop.Loop 的 LoopThinker,而测试注入 mock。实现可以
|
||||
// 调用工具(IoT、日历、提醒),且必须并发安全。
|
||||
type Thinker interface {
|
||||
// Think invokes one autonomous thinking pass for the given session/trigger
|
||||
// and returns the concluded text.
|
||||
// Think 为给定的会话/触发调用一轮自主思考,
|
||||
// 并返回结论文本。
|
||||
Think(ctx context.Context, sessionID, trigger string) (string, error)
|
||||
}
|
||||
|
||||
// Deps is the injected dependency set for a Backthink. Only Thinker and DB are
|
||||
// required; the rest degrade gracefully (a nil Bus disables event triggers, a nil
|
||||
// State yields a neutral user state, and a nil Pusher is built from Active/IM).
|
||||
// Deps 是注入给 Backthink 的依赖集合。只有 Thinker 与 DB 是
|
||||
// 必需的;其余优雅降级(Bus 为 nil 则关闭事件触发,State 为
|
||||
// nil 则给出中性用户状态,Pusher 为 nil 则由 Active/IM 构建)。
|
||||
type Deps struct {
|
||||
// Bus is the event-trigger source. Nil disables event-driven thinking.
|
||||
// Bus 是事件触发源。nil 则关闭事件驱动思考。
|
||||
Bus bus.Bus
|
||||
// Thinker runs one unattended thinking pass. Required.
|
||||
// Thinker 跑一轮无人值守的思考。必需。
|
||||
Thinker Thinker
|
||||
// State supplies the user's current condition for the interrupt decision.
|
||||
// Nil yields a neutral state.
|
||||
// State 提供用户当前状况,用于打扰决策。
|
||||
// nil 则给出中性状态。
|
||||
State StateProvider
|
||||
// Recent supplies the most recent main-session messages so the activity gate
|
||||
// can sense whether the owner just chatted (recent-conversation cooldown and
|
||||
// idle-window suppression). Nil disables activity sensing.
|
||||
// Recent 提供主会话最近的消息,好让活动门
|
||||
// 感知主人是否刚聊过(最近对话冷却与
|
||||
// 静默窗口抑制)。nil 则关闭活动感知。
|
||||
Recent recentProvider
|
||||
// Pusher delivers a proactive message. When nil it is built from Active (the
|
||||
// active-client sink) and IM (the IM bridge sink) plus Gate.
|
||||
// Pusher 投递主动消息。为 nil 时由 Active(活跃客户端槽)
|
||||
// 与 IM(IM 桥槽)加上 Gate 构建。
|
||||
Pusher Pusher
|
||||
// Active is the active web/native client sink, preferred for pushes.
|
||||
// Active 是活跃 Web/原生客户端槽,推送首选。
|
||||
Active ActiveClient
|
||||
// IM is the IM bridge sink, used as the fallback when no active client is up.
|
||||
// IM 是 IM 桥槽,在没有活跃客户端时用作降级。
|
||||
IM IMAdapter
|
||||
// Exporter mirrors a proactive message to the external push outlet (the
|
||||
// owner's self-hosted unified push service, then Getui). Optional: nil
|
||||
// disables the hook. It never blocks or breaks the Active/IM delivery.
|
||||
// Exporter 把主动消息镜像到外部推送出口(主人的自托管
|
||||
// 统一推送服务,其次是个推)。可选:nil 则
|
||||
// 关闭该钩子。它绝不阻塞或破坏 Active/IM 投递。
|
||||
Exporter PushExporter
|
||||
// Gate is the proactive-push rate limiter. Nil builds a default from Config.
|
||||
// Gate 是主动推送限流器。nil 则由 Config 构建默认值。
|
||||
Gate PushGate
|
||||
// DB is the SQLite handle for the think store. Required.
|
||||
// DB 是思考存储所用的 SQLite 句柄。必需。
|
||||
DB *sql.DB
|
||||
// SessionID scopes a think. Empty uses defaultSessionID.
|
||||
// SessionID 限定一次思考的作用域。为空则用 defaultSessionID。
|
||||
SessionID string
|
||||
// Now is the wall-clock source for decision/record timestamps. Nil uses
|
||||
// time.Now, which makes time-dependent behaviour injectable in tests.
|
||||
// Now 是决策/记录时间戳的墙钟来源。nil 则用
|
||||
// time.Now,从而让依赖时间的行为在测试中可注入。
|
||||
Now func() time.Time
|
||||
// Log is the base logger, tagged with source "services/backthink".
|
||||
// Log 是基础日志器,来源标记为 "services/backthink"。
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Backthink is the autonomous background-think service. Build it with New, start
|
||||
// the heartbeat and subscriptions with Start, and stop them with Stop.
|
||||
// Backthink 是自主后台思考服务。用 New 构建,用 Start 启动
|
||||
// 心跳与订阅,用 Stop 停止它们。
|
||||
type Backthink struct {
|
||||
cfg Config
|
||||
deps Deps
|
||||
@@ -121,17 +121,17 @@ type Backthink struct {
|
||||
subs []func()
|
||||
wg sync.WaitGroup
|
||||
|
||||
// eventMu guards the same-event dedup guard (event_dedup.go). It is separate
|
||||
// from the lifecycle lock so a dedup decision never blocks a sink swap.
|
||||
// eventMu 守护同一事件去重器(event_dedup.go)。它与
|
||||
// 生命周期锁分开,因此去重决策绝不会阻塞槽位换入。
|
||||
eventMu sync.Mutex
|
||||
lastEventKey string
|
||||
}
|
||||
|
||||
// New builds a Backthink from cfg and deps, applying defaults and validating the
|
||||
// required dependencies. It creates the SQLite think store (via Store) up front
|
||||
// and resolves the push path (an injected Pusher, or a default built from
|
||||
// Active/IM and the rate Gate). It returns ErrNilDB when DB is nil and
|
||||
// ErrNoThinker when Thinker is nil.
|
||||
// New 由 cfg 与 deps 构建 Backthink,套用默认值并校验
|
||||
// 必需依赖。它先行创建 SQLite 思考存储(经 Store)
|
||||
// 并解出推送路径(注入的 Pusher,或由
|
||||
// Active/IM 与频率 Gate 构建的默认值)。DB 为 nil 时返回 ErrNilDB,
|
||||
// Thinker 为 nil 时返回 ErrNoThinker。
|
||||
func New(cfg Config, deps Deps) (*Backthink, error) {
|
||||
if deps.Thinker == nil {
|
||||
return nil, ErrNoThinker
|
||||
@@ -147,7 +147,7 @@ func New(cfg Config, deps Deps) (*Backthink, error) {
|
||||
return nil, fmt.Errorf("backthink: new: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the gate up front so the sink setters can rebuild the pusher with it.
|
||||
// 提前解出频率门,好让槽位设置器能与它一起重建推送器。
|
||||
gate := deps.Gate
|
||||
if gate == nil {
|
||||
gate = newRateGate(cfg.PushLimit, cfg.PushWindow, cfg.PushCooldown, cfg.KindWindow, log)
|
||||
@@ -178,10 +178,10 @@ func New(cfg Config, deps Deps) (*Backthink, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins the background work: the heartbeat ticker and, when a Bus is
|
||||
// configured, the event subscriptions. It is idempotent and safe to call twice; a
|
||||
// second call is a no-op. The provided ctx (may be nil) is the parent of all
|
||||
// internal work: cancelling it stops the service exactly like Stop.
|
||||
// Start 启动后台工作:心跳定时器,以及(配置了 Bus 时)
|
||||
// 事件订阅。它是幂等的,调用两次安全;
|
||||
// 第二次调用是空操作。传入的 ctx(可为 nil)是所有
|
||||
// 内部工作的父级:取消它会像 Stop 一样停止服务。
|
||||
func (b *Backthink) Start(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -209,11 +209,11 @@ func (b *Backthink) Start(ctx context.Context) {
|
||||
"session_id", b.sessionID)
|
||||
}
|
||||
|
||||
// Stop halts the heartbeat and unsubscribes every bus subscription, then waits
|
||||
// for the in-flight thinking pass (bounded by ThinkTimeout) to drain. It is
|
||||
// idempotent; a second call returns nil immediately. When ctx is cancelled before
|
||||
// the drain completes it returns a wrapped context error rather than leaking the
|
||||
// goroutine.
|
||||
// Stop 停止心跳、退订所有总线订阅,然后等待
|
||||
// 进行中的思考轮(受 ThinkTimeout 约束)排空。它是
|
||||
// 幂等的;第二次调用立即返回 nil。若在排空完成前 ctx 已被
|
||||
// 取消,则返回包装后的 context 错误,而不是泄漏
|
||||
// goroutine。
|
||||
func (b *Backthink) Stop(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -248,22 +248,22 @@ func (b *Backthink) Stop(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// List returns the most recent thinking records for the M9/M10 monitor panel,
|
||||
// newest first. A non-positive limit selects the store default.
|
||||
// List 返回 M9/M10 监控面板用的最近思考记录,
|
||||
// 最新的在前。非正的 limit 选用存储默认值。
|
||||
func (b *Backthink) List(ctx context.Context, limit int) ([]Record, error) {
|
||||
return b.store.List(ctx, limit)
|
||||
}
|
||||
|
||||
// triggers returns the bus subjects the service watches for event-driven
|
||||
// thinking. It is a method (not a package var) so no package-level global slice
|
||||
// appears, per the coding constraint.
|
||||
// triggers 返回本服务为事件驱动思考所监听的总线主题。
|
||||
// 它是一个方法(而非包级变量),因此不会出现包级全局
|
||||
// 切片,符合编码约束。
|
||||
func (b *Backthink) triggers() []string {
|
||||
return []string{iot.DeviceUpdateSubject, SubjectReminderTrigger, SubjectSystemEvent}
|
||||
}
|
||||
|
||||
// subscribe registers one handler per trigger subject. Each subscription is
|
||||
// bound to ctx so cancelling it removes the registration; the unregister
|
||||
// functions are kept so Stop can also tear them down explicitly.
|
||||
// subscribe 为每个触发主题注册一个处理器。每个订阅都
|
||||
// 绑定到 ctx,因此取消它会移除注册;退订函数
|
||||
// 被保留下来,好让 Stop 也能显式拆除它们。
|
||||
func (b *Backthink) subscribe(ctx context.Context) {
|
||||
for _, subj := range b.triggers() {
|
||||
unsub, err := b.deps.Bus.Subscribe(ctx, subj, b.onEvent)
|
||||
@@ -277,11 +277,11 @@ func (b *Backthink) subscribe(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// onEvent is the bus handler for event-triggered thinking. It runs on the bus's
|
||||
// own worker goroutine, so it must not block on another subscriber. It first
|
||||
// applies the minimal-disturbance gate (design/12 §6 策略一): a low-value system
|
||||
// event is downgraded to a registration-only record rather than waking a full
|
||||
// thinking pass, so platform/system noise cannot flood the thinker.
|
||||
// onEvent 是事件驱动思考的总线处理器。它跑在总线自己的
|
||||
// worker goroutine 上,所以不得阻塞在另一个订阅者上。它先
|
||||
// 套用最小打扰门(design/12 §6 策略一):低价值的系统
|
||||
// 事件被降级为仅登记一条记录,而不唤醒一整轮
|
||||
// 思考,因此平台/系统噪声无法淹没思考器。
|
||||
func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error {
|
||||
tr := triggerForEvent(e)
|
||||
if !b.shouldThink(tr) {
|
||||
@@ -300,11 +300,11 @@ func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldThink gates event-triggered thinking (design/12 §6 策略一). On a public
|
||||
// platform a generic system event is low value, so only a high-importance
|
||||
// (owner-relevant) system event wakes a pass. IoT device updates and reminder
|
||||
// triggers are owner-relevant / high value and are never gated, so the productive
|
||||
// triggers stay fully functional.
|
||||
// shouldThink 为事件驱动思考把关(design/12 §6 策略一)。在公开
|
||||
// 平台上,泛化的系统事件价值低,所以只有高重要度
|
||||
// (与主人相关)的系统事件才唤醒一轮。IoT 设备更新与提醒
|
||||
// 触发与主人相关 / 价值高,从不受门控,因此这些有产出的
|
||||
// 触发保持完全可用。
|
||||
func (b *Backthink) shouldThink(tr Trigger) bool {
|
||||
if tr.Source == SubjectSystemEvent {
|
||||
return tr.Import >= ImportanceHigh
|
||||
@@ -312,11 +312,11 @@ func (b *Backthink) shouldThink(tr Trigger) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// tickLoop is the heartbeat goroutine. Per 裁决 07.4.4 (design/12 §1) it thinks
|
||||
// once immediately on process start — producing a humanized startup greeting —
|
||||
// before settling into one thinking pass per tick; it exits when ctx is
|
||||
// cancelled. The accepted side effect is that every process restart wakes one
|
||||
// immediate thinking round.
|
||||
// tickLoop 是心跳 goroutine。按裁决 07.4.4(design/12 §1),它在进程
|
||||
// 启动时立即思考一次——产出一句拟人化的启动问候——
|
||||
// 随后稳定为每 tick 一轮思考;ctx 被取消时
|
||||
// 退出。可接受的副作用是每次进程重启都会唤醒
|
||||
// 一轮即时思考。
|
||||
func (b *Backthink) tickLoop(ctx context.Context) {
|
||||
defer b.wg.Done()
|
||||
// 启动即思考:不等满一个心跳周期。已取消的上下文直接退出,不落一条失败的
|
||||
@@ -339,10 +339,10 @@ func (b *Backthink) tickLoop(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// think runs one unattended thinking pass: invoke the runtime, read the user
|
||||
// state, decide whether to interrupt, push when warranted and persist the record.
|
||||
// It is concurrency-safe provided the injected Thinker, StateProvider and Pusher
|
||||
// are concurrency-safe, which they are by contract.
|
||||
// think 跑一轮无人值守的思考:调用运行时、读取用户
|
||||
// 状态、判断是否打扰、值得时推送并持久化记录。
|
||||
// 只要注入的 Thinker、StateProvider 与 Pusher
|
||||
// 并发安全,它就是并发安全的——按契约它们都是。
|
||||
func (b *Backthink) think(parent context.Context, tr Trigger) {
|
||||
at := b.now()
|
||||
cctx, cancel := context.WithTimeout(parent, b.cfg.ThinkTimeout)
|
||||
@@ -363,9 +363,9 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
// P2 (design/20 §2): her own conclusion decides whether she wants to speak
|
||||
// and to which channel. parseProactive recovers the decision robustly and
|
||||
// fails safe (malformed/ambiguous → no push).
|
||||
// P2(design/20 §2):她自己的结论决定她想不想说、
|
||||
// 以及发到哪个渠道。parseProactive 稳健地恢复该决策,
|
||||
// 并失败安全(格式错乱/含糊 → 不推送)。
|
||||
message, channelHint, push := parseProactive(conclusion)
|
||||
|
||||
state := b.currentState(cctx)
|
||||
@@ -393,9 +393,9 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
|
||||
|
||||
pushRes := PushResult{Route: RouteNone, Detail: decision.Reason}
|
||||
if shouldPush {
|
||||
// Same-event dedup: a repeating identical event is suppressed after the
|
||||
// first push (防刷), while a genuinely new event still goes out. Heartbeats
|
||||
// are never deduped (they reset the chain) and stay gate-governed.
|
||||
// 同一事件去重:重复的相同事件在首次推送后被抑制
|
||||
// (防刷),而真正的新事件仍会发出。心跳
|
||||
// 从不去重(它们会重置该链),并保持受门控管辖。
|
||||
if b.eventRedundant(tr) {
|
||||
pushRes = PushResult{Route: RouteNone, Detail: "duplicate event push suppressed"}
|
||||
} else {
|
||||
@@ -422,8 +422,8 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
|
||||
}
|
||||
}
|
||||
|
||||
// currentState reads the user's current condition for the interrupt decision,
|
||||
// falling back to a neutral state (and a warn log) when the provider errors.
|
||||
// currentState 读取用户当前状况用于打扰决策,
|
||||
// 提供者报错时退回中性状态(并记一条 warn 日志)。
|
||||
func (b *Backthink) currentState(ctx context.Context) UserState {
|
||||
if b.deps.State == nil {
|
||||
return UserState{}
|
||||
@@ -436,12 +436,12 @@ func (b *Backthink) currentState(ctx context.Context) UserState {
|
||||
return s
|
||||
}
|
||||
|
||||
// lastUserActivity reports the wall-clock time of the owner's most recent message
|
||||
// in the main session, and whether any user message was found. It is the activity
|
||||
// signal for the activity gate (design/12 §6): a nil provider, a failed read or an
|
||||
// empty tail yield has=false so the activity gate falls through to the current
|
||||
// behaviour rather than guessing. The most recent user message is the newest
|
||||
// RoleUser entry in the recent tail (Recent returns oldest-first).
|
||||
// lastUserActivity 报告主人最近一条消息的墙钟时间
|
||||
// (在主会话里),以及是否找到了任何用户消息。它是活动
|
||||
// 门的活动信号(design/12 §6):提供者为 nil、读取失败或
|
||||
// 尾部为空都得到 has=false,因此活动门会落到当前
|
||||
// 行为而不是靠猜。最近的一条用户消息是最近尾部里
|
||||
// 最新的 RoleUser 条目(Recent 返回的是最旧在前)。
|
||||
func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) {
|
||||
if b.deps.Recent == nil {
|
||||
return time.Time{}, false
|
||||
@@ -458,14 +458,14 @@ func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// record persists a thinking pass. It uses a background context so an audit row
|
||||
// is written even when the think's own context has already been cancelled.
|
||||
// record 持久化一轮思考。它使用后台 context,因此即便
|
||||
// 该次思考自身的 context 已被取消,审计行仍会写入。
|
||||
func (b *Backthink) record(r Record) {
|
||||
if _, err := b.store.Append(context.Background(), r); err != nil {
|
||||
b.log.Error("backthink record failed", "error", err)
|
||||
}
|
||||
// Surface the persisted pass on the WS stream so the frontend's backthink
|
||||
// consumer (or its default fallback) observes it without polling the store.
|
||||
// 把持久化的这一轮暴露到 WS 流上,好让前端的 backthink
|
||||
// 消费者(或其默认兜底)无需轮询存储就能观测到它。
|
||||
if b.deps.Bus != nil {
|
||||
if err := b.deps.Bus.Publish(context.Background(), "ws.backthink", r); err != nil {
|
||||
b.log.Warn("backthink bus publish failed", "error", err)
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
)
|
||||
|
||||
// newBackthink builds a Backthink with a fresh in-memory DB and the given deps,
|
||||
// returning the zeroed deps filled with the DB handle so tests can inspect deps.
|
||||
// newBackthink 用一份新的内存 DB 与给定 deps 构建 Backthink,
|
||||
// 并返回填好 DB 句柄的 deps,好让测试能检查 deps。
|
||||
func newBackthink(t *testing.T, cfg Config, deps Deps) (*Backthink, Deps) {
|
||||
t.Helper()
|
||||
deps.DB = newTestDB(t)
|
||||
@@ -34,9 +34,9 @@ func TestNew_Validation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfig_Defaults verifies the tightened proactive-push defaults: a 15-minute
|
||||
// heartbeat, at most 1 push per 60 minutes, a 30-minute post-push cooldown, a
|
||||
// 10-minute recent-conversation cooldown, and a 2-hour per-flavor window.
|
||||
// TestConfig_Defaults 验证收紧后的主动推送默认值:15 分钟
|
||||
// 心跳、每 60 分钟最多 1 次推送、推送后 30 分钟冷却、
|
||||
// 最近对话 10 分钟冷却,以及按风格 2 小时的窗口。
|
||||
func TestConfig_Defaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := normalize(Config{})
|
||||
@@ -192,8 +192,8 @@ func TestBackthink_EventTrigger_NoPush(t *testing.T) {
|
||||
|
||||
func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) {
|
||||
t.Parallel()
|
||||
// design/12 §6 策略一: a low-value system event on a public platform does not
|
||||
// wake a full thinking pass — it is downgraded to a registration-only record.
|
||||
// design/12 §6 策略一:公开平台上的低价值系统事件不会
|
||||
// 唤醒一整轮思考——它被降级为仅登记一条记录。
|
||||
thinker := &mockThinker{text: "should not be called"}
|
||||
bs := bus.New()
|
||||
b, _ := newBackthink(t, Config{}, Deps{
|
||||
@@ -240,8 +240,8 @@ func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) {
|
||||
|
||||
func TestBackthink_SystemEventHighValueThinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A high-importance (owner-relevant) system event still wakes a thinking pass —
|
||||
// the denoise gate only downgrades low-value noise (design/12 §6 策略一).
|
||||
// 高重要度(与主人相关)的系统事件仍会唤醒一轮思考——
|
||||
// 降噪门只降级低价值噪声(design/12 §6 策略一)。
|
||||
thinker := &mockThinker{text: "important notice"}
|
||||
bs := bus.New()
|
||||
b, _ := newBackthink(t, Config{}, Deps{
|
||||
@@ -289,8 +289,8 @@ func TestBackthink_List_NewestFirst(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
// The single-row listing must be the same newest record as the head of the
|
||||
// full listing, and the full listing must be strictly newest-first.
|
||||
// 单行列表里的那一条必须与完整列表的头部是同一条最新
|
||||
// 记录,且完整列表必须是严格的由新到旧。
|
||||
if top[0].ID != all[0].ID {
|
||||
t.Fatalf("List(1)[0].ID = %d, want %d (== newest)", top[0].ID, all[0].ID)
|
||||
}
|
||||
@@ -312,7 +312,7 @@ func TestBackthink_Stop_NoLeak(t *testing.T) {
|
||||
if err := b.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
// Idempotent.
|
||||
// 幂等。
|
||||
if err := b.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("second Stop: %v", err)
|
||||
}
|
||||
@@ -331,9 +331,9 @@ func TestBackthink_Stop_UnsubscribesBus(t *testing.T) {
|
||||
b, _ := newBackthink(t, Config{Interval: 25 * time.Millisecond}, Deps{Bus: bs, Thinker: thinker})
|
||||
|
||||
b.Start(context.Background())
|
||||
// Confirm the subscription is live by triggering one think (identified by its
|
||||
// persisted record, so the 07.4.4 startup round and heartbeat ticks with their
|
||||
// own trigger strings cannot mask or overwrite it).
|
||||
// 通过触发一次思考来确认订阅是活的(以它持久化的
|
||||
// 记录为准,这样 07.4.4 的启动轮与心跳 tick 各自的
|
||||
// 触发字符串不会掩盖或覆盖它)。
|
||||
if err := bs.Publish(context.Background(), SubjectReminderTrigger, "first"); err != nil {
|
||||
t.Fatalf("Publish: %v", err)
|
||||
}
|
||||
@@ -363,10 +363,10 @@ func TestBackthink_Stop_UnsubscribesBus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackthink_EventPush_DedupSameEvent verifies the same-event dedup guard: a
|
||||
// repeating identical event (same source + summary) is suppressed after the first
|
||||
// push (防刷), while a genuinely different event still goes out. The duplicate is
|
||||
// recorded as suppressed (route none), not delivered.
|
||||
// TestBackthink_EventPush_DedupSameEvent 验证同一事件去重门:
|
||||
// 重复的相同事件(同源 + 同摘要)在首次推送后
|
||||
// 被抑制(防刷),而真正不同的事件仍会发出。重复的那条
|
||||
// 被记为已抑制(route none),而不是投递出去。
|
||||
func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
thinker := &mockThinker{text: "washing finished, clothes ready"}
|
||||
@@ -382,9 +382,9 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
|
||||
Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished",
|
||||
}})
|
||||
|
||||
b.think(context.Background(), ev) // first: pushes
|
||||
b.think(context.Background(), ev) // second: same event -> suppressed
|
||||
b.think(context.Background(), ev) // third: same event -> suppressed
|
||||
b.think(context.Background(), ev) // 第一次:推送
|
||||
b.think(context.Background(), ev) // 第二次:同一事件 -> 被抑制
|
||||
b.think(context.Background(), ev) // 第三次:同一事件 -> 被抑制
|
||||
|
||||
if got := pusher.count(); got != 1 {
|
||||
t.Fatalf("pusher calls = %d, want 1 (repeating same event deduped)", got)
|
||||
@@ -396,7 +396,7 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
|
||||
if len(recs) < 3 {
|
||||
t.Fatalf("len(recs) = %d, want at least 3 (one per think)", len(recs))
|
||||
}
|
||||
// Newest first: the two most recent records are the suppressed duplicates.
|
||||
// 最新的在前:最近的这两条就是被抑制的重复。
|
||||
for i := 0; i < 2; i++ {
|
||||
if recs[i].PushResult.Pushed || recs[i].PushResult.Route != RouteNone {
|
||||
t.Fatalf("recs[%d] = %+v, want suppressed duplicate", i, recs[i].PushResult)
|
||||
@@ -405,12 +405,12 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
|
||||
t.Fatalf("recs[%d].Detail = %q, want it to mention duplicate", i, recs[i].PushResult.Detail)
|
||||
}
|
||||
}
|
||||
// The third-most-recent record is the first (delivered) push.
|
||||
// 第三新的那条是首次(已投递的)推送。
|
||||
if !recs[2].PushResult.Pushed || recs[2].PushResult.Route != RouteActiveClient {
|
||||
t.Fatalf("recs[2] = %+v, want the first delivered push", recs[2].PushResult)
|
||||
}
|
||||
|
||||
// A genuinely different event resets the chain and goes out.
|
||||
// 真正不同的事件会重置该链并发出。
|
||||
ev2 := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{
|
||||
Device: iot.Device{ID: "dryer", Type: "sensor"}, Action: "finished",
|
||||
}})
|
||||
@@ -418,8 +418,8 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
|
||||
if got := pusher.count(); got != 2 {
|
||||
t.Fatalf("pusher calls after a different event = %d, want 2", got)
|
||||
}
|
||||
// After the different event, the original identical event is no longer a
|
||||
// consecutive duplicate, so it pushes again — the guard only dedups consecutively.
|
||||
// 在出现不同事件之后,原先那个相同事件不再是
|
||||
// 连续重复,因此它会再次推送——该门只去重连续重复。
|
||||
b.think(context.Background(), ev)
|
||||
if got := pusher.count(); got != 3 {
|
||||
t.Fatalf("pusher calls after an intervening different event = %d, want 3", got)
|
||||
|
||||
@@ -9,83 +9,83 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// Defaults applied by normalize when a Config field is left at its zero value.
|
||||
// They are compile-time constants so no magic number appears in the run path.
|
||||
// normalize 在 Config 字段留为零值时套用的默认值。
|
||||
// 它们是编译期常量,因此运行路径里不出现魔法数字。
|
||||
const (
|
||||
// defaultIntervalSec is the heartbeat period in seconds when unused
|
||||
// (15 minutes: the autonomous reflection is deliberately low-frequency).
|
||||
// defaultIntervalSec 是未使用时的心跳周期秒数
|
||||
// (15 分钟:自主反思刻意保持低频)。
|
||||
defaultIntervalSec = 900
|
||||
// defaultQuietStartHour and defaultQuietEndHour bound the do-not-disturb
|
||||
// window in local clock hours (00:00-07:00 vs 23:00-07:00: the window may
|
||||
// wrap midnight; two equal non-zero hours disable it).
|
||||
// defaultQuietStartHour 与 defaultQuietEndHour 以本地钟点约束免打扰
|
||||
// 窗口(00:00-07:00 对 23:00-07:00:窗口可以
|
||||
// 跨午夜;两个相等的非零钟点会禁用该窗口)。
|
||||
defaultQuietStartHour = 23
|
||||
defaultQuietEndHour = 7
|
||||
// defaultPushLimit caps proactive pushes within defaultPushWindow to honor
|
||||
// the anti-spam rule of design/11 §6 (QQ wind control, iLink window). The
|
||||
// default drops to 1 so the owner is interrupted at most once per hour.
|
||||
// defaultPushLimit 限制 defaultPushWindow 内的主动推送次数,以遵守
|
||||
// design/11 §6 的反骚扰规则(QQ 风控、iLink 窗口)。
|
||||
// 默认降到 1,因此主人每小时最多被打扰一次。
|
||||
defaultPushLimit = 1
|
||||
defaultPushWindow = 60 * time.Minute
|
||||
// defaultPushCooldown is the quiet gap after a successful proactive push,
|
||||
// so a successful delivery is not immediately repeated.
|
||||
// defaultPushCooldown 是一次成功主动推送之后的安静间隔,
|
||||
// 以免一次成功投递被立即重复。
|
||||
defaultPushCooldown = 30 * time.Minute
|
||||
// defaultRecentChatCooldown is the gap after the owner's last message within
|
||||
// which an autonomous push is held, so a fresh conversation is not interrupted.
|
||||
// defaultRecentChatCooldown 是主人最后一条消息之后的间隔,
|
||||
// 在此期间自主推送被扣住,以免打断刚开始的对话。
|
||||
defaultRecentChatCooldown = 10 * time.Minute
|
||||
// defaultKindWindow bounds how often any single push flavor (问候/提醒/分享/
|
||||
// 关心) may repeat, so the same style cannot spam within it.
|
||||
// defaultKindWindow 限制任何一种推送风格(问候/提醒/分享/
|
||||
// 关心)的重复频率,使同一风格无法在窗口内刷屏。
|
||||
defaultKindWindow = 2 * time.Hour
|
||||
// defaultThinkTimeout bounds a single unattended thought so it can never
|
||||
// wedge a worker goroutine.
|
||||
// defaultThinkTimeout 约束单次无人值守思考,因此它绝不会
|
||||
// 卡住 worker goroutine。
|
||||
defaultThinkTimeout = 30 * time.Second
|
||||
// defaultSessionID scopes a think when the caller supplies none. It is the
|
||||
// loop.Run durability key, deliberately distinct from history.MainSessionID so
|
||||
// an autonomous think never collides with the live main session's checkpoint
|
||||
// store (channelbridge saves/resumes under the main id). The think still sees
|
||||
// the main conversation: LoopThinker reads history.MainSessionID for context and
|
||||
// runs under loop.OwnerEnv (design/20 §2).
|
||||
// defaultSessionID 在调用方未提供时限定一次思考的作用域。它是
|
||||
// loop.Run 的持久化键,刻意与 history.MainSessionID 不同,
|
||||
// 这样自主思考绝不会与活跃主会话的检查点
|
||||
// 存储冲突(channelbridge 在主 id 下保存/恢复)。该次思考仍能看到
|
||||
// 主对话:LoopThinker 读 history.MainSessionID 取上下文,
|
||||
// 并在 loop.OwnerEnv 下运行(design/20 §2)。
|
||||
defaultSessionID = "sess-backthink"
|
||||
)
|
||||
|
||||
// DefaultSessionID is the exported loop.Run session key for an autonomous think. It is
|
||||
// exposed so the flow-viz publisher (cmd/cyrene) can also publish the backthink flow
|
||||
// graph, which the backthink page and the web-chat flow page both render.
|
||||
// DefaultSessionID 是自主思考所用的、对外暴露的 loop.Run 会话键。它
|
||||
// 之所以暴露,是为了让流程可视化发布器(cmd/cyrene)也能发布 backthink
|
||||
// 流程图,供 backthink 页与 Web 聊天流程页共同渲染。
|
||||
const DefaultSessionID = "sess-backthink"
|
||||
|
||||
// Config carries the autonomous-think tuning knobs. Zero values pick safe
|
||||
// defaults via normalize, so a partially filled Config is still usable.
|
||||
// Config 携带自主思考的调优旋钮。零值会经 normalize
|
||||
// 选取安全默认值,因此半填的 Config 仍可用。
|
||||
type Config struct {
|
||||
// Interval is the heartbeat period. When zero it is derived from
|
||||
// IntervalSec (or its default).
|
||||
// Interval 是心跳周期。为零时由
|
||||
// IntervalSec(或其默认值)推导。
|
||||
Interval time.Duration
|
||||
// IntervalSec is the heartbeat period in seconds, mapped from
|
||||
// config.BackgroundConfig.IntervalSec. Ignored when Interval is set.
|
||||
// IntervalSec 是心跳周期秒数,映射自
|
||||
// config.BackgroundConfig.IntervalSec。设置了 Interval 时忽略。
|
||||
IntervalSec int
|
||||
// QuietStartHour and QuietEndHour bound the do-not-disturb window in local
|
||||
// clock hours. Equal non-zero hours disable the window.
|
||||
// QuietStartHour 与 QuietEndHour 以本地钟点约束免打扰窗口。
|
||||
// 两个相等的非零钟点会禁用该窗口。
|
||||
QuietStartHour int
|
||||
QuietEndHour int
|
||||
// PushLimit bounds proactive pushes within PushWindow. Zero uses the default.
|
||||
// Non-positive PushWindow or PushLimit disables the limit.
|
||||
// PushLimit 限制 PushWindow 内的主动推送次数。为零用默认值。
|
||||
// 非正的 PushWindow 或 PushLimit 会禁用该限制。
|
||||
PushLimit int
|
||||
// PushWindow is the sliding window over which PushLimit is enforced.
|
||||
// PushWindow 是执行 PushLimit 的滑动窗口。
|
||||
PushWindow time.Duration
|
||||
// PushCooldown is the quiet gap after a successful proactive push before
|
||||
// another is allowed. Zero uses the default.
|
||||
// PushCooldown 是一次成功主动推送之后、允许下一次之前的
|
||||
// 安静间隔。为零用默认值。
|
||||
PushCooldown time.Duration
|
||||
// RecentChatCooldown is the gap after the owner's last message within which an
|
||||
// autonomous push is held. Zero uses the default.
|
||||
// RecentChatCooldown 是主人最后一条消息之后的间隔,在此期间
|
||||
// 自主推送被扣住。为零用默认值。
|
||||
RecentChatCooldown time.Duration
|
||||
// KindWindow bounds how often any single push flavor may repeat. Zero uses the
|
||||
// default.
|
||||
// KindWindow 限制任何一种推送风格可重复的频率。为零用
|
||||
// 默认值。
|
||||
KindWindow time.Duration
|
||||
// ThinkTimeout bounds a single thinking pass. Zero uses the default.
|
||||
// ThinkTimeout 约束单轮思考。为零用默认值。
|
||||
ThinkTimeout time.Duration
|
||||
}
|
||||
|
||||
// ConfigFromBackground maps a config.BackgroundConfig (the source of the heartbeat
|
||||
// interval, the shared quiet-hours window and the proactive-push frequency knobs)
|
||||
// into a backthink.Config, leaving anything the env does not expose at its
|
||||
// defaults for normalize to fill in.
|
||||
// ConfigFromBackground 把 config.BackgroundConfig(心跳周期、
|
||||
// 共享安静时段窗口与主动推送频次旋钮的来源)
|
||||
// 映射成 backthink.Config,env 未暴露的项保持其
|
||||
// 默认值,交给 normalize 填。
|
||||
func ConfigFromBackground(bg config.BackgroundConfig) Config {
|
||||
return Config{
|
||||
IntervalSec: bg.IntervalSec,
|
||||
@@ -99,7 +99,7 @@ func ConfigFromBackground(bg config.BackgroundConfig) Config {
|
||||
}
|
||||
}
|
||||
|
||||
// normalize fills a Config's zero fields with safe defaults.
|
||||
// normalize 用安全默认值填充 Config 的零值字段。
|
||||
func normalize(cfg Config) Config {
|
||||
if cfg.IntervalSec <= 0 {
|
||||
cfg.IntervalSec = defaultIntervalSec
|
||||
@@ -132,13 +132,13 @@ func normalize(cfg Config) Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// quietHoursLabel renders the do-not-disturb window for logs.
|
||||
// quietHoursLabel 为日志渲染免打扰窗口。
|
||||
func quietHoursLabel(start, end int) string {
|
||||
return fmt.Sprintf("%02d:00-%02d:00", start, end)
|
||||
}
|
||||
|
||||
// baseLog returns l when non-nil, otherwise a discard logger, so New always has a
|
||||
// safe logger to tag.
|
||||
// baseLog 在 l 非 nil 时返回 l,否则返回丢弃式日志器,好让 New 总是有
|
||||
// 一个可标记的安全日志器。
|
||||
func baseLog(l *slog.Logger) *slog.Logger {
|
||||
if l != nil {
|
||||
return l
|
||||
|
||||
@@ -7,24 +7,24 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// recentContextCount is how many of the most recent main-session messages are
|
||||
// folded into one autonomous think as conversation context. Compile-time so no
|
||||
// magic number appears in the run path.
|
||||
// recentContextCount 是折进一次自主思考作为对话上下文的最新
|
||||
// 主会话消息条数。编译期常量,因此
|
||||
// 运行路径里不出现魔法数字。
|
||||
const recentContextCount = 20
|
||||
|
||||
// recentProvider returns the most recent messages of a session in oldest-first
|
||||
// order, the tail an autonomous think reads to "see" the main conversation.
|
||||
// *history.Store satisfies it; tests inject a fake. It is the narrow slice of
|
||||
// history.HistoryStore that the thinker actually depends on, matching the loop's
|
||||
// own checkpointStore style of minimal seam interfaces.
|
||||
// recentProvider 以最旧在前的顺序返回会话最近的消息,
|
||||
// 即一次自主思考为「看到」主对话而读取的尾部。
|
||||
// *history.Store 满足它;测试注入 fake。它是 thinker 实际
|
||||
// 依赖的那一小片 history.HistoryStore,与 loop 自己的
|
||||
// checkpointStore 风格的最小接缝接口一致。
|
||||
type recentProvider interface {
|
||||
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
|
||||
}
|
||||
|
||||
// mainContext returns the most recent main-session messages converted to model
|
||||
// messages (oldest-first) for one autonomous think. A nil provider, a failed read
|
||||
// or an empty tail all yield nil so the think degrades to the guidance prompt
|
||||
// alone rather than failing the pass (design/20 §2).
|
||||
// mainContext 把最近的主会话消息转换为 model 消息
|
||||
// (最旧在前),供一次自主思考使用。提供者为 nil、读取失败
|
||||
// 或尾部为空都得到 nil,因此该次思考降级为仅用引导提示词,
|
||||
// 而不是让这一轮失败(design/20 §2)。
|
||||
func mainContext(ctx context.Context, prov recentProvider) []model.Message {
|
||||
if prov == nil {
|
||||
return nil
|
||||
@@ -40,9 +40,9 @@ func mainContext(ctx context.Context, prov recentProvider) []model.Message {
|
||||
return msgs
|
||||
}
|
||||
|
||||
// mapHistoryMessage converts one persisted chat message into a model message,
|
||||
// translating the history role and preserving an image part when the message
|
||||
// carries one so a multimodal main model still sees it.
|
||||
// mapHistoryMessage 把一条持久化的 chat 消息转换为 model 消息,
|
||||
// 翻译历史角色,并在消息携带图片时保留该图片片段,
|
||||
// 因此多模态主模型仍能看到它。
|
||||
func mapHistoryMessage(h history.Message) model.Message {
|
||||
msg := model.Message{
|
||||
Role: historyRoleToModel(h.Role),
|
||||
@@ -54,8 +54,8 @@ func mapHistoryMessage(h history.Message) model.Message {
|
||||
return msg
|
||||
}
|
||||
|
||||
// historyRoleToModel maps a persisted chat role to the model's role vocabulary,
|
||||
// defaulting unknown roles to the user lane.
|
||||
// historyRoleToModel 把持久化的 chat 角色映射到 model 的角色词汇,
|
||||
// 未知角色默认为 user 通道。
|
||||
func historyRoleToModel(role string) model.Role {
|
||||
switch role {
|
||||
case history.RoleAssistant:
|
||||
@@ -67,9 +67,9 @@ func historyRoleToModel(role string) model.Role {
|
||||
}
|
||||
}
|
||||
|
||||
// thinkInput assembles the full input for one unattended think: the recent
|
||||
// main-session context (if any) followed by a single guidance prompt turn, so the
|
||||
// runtime reflects on real conversation rather than an empty macro prompt.
|
||||
// thinkInput 拼装一次无人值守思考的完整输入:最近的主会话语境
|
||||
// (若有)后跟单个引导提示词回合,因此
|
||||
// 运行时针对真实对话反思,而不是空泛的宏提示词。
|
||||
func thinkInput(recent []model.Message, trigger string) []model.Message {
|
||||
out := make([]model.Message, 0, len(recent)+1)
|
||||
out = append(out, recent...)
|
||||
@@ -77,19 +77,19 @@ func thinkInput(recent []model.Message, trigger string) []model.Message {
|
||||
return out
|
||||
}
|
||||
|
||||
// guidancePrompt is the instruction handed to the runtime for one unattended
|
||||
// autonomous thought. Unlike the old backgroundPrompt it deliberately encourages a
|
||||
// warm, targeted proactive message and drops the "do not produce a chat-style
|
||||
// message" suppression. Identity/permission stay on the actor metadata + owner env,
|
||||
// never on this prompt text (red line); the prompt only describes the running
|
||||
// environment (memories readable/writable, tools callable) that the env already
|
||||
// grants.
|
||||
// guidancePrompt 是交给运行时的一次无人值守自主思考的
|
||||
// 指令。与旧 backgroundPrompt 不同,它刻意鼓励
|
||||
// 温暖、有针对性的主动消息,并去掉「不要产生 chat 风格
|
||||
// 消息」的抑制。身份/权限始终在 actor 元数据 + owner env 上,
|
||||
// 绝不在这段提示词文本上(红线);提示词只描述运行
|
||||
// 环境(记忆可读/可写、工具可调用),而那是 env 已经
|
||||
// 授予的。
|
||||
//
|
||||
// The prompt asks for a compact structured closing — a push/no_push decision, an
|
||||
// optional target channel and the message body — so the service can honor her own
|
||||
// judgment instead of gating on the trigger's importance (design/20 §2 P2). The
|
||||
// format is guidance, not a contract: parseProactive fails safe when she omits or
|
||||
// mangles it.
|
||||
// 提示词要求一个紧凑的结构化收尾——push/no_push 决策、
|
||||
// 可选的目标渠道与消息正文——好让服务尊重她自己的
|
||||
// 判断,而不是按触发的重要度把关(design/20 §2 P2)。该
|
||||
// 格式是引导而非契约:她省略或
|
||||
// 写乱该格式时,parseProactive 失败安全。
|
||||
//
|
||||
// 启动轮(裁决 07.4.4,design/12 §1)走 startupPrompt:以角色口吻说一句拟人化的
|
||||
// 「回来」问候。
|
||||
|
||||
@@ -6,26 +6,26 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Importance is the estimated severity of an event that woke a thinking pass.
|
||||
// Under design/20 §2 P2 it no longer decides *whether* she wants to speak (her
|
||||
// conclusion does); it decides the hard-gate exemptions: a critical safety event
|
||||
// punches through work/busy and quiet hours (裁决 07.4.2), and a high event still
|
||||
// passes quiet hours (the pre-existing cascade semantics).
|
||||
// Importance 是唤醒一轮思考的事件估计严重度。
|
||||
// 在 design/20 §2 P2 之下,它不再决定她*想不想*说话(那由她的
|
||||
// 结论决定);它决定硬门豁免:critical 安全事件
|
||||
// 穿透工作/忙碌与安静时段(裁决 07.4.2),而 high 事件仍
|
||||
// 通过安静时段(既有的级联语义)。
|
||||
type Importance int
|
||||
|
||||
const (
|
||||
// ImportanceLow is a routine trigger (e.g. the periodic heartbeat).
|
||||
// ImportanceLow 是例行触发(如周期性心跳)。
|
||||
ImportanceLow Importance = iota
|
||||
// ImportanceNormal is an ordinary event worth reflecting on (device change).
|
||||
// ImportanceNormal 是值得反思的普通事件(设备变化)。
|
||||
ImportanceNormal
|
||||
// ImportanceHigh is a notable event (a reminder the user set firing).
|
||||
// ImportanceHigh 是值得注意的事件(用户设置的提醒触发)。
|
||||
ImportanceHigh
|
||||
// ImportanceCritical is a safety-critical event that punches through
|
||||
// work/busy and quiet hours (裁决 07.4.2 安全域豁免).
|
||||
// ImportanceCritical 是穿透
|
||||
// 工作/忙碌与安静时段的安全关键事件(裁决 07.4.2 安全域豁免)。
|
||||
ImportanceCritical
|
||||
)
|
||||
|
||||
// String returns a stable label for logs and records.
|
||||
// String 返回用于日志与记录的稳定标签。
|
||||
func (i Importance) String() string {
|
||||
switch i {
|
||||
case ImportanceLow:
|
||||
@@ -41,55 +41,55 @@ func (i Importance) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// longIdleThreshold is the gap after the owner's last message beyond which the
|
||||
// activity gate only lets a genuinely important event through — except heartbeat
|
||||
// passes, which are never held by a long-silent gap (裁决 07.4.3). Compile-time
|
||||
// so no magic number appears.
|
||||
// longIdleThreshold 是主人最后一条消息之后的间隔,超过它活动门
|
||||
// 只放行真正重要的事件——但心跳轮除外,
|
||||
// 它们永远不会被长期静默的间隔拦下(裁决 07.4.3)。编译期
|
||||
// 常量,因此不出现魔法数字。
|
||||
const longIdleThreshold = 2 * time.Hour
|
||||
|
||||
// UserState describes the user's current condition as observed from device
|
||||
// telemetry (heart rate, sleep) and schedule (work, busy). It is the negative
|
||||
// signal in the interrupt decision: a sleeping, working or busy user is left
|
||||
// alone. Boredom (0..1) is the soft positive dial that widens interruption.
|
||||
// UserState 描述由设备遥测(心率、睡眠)与日程(工作、忙碌)
|
||||
// 观察到的用户当前状况。它是打扰决策里的负向
|
||||
// 信号:睡眠、工作或忙碌中的用户不被打扰。
|
||||
// 无聊度(0..1)是放宽打扰的软性正向旋钮。
|
||||
type UserState struct {
|
||||
// HeartRateBPM is the current heart rate, 0 when unknown.
|
||||
// HeartRateBPM 是当前心率,未知时为 0。
|
||||
HeartRateBPM int
|
||||
// Sleeping reports whether the user is currently asleep from sleep tracking.
|
||||
// Sleeping 报告睡眠追踪显示用户当前是否睡着。
|
||||
Sleeping bool
|
||||
// Working reports whether the user is in a work/scheduled block.
|
||||
// Working 报告用户是否处于工作/日程时段。
|
||||
Working bool
|
||||
// Busy reports whether the user is marked busy (calendar / do-not-disturb).
|
||||
// Busy 报告用户是否被标记为忙碌(日历 / 免打扰)。
|
||||
Busy bool
|
||||
// Boredom ranges 0..1. Higher boredom means the user is idle and more open
|
||||
// to a gentle interruption. Under design/20 §2 P2 the push intent is her own
|
||||
// conclusion, so this dial no longer gates the decision; it is kept as a
|
||||
// telemetry signal her judgment already absorbs.
|
||||
// Boredom 取值 0..1。无聊度越高表示用户越空闲、越愿意
|
||||
// 被温和地打扰。在 design/20 §2 P2 之下推送意图来自她自己的
|
||||
// 结论,所以这个旋钮不再为决策把关;它作为一个
|
||||
// 她的判断本就会吸收的遥测信号保留下来。
|
||||
Boredom float64
|
||||
}
|
||||
|
||||
// String returns a concise human-readable summary for logs/records.
|
||||
// String 返回用于日志/记录的简洁可读摘要。
|
||||
func (s UserState) String() string {
|
||||
return fmt.Sprintf("bpm=%d sleeping=%v working=%v busy=%v boredom=%.2f",
|
||||
s.HeartRateBPM, s.Sleeping, s.Working, s.Busy, s.Boredom)
|
||||
}
|
||||
|
||||
// DecisionInput is the set of signals Decide weighs: the event's importance, the
|
||||
// current wall-clock time, the user's current state and their boredom.
|
||||
// DecisionInput 是 Decide 所称量的信号集合:事件重要度、
|
||||
// 当前墙钟时间、用户当前状态及其无聊度。
|
||||
type DecisionInput struct {
|
||||
// Trigger is a human-readable source label, recorded for traceability.
|
||||
// Trigger 是可读的来源标签,为可追溯而记录。
|
||||
Trigger string
|
||||
// Import is the event's estimated importance.
|
||||
// Import 是事件的估计重要度。
|
||||
Import Importance
|
||||
// At is the current wall-clock time, in the local timezone used for the
|
||||
// do-not-disturb window.
|
||||
// At 是当前墙钟时间,处于免打扰窗口所用的
|
||||
// 本地时区。
|
||||
At time.Time
|
||||
// UserState carries the user's physiological/schedule condition.
|
||||
// UserState 携带用户的生理/日程状况。
|
||||
UserState UserState
|
||||
// LastActivity is the wall-clock time of the owner's most recent message.
|
||||
// HasActivity reports whether a user message was found; when false the
|
||||
// activity gate falls through without guessing.
|
||||
// LastActivity 是主人最近一条消息的墙钟时间。
|
||||
// HasActivity 报告是否找到了用户消息;为 false 时
|
||||
// 活动门直接落空、不靠猜。
|
||||
LastActivity time.Time
|
||||
// HasActivity reports whether LastActivity is meaningful.
|
||||
// HasActivity 报告 LastActivity 是否有意义。
|
||||
HasActivity bool
|
||||
// ConclusionPush 是她自己思考结论的推送意愿(design/20 §2 P2):「想不想说」
|
||||
// 由她的结论决定,本决策只回答「此刻能不能说」。
|
||||
@@ -99,11 +99,11 @@ type DecisionInput struct {
|
||||
Heartbeat bool
|
||||
}
|
||||
|
||||
// Decision is the outcome of Decide.
|
||||
// Decision 是 Decide 的结果。
|
||||
type Decision struct {
|
||||
// ShouldPush reports whether the user should be interrupted.
|
||||
// ShouldPush 报告是否应打扰用户。
|
||||
ShouldPush bool
|
||||
// Reason is a short human-readable justification, surfaced on the panel.
|
||||
// Reason 是简短可读的理由,会在面板上呈现。
|
||||
Reason string
|
||||
}
|
||||
|
||||
@@ -192,9 +192,9 @@ func (b *Backthink) activityHeld(in DecisionInput) (bool, string) {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// inQuietHours reports whether at falls inside the configured do-not-disturb
|
||||
// window. The window may wrap midnight (e.g. 23:00-07:00); two equal non-zero
|
||||
// hours disable it.
|
||||
// inQuietHours 报告 at 是否落在已配置的免打扰
|
||||
// 窗口内。窗口可以跨午夜(如 23:00-07:00);两个相等的非零
|
||||
// 钟点会禁用它。
|
||||
func (b *Backthink) inQuietHours(at time.Time) bool {
|
||||
h := at.Hour()
|
||||
start, end := b.cfg.QuietStartHour, b.cfg.QuietEndHour
|
||||
@@ -204,24 +204,24 @@ func (b *Backthink) inQuietHours(at time.Time) bool {
|
||||
return h >= start || h < end
|
||||
}
|
||||
|
||||
// PushKind classifies a proactive message into one of a few flavors so the
|
||||
// frequency gate can limit each flavor within a window, avoiding same-style spam
|
||||
// (design/12 §6 策略二). It is a soft tag derived from the message body's
|
||||
// keywords — it affects pacing, never identity or permission.
|
||||
// PushKind 把主动消息归类成几种风格,好让
|
||||
// 频率门能在窗口内限制每种风格,避免同一风格刷屏
|
||||
// (design/12 §6 策略二)。它是由消息正文关键词导出的软标签
|
||||
// ——它只影响节奏,绝不影响身份或权限。
|
||||
type PushKind int
|
||||
|
||||
const (
|
||||
// KindGreeting is a warm hello / good-morning / good-night opener.
|
||||
// KindGreeting 是温暖的打招呼 / 早安 / 晚安开场。
|
||||
KindGreeting PushKind = iota
|
||||
// KindReminder is a nudge about a schedule, task or deadline.
|
||||
// KindReminder 是关于日程、任务或截止时间的提醒。
|
||||
KindReminder
|
||||
// KindShare is a discovery, observation or interesting find.
|
||||
// KindShare 是发现、观察或有意思的见闻。
|
||||
KindShare
|
||||
// KindCare is a warm, caring check-in about the owner's wellbeing.
|
||||
// KindCare 是对主人身心状况的温暖关心问候。
|
||||
KindCare
|
||||
)
|
||||
|
||||
// String returns a stable label for logs and records.
|
||||
// String 返回用于日志与记录的稳定标签。
|
||||
func (k PushKind) String() string {
|
||||
switch k {
|
||||
case KindGreeting:
|
||||
@@ -237,11 +237,11 @@ func (k PushKind) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// classifyPush assigns a PushKind to a proactive message from its keyword
|
||||
// content. A message that matches no flavor defaults to KindCare (the warm,
|
||||
// generic intent of an unsolicited proactive note), which is the least
|
||||
// spam-prone grouping. Priority is reminder > care > greeting > share so the
|
||||
// dominant intent wins when several keywords co-occur.
|
||||
// classifyPush 按关键词内容给主动消息指派 PushKind。
|
||||
// 不匹配任何风格的消息默认为 KindCare(一条主动问候的
|
||||
// 温暖、泛化意图),那是最不容易
|
||||
// 刷屏的分组。优先级为 reminder > care > greeting > share,
|
||||
// 因此多个关键词共现时主流意图获胜。
|
||||
func classifyPush(message string) PushKind {
|
||||
switch {
|
||||
case hasAnyKeyword(message, kindReminderKeywords...):
|
||||
@@ -257,9 +257,9 @@ func classifyPush(message string) PushKind {
|
||||
}
|
||||
}
|
||||
|
||||
// keyword sets for classifyPush. They are package vars (not consts) so the
|
||||
// classification stays flexible and testable, but they are immutable by
|
||||
// convention and never mutated at run time.
|
||||
// classifyPush 所用的关键词集合。它们是包级变量(非常量)以便
|
||||
// 分类保持灵活可测,但按约定不可变,
|
||||
// 从不在运行时改写。
|
||||
var (
|
||||
kindReminderKeywords = []string{"提醒", "记得", "别忘了", "该去", "日程", "会议", "体检", "吃药", "喝药", "到点", "几点", "还有", "迟到", "明天", "马上", "最后", "截止"}
|
||||
kindCareKeywords = []string{"关心", "最近", "辛苦", "注意身体", "照顾好", "好好休息", "心情", "还好", "想你了", "想我", "担心", "保重", "你还好", "陪你", "累"}
|
||||
@@ -267,7 +267,7 @@ var (
|
||||
kindShareKeywords = []string{"发现", "分享", "看到", "听说", "注意到", "告诉你", "有个", "想到", "有意思", "有趣", "你知道吗"}
|
||||
)
|
||||
|
||||
// hasAnyKeyword reports whether s contains any of the given substrings.
|
||||
// hasAnyKeyword 报告 s 是否包含给定子串中的任意一个。
|
||||
func hasAnyKeyword(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(s, sub) {
|
||||
|
||||
@@ -5,14 +5,14 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// dayAt returns a mid-afternoon time in UTC (outside the quiet-hours window).
|
||||
// dayAt 返回 UTC 的午后时间(在安静时段窗口之外)。
|
||||
func dayAt() time.Time { return time.Date(2026, 9, 6, 14, 0, 0, 0, time.UTC) }
|
||||
|
||||
// nightAt returns a 02:00 time in UTC (inside the default 23-7 quiet window).
|
||||
// nightAt 返回 UTC 的 02:00(在默认 23-7 安静窗口之内)。
|
||||
func nightAt() time.Time { return time.Date(2026, 9, 6, 2, 0, 0, 0, time.UTC) }
|
||||
|
||||
// testBackthink builds a Backthink with a normalized empty Config so Decide uses
|
||||
// the default quiet-hours window (23:00-07:00).
|
||||
// testBackthink 用归一化的空 Config 构建 Backthink,因此 Decide 使用
|
||||
// 默认安静时段窗口(23:00-07:00)。
|
||||
func testBackthink() *Backthink {
|
||||
return &Backthink{cfg: normalize(Config{})}
|
||||
}
|
||||
@@ -144,8 +144,8 @@ func TestInQuietHours(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyPush verifies the proactive message body maps to the intended flavor
|
||||
// (问候/提醒/分享/关心) so the frequency gate can limit each flavor separately.
|
||||
// TestClassifyPush 验证主动消息正文映射到预期的风格
|
||||
// (问候/提醒/分享/关心),使频率门能分别限制每种风格。
|
||||
func TestClassifyPush(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -168,16 +168,16 @@ func TestClassifyPush(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecide_ActivityGate verifies the activity-aware soft gate: a fresh
|
||||
// conversation holds trivia (unless critical), a long-idle owner is only woken by
|
||||
// an important event — except a heartbeat pass, which is never held by a
|
||||
// long-silent gap (裁决 07.4.3) — and the in-between band does not over-suppress.
|
||||
// TestDecide_ActivityGate 验证活动感知软门:刚开始的
|
||||
// 对话会扣下琐碎内容(critical 除外),长期静默的主人只会被
|
||||
// 重要事件唤醒——但心跳轮除外,它永远不会被长期
|
||||
// 静默的间隔拦下(裁决 07.4.3)——而中间地带不会过度抑制。
|
||||
func TestDecide_ActivityGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := testBackthink()
|
||||
|
||||
// Just chatted (within RecentChatCooldown=10min): a normal daytime push is
|
||||
// held unless the event is safety-critical.
|
||||
// 刚聊过(在 RecentChatCooldown=10min 内):白天普通推送
|
||||
// 会被扣下,除非该事件是安全关键。
|
||||
fresh := DecisionInput{
|
||||
Import: ImportanceNormal,
|
||||
At: dayAt(),
|
||||
@@ -194,8 +194,8 @@ func TestDecide_ActivityGate(t *testing.T) {
|
||||
t.Fatalf("critical event should push even in a fresh conversation, got %+v", got)
|
||||
}
|
||||
|
||||
// Away a long while (> longIdleThreshold): only an important event is worth
|
||||
// it — but a heartbeat pass still goes through (裁决 07.4.3 长期静默段不拦心跳).
|
||||
// 离开很久(> longIdleThreshold):只有重要事件值得
|
||||
// 唤醒——但心跳轮仍会通过(裁决 07.4.3 长期静默段不拦心跳)。
|
||||
idle := DecisionInput{
|
||||
Import: ImportanceNormal,
|
||||
At: dayAt(),
|
||||
@@ -217,7 +217,7 @@ func TestDecide_ActivityGate(t *testing.T) {
|
||||
t.Fatalf("long idle heartbeat should still push (07.4.3), got %+v", got)
|
||||
}
|
||||
|
||||
// Middling activity (30 min ago) is not over-suppressed.
|
||||
// 中等活动(30 分钟前)不会被过度抑制。
|
||||
middle := DecisionInput{
|
||||
Import: ImportanceNormal,
|
||||
At: dayAt(),
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
package backthink
|
||||
|
||||
// eventRedundant reports whether tr is a duplicate of the immediately preceding
|
||||
// event-driven push (same source + summary), so a repeating device/reminder event
|
||||
// does not spam the owner (防刷). It is the one guard that still applies to an
|
||||
// event-driven push while the PushGate itself is bypassed for events (owner
|
||||
// mandate: "该发就发" — a genuine event goes out, only a true duplicate is held).
|
||||
// eventRedundant 报告 tr 是否与前一次事件驱动的推送重复
|
||||
// (同源 + 同摘要),因此重复的设备/提醒事件
|
||||
// 不会刷屏主人(防刷)。当 PushGate 对事件本身被绕过时,
|
||||
// 它是仅剩的一道仍适用于事件驱动推送的
|
||||
// 门(主人要求:"该发就发"——真正的事件照发,只扣下真正的重复)。
|
||||
//
|
||||
// It atomically records tr as the latest event key when the push proceeds, and
|
||||
// clears the key on any non-event push (heartbeat) so a later repeat of a prior
|
||||
// event is not spuriously suppressed by an intervening reflection.
|
||||
// 推送进行时它原子地把 tr 记为最新事件键,并在任何非事件推送
|
||||
// (心跳)时清除该键,以免更早事件的再次重复
|
||||
// 被中间一次反思错误地抑制。
|
||||
//
|
||||
// The caller runs it before handing the push to the Pusher; a true duplicate is
|
||||
// recorded as suppressed (RouteNone) rather than delivered.
|
||||
// 调用方把它跑在把推送交给 Pusher 之前;真正的重复
|
||||
// 会被记为已抑制(RouteNone),而不是投递出去。
|
||||
func (b *Backthink) eventRedundant(tr Trigger) bool {
|
||||
if tr.Kind != TriggerEvent {
|
||||
// A non-event push (heartbeat) breaks the same-event chain.
|
||||
// 非事件推送(心跳)会打断同一事件链。
|
||||
b.eventMu.Lock()
|
||||
b.lastEventKey = ""
|
||||
b.eventMu.Unlock()
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// mockThinker records every Think invocation and returns a fixed conclusion. It
|
||||
// respects ctx cancellation so Stop can drain a hung pass.
|
||||
// mockThinker 记录每次 Think 调用并返回固定结论。它
|
||||
// 尊重 ctx 取消,好让 Stop 能排空挂住的轮次。
|
||||
type mockThinker struct {
|
||||
mu sync.Mutex
|
||||
text string
|
||||
@@ -45,12 +45,12 @@ func (m *mockThinker) Think(ctx context.Context, sessionID, trigger string) (str
|
||||
func (m *mockThinker) count() int { m.mu.Lock(); defer m.mu.Unlock(); return m.calls }
|
||||
func (m *mockThinker) lastTrigger() string { m.mu.Lock(); defer m.mu.Unlock(); return m.last }
|
||||
|
||||
// mockState returns a fixed UserState.
|
||||
// mockState 返回固定的 UserState。
|
||||
type mockState struct{ state UserState }
|
||||
|
||||
func (m *mockState) Current(context.Context) (UserState, error) { return m.state, nil }
|
||||
|
||||
// mockPusher records Push calls and returns a fixed result.
|
||||
// mockPusher 记录 Push 调用并返回固定结果。
|
||||
type mockPusher struct {
|
||||
mu sync.Mutex
|
||||
result PushResult
|
||||
@@ -70,7 +70,7 @@ func (p *mockPusher) Push(ctx context.Context, req PushRequest) PushResult {
|
||||
|
||||
func (p *mockPusher) count() int { p.mu.Lock(); defer p.mu.Unlock(); return p.calls }
|
||||
|
||||
// mockActive implements ActiveClient.
|
||||
// mockActive 实现 ActiveClient。
|
||||
type mockActive struct {
|
||||
mu sync.Mutex
|
||||
n int
|
||||
@@ -87,7 +87,7 @@ func (m *mockActive) SendProactive(ctx context.Context, msg adapter.UnifiedMessa
|
||||
return m.n, m.err
|
||||
}
|
||||
|
||||
// mockIMAdapter implements IMAdapter.
|
||||
// mockIMAdapter 实现 IMAdapter。
|
||||
type mockIMAdapter struct {
|
||||
mu sync.Mutex
|
||||
err error
|
||||
@@ -105,14 +105,14 @@ func (m *mockIMAdapter) SendProactive(ctx context.Context, msg adapter.UnifiedMe
|
||||
|
||||
func (m *mockIMAdapter) count() int { m.mu.Lock(); defer m.mu.Unlock(); return m.calls }
|
||||
|
||||
// mockGate implements PushGate.
|
||||
// mockGate 实现 PushGate。
|
||||
type mockGate struct{ allow bool }
|
||||
|
||||
func (g *mockGate) Allow(context.Context, PushKind) bool { return g.allow }
|
||||
func (g *mockGate) RecordPush(PushKind) {}
|
||||
|
||||
// newTestDB opens an isolated in-memory SQLite handle for one test and closes it
|
||||
// on cleanup.
|
||||
// newTestDB 为单个测试打开一个隔离的内存 SQLite 句柄,并在
|
||||
// 清理时关闭它。
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
h, err := db.Open(context.Background(), ":memory:", nil)
|
||||
@@ -123,12 +123,12 @@ func newTestDB(t *testing.T) *sql.DB {
|
||||
return h
|
||||
}
|
||||
|
||||
// discardLog returns a logger that writes nowhere.
|
||||
// discardLog 返回一个什么都不写的日志器。
|
||||
func discardLog() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// eventually polls cond until it is true or the deadline passes.
|
||||
// eventually 轮询 cond 直到它为真或超过期限。
|
||||
func eventually(t *testing.T, timeout time.Duration, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
@@ -6,63 +6,63 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// The autonomous think is guided (guidancePrompt in context.go) to conclude with
|
||||
// a compact, machine-parseable decision so the service no longer has to infer
|
||||
// whether to push from the trigger's importance alone (design/20 §2 P2):
|
||||
// 自主思考被引导(context.go 的 guidancePrompt)以如下紧凑、
|
||||
// 机器可解析的决策收尾,好让服务不必再从触发的重要度
|
||||
// 单独推断是否推送(design/20 §2 P2):
|
||||
//
|
||||
// [我决定:push] (or no_push)
|
||||
// [我决定:push] (或 no_push)
|
||||
// [渠道:web|im|kook|telegram|...|auto]
|
||||
// <the warm message body>
|
||||
// <温暖的正文内容>
|
||||
//
|
||||
// The markers are parsed as plain text so a model that formats them slightly
|
||||
// differently still yields a usable decision; a malformed/ambiguous decision
|
||||
// marker fails safe to "no push".
|
||||
// 这些标记按纯文本解析,因此模型稍有不同地
|
||||
// 格式化它们时仍能得出可用的决策;格式错乱/含糊的决策
|
||||
// 标记失败安全为「不推送」。
|
||||
var (
|
||||
// decideMarker matches the push/no_push decision token, tolerating a colon in
|
||||
// either width and optional spaces. It is used to recover the decision, so it
|
||||
// stays strict on the bracketed form: a bare "我决定:push" is treated as prose,
|
||||
// not as a decision (fail safe).
|
||||
// decideMarker 匹配 push/no_push 决策词,容忍两种宽度的
|
||||
// 冒号与可选空格。它用于恢复决策,所以对
|
||||
// 带括号的形式保持严格:裸的 "我决定:push" 被当作散文,
|
||||
// 而不是决策(失败安全)。
|
||||
decideMarker = regexp.MustCompile(`(?i)\[\s*我决定\s*[::]\s*(no_push|push)\s*\]`)
|
||||
// channelMarker matches the target-channel token on the decision line.
|
||||
// channelMarker 匹配决策行上的目标渠道词。
|
||||
channelMarker = regexp.MustCompile(`(?i)\[\s*渠道\s*[::]\s*([a-z0-9_\-]+)\s*\]`)
|
||||
// decidePrefixes are the marker shapes that signal a decision was attempted
|
||||
// but could not be parsed, so the pass is treated as ambiguous (fail safe). It
|
||||
// is deliberately the bracketed form only, so a message that merely contains
|
||||
// "我决定:" in prose (common in Chinese) is not misread as a malformed marker.
|
||||
// decidePrefixes 是「尝试了决策但无法解析」的标记形状,
|
||||
// 这类轮次被视为含糊(失败安全)。它
|
||||
// 刻意只含带括号形式,因此仅仅在散文中出现
|
||||
// "我决定:"(中文里很常见)的消息不会被误读为格式错乱的标记。
|
||||
decidePrefixes = []string{"[我决定"}
|
||||
|
||||
// stripDecideRe / stripChannelRe recognize the decision/channel markers as a
|
||||
// token to delete from the user-facing body, in their intended bracketed form
|
||||
// and with full-width brackets, so a well-formatted decision never leaks.
|
||||
// stripDecideRe / stripChannelRe 把决策/渠道标记识别为
|
||||
// 需要从用户可见正文中删除的词,按它们预期的带括号形式
|
||||
// 与全角括号形式处理,因此格式良好的决策永不泄漏。
|
||||
stripDecideRe = regexp.MustCompile(`(?i)[【\[]\s*我决定\s*[::]\s*(no_push|push)\s*[】\]]`)
|
||||
stripChannelRe = regexp.MustCompile(`(?i)[【\[]\s*渠道\s*[::]\s*[a-z0-9_\-]+\s*[】\]]`)
|
||||
bareDecideRe = regexp.MustCompile(`(?i)我决定\s*[::]\s*(no_push|push)`)
|
||||
bareChannelRe = regexp.MustCompile(`(?i)渠道\s*[::]\s*[a-z0-9_\-]+`)
|
||||
)
|
||||
|
||||
// nonePhrases are conclusions that mean "nothing to report" when no marker is
|
||||
// present. The body is normalized (case-folded, punctuation/spaces stripped)
|
||||
// before comparison so "无", "无。" and "Nothing to report." all collapse to the
|
||||
// same token.
|
||||
// nonePhrases 是没有标记时表示「无可汇报」的结论。
|
||||
// 正文在比较前先归一化(折叠大小写、去掉标点/空格),
|
||||
// 因此 "无"、"无。" 与 "Nothing to report." 都收敛到
|
||||
// 同一个词。
|
||||
var nonePhrases = []string{
|
||||
"无", "没什么", "没有", "无物可报", "暂无", "没有值得说的",
|
||||
"nothing", "none", "nothingtoreport", "nothingworthreporting", "nothingnew",
|
||||
}
|
||||
|
||||
// parseProactive recovers the push decision, the target-channel hint and the
|
||||
// clean message body from a think conclusion. It is a pure function: it reads no
|
||||
// state and performs no I/O, so it is safe to call concurrently and cheap to test.
|
||||
// parseProactive 从思考结论中恢复推送决策、目标渠道提示与
|
||||
// 干净的正文消息。它是纯函数:不读状态、不做 I/O,
|
||||
// 因此可安全并发调用、测试成本低。
|
||||
//
|
||||
// Semantics (design/20 §2 P2):
|
||||
// - A well-formed [我决定:push|no_push] marker is authoritative.
|
||||
// - A malformed/ambiguous decision marker → no push (fail safe).
|
||||
// - No marker: a body that reads as "无"/"nothing to report" → no push;
|
||||
// otherwise the body is treated as a proactive message (push=true), matching
|
||||
// the pre-P2 default.
|
||||
// - The channel hint is the [渠道:...] token, lowercased; empty/auto keeps the
|
||||
// default active-client-first, IM-fallback route.
|
||||
// - A push decision whose body is empty or still reads as "nothing" → no push
|
||||
// (fail safe), so a lone marker never pushes an empty message.
|
||||
// 语义(design/20 §2 P2):
|
||||
// - 格式良好的 [我决定:push|no_push] 标记具有权威性。
|
||||
// - 格式错乱/含糊的决策标记 → 不推送(失败安全)。
|
||||
// - 无标记:正文读作 "无"/"nothing to report" → 不推送;
|
||||
// 否则正文被当作主动消息(push=true),与
|
||||
// P2 之前的默认一致。
|
||||
// - 渠道提示是 [渠道:...] 词,转小写;为空/auto 则保持
|
||||
// 默认的活跃客户端优先、IM 降级路由。
|
||||
// - 决策为推送但正文为空或仍读作「无」 → 不推送
|
||||
// (失败安全),因此单独一个标记绝不会推出空消息。
|
||||
func parseProactive(conclusion string) (message, channelHint string, push bool) {
|
||||
text := strings.TrimSpace(conclusion)
|
||||
|
||||
@@ -83,8 +83,8 @@ func parseProactive(conclusion string) (message, channelHint string, push bool)
|
||||
return message, channelHint, push
|
||||
}
|
||||
|
||||
// matchDecide returns the matched decision token (lowercased) and whether a
|
||||
// well-formed decision marker was present.
|
||||
// matchDecide 返回匹配到的决策词(已转小写)以及
|
||||
// 是否存在格式良好的决策标记。
|
||||
func matchDecide(text string) (string, bool) {
|
||||
m := decideMarker.FindStringSubmatch(text)
|
||||
if m == nil {
|
||||
@@ -93,8 +93,8 @@ func matchDecide(text string) (string, bool) {
|
||||
return strings.ToLower(m[1]), true
|
||||
}
|
||||
|
||||
// hasAmbiguousDecide reports whether a decision marker was attempted but could
|
||||
// not be parsed, which the caller treats as "do not push" (fail safe).
|
||||
// hasAmbiguousDecide 报告是否尝试了决策标记但
|
||||
// 无法解析,调用方把它当作「不推送」(失败安全)。
|
||||
func hasAmbiguousDecide(text string) bool {
|
||||
if decideMarker.MatchString(text) {
|
||||
return false
|
||||
@@ -107,9 +107,9 @@ func hasAmbiguousDecide(text string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// matchChannel returns the lowercased target-channel token, or "" when the
|
||||
// conclusion carries no (or a malformed) channel marker. A malformed channel
|
||||
// marker degrades to the default route rather than failing the whole pass.
|
||||
// matchChannel 返回已转小写的目标渠道词;结论未携带
|
||||
// (或携带格式错乱的)渠道标记时返回 ""。格式错乱的渠道
|
||||
// 标记降级为默认路由,而不是让整轮失败。
|
||||
func matchChannel(text string) string {
|
||||
m := channelMarker.FindStringSubmatch(text)
|
||||
if m == nil {
|
||||
@@ -118,15 +118,15 @@ func matchChannel(text string) string {
|
||||
return strings.ToLower(m[1])
|
||||
}
|
||||
|
||||
// stripInternalMarkers removes the structured decision/channel markers so a
|
||||
// proactive message body never carries them to the user. It handles the intended
|
||||
// bracketed form ([我决定:push][渠道:kook]) plus the drift forms a model may emit —
|
||||
// bare "我决定:push" / "渠道:kook" (no brackets) and full-width 【我决定:push】 — then
|
||||
// drops any line that becomes empty and collapses the remainder. It is applied
|
||||
// both inside parseProactive (so the message and record are clean) and again as a
|
||||
// final guard in buildProactiveMessage, so a marker cannot survive into the
|
||||
// outbound body no matter how it was formatted. Only our logs/records may carry
|
||||
// the marker; the user-facing message must be pure warm content.
|
||||
// stripInternalMarkers 移除结构化的决策/渠道标记,因此
|
||||
// 主动消息正文绝不会把它们带给用户。它处理预期的
|
||||
// 带括号形式([我决定:push][渠道:kook])以及模型可能发出的漂移形式——
|
||||
// 裸的 "我决定:push" / "渠道:kook"(无括号)与全角 【我决定:push】——然后
|
||||
// 丢弃因此变空的行并压缩剩余部分。它既在
|
||||
// parseProactive 内部应用(使消息与记录干净),又作为
|
||||
// buildProactiveMessage 里的最后一道守护,因此无论标记如何
|
||||
// 被格式化,都无法存活到出站正文里。只有我们的日志/记录可以携带
|
||||
// 该标记;面向用户的消息必须是纯温暖内容。
|
||||
func stripInternalMarkers(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
@@ -144,8 +144,8 @@ func stripInternalMarkers(text string) string {
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// looksNone reports whether the body says "nothing worth saying". It is used both
|
||||
// to drive the no-marker default and to fail-safe a contradictory push marker.
|
||||
// looksNone 报告正文是否表示「没什么值得说的」。它既用于
|
||||
// 驱动无标记时的默认,也用于对矛盾的 push 标记失败安全。
|
||||
func looksNone(text string) bool {
|
||||
t := normalizeNone(text)
|
||||
if t == "" {
|
||||
@@ -159,8 +159,8 @@ func looksNone(text string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeNone lowercases the body and strips spaces and punctuation so the
|
||||
// nonePhrases compare cleanly across "无", "无。", "Nothing to report." and similar.
|
||||
// normalizeNone 把正文转小写并去掉空格与标点,好让
|
||||
// nonePhrases 在 "无"、"无。"、"Nothing to report." 之类之间干净比较。
|
||||
func normalizeNone(text string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(strings.TrimSpace(text)) {
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestParseProactive_NoPushMarker(t *testing.T) {
|
||||
|
||||
func TestParseProactive_ChannelColonWidth(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Tolerate a full-width colon and trailing spaces, and uppercase channel value.
|
||||
// 容忍全角冒号与尾随空格,以及大写渠道值。
|
||||
conclusion := "[我决定:push] [渠道:WEB] 晚上早点睡"
|
||||
msg, hint, push := parseProactive(conclusion)
|
||||
if !push {
|
||||
@@ -104,11 +104,11 @@ func TestParseProactive_MalformedFailsSafe(t *testing.T) {
|
||||
|
||||
func TestParseProactive_MarkerWithoutBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A lone push marker with no body must not push an empty message (fail safe).
|
||||
// 单独一个 push 标记、正文为空时绝不能推出空消息(失败安全)。
|
||||
if _, _, push := parseProactive("[我决定:push]"); push {
|
||||
t.Fatalf("push = true for a marker with no body, want false")
|
||||
}
|
||||
// A push marker whose body says "nothing" is contradictory → fail safe.
|
||||
// push 标记的正文写着「无」是自相矛盾的 → 失败安全。
|
||||
if _, _, push := parseProactive("[我决定:push]\n无"); push {
|
||||
t.Fatalf("push = true for a push marker with nothing body, want false")
|
||||
}
|
||||
@@ -131,8 +131,8 @@ func TestThink_ParsesStructuredDecision_EndToEnd(t *testing.T) {
|
||||
|
||||
b.think(context.Background(), heartbeatTrigger())
|
||||
|
||||
// The channel hint "kook" routes to the IM bridge (a specific platform), not the
|
||||
// active client.
|
||||
// 渠道提示 "kook" 路由到 IM 桥(一个具体平台),而不是
|
||||
// 活跃客户端。
|
||||
if active.calls != 0 {
|
||||
t.Fatalf("active calls = %d, want 0 (specific platform routes to IM)", active.calls)
|
||||
}
|
||||
@@ -172,8 +172,8 @@ func TestThink_ParsesStructuredDecision_EndToEnd(t *testing.T) {
|
||||
|
||||
func TestThink_NoMarkerDefaultRoute_IM(t *testing.T) {
|
||||
t.Parallel()
|
||||
// No channel marker → auto → a single IM channel (the bridge picks her choice /
|
||||
// her recent platform / the whitelist default), NOT the active web client.
|
||||
// 无渠道标记 → auto → 单个 IM 渠道(由桥挑出她的选择 /
|
||||
// 她最近的平台 / 白名单默认),而不是活跃 Web 客户端。
|
||||
conclusion := "[我决定:push]\n洗衣机洗好了,衣服可以收了"
|
||||
thinker := &mockThinker{text: conclusion}
|
||||
active := &mockActive{n: 2}
|
||||
@@ -241,8 +241,8 @@ func TestThink_ConcludedNoPush_NotPushed(t *testing.T) {
|
||||
|
||||
func TestThink_DoNotDisturbBlocksPush(t *testing.T) {
|
||||
t.Parallel()
|
||||
// She wants to push, but the user is asleep → the hard safety gate refuses,
|
||||
// regardless of her decision.
|
||||
// 她想推送,但用户正在睡觉 → 硬安全门拒绝,
|
||||
// 与她的决策无关。
|
||||
conclusion := "[我决定:push]\n想跟你说晚安"
|
||||
thinker := &mockThinker{text: conclusion}
|
||||
b, _ := newBackthink(t, Config{}, Deps{
|
||||
@@ -292,9 +292,9 @@ func TestBuildProactiveMessage_SpecificPlatform(t *testing.T) {
|
||||
|
||||
func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) {
|
||||
t.Parallel()
|
||||
// When a specific platform is requested but no IM sink is wired, the pusher
|
||||
// falls back to the default route so the owner still receives the message, and
|
||||
// records the requested hint.
|
||||
// 请求了具体平台但未接线 IM 槽时,推送器
|
||||
// 降级到默认路由,好让主人仍能收到消息,并
|
||||
// 记录下请求的提示。
|
||||
active := &mockActive{n: 1}
|
||||
p := newPusher(active, nil, &mockGate{allow: true}, discardLog())
|
||||
res := p.Push(context.Background(), PushRequest{
|
||||
@@ -309,9 +309,9 @@ func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripInternalMarkers_CleanBody verifies the internal decision/channel
|
||||
// markers are removed from a proactive body in every shape a model may emit
|
||||
// (bracketed, full-width, bare), so a marker can never reach the user-facing text.
|
||||
// TestStripInternalMarkers_CleanBody 验证内部决策/渠道
|
||||
// 标记在模型可能发出的每种形状下(带括号、全角、裸写)
|
||||
// 都被从主动正文中移除,因此标记永远无法到达面向用户的文本。
|
||||
func TestStripInternalMarkers_CleanBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -336,9 +336,9 @@ func TestStripInternalMarkers_CleanBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestThink_BareMarkersStrippedFromBody is the regression for the KOOK leak: a
|
||||
// conclusion carrying bare (bracket-less) "我决定:push"/"渠道:..." markers must
|
||||
// still deliver a clean warm body, never the structured decision text.
|
||||
// TestThink_BareMarkersStrippedFromBody 是 KOOK 泄漏的回归用例:
|
||||
// 携带裸写(无括号)"我决定:push"/"渠道:..." 标记的结论仍须
|
||||
// 投递干净的温暖正文,绝不是结构化的决策文本。
|
||||
func TestThink_BareMarkersStrippedFromBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
conclusion := "我决定:push\n渠道:kook\n记得喝水哦"
|
||||
|
||||
+129
-129
@@ -12,56 +12,56 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
)
|
||||
|
||||
// Route identifies how a proactive message was actually delivered, for the
|
||||
// monitor panel and the push result record.
|
||||
// Route 标识一条主动消息实际是如何投递的,供监控面板
|
||||
// 与推送结果记录使用。
|
||||
type Route string
|
||||
|
||||
const (
|
||||
// RouteNone means no push was attempted or no target was available.
|
||||
// RouteNone 表示未尝试推送,或无可用目标。
|
||||
RouteNone Route = "none"
|
||||
// RouteRateLimited means the push was refused by the rate limiter.
|
||||
// RouteRateLimited 表示推送被限流器拒绝。
|
||||
RouteRateLimited Route = "rate_limited"
|
||||
// RouteActiveClient means the message went to an active web/native client.
|
||||
// RouteActiveClient 表示消息发往了活跃 Web/原生客户端。
|
||||
RouteActiveClient Route = "active_client"
|
||||
// RouteIMBridge means the message fell back to the IM bridge.
|
||||
// RouteIMBridge 表示消息降级到 IM 桥。
|
||||
RouteIMBridge Route = "im_bridge"
|
||||
)
|
||||
|
||||
// Channel-hint tokens recognized by the push router. They come from the
|
||||
// conclusion's [渠道:...] marker (parseProactive) and select where a proactive
|
||||
// message is delivered. An empty/unknown hint is treated as auto. A concrete
|
||||
// platform id (kook/telegram/...) is a value other than these three.
|
||||
// 推送路由器识别的渠道提示词。它们来自结论的
|
||||
// [渠道:...] 标记(parseProactive),用于选择主动消息
|
||||
// 的投递去向。为空/未知的提示按 auto 处理。具体的
|
||||
// 平台 id(kook/telegram/...)是这三个之外的值。
|
||||
const (
|
||||
channelAuto = "auto"
|
||||
channelIM = "im"
|
||||
channelWeb = "web"
|
||||
// defaultProactivePlatform is the Platform the outbound proactive message
|
||||
// carries when the conclusion names no specific channel.
|
||||
// defaultProactivePlatform 是结论未指明具体渠道时,
|
||||
// 出站主动消息携带的 Platform。
|
||||
defaultProactivePlatform = "cyrene-backthink"
|
||||
)
|
||||
|
||||
// String returns the route as a plain label.
|
||||
// String 把路由作为普通标签返回。
|
||||
func (r Route) String() string { return string(r) }
|
||||
|
||||
// PushResult is the outcome of one proactive push attempt, recorded with each
|
||||
// thinking pass (design/12 §4).
|
||||
// PushResult 是一次主动推送尝试的结果,随每轮思考
|
||||
// 一起记录(design/12 §4)。
|
||||
type PushResult struct {
|
||||
// Pushed reports whether the message was delivered.
|
||||
// Pushed 报告消息是否已投递。
|
||||
Pushed bool
|
||||
// Route is the delivery route taken.
|
||||
// Route 是实际采用的路由。
|
||||
Route Route
|
||||
// Bypassed reports whether this push was event-driven and skipped the PushGate
|
||||
// (a real event, "该发就发"). A heartbeat push is gate-governed and never
|
||||
// bypassed.
|
||||
// Bypassed 报告本次推送是否为事件驱动、跳过了 PushGate
|
||||
// (真正的事件,"该发就发")。心跳推送受门控管辖,绝不
|
||||
// 被绕过。
|
||||
Bypassed bool
|
||||
// Detail is a short human-readable note (e.g. client count, decision reason).
|
||||
// Detail 是简短可读的备注(如客户端数量、决策理由)。
|
||||
Detail string
|
||||
// Err is the underlying error text when delivery failed, or that of the
|
||||
// provider that decided not to push.
|
||||
// Err 是投递失败时的底层错误文本,或决定不推送的
|
||||
// 提供者的错误文本。
|
||||
Err string
|
||||
}
|
||||
|
||||
// String returns a concise summary for logs.
|
||||
// String 返回用于日志的简洁摘要。
|
||||
func (p PushResult) String() string {
|
||||
if p.Err != "" {
|
||||
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q err=%q", p.Pushed, p.Route, p.Bypassed, p.Detail, p.Err)
|
||||
@@ -69,110 +69,110 @@ func (p PushResult) String() string {
|
||||
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q", p.Pushed, p.Route, p.Bypassed, p.Detail)
|
||||
}
|
||||
|
||||
// ActiveClient delivers a proactive message to every currently active web/native
|
||||
// client and reports how many received it. It is the preferred push target. The
|
||||
// gateway WebSocket hub implements this in production; tests inject a mock.
|
||||
// ActiveClient 把主动消息投递给所有当前活跃的 Web/原生
|
||||
// 客户端,并报告有多少个收到。它是首选的推送目标。
|
||||
// 生产中由 gateway 的 WebSocket hub 实现;测试注入 mock。
|
||||
type ActiveClient interface {
|
||||
// SendProactive pushes msg to each active client and returns the number of
|
||||
// clients that accepted it.
|
||||
// SendProactive 把 msg 推给每个活跃客户端,并返回
|
||||
// 接受它的客户端数量。
|
||||
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error)
|
||||
}
|
||||
|
||||
// IMAdapter is the IM bridge fallback: it pushes a proactive message over an IM
|
||||
// channel. Every adapter.PlatformAdapter satisfies this subset, so the service
|
||||
// depends on the narrow interface rather than the concrete adapter type.
|
||||
// IMAdapter 是 IM 桥降级:它把主动消息经 IM
|
||||
// 渠道推送。每个 adapter.PlatformAdapter 都满足这个子集,因此本服务
|
||||
// 依赖这个窄接口而非具体适配器类型。
|
||||
type IMAdapter interface {
|
||||
// SendProactive pushes msg to the owner's IM channel.
|
||||
// SendProactive 把 msg 推给主人的 IM 渠道。
|
||||
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error
|
||||
}
|
||||
|
||||
// PushGate decides whether a proactive push may proceed right now, applying the
|
||||
// rate limits of design/11 §6 plus a per-flavor limit and a post-push cooldown. It
|
||||
// is a seam over channel/session's ProactivePolicy/RateLimiter so the service can
|
||||
// be tested with a mock.
|
||||
// PushGate 判断一次主动推送此刻能否进行,套用
|
||||
// design/11 §6 的限频规则,外加按风格限制与推送后冷却。它
|
||||
// 是对 channel/session 的 ProactivePolicy/RateLimiter 的接缝,因此本服务
|
||||
// 可以用 mock 测试。
|
||||
type PushGate interface {
|
||||
// Allow reports whether a proactive push of the given kind may be sent now.
|
||||
// Allow 报告给定风格的主动推送此刻能否发送。
|
||||
Allow(ctx context.Context, kind PushKind) bool
|
||||
// RecordPush notes a successful delivery so the cooldown/per-kind windows
|
||||
// start from a real successful push, not a merely-attempted one.
|
||||
// RecordPush 记下一次成功投递,使冷却/按风格窗口
|
||||
// 从一次真正的成功推送起算,而不是仅仅尝试过。
|
||||
RecordPush(kind PushKind)
|
||||
}
|
||||
|
||||
// PushRequest carries one proactive push attempt: the outbound message plus the
|
||||
// channel target the autonomous conclusion asked for (design/20 §2 P2).
|
||||
// ChannelHint is the lowercased [渠道:...] token: ""/auto pick the default (the
|
||||
// owner mandate: ONE channel, chosen by her / her most recent platform / the
|
||||
// whitelist default); "web" targets the active client; "im" targets the IM bridge;
|
||||
// a concrete platform id (kook/telegram/weixin-ilink/...) targets the IM bridge
|
||||
// with the message tagged for that platform.
|
||||
// PushRequest 携带一次主动推送尝试:出站消息加上
|
||||
// 自主结论所要求的目标渠道(design/20 §2 P2)。
|
||||
// ChannelHint 是已转小写的 [渠道:...] 词:""/auto 选默认(
|
||||
// 主人要求:只发一个渠道,由她 / 她最近的平台 /
|
||||
// 白名单默认决定);"web" 指向活跃客户端;"im" 指向 IM 桥;
|
||||
// 具体平台 id(kook/telegram/weixin-ilink/...)指向 IM 桥
|
||||
// 并把消息标记为该平台。
|
||||
type PushRequest struct {
|
||||
// Message is the normalized outbound proactive message.
|
||||
// Message 是归一化后的出站主动消息。
|
||||
Message adapter.UnifiedMessage
|
||||
// ChannelHint is the requested target channel, "" when she did not choose one.
|
||||
// ChannelHint 是请求的目标渠道,她未选择时为 ""。
|
||||
ChannelHint string
|
||||
// TriggerKind records what woke this pass. An event-driven push bypasses the
|
||||
// PushGate (owner mandate: "该发就发"); a heartbeat stays gate-governed.
|
||||
// TriggerKind 记录唤醒本轮的原因。事件驱动的推送绕过
|
||||
// PushGate(主人要求:"该发就发");心跳保持受门控管辖。
|
||||
TriggerKind TriggerKind
|
||||
}
|
||||
|
||||
// Pusher delivers a proactive message to ONE target channel. It is bounded by the
|
||||
// rate gate and honors the channel hint the autonomous conclusion selected: "web"
|
||||
// goes to the active client, everything else goes over the IM bridge a single time,
|
||||
// where the bridge picks the one channel (her choice → her most recent platform →
|
||||
// whitelist default, WeChat included).
|
||||
// Pusher 把主动消息投递到一个目标渠道。它受
|
||||
// 频率门约束,并尊重自主结论所选的渠道提示:"web"
|
||||
// 去活跃客户端,其余都经 IM 桥走一次,
|
||||
// 由桥挑出那一个渠道(她的选择 → 她最近的平台 →
|
||||
// 白名单默认,微信含在内)。
|
||||
type Pusher interface {
|
||||
// Push delivers req to a single target channel, per the channel hint. It never
|
||||
// blocks on a slow target.
|
||||
// Push 按渠道提示把 req 投递到单个目标渠道。它绝不
|
||||
// 阻塞在慢目标上。
|
||||
Push(ctx context.Context, req PushRequest) PushResult
|
||||
}
|
||||
|
||||
// pusher is the concrete Pusher: it consults the gate, then routes the message to
|
||||
// exactly one target — the active client for a "web" hint, otherwise the IM bridge.
|
||||
// After the local delivery it mirrors the message to the optional push outlet
|
||||
// (self-hosted service → Getui fallback) without blocking that delivery.
|
||||
// pusher 是具体的 Pusher:它先咨询频率门,再把消息路由到
|
||||
// 恰好一个目标——"web" 提示走活跃客户端,否则走 IM 桥。
|
||||
// 本地投递之后,它把消息镜像到可选的推送出口
|
||||
// (自托管服务 → 个推降级),且不阻塞该投递。
|
||||
type pusher struct {
|
||||
active ActiveClient
|
||||
im IMAdapter
|
||||
gate PushGate
|
||||
exporter PushExporter
|
||||
log *slog.Logger
|
||||
// exportDone, when non-nil, receives one value after each asynchronous export
|
||||
// completes. It exists so tests can deterministically observe the hook; the
|
||||
// send is non-blocking, so a full channel never stalls the exporter.
|
||||
// exportDone 非 nil 时,会在每次异步导出完成后收到一个值。
|
||||
// 它存在是为了让测试能确定性地观测该钩子;发送是非阻塞的,
|
||||
// 所以通道满也不会卡住导出器。
|
||||
exportDone chan struct{}
|
||||
}
|
||||
|
||||
// newPusher builds a Pusher from the optional active-client sink, IM sink and
|
||||
// rate gate. Any of them may be nil; a nil target simply makes that route
|
||||
// unavailable. The push outlet is attached separately with withExporter.
|
||||
// newPusher 由可选的活跃客户端槽、IM 槽与
|
||||
// 频率门构建 Pusher。它们都可以为 nil;目标为 nil 只是让那条路由
|
||||
// 不可用。推送出口用 withExporter 单独挂上。
|
||||
func newPusher(active ActiveClient, im IMAdapter, gate PushGate, log *slog.Logger) *pusher {
|
||||
return &pusher{active: active, im: im, gate: gate, log: log}
|
||||
}
|
||||
|
||||
// withExporter attaches the push outlet hook and returns the pusher for fluent
|
||||
// use. A nil exporter leaves the hook disabled.
|
||||
// withExporter 挂上推送出口钩子并返回 pusher 以便链式
|
||||
// 调用。exporter 为 nil 则钩子保持关闭。
|
||||
func (p *pusher) withExporter(exporter PushExporter) *pusher {
|
||||
p.exporter = exporter
|
||||
return p
|
||||
}
|
||||
|
||||
// Push implements Pusher: it applies the rate gate, then routes the message to a
|
||||
// single target. A "web" hint reaches the active web/native client; every other
|
||||
// hint ("im", "auto", or a concrete platform) goes over the IM bridge once, where
|
||||
// the bridge selects one owner channel (her [渠道:...] choice, then the platform she
|
||||
// used most recently, then the whitelist default). The IM bridge is primary, so a
|
||||
// push never fans out to several channels. When no IM sink is wired the active
|
||||
// client is the honest fallback, so the owner is still reached rather than the
|
||||
// message being dropped.
|
||||
// Push 实现 Pusher:它套用频率门,然后把消息路由到
|
||||
// 单个目标。"web" 提示到达活跃 Web/原生客户端;其它任何
|
||||
// 提示("im"、"auto" 或具体平台)经 IM 桥走一次,由
|
||||
// 桥挑出一个主人渠道(她的 [渠道:...] 选择,其次是她
|
||||
// 最近用过的平台,再次是白名单默认)。IM 桥是主路径,所以一次
|
||||
// 推送绝不扇出到多个渠道。未接线 IM 槽时,活跃
|
||||
// 客户端就是诚实的降级,因此主人仍能被触达,而不是
|
||||
// 消息被丢弃。
|
||||
func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return PushResult{Pushed: false, Route: RouteNone, Err: err.Error()}
|
||||
}
|
||||
kind := classifyPush(req.Message.Content)
|
||||
// A real event-driven push bypasses the PushGate: it should go out as-is ("该发就发"),
|
||||
// independent of the heartbeat throttle (60min≤1 / cooldown / per-flavor window).
|
||||
// Only the periodic heartbeat stays gate-governed, so the owner is never spammed
|
||||
// by an empty reflection. Same-event dedup is applied upstream in think().
|
||||
// 真正事件驱动的推送绕过 PushGate:它应原样发出("该发就发"),
|
||||
// 不受心跳限流约束(60min≤1 / 冷却 / 按风格窗口)。
|
||||
// 只有周期性心跳保持受门控管辖,主人因此绝不会被
|
||||
// 空洞的反思刷屏。同一事件去重在 think() 上游套用。
|
||||
eventDriven := req.TriggerKind == TriggerEvent
|
||||
if p.gate != nil && !eventDriven && !p.gate.Allow(ctx, kind) {
|
||||
p.log.Warn("backthink push rate limited", "kind", kind.String())
|
||||
@@ -186,10 +186,10 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
|
||||
if hint == channelWeb {
|
||||
res = p.pushActive(ctx, msg)
|
||||
} else {
|
||||
// "im"/"auto"/a concrete platform all travel over the IM bridge once. A
|
||||
// generic hint hands the sentinel platform so the bridge falls through to its
|
||||
// recent-active / default selection; a concrete hint is honored when the
|
||||
// channel is whitelisted and connected.
|
||||
// "im"/"auto"/具体平台都经 IM 桥走一次。
|
||||
// 泛化提示交出哨兵平台,让桥落到它的
|
||||
// 最近活跃 / 默认选择上;具体提示在渠道
|
||||
// 已加入白名单且已连接时被尊重。
|
||||
if hint == channelIM || hint == channelAuto {
|
||||
msg.Platform = defaultProactivePlatform
|
||||
} else {
|
||||
@@ -200,16 +200,16 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
|
||||
res = p.pushActive(ctx, msg)
|
||||
}
|
||||
}
|
||||
// Only a real delivery advances the cooldown/per-kind windows (and only for a
|
||||
// gate-governed push), so an attempted-but-failed push does not suppress a later
|
||||
// genuine one, and an event push never contributes to the heartbeat throttle.
|
||||
// 只有真正的投递才推进冷却/按风格窗口(且只对
|
||||
// 受门控的推送),因此尝试过但失败的推送不会抑制之后的
|
||||
// 真实推送,而事件推送也绝不计入心跳限流。
|
||||
if res.Pushed && p.gate != nil && !eventDriven {
|
||||
p.gate.RecordPush(kind)
|
||||
}
|
||||
// D1: mirror the proactive message to the external push outlet (self-hosted
|
||||
// unified push service → Getui fallback). Fire-and-forget: a slow or failing
|
||||
// outlet never delays or breaks the local delivery above, and a rate-limited
|
||||
// push returned before reaching here.
|
||||
// D1:把主动消息镜像到外部推送出口(自托管
|
||||
// 统一推送服务 → 个推降级)。发后不管:慢或失败的
|
||||
// 出口绝不延迟或破坏上面的本地投递,而且限流被拒的
|
||||
// 推送在此之前就已返回。
|
||||
p.exportAsync(msg)
|
||||
res = annotateHint(res, req.ChannelHint)
|
||||
if eventDriven {
|
||||
@@ -219,9 +219,9 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
|
||||
return res
|
||||
}
|
||||
|
||||
// exportAsync reports msg to the push outlet on a detached, bounded goroutine so
|
||||
// the proactive path never blocks on the outlet. A nil exporter (hook disabled)
|
||||
// or a nil outlet result is a no-op; a delivery miss or error is only logged.
|
||||
// exportAsync 在脱离的、有界的 goroutine 上把 msg 上报给推送出口,
|
||||
// 因此主动路径绝不在出口上阻塞。exporter 为 nil(钩子关闭)
|
||||
// 或出口结果为 nil 都是空操作;投递未命中或出错只记日志。
|
||||
func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
|
||||
if p.exporter == nil {
|
||||
return
|
||||
@@ -246,7 +246,7 @@ func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
|
||||
}()
|
||||
}
|
||||
|
||||
// pushActive delivers only to the active web/native client.
|
||||
// pushActive 只投递给活跃 Web/原生客户端。
|
||||
func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
|
||||
if p.active != nil {
|
||||
n, err := p.active.SendProactive(ctx, msg)
|
||||
@@ -259,7 +259,7 @@ func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) Pus
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
|
||||
}
|
||||
|
||||
// pushIM delivers only to the IM bridge.
|
||||
// pushIM 只投递给 IM 桥。
|
||||
func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
|
||||
if p.im == nil {
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no IM target"}
|
||||
@@ -271,9 +271,9 @@ func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushRes
|
||||
return PushResult{Pushed: true, Route: RouteIMBridge, Detail: "delivered via IM bridge"}
|
||||
}
|
||||
|
||||
// normalizeChannelHint lowercases and trims a channel hint, mapping synonyms to
|
||||
// the canonical route tokens (auto/im/web); anything else is a concrete platform
|
||||
// id (kook/telegram/...). Unknown/blank maps to auto.
|
||||
// normalizeChannelHint 把渠道提示转小写并去空白,把同义词映射到
|
||||
// 规范路由词(auto/im/web);其它一切视为具体平台
|
||||
// id(kook/telegram/...)。未知/空映射为 auto。
|
||||
func normalizeChannelHint(hint string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(hint)) {
|
||||
case "", "auto":
|
||||
@@ -287,15 +287,15 @@ func normalizeChannelHint(hint string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// isSpecificChannel reports whether hint names a concrete platform rather than a
|
||||
// routing keyword (auto/im/web).
|
||||
// isSpecificChannel 报告 hint 指的是具体平台,而不是
|
||||
// 路由关键词(auto/im/web)。
|
||||
func isSpecificChannel(hint string) bool {
|
||||
return hint != channelAuto && hint != channelIM && hint != channelWeb
|
||||
}
|
||||
|
||||
// annotateHint appends the requested channel hint to the push result detail so
|
||||
// the record/panel surfaces which channel she chose, even when the route fell
|
||||
// back. Blank/auto hints add nothing.
|
||||
// annotateHint 把请求的渠道提示附加到推送结果 detail 上,
|
||||
// 好让记录/面板显示她选了哪个渠道,即便路由发生
|
||||
// 了降级。空白/auto 提示不加任何内容。
|
||||
func annotateHint(res PushResult, hint string) PushResult {
|
||||
h := strings.ToLower(strings.TrimSpace(hint))
|
||||
if h == "" || h == channelAuto {
|
||||
@@ -310,8 +310,8 @@ func annotateHint(res PushResult, hint string) PushResult {
|
||||
return res
|
||||
}
|
||||
|
||||
// annotateBypass tags an event-driven push result so the record/panel shows it
|
||||
// skipped the PushGate, distinct from a heartbeat push that was rate-limited.
|
||||
// annotateBypass 给事件驱动的推送结果打标记,使记录/面板显示它
|
||||
// 跳过了 PushGate,与因限流被拒的心跳推送区分开。
|
||||
func annotateBypass(res PushResult) PushResult {
|
||||
if res.Detail == "" {
|
||||
res.Detail = "event_bypass"
|
||||
@@ -321,10 +321,10 @@ func annotateBypass(res PushResult) PushResult {
|
||||
return res
|
||||
}
|
||||
|
||||
// rateGate is the production PushGate: a sliding-window counter over
|
||||
// session.RateLimiter (design/11 §6 anti-spam rule) combined with a post-push
|
||||
// cooldown and a per-flavor (问候/提醒/分享/关心) window so the owner is never
|
||||
// spammed with the same style (design/12 §6 策略二).
|
||||
// rateGate 是生产用的 PushGate:一个基于
|
||||
// session.RateLimiter 的滑动窗口计数器(design/11 §6 反骚扰规则),
|
||||
// 结合推送后冷却与按风格(问候/提醒/分享/关心)窗口,
|
||||
// 使主人绝不会被同一风格刷屏(design/12 §6 策略二)。
|
||||
type rateGate struct {
|
||||
log *slog.Logger
|
||||
limiter *session.RateLimiter
|
||||
@@ -337,10 +337,10 @@ type rateGate struct {
|
||||
lastKind map[PushKind]time.Time
|
||||
}
|
||||
|
||||
// newRateGate builds a gate that allows at most limit pushes within window, keeps
|
||||
// pushes cooldown apart after a success, and limits each PushKind to one push per
|
||||
// kindWindow. A non-positive limit or window yields an always-allow limiter (no
|
||||
// rate rule); a non-positive cooldown or kindWindow disables that specific guard.
|
||||
// newRateGate 构建一个门:在 window 内最多放行 limit 次推送,
|
||||
// 成功之后推送之间至少间隔 cooldown,且每种 PushKind 在
|
||||
// kindWindow 内只允许一次。非正的 limit 或 window 得到始终放行的
|
||||
// 限流器(无限频规则);非正的 cooldown 或 kindWindow 会禁用对应的那道守护。
|
||||
func newRateGate(limit int, window, cooldown, kindWindow time.Duration, log *slog.Logger) *rateGate {
|
||||
return &rateGate{
|
||||
log: log,
|
||||
@@ -352,9 +352,9 @@ func newRateGate(limit int, window, cooldown, kindWindow time.Duration, log *slo
|
||||
}
|
||||
}
|
||||
|
||||
// Allow implements PushGate. A canceled context denies (fail closed). It refuses
|
||||
// when the last successful push is inside the cooldown, or when the same flavor
|
||||
// was delivered within kindWindow, before consulting the sliding-window limiter.
|
||||
// Allow 实现 PushGate。已取消的 context 一律拒绝(fail closed)。
|
||||
// 当最近一次成功推送仍在冷却期内,或同一风格
|
||||
// 在 kindWindow 内已投递过,它就在咨询滑动窗口限流器之前拒绝。
|
||||
func (g *rateGate) Allow(ctx context.Context, kind PushKind) bool {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false
|
||||
@@ -371,8 +371,8 @@ func (g *rateGate) Allow(ctx context.Context, kind PushKind) bool {
|
||||
return g.limiter.Allow()
|
||||
}
|
||||
|
||||
// RecordPush implements PushGate: it stamps the last successful push time and the
|
||||
// per-flavor last time so the cooldown and kind window start from a real delivery.
|
||||
// RecordPush 实现 PushGate:它记下最近成功推送时间与
|
||||
// 按风格的最近时间,使冷却与风格窗口从真实投递起算。
|
||||
func (g *rateGate) RecordPush(kind PushKind) {
|
||||
now := g.now()
|
||||
g.mu.Lock()
|
||||
@@ -381,15 +381,15 @@ func (g *rateGate) RecordPush(kind PushKind) {
|
||||
g.lastKind[kind] = now
|
||||
}
|
||||
|
||||
// buildProactiveMessage assembles the normalized outbound message carrying a
|
||||
// think conclusion's clean body. It tags the platform with the requested channel
|
||||
// when the conclusion names a concrete platform, otherwise the default backthink
|
||||
// platform. The concrete active-client and IM adapters apply their own
|
||||
// routing/targeting to the owner (red line: owner only).
|
||||
// buildProactiveMessage 拼装承载思考结论文本的、
|
||||
// 归一化的出站消息。当结论指明具体平台时,它用请求的渠道
|
||||
// 给平台打标签,否则用 backthink 的默认
|
||||
// 平台。具体的活跃客户端与 IM 适配器各自对主人施加
|
||||
// 自己的路由/寻址(红线:只针对主人)。
|
||||
//
|
||||
// The body is run through stripInternalMarkers as a final guard so a structured
|
||||
// decision/channel marker that survived parsing can never reach the user-facing
|
||||
// message (only our logs/records may carry it).
|
||||
// 正文会再过一遍 stripInternalMarkers 作为最后一道守护,因此
|
||||
// 侥幸存活过解析的结构化决策/渠道标记绝不
|
||||
// 能到达面向用户的消息(只有我们的日志/记录可以携带它)。
|
||||
func (b *Backthink) buildProactiveMessage(message, channelHint string) adapter.UnifiedMessage {
|
||||
platform := defaultProactivePlatform
|
||||
if h := normalizeChannelHint(channelHint); isSpecificChannel(h) {
|
||||
|
||||
@@ -15,58 +15,58 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// The push outlet (D1, design/12 §4/§6): proactive messages are mirrored to an
|
||||
// external push service so the owner's mobile client is reached even when no
|
||||
// WebSocket client or IM channel is live. Delivery is two-tier by design:
|
||||
// 推送出口(D1,design/12 §4/§6):主动消息被镜像到
|
||||
// 外部推送服务,使主人的移动端即便在没有
|
||||
// WebSocket 客户端或 IM 渠道在线时也能被触达。按设计投递分两级:
|
||||
//
|
||||
// 1. the owner's self-hosted unified push service (primary; PUSH_EXPORTER_URL),
|
||||
// 2. Getui (个推) as the fallback for when the self-hosted service is down.
|
||||
// 1. 主人的自托管统一推送服务(主级;PUSH_EXPORTER_URL),
|
||||
// 2. 个推(Getui)作为自托管服务不可用时的降级。
|
||||
//
|
||||
// Tier 2 has no SDK integration yet, so an unavailable primary logs an explicit
|
||||
// "Getui fallback not integrated" note and reports not-delivered instead of
|
||||
// pretending the message went out. The hook is fire-and-forget: it never delays
|
||||
// or breaks the existing active-client / IM delivery.
|
||||
// 第 2 级尚未接入 SDK,因此主级不可用时只记一条明确的
|
||||
// "Getui fallback not integrated" 备注,并上报未投递,而不
|
||||
// 假装消息已发出。该钩子是发后不管:它绝不延迟
|
||||
// 或破坏既有的活跃客户端 / IM 投递。
|
||||
|
||||
// defaultExportTimeout bounds one export attempt so a hung endpoint cannot pin a
|
||||
// goroutine forever.
|
||||
// defaultExportTimeout 约束单次导出尝试,因此挂死的端点无法永久
|
||||
// 占住一个 goroutine。
|
||||
const defaultExportTimeout = 5 * time.Second
|
||||
|
||||
// maxExportResponseBytes caps how much of the outlet's response body is drained
|
||||
// before the connection is reused, so a hostile/buggy endpoint cannot stream an
|
||||
// unbounded body into memory.
|
||||
// maxExportResponseBytes 限制连接复用前从出口响应体里
|
||||
// 抽掉多少数据,因此恶意或有缺陷的端点无法把一个
|
||||
// 无界的响应体流进内存。
|
||||
const maxExportResponseBytes = 4 << 10
|
||||
|
||||
// PushExporter reports one proactive message to an external push outlet. Export
|
||||
// must be safe for concurrent use and must honor ctx. A nil error with
|
||||
// delivered=false means the outlet could not deliver (for example the fallback
|
||||
// tier is not wired); the caller treats that as a logged miss, never a failure
|
||||
// of the local push path.
|
||||
// PushExporter 把一条主动消息上报给外部推送出口。Export
|
||||
// 必须并发安全且必须尊重 ctx。nil 错误配上
|
||||
// delivered=false 表示出口无法投递(例如降级
|
||||
// 级尚未接线);调用方把它当作一次记账的未命中,绝不当作本地
|
||||
// 推送路径的失败。
|
||||
type PushExporter interface {
|
||||
Export(ctx context.Context, msg adapter.UnifiedMessage) (delivered bool, err error)
|
||||
}
|
||||
|
||||
// PushOutletConfig carries the push-outlet wiring. Enabled=false disables the
|
||||
// hook entirely (NewPushOutlet returns nil). URL/Token/Timeout describe the
|
||||
// self-hosted primary service; the Getui* fields are reserved for the deferred
|
||||
// fallback and are not consumed yet.
|
||||
// PushOutletConfig 携带推送出口的接线。Enabled=false 完全关闭
|
||||
// 该钩子(NewPushOutlet 返回 nil)。URL/Token/Timeout 描述
|
||||
// 自托管主服务;Getui* 字段保留给延后的
|
||||
// 降级级,目前尚未消费。
|
||||
type PushOutletConfig struct {
|
||||
// Enabled turns the outlet hook on.
|
||||
// Enabled 打开出口钩子。
|
||||
Enabled bool
|
||||
// URL is the self-hosted unified push service endpoint (HTTP POST JSON).
|
||||
// Empty leaves the primary tier unconfigured.
|
||||
// URL 是自托管统一推送服务端点(HTTP POST JSON)。
|
||||
// 为空则主级保持未配置。
|
||||
URL string
|
||||
// Token is sent as "Authorization: Bearer <token>". It is never logged.
|
||||
// Token 以 "Authorization: Bearer <token>" 发送。它绝不写日志。
|
||||
Token string
|
||||
// Timeout bounds one export attempt. Zero uses defaultExportTimeout.
|
||||
// Timeout 约束单次导出尝试。为零用 defaultExportTimeout。
|
||||
Timeout time.Duration
|
||||
// Log is the base logger. Nil uses a discard logger.
|
||||
// Log 是基础日志器。nil 则用丢弃式日志器。
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// NewPushOutlet builds the two-tier push outlet from cfg. It returns nil when
|
||||
// the hook is disabled, so the pusher simply skips the export step. When it is
|
||||
// enabled the primary tier is the self-hosted HTTP endpoint (present only when
|
||||
// URL is set) and the fallback tier is Getui, which is not integrated yet.
|
||||
// NewPushOutlet 由 cfg 构建两级推送出口。钩子关闭时它返回 nil,
|
||||
// 因此推送器直接跳过导出步骤。启用时,
|
||||
// 主级是自托管 HTTP 端点(仅当
|
||||
// URL 已设置时存在),降级级是个推,尚未接入。
|
||||
func NewPushOutlet(cfg PushOutletConfig) PushExporter {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
@@ -90,19 +90,19 @@ func NewPushOutlet(cfg PushOutletConfig) PushExporter {
|
||||
return &pushOutlet{primary: primary, log: log}
|
||||
}
|
||||
|
||||
// pushOutlet is the two-tier exporter: self-hosted service first, Getui second.
|
||||
// The fallback is nil until the Getui SDK is integrated, so a primary miss is
|
||||
// logged and reported as not delivered rather than silently dropped.
|
||||
// pushOutlet 是两级导出器:先自托管服务,再个推。
|
||||
// 在个推 SDK 接入前降级级为 nil,因此主级未命中时
|
||||
// 只记日志并上报未投递,而不是静默丢弃。
|
||||
type pushOutlet struct {
|
||||
primary PushExporter
|
||||
fallback PushExporter
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Export implements PushExporter. It tries the primary tier and, when that does
|
||||
// not deliver, the fallback tier. With no fallback wired it logs the pending
|
||||
// integration and returns (false, nil): the caller treats that as a miss, never
|
||||
// as a reason to fail the proactive push.
|
||||
// Export 实现 PushExporter。它先试主级,主级
|
||||
// 未投递时再试降级级。未接降级级时它记录待
|
||||
// 接入状态并返回 (false, nil):调用方把这当作未命中,
|
||||
// 绝不当作让主动推送失败的理由。
|
||||
func (o *pushOutlet) Export(ctx context.Context, msg adapter.UnifiedMessage) (bool, error) {
|
||||
if o.primary != nil {
|
||||
delivered, err := o.primary.Export(ctx, msg)
|
||||
@@ -123,34 +123,34 @@ func (o *pushOutlet) Export(ctx context.Context, msg adapter.UnifiedMessage) (bo
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// pushExportPayload is the JSON body POSTed to the self-hosted push service. It
|
||||
// is deliberately small and stable: the service owns device targeting, the
|
||||
// message carries only what is needed to render and de-duplicate it.
|
||||
// pushExportPayload 是 POST 到自托管推送服务的 JSON 体。它
|
||||
// 刻意小而稳定:设备寻址归该服务,消息
|
||||
// 只携带渲染与去重所需的内容。
|
||||
type pushExportPayload struct {
|
||||
// Source identifies the producer so the service can route/filter.
|
||||
// Source 标识生产者,好让服务能路由/过滤。
|
||||
Source string `json:"source"`
|
||||
// Title is a short display title (the persona name).
|
||||
// Title 是简短显示标题(角色名)。
|
||||
Title string `json:"title"`
|
||||
// Content is the proactive message body (already stripped of internal
|
||||
// decision markers by buildProactiveMessage).
|
||||
// Content 是主动消息正文(内部决策标记
|
||||
// 已由 buildProactiveMessage 剥掉)。
|
||||
Content string `json:"content"`
|
||||
// Platform is the outbound platform tag (cyrene-backthink or a concrete IM
|
||||
// platform).
|
||||
// Platform 是出站平台标签(cyrene-backthink 或具体的 IM
|
||||
// 平台)。
|
||||
Platform string `json:"platform"`
|
||||
// Timestamp is the message time in Unix milliseconds.
|
||||
// Timestamp 是消息时间,Unix 毫秒。
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// httpPushExporter posts the message to the self-hosted push service. A 2xx
|
||||
// response means delivered; any other status or transport error is an error.
|
||||
// The token travels only in the Authorization header and never reaches a log.
|
||||
// httpPushExporter 把消息 POST 到自托管推送服务。2xx
|
||||
// 响应表示已投递;其它任何状态或传输错误都算错误。
|
||||
// token 只出现在 Authorization 头里,绝不进入日志。
|
||||
type httpPushExporter struct {
|
||||
url string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// Export implements PushExporter over HTTP POST JSON.
|
||||
// Export 实现 PushExporter,走 HTTP POST JSON。
|
||||
func (e *httpPushExporter) Export(ctx context.Context, msg adapter.UnifiedMessage) (bool, error) {
|
||||
body, err := json.Marshal(pushExportPayload{
|
||||
Source: "cyrene/backthink",
|
||||
@@ -182,8 +182,8 @@ func (e *httpPushExporter) Export(ctx context.Context, msg adapter.UnifiedMessag
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// redact removes the endpoint URL from a transport error so a secret embedded in
|
||||
// the URL (for example a query token) never reaches a log line.
|
||||
// redact 从传输错误里移除端点 URL,因此 URL 中内嵌的
|
||||
// 秘密(例如查询参数里的 token)绝不进入日志行。
|
||||
func (e *httpPushExporter) redact(err error) error {
|
||||
if err == nil || e.url == "" {
|
||||
return err
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// mockExporter records exported messages and can block or fail on demand so the
|
||||
// pusher hook's non-blocking / failure-tolerant behaviour is testable.
|
||||
// mockExporter 记录已导出的消息,并可按需阻塞或失败,
|
||||
// 使推送器钩子的非阻塞 / 容错行为可测。
|
||||
type mockExporter struct {
|
||||
mu sync.Mutex
|
||||
msgs []adapter.UnifiedMessage
|
||||
@@ -49,9 +49,9 @@ func (m *mockExporter) messages() []adapter.UnifiedMessage {
|
||||
return append([]adapter.UnifiedMessage(nil), m.msgs...)
|
||||
}
|
||||
|
||||
// TestPushOutletHTTPPrimaryDelivers proves the self-hosted service is the
|
||||
// primary tier: the payload is POSTed with the bearer token and a 2xx response
|
||||
// is reported as delivered.
|
||||
// TestPushOutletHTTPPrimaryDelivers 证明自托管服务是
|
||||
// 主级:负载带着 bearer token 被 POST,2xx 响应
|
||||
// 被上报为已投递。
|
||||
func TestPushOutletHTTPPrimaryDelivers(t *testing.T) {
|
||||
t.Parallel()
|
||||
type payload struct {
|
||||
@@ -109,14 +109,14 @@ func TestPushOutletHTTPPrimaryDelivers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushOutletUnreachableLogsGetuiFallback asserts an unreachable primary is a
|
||||
// logged miss (not an error), names the pending Getui fallback, and never logs
|
||||
// the token.
|
||||
// TestPushOutletUnreachableLogsGetuiFallback 断言主级不可达是一次
|
||||
// 记账的未命中(不是错误),会点出待接入的个推降级,且绝不记录
|
||||
// token。
|
||||
func TestPushOutletUnreachableLogsGetuiFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
url := srv.URL
|
||||
srv.Close() // now unreachable
|
||||
srv.Close() // 此时已不可达
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, nil))
|
||||
@@ -137,7 +137,7 @@ func TestPushOutletUnreachableLogsGetuiFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushOutletHTTPErrorReportsMiss asserts a non-2xx primary response is a miss.
|
||||
// TestPushOutletHTTPErrorReportsMiss 断言主级非 2xx 响应算未命中。
|
||||
func TestPushOutletHTTPErrorReportsMiss(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -155,8 +155,8 @@ func TestPushOutletHTTPErrorReportsMiss(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewPushOutletDisabledOrUnconfigured covers the two degraded shapes: the
|
||||
// hook off (nil) and enabled without a primary URL (miss + Getui note).
|
||||
// TestNewPushOutletDisabledOrUnconfigured 覆盖两种降级形态:钩子
|
||||
// 关闭(nil)与启用但无主级 URL(未命中 + 个推备注)。
|
||||
func TestNewPushOutletDisabledOrUnconfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
if exp := NewPushOutlet(PushOutletConfig{Enabled: false, URL: "http://example.invalid"}); exp != nil {
|
||||
@@ -178,8 +178,8 @@ func TestNewPushOutletDisabledOrUnconfigured(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPusherExportsProactiveToOutlet asserts the hook mirrors the delivered
|
||||
// proactive message to the outlet after the IM delivery.
|
||||
// TestPusherExportsProactiveToOutlet 断言钩子在 IM 投递之后
|
||||
// 把已投递的主动消息镜像到出口。
|
||||
func TestPusherExportsProactiveToOutlet(t *testing.T) {
|
||||
t.Parallel()
|
||||
exp := &mockExporter{delivered: true}
|
||||
@@ -205,9 +205,9 @@ func TestPusherExportsProactiveToOutlet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPusherExportHookNonBlockingAndFailureTolerant asserts a hung/failing outlet
|
||||
// never delays the push and never turns a delivered proactive message into a
|
||||
// failure.
|
||||
// TestPusherExportHookNonBlockingAndFailureTolerant 断言挂死/失败的出口
|
||||
// 绝不延迟推送,也绝不把已投递的主动消息变成
|
||||
// 失败。
|
||||
func TestPusherExportHookNonBlockingAndFailureTolerant(t *testing.T) {
|
||||
t.Parallel()
|
||||
started := make(chan struct{})
|
||||
@@ -238,8 +238,8 @@ func TestPusherExportHookNonBlockingAndFailureTolerant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPusherRateLimitedPushIsNotExported asserts a rate-limited push never
|
||||
// reaches the outlet: the hook mirrors real proactive sends only.
|
||||
// TestPusherRateLimitedPushIsNotExported 断言被限流的推送绝不
|
||||
// 到达出口:该钩子只镜像真实的主动发送。
|
||||
func TestPusherRateLimitedPushIsNotExported(t *testing.T) {
|
||||
t.Parallel()
|
||||
exp := &mockExporter{delivered: true}
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
|
||||
func TestPusher_AutoRoutesIM(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The owner mandate: a default (auto) push reaches ONE channel over the IM
|
||||
// bridge (the bridge picks her [渠道:...] choice, then her most recent platform,
|
||||
// then the whitelist default) — not the active web client by default.
|
||||
active := &mockActive{n: 2} // a web client is online but is NOT the default target
|
||||
// 主人要求:默认(auto)推送经 IM 桥到达 ONE 个渠道
|
||||
//(桥依次挑她的 [渠道:...] 选择、她最近的平台、
|
||||
// 白名单默认)——默认不是活跃 Web 客户端。
|
||||
active := &mockActive{n: 2} // 有 Web 客户端在线,但它不是默认目标
|
||||
im := &mockIMAdapter{}
|
||||
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
|
||||
|
||||
@@ -58,8 +58,8 @@ func TestPusher_WebHintRoutesActive(t *testing.T) {
|
||||
|
||||
func TestPusher_ConcreteHintRoutesIM_SingleTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A concrete decision channel is honored over the IM bridge, and exactly ONE
|
||||
// target is used (the IM bridge carries it; the active client is not touched).
|
||||
// 具体的决策渠道经 IM 桥被尊重,且只用 ONE 个
|
||||
// 目标(由 IM 桥承载;活跃客户端不被触碰)。
|
||||
active := &mockActive{n: 2}
|
||||
im := &mockIMAdapter{}
|
||||
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
|
||||
@@ -85,8 +85,8 @@ func TestPusher_ConcreteHintRoutesIM_SingleTarget(t *testing.T) {
|
||||
|
||||
func TestPusher_NoIMSink_DegradesToActive(t *testing.T) {
|
||||
t.Parallel()
|
||||
// With no IM sink wired (backthink IM seam absent) the push degrades to the
|
||||
// active web client so the owner is still reached rather than dropped.
|
||||
// 未接线 IM 槽(backthink 的 IM 接缝缺失)时推送降级到
|
||||
// 活跃 Web 客户端,好让主人仍被触达而不是被丢弃。
|
||||
active := &mockActive{n: 1}
|
||||
p := newPusher(active, nil, &mockGate{allow: true}, discardLog())
|
||||
|
||||
@@ -156,8 +156,8 @@ func TestPusher_CancelledContext_Denied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPusher_RateLimit exercises the production rateGate over session.RateLimiter:
|
||||
// with a limit of 1 push per window, only the first of three attempts passes.
|
||||
// TestPusher_RateLimit 在 session.RateLimiter 上验证生产 rateGate:
|
||||
// 窗口内 limit 为 1 次推送时,三次尝试中只有第一次通过。
|
||||
func TestPusher_RateLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
active := &mockActive{n: 1}
|
||||
@@ -181,9 +181,9 @@ func TestPusher_RateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateGate_CooldownAfterSuccess verifies the post-push cooldown: after a
|
||||
// successful delivery a further push is refused until the cooldown elapses,
|
||||
// regardless of the configured window counter.
|
||||
// TestRateGate_CooldownAfterSuccess 验证推送后冷却:一次成功
|
||||
// 投递之后,在冷却期结束前再推送会被拒绝,
|
||||
// 与已配置的窗口计数无关。
|
||||
func TestRateGate_CooldownAfterSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
t0 := time.Now()
|
||||
@@ -191,7 +191,7 @@ func TestRateGate_CooldownAfterSuccess(t *testing.T) {
|
||||
log: discardLog(),
|
||||
limiter: session.NewRateLimiter(10, time.Hour),
|
||||
cooldown: 30 * time.Minute,
|
||||
kindWindow: 0, // disabled to isolate the cooldown
|
||||
kindWindow: 0, // 置零以单独验证冷却
|
||||
now: func() time.Time { return t0 },
|
||||
lastKind: make(map[PushKind]time.Time),
|
||||
}
|
||||
@@ -208,16 +208,16 @@ func TestRateGate_CooldownAfterSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateGate_KindWindowLimitsSameFlavor verifies the per-flavor (问候/提醒/分享/
|
||||
// 关心) limit: the same flavor cannot repeat within the kind window, while a
|
||||
// different flavor and a later same-flavor push (after the window) still pass.
|
||||
// TestRateGate_KindWindowLimitsSameFlavor 验证按风格(问候/提醒/分享/
|
||||
// 关心)限制:同一风格不能在风格窗口内重复,而
|
||||
// 不同风格与更晚的同一风格推送(窗口之后)仍可通过。
|
||||
func TestRateGate_KindWindowLimitsSameFlavor(t *testing.T) {
|
||||
t.Parallel()
|
||||
t0 := time.Now()
|
||||
g := &rateGate{
|
||||
log: discardLog(),
|
||||
limiter: session.NewRateLimiter(10, time.Hour),
|
||||
cooldown: 0, // disabled to isolate the kind window
|
||||
cooldown: 0, // 置零以单独验证风格窗口
|
||||
kindWindow: 2 * time.Hour,
|
||||
now: func() time.Time { return t0 },
|
||||
lastKind: make(map[PushKind]time.Time),
|
||||
@@ -238,10 +238,10 @@ func TestRateGate_KindWindowLimitsSameFlavor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPusher_EventBypassesGate verifies an event-driven push skips the PushGate:
|
||||
// with a limit of 1 push per window, three consecutive event pushes are ALL
|
||||
// delivered (owner mandate "该发就发"), each marked Bypassed and each advancing
|
||||
// no cooldown/kind window.
|
||||
// TestPusher_EventBypassesGate 验证事件驱动的推送跳过 PushGate:
|
||||
// 窗口内 limit 为 1 次推送时,连续三次事件推送全部
|
||||
// 被投递(主人要求 "该发就发"),每次都标记 Bypassed,且都不推进
|
||||
// 冷却/风格窗口。
|
||||
func TestPusher_EventBypassesGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
active := &mockActive{n: 1}
|
||||
@@ -266,9 +266,9 @@ func TestPusher_EventBypassesGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPusher_HeartbeatStillRateLimited verifies a heartbeat-driven push stays
|
||||
// gate-governed: with a limit of 1 push per window, the first passes and the
|
||||
// second is refused as rate_limited, never Bypassed.
|
||||
// TestPusher_HeartbeatStillRateLimited 验证心跳驱动的推送仍
|
||||
// 受门控管辖:窗口内 limit 为 1 次推送时,第一次通过,
|
||||
// 第二次作为 rate_limited 被拒,绝不是 Bypassed。
|
||||
func TestPusher_HeartbeatStillRateLimited(t *testing.T) {
|
||||
t.Parallel()
|
||||
active := &mockActive{n: 1}
|
||||
@@ -289,9 +289,9 @@ func TestPusher_HeartbeatStillRateLimited(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPusher_EventBypass_DoesNotAdvanceGate verifies an event-driven push does not
|
||||
// consume the heartbeat limit: after an event push, a following heartbeat push
|
||||
// still gets its own full allowance.
|
||||
// TestPusher_EventBypass_DoesNotAdvanceGate 验证事件驱动的推送不会
|
||||
// 消耗心跳额度:一次事件推送之后,紧随的心跳推送
|
||||
// 仍拿到自己那份完整额度。
|
||||
func TestPusher_EventBypass_DoesNotAdvanceGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
active := &mockActive{n: 1}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package backthink
|
||||
|
||||
// The production composition root builds the gateway hub and the channel bridge
|
||||
// *after* backthink.New (the hub needs the loop, the bridge needs the loop and
|
||||
// owner profile, both of which are assembled later), so the ActiveClient and
|
||||
// IMAdapter sinks are unavailable at construction time. These setters inject them
|
||||
// once the gateway / bridge exist and rebuild the pusher, keeping the resolved
|
||||
// rate gate so a later push routes correctly.
|
||||
// 生产组合根在 backthink.New *之后* 才构建 gateway hub 与 channel
|
||||
// bridge(hub 需要 loop,bridge 需要 loop 与主人档案,
|
||||
// 两者都在更晚时才组装),因此构造时拿不到 ActiveClient 与
|
||||
// IMAdapter 槽。这些设置器在 gateway / bridge 存在后注入它们,
|
||||
// 并重建推送器,同时保留已解出的
|
||||
// 频率门,使之后的推送路由正确。
|
||||
|
||||
// SetActiveSink wires the production active web/native client sink (the gateway
|
||||
// hub) and rebuilds the pusher. Call it after the gateway exists, before Start.
|
||||
// SetActiveSink 接入生产用的活跃 Web/原生客户端槽(gateway
|
||||
// hub)并重建推送器。请在 gateway 存在之后、Start 之前调用。
|
||||
func (b *Backthink) SetActiveSink(active ActiveClient) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
@@ -16,8 +16,8 @@ func (b *Backthink) SetActiveSink(active ActiveClient) {
|
||||
b.rebuildPusherLocked()
|
||||
}
|
||||
|
||||
// SetIMSink wires the production IM bridge sink (the channel bridge) and rebuilds
|
||||
// the pusher. Call it after the channel bridge exists, before Start.
|
||||
// SetIMSink 接入生产用的 IM 桥槽(channel bridge)并重建
|
||||
// 推送器。请在 channel bridge 存在之后、Start 之前调用。
|
||||
func (b *Backthink) SetIMSink(im IMAdapter) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
@@ -25,14 +25,14 @@ func (b *Backthink) SetIMSink(im IMAdapter) {
|
||||
b.rebuildPusherLocked()
|
||||
}
|
||||
|
||||
// rebuildPusherLocked replaces the pusher from the current sinks, gate and push
|
||||
// outlet. The caller must hold b.mu.
|
||||
// rebuildPusherLocked 用当前槽位、频率门与推送出口替换
|
||||
// 推送器。调用方必须持有 b.mu。
|
||||
func (b *Backthink) rebuildPusherLocked() {
|
||||
b.pusher = newPusher(b.deps.Active, b.deps.IM, b.gate, b.log).withExporter(b.deps.Exporter)
|
||||
}
|
||||
|
||||
// currentPusher returns the active pusher under the lifecycle lock, so a sink
|
||||
// swap never races with a concurrent thinking pass that is about to push.
|
||||
// currentPusher 在生命周期锁下返回当前推送器,因此一次槽位
|
||||
// 换入绝不会与即将推送的并发思考轮发生竞态。
|
||||
func (b *Backthink) currentPusher() Pusher {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// TestBackthink_SetActiveSink_RebuildsPusher verifies the sink setters rebuild the
|
||||
// pusher so a later push routes to the injected active client instead of the
|
||||
// default nil-sink RouteNone pusher built at New.
|
||||
// TestBackthink_SetActiveSink_RebuildsPusher 验证槽位设置器会重建
|
||||
// 推送器,使之后的推送路由到注入的活跃客户端,而不是
|
||||
// New 时构建的默认 nil 槽 RouteNone 推送器。
|
||||
func TestBackthink_SetActiveSink_RebuildsPusher(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
bt, err := New(Config{}, Deps{
|
||||
@@ -42,8 +42,8 @@ func TestBackthink_SetActiveSink_RebuildsPusher(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackthink_SetIMSink_Fallback verifies a wired IM sink receives the push when
|
||||
// no active client is online (so the owner is still reached).
|
||||
// TestBackthink_SetIMSink_Fallback 验证没有活跃客户端在线时,
|
||||
// 已接线的 IM 槽会收到推送(主人因此仍被触达)。
|
||||
func TestBackthink_SetIMSink_Fallback(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
bt, err := New(Config{}, Deps{
|
||||
@@ -56,7 +56,7 @@ func TestBackthink_SetIMSink_Fallback(t *testing.T) {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
|
||||
active := &mockActive{n: 0} // no active client online
|
||||
active := &mockActive{n: 0} // 没有活跃客户端在线
|
||||
im := &mockIMAdapter{}
|
||||
bt.SetActiveSink(active)
|
||||
bt.SetIMSink(im)
|
||||
@@ -72,9 +72,9 @@ func TestBackthink_SetIMSink_Fallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackthink_SinkSetters_ConcurrentSafe verifies the setter and the read of the
|
||||
// current pusher do not race under the lifecycle lock, so an in-flight pass never
|
||||
// touches a half-rebuilt pusher.
|
||||
// TestBackthink_SinkSetters_ConcurrentSafe 验证设置器与对
|
||||
// 当前推送器的读取在生命周期锁下不产生竞态,因此进行中的轮次绝不会
|
||||
// 碰到半重建的推送器。
|
||||
func TestBackthink_SinkSetters_ConcurrentSafe(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
bt, err := New(Config{}, Deps{
|
||||
|
||||
@@ -11,33 +11,33 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// StateProvider supplies the user's current condition for the interrupt decision
|
||||
// (design/12 §2). Production wires IoT/telemetry and calendar; tests inject a
|
||||
// mock. It must be concurrency-safe.
|
||||
// StateProvider 为打扰决策提供用户当前状况
|
||||
// (design/12 §2)。生产接线 IoT/遥测与日历;测试注入
|
||||
// mock。它必须并发安全。
|
||||
type StateProvider interface {
|
||||
// Current returns the user's current UserState. A nil-able error means "use
|
||||
// a neutral state"; a hard error is logged by the caller and treated as
|
||||
// neutral.
|
||||
// Current 返回用户当前的 UserState。可 nil 的错误表示「用
|
||||
// 中性状态」;硬错误由调用方记日志并按
|
||||
// 中性处理。
|
||||
Current(ctx context.Context) (UserState, error)
|
||||
}
|
||||
|
||||
// IoTStateProvider is a best-effort StateProvider built over an iot.Manager. It
|
||||
// scans the managed devices and reads heart-rate and sleep signals from any
|
||||
// device whose id/name/type mentions the corresponding keyword (e.g. "heart",
|
||||
// "心率", "sleep", "睡眠"). Work/busy come from a device/state flag; when nothing
|
||||
// matches it returns a neutral state rather than an error, so the service still
|
||||
// runs without full telemetry wiring.
|
||||
// IoTStateProvider 是基于 iot.Manager 的尽力而为 StateProvider。它
|
||||
// 扫描纳管设备,并从任何 id/名称/类型提到对应关键词的设备
|
||||
// 读取心率与睡眠信号(例如 "heart"、
|
||||
// "心率"、"sleep"、"睡眠")。工作/忙碌来自设备/状态标志;什么都不
|
||||
// 匹配时它返回中性状态而不是错误,因此即便没有完整
|
||||
// 遥测接线,服务也能运行。
|
||||
type IoTStateProvider struct {
|
||||
mgr *iot.Manager
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewIoTStateProvider wraps mgr as a StateProvider.
|
||||
// NewIoTStateProvider 把 mgr 包装为 StateProvider。
|
||||
func NewIoTStateProvider(mgr *iot.Manager, log *slog.Logger) *IoTStateProvider {
|
||||
return &IoTStateProvider{mgr: mgr, log: logger.WithSource(baseLog(log), "services/backthink")}
|
||||
}
|
||||
|
||||
// Current implements StateProvider.
|
||||
// Current 实现 StateProvider。
|
||||
func (p *IoTStateProvider) Current(ctx context.Context) (UserState, error) {
|
||||
if p.mgr == nil {
|
||||
return UserState{}, fmt.Errorf("backthink: state: %w", iot.ErrProviderNotRegistered)
|
||||
@@ -71,7 +71,7 @@ func (p *IoTStateProvider) Current(ctx context.Context) (UserState, error) {
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// numState reads the first numeric value among keys from a device's state map.
|
||||
// numState 从设备状态 map 中按 keys 读取首个数值。
|
||||
func numState(d iot.Device, keys ...string) (int, bool) {
|
||||
for _, k := range keys {
|
||||
v, ok := d.State[k]
|
||||
@@ -94,13 +94,13 @@ func numState(d iot.Device, keys ...string) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// boolState reads a bool (or a "sleep"/"asleep" string) among keys.
|
||||
// boolState 在 keys 中读取一个 bool(或 "sleep"/"asleep" 字符串)。
|
||||
func boolState(d iot.Device, keys ...string) bool {
|
||||
b, _ := boolStateOK(d, keys...)
|
||||
return b
|
||||
}
|
||||
|
||||
// boolStateOK reports whether a bool-typed state flag was found and its value.
|
||||
// boolStateOK 报告是否找到了 bool 类型的状态标志及其取值。
|
||||
func boolStateOK(d iot.Device, keys ...string) (bool, bool) {
|
||||
for _, k := range keys {
|
||||
v, ok := d.State[k]
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
)
|
||||
|
||||
// stateMockProvider is a tiny iot.Provider that returns a heart-rate watch, a
|
||||
// sleep band and a working-calendar device, to exercise the state decoding.
|
||||
// stateMockProvider 是一个极小的 iot.Provider,返回心率手表、
|
||||
// 睡眠手环与工作日历设备,用于演练状态解码。
|
||||
type stateMockProvider struct{}
|
||||
|
||||
func (stateMockProvider) Name() string { return "state-mock" }
|
||||
@@ -73,8 +73,8 @@ func TestIoTStateProvider_EmptyNeutral(t *testing.T) {
|
||||
t.Fatalf("iot.New: %v", err)
|
||||
}
|
||||
p := NewIoTStateProvider(mgr, nil)
|
||||
// The mock provider has no heart-rate/sleep/working device, so it must yield
|
||||
// a zero state instead of an error, keeping the service operable.
|
||||
// 该 mock 提供者没有心率/睡眠/工作设备,所以它必须给出
|
||||
// 零值状态而不是错误,保持服务可运行。
|
||||
st, err := p.Current(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Current: %v", err)
|
||||
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// defaultListLimit is the number of most-recent records returned by List when the
|
||||
// caller passes a non-positive limit.
|
||||
// defaultListLimit 是调用方传入非正 limit 时,List 返回的
|
||||
// 最近记录条数。
|
||||
const defaultListLimit = 50
|
||||
|
||||
// DDL for the thinking record store. at/created_at follow the project timestamp
|
||||
// convention (RFC3339 with a timezone); trigger and conclusion are the free-text
|
||||
// fields the panel renders; the push_* columns carry a decomposed PushResult so
|
||||
// List need not parse a blob. The primary key is the project-standard auto id.
|
||||
// 思考记录存储的 DDL。at/created_at 遵循项目时间戳
|
||||
// 约定(带时区的 RFC3339);trigger 与 conclusion 是面板渲染的
|
||||
// 自由文本字段;push_* 列携带分解后的 PushResult,
|
||||
// 使 List 不必解析 blob。主键是项目标准的自增 id。
|
||||
const createRecordsSQL = `
|
||||
CREATE TABLE IF NOT EXISTS backthink_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -46,10 +46,10 @@ FROM backthink_records
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`
|
||||
|
||||
// Record is one persisted thinking pass (design/12 §4), returned by Store.List
|
||||
// and the service's List. Trigger is the source subsystem label, At the decision
|
||||
// timestamp, Conclusion the runtime's output, ShouldPush the interrupt decision
|
||||
// and PushResult how (or whether) the push went out.
|
||||
// Record 是一轮已持久化的思考(design/12 §4),由 Store.List
|
||||
// 与服务的 List 返回。Trigger 是来源子系统标签,At 是决策
|
||||
// 时间戳,Conclusion 是运行时的输出,ShouldPush 是打扰决策,
|
||||
// PushResult 是推送如何(或是否)发出。
|
||||
type Record struct {
|
||||
ID int64
|
||||
Trigger string
|
||||
@@ -59,17 +59,17 @@ type Record struct {
|
||||
PushResult PushResult
|
||||
}
|
||||
|
||||
// Store persists thinking records in SQLite. It creates its table and index in
|
||||
// NewStore so the module is self-contained (no migration bookkeeping), mirrors
|
||||
// how channel/session creates its own table. It is concurrency-safe by virtue of
|
||||
// the underlying *sql.DB.
|
||||
// Store 把思考记录持久化到 SQLite。它在
|
||||
// NewStore 中建表与索引,因此本模块自包含(无需迁移记账),
|
||||
// 与 channel/session 自建表的做法一致。它凭借底层的 *sql.DB
|
||||
// 而并发安全。
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewStore builds a Store, ensuring the backthink_records table and index exist.
|
||||
// It returns ErrNilDB when db is nil. ctx is used for the DDL.
|
||||
// NewStore 构建 Store,确保 backthink_records 表与索引存在。
|
||||
// db 为 nil 时返回 ErrNilDB。ctx 用于 DDL。
|
||||
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilDB
|
||||
@@ -84,7 +84,7 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
return &Store{db: db, log: log}, nil
|
||||
}
|
||||
|
||||
// Append persists r and returns the new record id.
|
||||
// Append 持久化 r 并返回新记录 id。
|
||||
func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, insertRecordSQL,
|
||||
r.Trigger,
|
||||
@@ -109,8 +109,8 @@ func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// List returns the most recent records newest-first. A non-positive limit selects
|
||||
// defaultListLimit.
|
||||
// List 返回最近的记录,最新的在前。非正的 limit 选用
|
||||
// defaultListLimit。
|
||||
func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
|
||||
if limit <= 0 {
|
||||
limit = defaultListLimit
|
||||
@@ -158,7 +158,7 @@ func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// boolInt maps a bool to the SQLite INTEGER 1/0 representation.
|
||||
// boolInt 把 bool 映射为 SQLite 的 INTEGER 1/0 表示。
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
@@ -166,11 +166,11 @@ func boolInt(b bool) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// intBool maps a SQLite INTEGER to a bool.
|
||||
// intBool 把 SQLite 的 INTEGER 映射为 bool。
|
||||
func intBool(n int) bool { return n != 0 }
|
||||
|
||||
// parseTime parses a stored RFC3339(nano/with-zone) timestamp, falling back to
|
||||
// the zero time so a malformed row never breaks a listing.
|
||||
// parseTime 解析存储的 RFC3339(带纳秒/时区)时间戳,失败时回退到
|
||||
// 零值时间,因此格式错乱的行绝不会破坏列表查询。
|
||||
func parseTime(s string) time.Time {
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestStore_AppendList(t *testing.T) {
|
||||
if len(records) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(records))
|
||||
}
|
||||
// Newest first (descending id).
|
||||
// 最新的在前(按 id 降序)。
|
||||
if records[0].ID != 3 || records[1].ID != 2 || records[2].ID != 1 {
|
||||
t.Fatalf("order = %d,%d,%d, want 3,2,1", records[0].ID, records[1].ID, records[2].ID)
|
||||
}
|
||||
|
||||
@@ -7,29 +7,29 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// LoopThinker adapts a *loop.Loop into a Thinker. It is the production wiring
|
||||
// that lets an autonomous background pass drive the full tool loop (memory
|
||||
// injection, model steps, tool calls such as IoT/calendar/reminders) while seeing
|
||||
// the main session's recent conversation and running under the owner environment.
|
||||
// LoopThinker 把 *loop.Loop 适配为 Thinker。它是生产接线,
|
||||
// 让一次自主后台轮驱动完整的工具循环(记忆
|
||||
// 注入、模型步骤、IoT/日历/提醒等工具调用),同时看到
|
||||
// 主会话的最近对话并在 owner 环境下运行。
|
||||
type LoopThinker struct {
|
||||
loop *loop.Loop
|
||||
recent recentProvider
|
||||
}
|
||||
|
||||
// NewLoopThinker wraps l as a Thinker over the given recent-messages provider
|
||||
// (*history.Store in production; nil degrades the think to the guidance prompt
|
||||
// alone, see mainContext).
|
||||
// NewLoopThinker 把 l 包装成基于给定最近消息提供者的 Thinker
|
||||
// (生产中是 *history.Store;nil 会让思考降级为仅用引导提示词,
|
||||
// 见 mainContext)。
|
||||
func NewLoopThinker(l *loop.Loop, recent recentProvider) *LoopThinker {
|
||||
return &LoopThinker{loop: l, recent: recent}
|
||||
}
|
||||
|
||||
// Think runs one unattended think through the wrapped loop and returns the
|
||||
// conclusion text. It inherits the main session's recent conversation as input
|
||||
// context and injects the owner session environment (MemWritable=true, ActorOwner,
|
||||
// full tool set) so the think can read/write memory and call tools. The loop's
|
||||
// Run session id is the service's own (not the main session id) so the think never
|
||||
// collides with the live main session's durable checkpoint store (design/20).
|
||||
// It returns ErrNoLoop when no loop was injected.
|
||||
// Think 经被包装的 loop 跑一轮无人值守思考并返回
|
||||
// 结论文本。它继承主会话的最近对话作为输入
|
||||
// 上下文,并注入 owner 会话环境(MemWritable=true、ActorOwner、
|
||||
// 完整工具集),因此该次思考能读写记忆并调用工具。loop 的
|
||||
// Run 会话 id 是本服务自己的(不是主会话 id),所以该次思考绝不会
|
||||
// 与活跃主会话的持久化检查点存储冲突(design/20)。
|
||||
// 未注入 loop 时返回 ErrNoLoop。
|
||||
func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (string, error) {
|
||||
if t.loop == nil {
|
||||
return "", ErrNoLoop
|
||||
@@ -37,8 +37,8 @@ func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (str
|
||||
recent := mainContext(ctx, t.recent)
|
||||
messages := thinkInput(recent, trigger)
|
||||
envCtx := loop.WithSessionEnv(ctx, loop.OwnerEnv())
|
||||
// Mute UI broadcast: background reflection must not leak its intermediate
|
||||
// reasoning into the user-facing chat stream (it only needs the conclusion).
|
||||
// 静音 UI 广播:后台反思不得把其中间
|
||||
// 推理泄漏进面向用户的 chat 流(它只需要结论)。
|
||||
res, err := t.loop.Run(loop.WithMutedBroadcast(envCtx), sessionID, messages)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("backthink: think: %w", err)
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// fakeRecent is a recentProvider stub returning a fixed tail/error for the
|
||||
// main-session context read.
|
||||
// fakeRecent 是 recentProvider 的 stub,为
|
||||
// 主会话语境读取返回固定的尾部/错误。
|
||||
type fakeRecent struct {
|
||||
msgs []history.Message
|
||||
err error
|
||||
@@ -25,8 +25,8 @@ func (f *fakeRecent) Recent(context.Context, string, int) ([]history.Message, er
|
||||
return f.msgs, f.err
|
||||
}
|
||||
|
||||
// captureAdapter is a model.Adapter that records every request it is handed so a
|
||||
// test can assert on the messages the loop actually fed to the model.
|
||||
// captureAdapter 是一个 model.Adapter,记录交给它的每个请求,好让
|
||||
// 测试能对 loop 实际喂给模型的消息做断言。
|
||||
type captureAdapter struct {
|
||||
reqs []model.Request
|
||||
}
|
||||
@@ -42,9 +42,9 @@ func (c *captureAdapter) ChatStream(_ context.Context, req model.Request, _ func
|
||||
return c.Chat(context.Background(), req, model.Options{})
|
||||
}
|
||||
|
||||
// newThinkLoop builds a real loop over ad with a working manual memory-extraction
|
||||
// path so a test can prove the owner environment is in effect (extractMemory runs
|
||||
// only when MemWritable=true).
|
||||
// newThinkLoop 在 ad 之上构建一个真实 loop,并带一条可用的手动记忆抽取
|
||||
// 路径,好让测试能证明 owner 环境确实生效(extractMemory 只在
|
||||
// MemWritable=true 时运行)。
|
||||
func newThinkLoop(t *testing.T, ad model.Adapter) (*loop.Loop, *memory.Store) {
|
||||
t.Helper()
|
||||
a, err := agent.New("You are Cyrene.", nil, nil, agent.WithModel(ad, nil))
|
||||
@@ -61,7 +61,7 @@ func newThinkLoop(t *testing.T, ad model.Adapter) (*loop.Loop, *memory.Store) {
|
||||
return l, store
|
||||
}
|
||||
|
||||
// newMemoryStore opens an isolated in-memory memory store for one test.
|
||||
// newMemoryStore 为单个测试打开一个隔离的内存记忆存储。
|
||||
func newMemoryStore(t *testing.T) *memory.Store {
|
||||
t.Helper()
|
||||
d, err := db.Open(context.Background(), ":memory:", nil)
|
||||
@@ -76,7 +76,7 @@ func newMemoryStore(t *testing.T) *memory.Store {
|
||||
return s
|
||||
}
|
||||
|
||||
// modelText returns the concatenated text of a model message's text parts.
|
||||
// modelText 返回一条 model 消息各文本片段拼接后的文本。
|
||||
func modelText(m model.Message) string {
|
||||
var b strings.Builder
|
||||
for _, c := range m.Content {
|
||||
@@ -87,9 +87,9 @@ func modelText(m model.Message) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestGuidancePrompt_EncouragesProactiveWithoutSuppression guards the rewrite:
|
||||
// the guidance must drop the old suppression phrases and push warm, targeted
|
||||
// proactive expression (design/20 §2).
|
||||
// TestGuidancePrompt_EncouragesProactiveWithoutSuppression 守护这次改写:
|
||||
// 引导必须去掉旧的抑制措辞,并推动温暖、有针对性的
|
||||
// 主动表达(design/20 §2)。
|
||||
func TestGuidancePrompt_EncouragesProactiveWithoutSuppression(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := guidancePrompt("heartbeat")
|
||||
@@ -105,8 +105,8 @@ func TestGuidancePrompt_EncouragesProactiveWithoutSuppression(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainContext_MapsRecentMessages proves the recent main-session tail is
|
||||
// converted in order with roles mapped and image parts preserved.
|
||||
// TestMainContext_MapsRecentMessages 证明主会话最近尾部被
|
||||
// 按序转换、角色被映射、图片片段被保留。
|
||||
func TestMainContext_MapsRecentMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
prov := &fakeRecent{msgs: []history.Message{
|
||||
@@ -129,8 +129,8 @@ func TestMainContext_MapsRecentMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainContext_Degrades ensures a nil provider or a failed read yields a nil
|
||||
// tail so the think runs on the guidance prompt alone rather than failing.
|
||||
// TestMainContext_Degrades 确保提供者为 nil 或读取失败都得到 nil
|
||||
// 尾部,因此该次思考仅凭引导提示词运行,而不是失败。
|
||||
func TestMainContext_Degrades(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := mainContext(context.Background(), nil); got != nil {
|
||||
@@ -141,8 +141,8 @@ func TestMainContext_Degrades(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestThinkInput_AppendsGuidanceLast verifies the assembled input is the recent
|
||||
// context followed by a single guidance turn.
|
||||
// TestThinkInput_AppendsGuidanceLast 验证拼装出的输入是最近
|
||||
// 语境后跟单个引导回合。
|
||||
func TestThinkInput_AppendsGuidanceLast(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := thinkInput([]model.Message{model.TextMessage(model.RoleUser, "hi")}, "heartbeat")
|
||||
@@ -154,10 +154,10 @@ func TestThinkInput_AppendsGuidanceLast(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoopThinker_Think_UsesMainContextAndOwnerEnv drives a real think and asserts
|
||||
// three things from design/20 P1: the model saw the main-session recent message,
|
||||
// the guidance prompt carries no suppression, and the think ran under the owner
|
||||
// environment (the manual extractor persists only when MemWritable=true).
|
||||
// TestLoopThinker_Think_UsesMainContextAndOwnerEnv 驱动一次真实思考并
|
||||
// 断言 design/20 P1 的三件事:模型看到了主会话的最近消息、
|
||||
// 引导提示词不携带抑制措辞,且该次思考跑在 owner
|
||||
// 环境之下(手动抽取器只在 MemWritable=true 时持久化)。
|
||||
func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) {
|
||||
ad := &captureAdapter{}
|
||||
l, store := newThinkLoop(t, ad)
|
||||
@@ -194,7 +194,7 @@ func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) {
|
||||
t.Fatalf("input missing the guidance prompt: %+v", last.Messages)
|
||||
}
|
||||
|
||||
// Owner env proved: ManualExtractor only persists under MemWritable=true.
|
||||
// 已证明 owner 环境:ManualExtractor 只在 MemWritable=true 时持久化。
|
||||
items, err := store.ListByPersona(context.Background(), "persona-a")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByPersona: %v", err)
|
||||
@@ -207,8 +207,8 @@ func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoopThinker_NilHistoryDegrades keeps the pre-existing backpressure: with no
|
||||
// history provider the think still runs (guidance prompt only) and never panics.
|
||||
// TestLoopThinker_NilHistoryDegrades 保持既有的背压行为:没有
|
||||
// 历史提供者时该次思考仍会运行(只用引导提示词)且绝不 panic。
|
||||
func TestLoopThinker_NilHistoryDegrades(t *testing.T) {
|
||||
ad := &captureAdapter{}
|
||||
l, _ := newThinkLoop(t, ad)
|
||||
@@ -221,9 +221,9 @@ func TestLoopThinker_NilHistoryDegrades(t *testing.T) {
|
||||
t.Fatal("no model request captured")
|
||||
}
|
||||
last := ad.reqs[len(ad.reqs)-1]
|
||||
// With no history provider the input carries the guidance prompt and no
|
||||
// main-session messages. (The agent prepends its own instruction system message,
|
||||
// so we assert presence rather than an exact count.)
|
||||
// 没有历史提供者时,输入携带引导提示词、不含
|
||||
// 主会话消息。(agent 会自行前置一条指令系统消息,
|
||||
// 所以我们断言存在性而非精确条数。)
|
||||
foundGuidance := false
|
||||
for _, m := range last.Messages {
|
||||
if strings.Contains(modelText(m), guidancePrompt("heartbeat")) {
|
||||
|
||||
@@ -7,22 +7,22 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
)
|
||||
|
||||
// TriggerKind classifies what woke a thinking pass, so the push path can apply the
|
||||
// right policy: a heartbeat is gate-governed (60min≤1 / cooldown / per-flavor), an
|
||||
// event is a real signal the owner wants to hear about and bypasses the rate gate.
|
||||
// TriggerKind 归类唤醒一轮思考的原因,好让推送路径套用
|
||||
// 正确的策略:心跳受门控管辖(60min≤1 / 冷却 / 按风格),
|
||||
// 事件是主人确实想听到的真实信号、绕过频率门。
|
||||
type TriggerKind int
|
||||
|
||||
const (
|
||||
// TriggerHeartbeat is the periodic autonomous reflection. Its proactive push is
|
||||
// throttled by the PushGate so the owner is never spammed by empty reflections.
|
||||
// TriggerHeartbeat 是周期性自主反思。它的主动推送受
|
||||
// PushGate 限流,使主人绝不会被空洞的反思刷屏。
|
||||
TriggerHeartbeat TriggerKind = iota
|
||||
// TriggerEvent is a real event (IoT device change, reminder, owner-relevant
|
||||
// system event). Its push bypasses the PushGate — "该发就发" — subject only to
|
||||
// same-event dedup.
|
||||
// TriggerEvent 是真实事件(IoT 设备变化、提醒、与主人相关的
|
||||
// 系统事件)。它的推送绕过 PushGate——"该发就发"——只受
|
||||
// 同一事件去重约束。
|
||||
TriggerEvent
|
||||
)
|
||||
|
||||
// String returns a stable label for logs and records.
|
||||
// String 返回用于日志与记录的稳定标签。
|
||||
func (k TriggerKind) String() string {
|
||||
switch k {
|
||||
case TriggerEvent:
|
||||
@@ -34,40 +34,40 @@ func (k TriggerKind) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Bus subjects the service subscribes to for event-triggered thinking. They are
|
||||
// the contract defined by design/12 §1: an IoT state change, a reminder firing,
|
||||
// or a generic system event.
|
||||
// 本服务为事件驱动思考订阅的总线主题。它们是
|
||||
// design/12 §1 定义的契约:一次 IoT 状态变化、一次提醒触发,
|
||||
// 或一次泛化的系统事件。
|
||||
const (
|
||||
// SubjectReminderTrigger is the bus subject on which reminders fire.
|
||||
// SubjectReminderTrigger 是提醒触发所用的总线主题。
|
||||
SubjectReminderTrigger = "reminder.trigger"
|
||||
// SubjectSystemEvent is the bus subject for generic system events.
|
||||
// SubjectSystemEvent 是泛化系统事件的总线主题。
|
||||
SubjectSystemEvent = "system.event"
|
||||
)
|
||||
|
||||
// SystemEvent is the optional typed payload a caller may publish to
|
||||
// SubjectSystemEvent to convey a system notice. Import defaults to Normal. Under
|
||||
// design/12 §6 策略一, a low-value system notice (a join/leave ping, generic
|
||||
// platform noise) is downgraded to a registration-only record and does not wake a
|
||||
// full thinking pass; only a high-importance (owner-relevant) system event thinks,
|
||||
// so a busy public platform cannot trigger a flood of expensive reflections.
|
||||
// SystemEvent 是调用方可发布到 SubjectSystemEvent 以传达
|
||||
// 系统通知的可选类型化负载。Import 默认为 Normal。在
|
||||
// design/12 §6 策略一之下,低价值的系统通知(加入/退出 ping、泛化的
|
||||
// 平台噪声)被降级为仅登记一条记录,不唤醒
|
||||
// 一整轮思考;只有高重要度(与主人相关)的系统事件才思考,
|
||||
// 因此繁忙的公开平台无法触发昂贵反思的洪流。
|
||||
type SystemEvent struct {
|
||||
Summary string
|
||||
Import Importance
|
||||
}
|
||||
|
||||
// Trigger describes what woke a thinking pass: the source subsystem, a
|
||||
// human-readable summary and the event's estimated importance.
|
||||
// Trigger 描述唤醒一轮思考的原因:来源子系统、
|
||||
// 可读摘要与事件的估计重要度。
|
||||
type Trigger struct {
|
||||
Source string
|
||||
Summary string
|
||||
Import Importance
|
||||
// Kind records whether this pass was the periodic heartbeat or a real event, so
|
||||
// the push path can bypass the rate gate for a genuine event (owner mandate).
|
||||
// Kind 记录本轮是周期性心跳还是真实事件,好让
|
||||
// 推送路径能为真实事件绕过频率门(主人要求)。
|
||||
Kind TriggerKind
|
||||
}
|
||||
|
||||
// heartbeatTrigger is the periodic, low-importance trigger. Low importance means
|
||||
// the pass rarely interrupts the user, honouring "默认不发送 chat 类型消息".
|
||||
// heartbeatTrigger 是周期性、低重要度的触发。低重要度意味着
|
||||
// 该轮很少打扰用户,遵守 "默认不发送 chat 类型消息"。
|
||||
func heartbeatTrigger() Trigger {
|
||||
return Trigger{Source: "heartbeat", Summary: "periodic autonomous reflection", Import: ImportanceLow, Kind: TriggerHeartbeat}
|
||||
}
|
||||
@@ -84,10 +84,10 @@ func startupTrigger() Trigger {
|
||||
return Trigger{Source: "startup", Summary: StartupSummary, Import: ImportanceLow, Kind: TriggerHeartbeat}
|
||||
}
|
||||
|
||||
// triggerForEvent maps a bus event to a Trigger. An IoT device change is treated
|
||||
// as normal importance, a reminder firing as high importance, and a generic
|
||||
// system event as normal importance. Every event is tagged TriggerEvent so its
|
||||
// proactive push bypasses the heartbeat PushGate.
|
||||
// triggerForEvent 把总线事件映射为 Trigger。IoT 设备变化按
|
||||
// 普通重要度处理,提醒触发按高重要度,泛化的
|
||||
// 系统事件按普通重要度。每个事件都打上 TriggerEvent,因此其
|
||||
// 主动推送绕过心跳的 PushGate。
|
||||
func triggerForEvent(e bus.Event) Trigger {
|
||||
switch e.Subject {
|
||||
case iot.DeviceUpdateSubject:
|
||||
|
||||
Reference in new issue
Block a user