From 8890c30bb570857432e6da12e96291aa34da4e04 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 12:57:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(model):=20=E6=8C=89=20provider=20=E5=BD=A2?= =?UTF-8?q?=E7=8A=B6=E7=8B=AC=E7=AB=8B=E9=80=82=E9=85=8D=E6=8E=A8=E7=90=86?= =?UTF-8?q?=E5=BC=80=E5=85=B3=20+=20per-persona=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=95=86=E9=93=BE=E7=9C=9F=E6=8E=A5=E7=BA=BF=EF=BC=88=E8=A3=81?= =?UTF-8?q?=E5=86=B3=2003.4.2/08.4.1/03.4.1=20=E4=BD=99=E9=A1=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 reasoning_shape.go:**每个 provider 形状一个 resolver,只发自己形状的字段** · deepseek:thinking{type} + reasoning_effort(依据 DevDocs/reference/deepseek 官方本地副本) · vllm:chat_template_kwargs.enable_thinking · dashscope:顶层 enable_thinking(**只允许显式声明**) · openai:仅 reasoning_effort · anthropic:自己的 thinking{budget_tokens}(未改) · 未知 kind → WARN 且一个字段都不发(不许静默失效) - 形状载体:models.json 组级 `reasoning.kind`(可热更新,未声明则不写该键)+ env `LLM_REASONING_KIND` - 红线:`effort:none` 在只有布尔开关的形状上翻成**显式关思考**,绝不降成任何强度档 - 03.4.1 余项:`Request.PersonaID` + `RuntimeLLM.ForPersona(pid)` 视图; 每 persona 协议一条链并消费 `RouteProviderForPersona`;`agent/request.go` 非流式路径同一钩子 - **生产装配(主会话补)**:默认 agent 与 personaAgentPool 都改用 `ForPersona(pid)` 视图—— 否则角色级模型/提供商路由在生产里永不被选中(形同虚设) - 复核:build/vet 绿 + model/agent/config/cmd 包测试绿;5 条变异反证先红后还原 (其中一条「红但没钉住」已改用例:通道读取加 2s 超时,避免整包 panic 掩盖信号) - 未落:DashScope 的 auto 推断(缺官方依据,只允许显式声明);Qwen `/think` 前导消息 (design/01 §2.1 与 design/11 §3 口径冲突,停手报告) --- cmd/cyrene/persona_agents.go | 5 +- cmd/cyrene/wire_runtime.go | 8 +- internal/config/config.go | 10 + internal/config/runtimeconfig/models.go | 104 +++++- .../config/runtimeconfig/reasoning_test.go | 126 +++++++ internal/runtime/agent/request.go | 20 + .../runtime/agent/request_persona_test.go | 97 +++++ internal/runtime/model/anthropic.go | 10 +- internal/runtime/model/model.go | 15 + internal/runtime/model/openai.go | 20 +- internal/runtime/model/openai_encode.go | 42 ++- .../runtime/model/persona_provider_test.go | 254 +++++++++++++ internal/runtime/model/persona_stack.go | 72 ++++ internal/runtime/model/reasoning.go | 99 +---- internal/runtime/model/reasoning_shape.go | 267 ++++++++++++++ .../runtime/model/reasoning_shape_test.go | 345 ++++++++++++++++++ internal/runtime/model/runtime.go | 136 ++++++- 17 files changed, 1496 insertions(+), 134 deletions(-) create mode 100644 internal/config/runtimeconfig/reasoning_test.go create mode 100644 internal/runtime/agent/request_persona_test.go create mode 100644 internal/runtime/model/persona_provider_test.go create mode 100644 internal/runtime/model/persona_stack.go create mode 100644 internal/runtime/model/reasoning_shape.go create mode 100644 internal/runtime/model/reasoning_shape_test.go diff --git a/cmd/cyrene/persona_agents.go b/cmd/cyrene/persona_agents.go index 0b808e8..c0e9141 100644 --- a/cmd/cyrene/persona_agents.go +++ b/cmd/cyrene/persona_agents.go @@ -55,8 +55,11 @@ func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatc if !ok || p == nil { continue } + // 该角色的 agent 必须绑它自己的 persona 视图:否则 models.json 的 personaModels / + // personaProviders(角色级模型/提供商路由)在生产里不会被消费,形同虚设。 + stack := llm.ForPersona(c.PersonaID) ag, err := agent.New(instructionsFromPersona(p), dispatcher, guards, - agent.WithModel(llm, llm), + agent.WithModel(stack, stack), agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"})) if err != nil { log.Warn("persona agent skipped", "persona", c.PersonaID, "error", err) diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index b62e3d8..035adce 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -150,14 +150,18 @@ func (a *app) wireRuntime() error { guards := guardrail.New() instructions := a.defaultInstructions() + // 默认角色的 agent 也要绑它自己的 persona 视图:models.json 的 personaModels / + // personaProviders 可能给默认角色单独指定模型或提供商,用裸栈会漏掉这一层 + // (persona 为空时该视图与裸栈等价,行为不变)。 + personaID := a.defaultPersonaID() + defaultStack := runtimeStack.ForPersona(personaID) ag, aerr := agent.New(instructions, a.dispatcher, guards, - agent.WithModel(runtimeStack, runtimeStack), + agent.WithModel(defaultStack, defaultStack), agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"})) if aerr != nil { return fmt.Errorf("agent: %w", aerr) } - personaID := a.defaultPersonaID() store := a.memStore // One agent per role, all sharing the dispatcher/guards/adapter above, so a session // can answer as any role (design/22 §4.2 方案 A); the loop picks per turn. diff --git a/internal/config/config.go b/internal/config/config.go index 5ef3cab..d7ce550 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -112,6 +112,16 @@ type LLMConfig struct { // budget. Zero disables the cap (the adapter default then applies). FallbackMaxTokens int `env:"LLM_FALLBACK_MAX_TOKENS" default:"8192"` + // ReasoningKind declares this OpenAI-compatible endpoint's reasoning-switch + // wire shape (裁决 03.4.2/08.4.1: 不同提供商的开关形状不同,必须逐 provider + // 独立适配). Values mirror runtimeconfig.ReasoningKind*: "" or "auto" infers + // the vendor from the model name/base URL and sends nothing when the shape is + // ambiguous (the conservative default, so an existing .env is unchanged); + // "deepseek" / "openai" / "vllm" / "dashscope" select that endpoint's shape + // explicitly. It is the env-fallback twin of the models.json group's + // `reasoning.kind`, which is the primary carrier (hot-reloadable, panel-edited). + ReasoningKind string `env:"LLM_REASONING_KIND" default:""` + // Anthropic native provider, used when Provider=anthropic. Independent // config block so each vendor is a self-contained config. AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL"` diff --git a/internal/config/runtimeconfig/models.go b/internal/config/runtimeconfig/models.go index 34d2b53..cec48fc 100644 --- a/internal/config/runtimeconfig/models.go +++ b/internal/config/runtimeconfig/models.go @@ -31,6 +31,40 @@ const ( modelsFileName = "models.json" ) +// ReasoningKind* 是 provider 级推理开关形状的合法取值(裁决 03.4.2 / 08.4.1: +// 不同提供商的思考开关形状各不相同,必须逐 provider 独立适配)。这些常量是 +// 形状名的**唯一来源**:配置层(本包)校验它们,模型适配器按它们选 wire 字段, +// 面板按它们渲染下拉框。 +// +// 取值刻意用**部署形态**命名(vllm / dashscope)而不是厂商名:同一家厂商的 +// 模型在不同形态下的字段形状不同(vLLM 用 chat_template_kwargs.enable_thinking, +// DashScope 兼容模式用顶层 enable_thinking),形状由端点决定,不是由模型名决定。 +const ( + // ReasoningKindAuto 是缺省值:按模型名/BaseURL 推断厂商,形状有歧义时 + // 不下发任何开关(只报告),因此它对旧配置零行为变化。 + ReasoningKindAuto = "auto" + // ReasoningKindDeepSeek 是 DeepSeek:顶层 thinking:{type} + reasoning_effort。 + ReasoningKindDeepSeek = "deepseek" + // ReasoningKindOpenAI 是 OpenAI 系:只有 reasoning_effort,无布尔开关。 + ReasoningKindOpenAI = "openai" + // ReasoningKindVLLM 是 vLLM 类部署(承载 Qwen3 等): + // chat_template_kwargs.enable_thinking。 + ReasoningKindVLLM = "vllm" + // ReasoningKindDashScope 是 DashScope 兼容模式:顶层 enable_thinking。 + ReasoningKindDashScope = "dashscope" +) + +// ReasoningOptions 是一个 provider 组的推理开关形状声明。它是「按提供商 +// 独立适配」的配置载体(裁决 03.4.2 / 08.4.1):面板与 models.json 对每个 +// 供应商各写一份,因此 vLLM 端点与 DashScope 端点可以同时存在、各自下发 +// 自己形状的开关,而不是被套进一个「通用」形状。默认(零值)保守:不声明 +// 形状就不下发开关,只报告——发错形状会 400,比不发更糟。 +type ReasoningOptions struct { + // Kind 显式声明该端点的思考开关形状(见 ReasoningKind* 常量)。 + // 空或 "auto" = 按模型名/BaseURL 推断厂商(形状有歧义时不下发)。 + Kind string `json:"kind,omitempty"` +} + // Group is one independent multi-supplier LLM configuration. Each vendor's // base/key/model/fallback is a self-contained unit, so multiple vendors coexist // and Provider selects the active wire protocol. @@ -59,6 +93,13 @@ type Group struct { // (裁决 03.4.1 的 per-persona provider). Pair it with PersonaModels; an entry // without a provider keeps this group's protocol. PersonaProviders map[string]string `json:"personaProviders,omitempty"` + // Reasoning declares this provider/endpoint's reasoning-switch wire shape + // (裁决 03.4.2 / 08.4.1). It is a provider-level option because the shape + // belongs to the endpoint, not to a persona: every group carries its own, so + // two vendors with different switch shapes coexist without a shared guess. + // omitzero keeps an undeclared shape out of models.json entirely (an empty + // object would be schema noise for every group). + Reasoning ReasoningOptions `json:"reasoning,omitzero"` } // ErrInvalidGroup is returned when a group fails schema validation. @@ -93,6 +134,27 @@ func (g Group) Validate() error { if err := validatePersonaMap("personaProviders", g.PersonaProviders, true); err != nil { return err } + return validateReasoning(g.Provider, g.Reasoning) +} + +// validateReasoning checks a provider-level reasoning shape declaration. A blank +// or "auto" kind is always valid (the conservative default: infer from the model +// name/base URL, and send nothing when the shape is ambiguous). Any other kind +// must be a shape this build implements and has evidence for; and the +// openai-compatible shape names are rejected on an anthropic group, whose switch +// shape is defined by its own protocol — failing loudly beats a silently inert +// option. +func validateReasoning(provider string, r ReasoningOptions) error { + kind := strings.TrimSpace(r.Kind) + switch kind { + case "", ReasoningKindAuto, ReasoningKindDeepSeek, ReasoningKindOpenAI, + ReasoningKindVLLM, ReasoningKindDashScope: + default: + return fmt.Errorf("reasoning.kind %q must be one of auto/deepseek/openai/vllm/dashscope", kind) + } + if provider == ProviderAnthropic && kind != "" && kind != ReasoningKindAuto { + return fmt.Errorf("reasoning.kind %q applies to the openai-compatible provider only", kind) + } return nil } @@ -140,14 +202,16 @@ func (g Group) ToLLMConfig() config.LLMConfig { AnthropicAPIKey: g.APIKey, AnthropicModel: g.Model, AnthropicFallback: g.Fallback, + ReasoningKind: strings.TrimSpace(g.Reasoning.Kind), } } return config.LLMConfig{ - Provider: ProviderOpenAI, - BaseURL: g.BaseURL, - APIKey: g.APIKey, - Model: g.Model, - Fallback: g.Fallback, + Provider: ProviderOpenAI, + BaseURL: g.BaseURL, + APIKey: g.APIKey, + Model: g.Model, + Fallback: g.Fallback, + ReasoningKind: strings.TrimSpace(g.Reasoning.Kind), } } @@ -221,24 +285,30 @@ func loadFile(path string) ([]Group, error) { // group's `multimodal` flag, which the provider panel writes (design/06 §0 首条; // 裁决 14.4.1). LLM_VISION_MODEL configures the vision *tool's* model, not the // main model's ability. +// +// Reasoning.Kind is carried through so an env-only deployment can still declare +// its endpoint's switch shape (LLM_REASONING_KIND); its env default is blank, +// which keeps every existing deployment at "auto" (no new field is ever sent). func GroupFromEnv(env config.LLMConfig) []Group { if env.Provider == ProviderAnthropic { return []Group{{ - ID: ProviderAnthropic, - Provider: ProviderAnthropic, - BaseURL: env.AnthropicBaseURL, - APIKey: env.AnthropicAPIKey, - Model: env.AnthropicModel, - Fallback: env.AnthropicFallback, + ID: ProviderAnthropic, + Provider: ProviderAnthropic, + BaseURL: env.AnthropicBaseURL, + APIKey: env.AnthropicAPIKey, + Model: env.AnthropicModel, + Fallback: env.AnthropicFallback, + Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)}, }} } return []Group{{ - ID: ProviderOpenAI, - Provider: ProviderOpenAI, - BaseURL: env.BaseURL, - APIKey: env.APIKey, - Model: env.Model, - Fallback: env.Fallback, + ID: ProviderOpenAI, + Provider: ProviderOpenAI, + BaseURL: env.BaseURL, + APIKey: env.APIKey, + Model: env.Model, + Fallback: env.Fallback, + Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)}, }} } diff --git a/internal/config/runtimeconfig/reasoning_test.go b/internal/config/runtimeconfig/reasoning_test.go new file mode 100644 index 0000000..97509c5 --- /dev/null +++ b/internal/config/runtimeconfig/reasoning_test.go @@ -0,0 +1,126 @@ +package runtimeconfig + +import ( + "encoding/json" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// 本文件钉住 provider 级推理开关形状的配置面(裁决 03.4.2 / 08.4.1):每个 +// provider 组各声明自己的形状,只有本版本实现且有依据的形状才被接受,未声明的 +// 组在 models.json 里不留下空对象(保守默认 = 不发任何推理字段)。 + +// TestValidateReasoningKind 覆盖合法/非法形状:未知形状必须让保存失败(否则 +// 「配了却不生效」正是裁决要消灭的假适配),anthropic 组不允许声明 OpenAI 侧形状。 +func TestValidateReasoningKind(t *testing.T) { + t.Parallel() + cases := []struct { + name string + provider string + kind string + wantErr string + }{ + {name: "unset", provider: ProviderOpenAI}, + {name: "auto", provider: ProviderOpenAI, kind: ReasoningKindAuto}, + {name: "deepseek", provider: ProviderOpenAI, kind: ReasoningKindDeepSeek}, + {name: "openai", provider: ProviderOpenAI, kind: ReasoningKindOpenAI}, + {name: "vllm", provider: ProviderOpenAI, kind: ReasoningKindVLLM}, + {name: "dashscope", provider: ProviderOpenAI, kind: ReasoningKindDashScope}, + {name: "padded", provider: ProviderOpenAI, kind: " vllm "}, + {name: "auto_on_anthropic", provider: ProviderAnthropic, kind: ReasoningKindAuto}, + {name: "unknown", provider: ProviderOpenAI, kind: "gemini", wantErr: "must be one of"}, + {name: "openai_shape_on_anthropic", provider: ProviderAnthropic, kind: ReasoningKindVLLM, + wantErr: "openai-compatible"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := Group{ + ID: "g", Provider: tc.provider, BaseURL: "https://x/v1", Model: "m", + Reasoning: ReasoningOptions{Kind: tc.kind}, + } + err := g.Validate() + if tc.wantErr == "" { + if err != nil { + t.Fatalf("Validate() = %v, want nil for kind %q", err, tc.kind) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("Validate() = %v, want an error containing %q", err, tc.wantErr) + } + }) + } +} + +// TestReasoningKindJSONShape asserts an undeclared shape is absent from models.json +// (no empty object churn on every group) and a declared one round-trips by name. +func TestReasoningKindJSONShape(t *testing.T) { + t.Parallel() + plain := Group{ID: "g", Provider: ProviderOpenAI, BaseURL: "https://x/v1", Model: "m"} + raw, err := json.Marshal(plain) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if strings.Contains(string(raw), "reasoning") { + t.Fatalf("marshal = %s, want no reasoning key for an undeclared shape", raw) + } + plain.Reasoning.Kind = ReasoningKindDashScope + raw, err = json.Marshal(plain) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if !strings.Contains(string(raw), `"reasoning":{"kind":"dashscope"}`) { + t.Fatalf("marshal = %s, want the declared shape serialized", raw) + } + + path := t.TempDir() + "/models.json" + if err := SaveModels(path, []Group{plain}); err != nil { + t.Fatalf("SaveModels: %v", err) + } + groups, err := LoadModels(path, config.LLMConfig{}) + if err != nil { + t.Fatalf("LoadModels: %v", err) + } + if got := groups[0].Reasoning.Kind; got != ReasoningKindDashScope { + t.Fatalf("round-tripped kind = %q, want %q", got, ReasoningKindDashScope) + } +} + +// TestToLLMConfigCarriesReasoningKind 断言形状随组配置抵达适配器那一层(否则 +// models.json 的声明会静默失效)。 +func TestToLLMConfigCarriesReasoningKind(t *testing.T) { + t.Parallel() + for _, provider := range []string{ProviderOpenAI, ProviderAnthropic} { + g := Group{ + ID: "g", Provider: provider, BaseURL: "https://x", APIKey: "k", Model: "m", + Reasoning: ReasoningOptions{Kind: " " + ReasoningKindDeepSeek + " "}, + } + if got := g.ToLLMConfig().ReasoningKind; got != ReasoningKindDeepSeek { + t.Errorf("provider %s: ReasoningKind = %q, want the trimmed declared shape", provider, got) + } + } +} + +// TestGroupFromEnvCarriesReasoningKind pins the env fallback twin: an env-only +// deployment can declare its endpoint's shape, and its default stays blank so no +// reasoning field is ever sent without an explicit declaration. +func TestGroupFromEnvCarriesReasoningKind(t *testing.T) { + t.Parallel() + if got := GroupFromEnv(config.LLMConfig{})[0].Reasoning.Kind; got != "" { + t.Fatalf("env default kind = %q, want empty (conservative)", got) + } + env := config.LLMConfig{BaseURL: "https://x/v1", APIKey: "k", Model: "m", ReasoningKind: " vllm "} + groups := GroupFromEnv(env) + if len(groups) != 1 { + t.Fatalf("groups = %d, want 1", len(groups)) + } + if got := groups[0].Reasoning.Kind; got != ReasoningKindVLLM { + t.Fatalf("env kind = %q, want %q", got, ReasoningKindVLLM) + } + if err := groups[0].Validate(); err != nil { + t.Fatalf("a fully configured env group must validate: %v", err) + } +} diff --git a/internal/runtime/agent/request.go b/internal/runtime/agent/request.go index 65337aa..0b6c31d 100644 --- a/internal/runtime/agent/request.go +++ b/internal/runtime/agent/request.go @@ -10,6 +10,17 @@ const defaultPurpose = "agent" // configured model/temperature/max-tokens/purpose and the registered tool // schemas. When a router is injected it overrides the model from the request // contents, which is how vision requests are routed to the vision model. +// +// 本路径与流式主路径(loop.buildRequest)共用同一处 persona 钩子(裁决 03.4.1): +// 角色由**适配器视图**携带(model.PersonaScoped,见 model/persona_stack.go), +// 写成请求上的 PersonaID,模型路由也按它走。为什么角色不在本函数参数里:RunStep +// 由 loop 与 automations 调用,角色只存在于 loop 的会话环境 ctx 里,而 agent +// 不能反向 import loop;按角色分开注入的适配器视图(一角色一 agent, +// design/22 §4.2 方案 A)是唯一不依赖 ctx 的载体。 +// +// 角色**已知**时才切到 RouteRequestForPersona:未知角色(普通 *model.Router 或 +// 未绑角色的视图)保持原来的 RouteRequest —— 后者带 vision 模型的改写规则, +// RouteRequestForPersona 刻意不含(图像路由已归 vision.Router,裁决 14.4.1/14.4.5)。 func (a *Agent) buildRequest(messages []model.Message) model.Request { req := model.Request{ Model: a.opts.Model, @@ -19,7 +30,16 @@ func (a *Agent) buildRequest(messages []model.Message) model.Request { Purpose: a.purpose(), Tools: a.toolSchemas(), } + if ps, ok := a.adapter.(model.PersonaScoped); ok { + req.PersonaID = ps.ScopedPersona() + } if a.router != nil { + if pr, ok := a.router.(model.PersonaRouter); ok && req.PersonaID != "" { + if routeModel, _ := pr.RouteRequestForPersona(req.PersonaID, req); routeModel != "" { + req.Model = routeModel + } + return req + } if routeModel, _ := a.router.RouteRequest(req); routeModel != "" { req.Model = routeModel } diff --git a/internal/runtime/agent/request_persona_test.go b/internal/runtime/agent/request_persona_test.go new file mode 100644 index 0000000..3eca11e --- /dev/null +++ b/internal/runtime/agent/request_persona_test.go @@ -0,0 +1,97 @@ +package agent + +import ( + "context" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件钉住非流式路径(agent.RunStep)的 persona 钩子(裁决 03.4.1):它必须与 +// 流式主路径(loop.buildRequest 的 RouteRequestForPersona)同源。角色由适配器视图 +// 携带(model.PersonaScoped),因为它不经 ctx 也能到达这里,且一角色一 agent 的装配 +// 方式(design/22 §4.2 方案 A)本来就把它定在装配处。 + +// personaScopedAdapter 是带角色的 mockAdapter 视图,模拟 model.PersonaStack: +// RunStep 应据此把角色写进请求,并按角色路由模型名。 +type personaScopedAdapter struct { + mockAdapter + persona string +} + +// ScopedPersona 实现 model.PersonaScoped。 +func (p *personaScopedAdapter) ScopedPersona() string { return p.persona } + +// TestRunStep_PersonaScopedAdapterRoutesByPersona 断言:带角色的适配器视图让非流式 +// 路径按 persona 路由(而不是按消息 role),并把角色带到请求上,供模型层选 +// persona 的协议链(models.json personaProviders)。 +func TestRunStep_PersonaScopedAdapterRoutesByPersona(t *testing.T) { + t.Parallel() + router := model.NewRouter("default", + model.WithRoleModel(model.RoleUser, "user-model"), + model.WithPersonaModel("cyrene", "persona-model"), + ) + ad := &personaScopedAdapter{ + mockAdapter: mockAdapter{resp: model.Response{Model: "persona-model", Content: "ok"}}, + persona: "cyrene", + } + a := newTestAgent(t, ad, nil, WithModel(ad, router)) + + if _, err := a.RunStep(context.Background(), "s", + []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil { + t.Fatalf("RunStep: %v", err) + } + if ad.gotReq.PersonaID != "cyrene" { + t.Fatalf("req.PersonaID = %q, want cyrene", ad.gotReq.PersonaID) + } + if ad.gotReq.Model != "persona-model" { + t.Fatalf("req.Model = %q, want persona-model (persona binding wins over the message role)", + ad.gotReq.Model) + } +} + +// TestRunStep_PersonaStampedWithoutRouter asserts the persona travels with the +// adapter view even when no router is injected — the carrier must not depend on +// the routing decision, or an adapter-only wiring would silently lose it. +func TestRunStep_PersonaStampedWithoutRouter(t *testing.T) { + t.Parallel() + ad := &personaScopedAdapter{ + mockAdapter: mockAdapter{resp: model.Response{Content: "ok"}}, + persona: "cyrene", + } + a := newTestAgent(t, ad, nil, WithModel(ad, nil)) + + if _, err := a.RunStep(context.Background(), "s", + []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil { + t.Fatalf("RunStep: %v", err) + } + if ad.gotReq.PersonaID != "cyrene" { + t.Fatalf("req.PersonaID = %q, want cyrene", ad.gotReq.PersonaID) + } +} + +// TestRunStep_UnknownPersonaKeepsLegacyRouting 断言未绑定角色的 agent 行为不变: +// 没有角色就继续走原来的 RouteRequest,其中包含「有图像 → 换视觉模型」这条老规则 +// (RouteRequestForPersona 故意不含它,图像路由已归 vision.Router)。这条断言防止 +// 把两代路由语义混在一起。 +func TestRunStep_UnknownPersonaKeepsLegacyRouting(t *testing.T) { + t.Parallel() + router := model.NewRouter("default", + model.WithRoleModel(model.RoleUser, "user-model"), + model.WithVisionModel("vision-model"), + ) + ad := &mockAdapter{resp: model.Response{Model: "vision-model", Content: "ok"}} + a := newTestAgent(t, ad, nil, WithModel(ad, router)) + + msg := model.ImageMessage(model.RoleUser, "what is this", "data:image/png;base64,abc") + if _, err := a.RunStep(context.Background(), "s", []model.Message{msg}); err != nil { + t.Fatalf("RunStep: %v", err) + } + if ad.gotReq.PersonaID != "" { + t.Fatalf("req.PersonaID = %q, want empty for an unbound agent", ad.gotReq.PersonaID) + } + if ad.gotReq.Model != "vision-model" { + t.Fatalf("req.Model = %q, want vision-model (the legacy image routing must survive)", + ad.gotReq.Model) + } +} diff --git a/internal/runtime/model/anthropic.go b/internal/runtime/model/anthropic.go index 9e085ee..3c0eb15 100644 --- a/internal/runtime/model/anthropic.go +++ b/internal/runtime/model/anthropic.go @@ -12,6 +12,7 @@ import ( "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // Anthropic Messages (/v1/messages) wire protocol constants. @@ -163,6 +164,13 @@ 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: 禁止静默无效). + a.log.Warn("reasoning.kind ignored: the anthropic protocol defines its own thinking shape", + "provider", ProviderAnthropic, "field", "reasoning.kind", "kind", kind) + } for _, o := range opts { o(a) } @@ -311,7 +319,7 @@ func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, e // provider actually applies. Strength goes through output_config.effort // instead (design/04 §3.2 fact 6). thinking, output = anthropicDeepSeekReasoning(a.log, req.Model, req.Thinking, req.ReasoningEffort) - reportForcedToolChoiceWithThinking(a.log, req.Model, req.ToolChoice, anthropicThinkingOn(thinking)) + 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 diff --git a/internal/runtime/model/model.go b/internal/runtime/model/model.go index 7725ef4..4033e08 100644 --- a/internal/runtime/model/model.go +++ b/internal/runtime/model/model.go @@ -172,6 +172,21 @@ type Request struct { MaxTokens int // Purpose labels the call for CallRecord accounting (e.g. "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. + // + // 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. + PersonaID string // Tools is the interface-level placeholder for M2 function calling. Tools []ToolSpec // ToolChoice optionally directs how the provider selects a tool. It is an diff --git a/internal/runtime/model/openai.go b/internal/runtime/model/openai.go index 33063c5..71284ac 100644 --- a/internal/runtime/model/openai.go +++ b/internal/runtime/model/openai.go @@ -40,6 +40,11 @@ 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 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. @@ -77,13 +82,18 @@ 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. +// 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. func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapter { a := &OpenAIChatAdapter{ - name: "openai", - baseURL: strings.TrimRight(cfg.BaseURL, "/"), - apiKey: cfg.APIKey, - model: cfg.Model, + name: "openai", + baseURL: strings.TrimRight(cfg.BaseURL, "/"), + apiKey: cfg.APIKey, + model: cfg.Model, + reasoningKind: strings.TrimSpace(cfg.ReasoningKind), client: &http.Client{ Transport: http.DefaultTransport, Timeout: defaultHTTPTimeout, diff --git a/internal/runtime/model/openai_encode.go b/internal/runtime/model/openai_encode.go index 7e85acf..9f9f664 100644 --- a/internal/runtime/model/openai_encode.go +++ b/internal/runtime/model/openai_encode.go @@ -25,6 +25,15 @@ type openAIChatRequest struct { // 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 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 *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 *bool `json:"enable_thinking,omitempty"` } // openAIThinking is the wire form of DeepSeek's thinking switch: enabled or @@ -33,6 +42,12 @@ 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. +type openAIChatTemplateKwargs struct { + EnableThinking *bool `json:"enable_thinking,omitempty"` +} + // openAIStreamOpts asks the provider to include usage in the final stream chunk. type openAIStreamOpts struct { IncludeUsage bool `json:"include_usage"` @@ -85,20 +100,23 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro // 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 (design/04 §3.2). + // 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). vendor := reasoningVendorFor(req.Model, a.baseURL) - effort := resolveReasoningEffort(a.log, vendor, req.ReasoningEffort, req.Model) - thinking, effort := resolveOpenAIReasoning(a.log, vendor, req.Model, req.Thinking, effort) - reportForcedToolChoiceWithThinking(a.log, req.Model, req.ToolChoice, openAIThinkingOn(thinking, effort)) + plan := resolveOpenAIReasoning(a.log, a.reasoningKind, vendor, req.Model, req.Thinking, req.ReasoningEffort) + reportForcedToolChoiceWithThinking(a.log, plan.provider, req.Model, req.ToolChoice, plan.thinkingOn()) wire := openAIChatRequest{ - Model: req.Model, - Messages: msgs, - Temperature: req.Temperature, - MaxTokens: req.MaxTokens, - Stream: stream, - Tools: buildTools(req.Tools), - Thinking: thinking, - ReasoningEffort: effort, + Model: req.Model, + Messages: msgs, + Temperature: req.Temperature, + MaxTokens: req.MaxTokens, + Stream: stream, + Tools: buildTools(req.Tools), + Thinking: plan.thinking, + ReasoningEffort: plan.effort, + ChatTemplateKwargs: plan.template, + EnableThinking: plan.enable, } if stream { wire.StreamOptions = &openAIStreamOpts{IncludeUsage: true} diff --git a/internal/runtime/model/persona_provider_test.go b/internal/runtime/model/persona_provider_test.go new file mode 100644 index 0000000..6bc1ce9 --- /dev/null +++ b/internal/runtime/model/persona_provider_test.go @@ -0,0 +1,254 @@ +package model + +import ( + "context" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// 本文件钉住 03.4.1 剩下的那半条接线:**per-persona provider**(跨协议换 adapter) +// 必须真的被消费。此前 RouteProviderForPersona 没有任何消费方,于是「某个人格走另 +// 一个厂商」只是配置里的一句话;现在带 PersonaID 的请求必须落到那个人格声明的协议 +// 链上(由 models.json 里同协议的组承载),不带角色的请求保持原样。 + +// personaProviderFixture 是一次 persona-provider 装配:生效组(openai)+ 被 +// persona 指向的另一个协议的组(anthropic),两个真实 stub server 分别记录请求。 +type personaProviderFixture struct { + m *RuntimeLLM + openaiCh chan capturedRequest + anthropicCh chan anthropicCaptured +} + +// newPersonaProviderFixture 建一个 models.json:首组(生效组)声明 persona +// cyrene 用自己的模型 "persona-model" 走 anthropic 协议;第二组就是那个 anthropic +// 端点。extra 允许用例改写首组以覆盖各种残缺/同协议配置。 +func newPersonaProviderFixture(t *testing.T, mutate func(*runtimeconfig.Group)) *personaProviderFixture { + t.Helper() + openaiCh := make(chan capturedRequest, 4) + openaiSrv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, openaiCh) + anthropicCh := make(chan anthropicCaptured, 4) + anthropicSrv := newAnthropicServer(t, http.StatusOK, + "application/json", anthropicTextResponse, anthropicCh) + + main := runtimeconfig.Group{ + ID: "main", Provider: ProviderOpenAI, BaseURL: openaiSrv.URL, APIKey: "sk", + Model: "main-model", + PersonaModels: map[string]string{"cyrene": "persona-model"}, + PersonaProviders: map[string]string{"cyrene": ProviderAnthropic}, + } + if mutate != nil { + mutate(&main) + } + path := writeRTFile(t, []runtimeconfig.Group{ + main, + {ID: "claude", Provider: ProviderAnthropic, BaseURL: anthropicSrv.URL, + APIKey: "sk-ant", Model: "claude-fallback"}, + }) + return &personaProviderFixture{ + m: NewRuntimeLLM(path, rtEnv(), nil), + openaiCh: openaiCh, + anthropicCh: anthropicCh, + } +} + +// TestRuntimeLLM_PersonaProviderSelectsTheOtherProtocol 是主用例:带 persona 的 +// 请求必须走该 persona 声明的协议端(anthropic),并把 persona 绑定的模型名带过去; +// 不带 persona 的请求必须仍走生效组(openai)。 +func TestRuntimeLLM_PersonaProviderSelectsTheOtherProtocol(t *testing.T) { + t.Parallel() + f := newPersonaProviderFixture(t, nil) + + if _, err := f.m.Chat(context.Background(), Request{ + PersonaID: "cyrene", Model: "persona-model", + Messages: []Message{TextMessage(RoleUser, "hi")}, + }, Options{}); err != nil { + t.Fatalf("Chat with persona: %v", err) + } + got := recvWithin(t, f.anthropicCh, "the persona's anthropic endpoint") + if got.path != "/v1/messages" { + t.Fatalf("persona request path = %q, want /v1/messages (the persona's protocol)", got.path) + } + if got.body["model"] != "persona-model" { + t.Fatalf("persona request model = %v, want persona-model", got.body["model"]) + } + + if _, err := f.m.Chat(context.Background(), Request{ + Model: "main-model", Messages: []Message{TextMessage(RoleUser, "hi")}, + }, Options{}); err != nil { + t.Fatalf("Chat without persona: %v", err) + } + plain := recvWithin(t, f.openaiCh, "the default openai endpoint") + if plain.path != "/chat/completions" { + t.Fatalf("default request path = %q, want /chat/completions", plain.path) + } + if plain.body["model"] != "main-model" { + t.Fatalf("default request model = %v, want main-model", plain.body["model"]) + } +} + +// 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. +func TestRuntimeLLM_PersonaProviderAppliesToStreamingToo(t *testing.T) { + t.Parallel() + anthropicCh := make(chan anthropicCaptured, 1) + const sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\n" + + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + anthropicSrv := newAnthropicServer(t, http.StatusOK, "text/event-stream", sse, anthropicCh) + openaiSrv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, nil) + + path := writeRTFile(t, []runtimeconfig.Group{ + {ID: "main", Provider: ProviderOpenAI, BaseURL: openaiSrv.URL, APIKey: "sk", Model: "main-model", + PersonaModels: map[string]string{"cyrene": "persona-model"}, + PersonaProviders: map[string]string{"cyrene": ProviderAnthropic}}, + {ID: "claude", Provider: ProviderAnthropic, BaseURL: anthropicSrv.URL, APIKey: "sk-ant", Model: "x"}, + }) + m := NewRuntimeLLM(path, rtEnv(), nil) + + if _, err := m.ChatStream(context.Background(), Request{ + PersonaID: "cyrene", Model: "persona-model", + Messages: []Message{TextMessage(RoleUser, "hi")}, + }, func(StreamEvent) {}); err != nil { + t.Fatalf("ChatStream with persona: %v", err) + } + got := recvWithin(t, anthropicCh, "the persona's anthropic endpoint") + if got.body["model"] != "persona-model" || got.body["stream"] != true { + t.Fatalf("stream request body = %v, want the persona's model streamed over its protocol", got.body) + } +} + +// 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. +func TestPersonaStack_StampsPersonaOnEveryRequest(t *testing.T) { + t.Parallel() + f := newPersonaProviderFixture(t, nil) + view := f.m.ForPersona(" cyrene ") // 空白也会被规范化 + + if got := view.ScopedPersona(); got != "cyrene" { + t.Fatalf("ScopedPersona() = %q, want cyrene (trimmed)", got) + } + if want := f.m.Name() + ":cyrene"; view.Name() != want { + t.Errorf("Name() = %q, want %q", view.Name(), want) + } + if got, _ := view.RouteRequest(Request{}); got != "persona-model" { + t.Fatalf("RouteRequest() = %q, want the persona's own model", got) + } + if _, err := view.Chat(context.Background(), Request{ + Messages: []Message{TextMessage(RoleUser, "hi")}, + }, Options{}); err != nil { + t.Fatalf("Chat through the persona view: %v", err) + } + got := recvWithin(t, f.anthropicCh, "the persona's anthropic endpoint") + if got.body["model"] != "persona-model" { + t.Fatalf("view request model = %v, want persona-model (stamped persona routed it)", got.body["model"]) + } +} + +// TestPersonaStack_BlankPersonaKeepsDefaultRoute asserts an unbound view is a +// pass-through: an empty persona must not turn into a binding of its own. +func TestPersonaStack_BlankPersonaKeepsDefaultRoute(t *testing.T) { + t.Parallel() + f := newPersonaProviderFixture(t, nil) + view := f.m.ForPersona("") + + if view.ScopedPersona() != "" { + t.Fatalf("ScopedPersona() = %q, want empty", view.ScopedPersona()) + } + if view.Name() != f.m.Name() { + t.Errorf("Name() = %q, want the plain stack name %q", view.Name(), f.m.Name()) + } + if got, _ := view.RouteRequest(Request{}); got != "main-model" { + t.Fatalf("RouteRequest() = %q, want the default model", got) + } + if _, err := view.Chat(context.Background(), Request{ + Messages: []Message{TextMessage(RoleUser, "hi")}, + }, Options{}); err != nil { + t.Fatalf("Chat through the blank view: %v", err) + } + if got := recvWithin(t, f.openaiCh, "the default openai endpoint"); got.body["model"] != "main-model" { + t.Fatalf("blank view request went to %v, want the default chain", got.body["model"]) + } +} + +// 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. +func TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain(t *testing.T) { + t.Parallel() + f := newPersonaProviderFixture(t, func(g *runtimeconfig.Group) { + g.PersonaModels = nil + }) + if _, err := f.m.Chat(context.Background(), Request{ + PersonaID: "cyrene", Messages: []Message{TextMessage(RoleUser, "hi")}, + }, Options{}); err != nil { + t.Fatalf("Chat: %v", err) + } + got := recvWithin(t, f.openaiCh, "the default openai endpoint") + if got.path != "/chat/completions" { + t.Fatalf("path = %q, want the default chain (%v)", got.path, got.body) + } + if got.body["model"] != "main-model" { + t.Fatalf("model = %v, want the group default model", got.body["model"]) + } +} + +// 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. +func TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain(t *testing.T) { + t.Parallel() + openaiCh := make(chan capturedRequest, 1) + openaiSrv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, openaiCh) + path := writeRTFile(t, []runtimeconfig.Group{ + {ID: "main", Provider: ProviderOpenAI, BaseURL: openaiSrv.URL, APIKey: "sk", Model: "main-model", + PersonaModels: map[string]string{"cyrene": "persona-model"}, + PersonaProviders: map[string]string{"cyrene": ProviderAnthropic}}, + }) + log, buf := captureLog() + m := NewRuntimeLLM(path, rtEnv(), log) + + if _, err := m.Chat(context.Background(), Request{ + PersonaID: "cyrene", Messages: []Message{TextMessage(RoleUser, "hi")}, + }, Options{}); err != nil { + t.Fatalf("Chat: %v", err) + } + if got := recvWithin(t, openaiCh, "the default openai endpoint"); got.path != "/chat/completions" { + t.Fatalf("path = %q, want the default chain", got.path) + } + rec := buf.String() + for _, want := range []string{"level=WARN", "per-persona provider", "anthropic"} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } +} + +// 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. +func TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain(t *testing.T) { + t.Parallel() + f := newPersonaProviderFixture(t, func(g *runtimeconfig.Group) { + g.PersonaProviders = map[string]string{"cyrene": ProviderOpenAI} + }) + if chains := f.m.load().providerChains; chains != nil { + t.Fatalf("providerChains = %v, want none built for the active protocol", chains) + } + if _, err := f.m.Chat(context.Background(), Request{ + PersonaID: "cyrene", Model: "persona-model", + Messages: []Message{TextMessage(RoleUser, "hi")}, + }, Options{}); err != nil { + t.Fatalf("Chat: %v", err) + } + if got := recvWithin(t, f.openaiCh, "the default openai endpoint"); got.body["model"] != "persona-model" { + t.Fatalf("model = %v, want the persona's model over the default chain", got.body["model"]) + } +} diff --git a/internal/runtime/model/persona_stack.go b/internal/runtime/model/persona_stack.go new file mode 100644 index 0000000..57d4ec6 --- /dev/null +++ b/internal/runtime/model/persona_stack.go @@ -0,0 +1,72 @@ +package model + +import "context" + +// 本文件实现「固定角色」的 LLM 栈视图(裁决 03.4.1 的 per-persona provider: +// 跨协议换 adapter)。 +// +// 为什么需要它:跨协议的 per-persona 路由要求「请求携带 persona」才能选链, +// 而 persona 只存在于 loop 的会话环境里(挂在 loop 自己的私有 ctx 键上), +// 且 model 不能反向 import loop。但 loop 取适配器的位置恰好是**按角色**的: +// `loop.streamStep` 用 `ag.Adapter()`,而 agent 池是一角色一个 agent +// (design/22 §4.2 方案 A)。于是「这个 agent 服务哪个角色」由装配处一次性 +// 决定、由适配器视图携带,是唯一不外泄、可测、且不动 loop/agent 结构的载体。 +// +// 视图只做两件事:把角色贴到每个请求上(PersonaID),并按该角色路由模型名; +// 选链与降级仍由 RuntimeLLM 完成(它看不到 loop 的角色解析结果)。 + +// PersonaScoped 由「固定服务某个角色」的适配器视图实现。agent 的非流式路径 +// (internal/runtime/agent/request.go)读它,好让同一个 persona 钩子在 +// RunStep 与流式主路径上都成立。空字符串表示该视图不绑定角色。 +type PersonaScoped interface { + // ScopedPersona 返回该视图固定的角色 id。 + ScopedPersona() string +} + +// PersonaStack 是 LLM 栈的角色视图:它实现 Adapter(贴角色后委托给底层栈) +// 与 ModelRouter(按角色路由模型名)。把它当作某一角色的 agent 的 +// adapter 与 router 注入,该 agent 的每个请求就都带上了这个角色。 +// +// 它是无状态的,可安全并发使用。 +type PersonaStack struct { + persona string + inner *RuntimeLLM +} + +// ForPersona 返回服务 persona 的栈视图。空白 persona 返回一个不绑定角色的 +// 视图(等价于底层栈),使「没解析出角色」的装配处保持既有行为。 +func (m *RuntimeLLM) ForPersona(persona string) *PersonaStack { + return &PersonaStack{persona: trimKey(persona), inner: m} +} + +// ScopedPersona 实现 PersonaScoped。 +func (p *PersonaStack) ScopedPersona() string { return p.persona } + +// Name 实现 Adapter。带上角色后缀,使 CallRecord 与日志能分辨是哪个角色在 +// 说话;未绑定角色时与底层栈同名。 +func (p *PersonaStack) Name() string { + if p.persona == "" { + return p.inner.Name() + } + return p.inner.Name() + ":" + p.persona +} + +// Chat 实现 Adapter:贴上角色后委托给底层栈。 +func (p *PersonaStack) Chat(ctx context.Context, req Request, opts Options) (Response, error) { + req.PersonaID = p.persona + return p.inner.Chat(ctx, req, opts) +} + +// ChatStream 实现 Adapter:贴上角色后委托给底层栈。 +func (p *PersonaStack) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) { + req.PersonaID = p.persona + return p.inner.ChatStream(ctx, req, onEvent) +} + +// RouteRequest 实现 ModelRouter:按该视图的角色路由模型名,未绑定角色时 +// RouteRequestForPersona 会回落到消息 role 的同一条老路。它刻意不实现 +// PersonaRouter:loop 在拿到真实角色时自己调 RouteRequestForPersona, +// 视图再顶一层只会让「谁的角色说了算」含糊。 +func (p *PersonaStack) RouteRequest(req Request) (string, bool) { + return p.inner.RouteRequestForPersona(p.persona, req) +} diff --git a/internal/runtime/model/reasoning.go b/internal/runtime/model/reasoning.go index 82a0a05..226da01 100644 --- a/internal/runtime/model/reasoning.go +++ b/internal/runtime/model/reasoning.go @@ -132,17 +132,17 @@ func normalizeDeepSeekEffort(effort string) (string, bool) { // 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. +// +// 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). func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, model string) string { effort = strings.TrimSpace(effort) if effort == "" { return "" } - allowed, ok := reasoningEffortAllowed[vendor] - if !ok { - log.Warn("reasoning_effort ignored: provider does not support it", - "provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model) - return "" - } + allowed := reasoningEffortAllowed[vendor] if allowed[effort] { return effort } @@ -159,57 +159,6 @@ func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, mo return "" } -// resolveOpenAIReasoning maps the caller's reasoning knobs onto the DeepSeek -// OpenAI-compatible wire fields and returns the top-level thinking switch (nil = -// omitted) together with the reasoning_effort to send ("" = omitted). A -// non-DeepSeek vendor has no portable field for the switch, so its directive is -// only reported. -// -// When both knobs are given and disagree, the "off" side wins and the conflict is -// reported: the provider documents no precedence, and letting a strength tier -// re-open thinking is exactly the inversion this resolution exists to prevent -// (design/04 §3.2 fact 2). -func resolveOpenAIReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) (*openAIThinking, string) { - if vendor != vendorDeepSeek { - reportUnmappedThinking(log, vendor, model, th) - return nil, effort - } - var block *openAIThinking - if th != nil { - if th.BudgetTokens > 0 { - log.Warn("thinking.budget_tokens ignored: the DeepSeek chat-completions protocol takes reasoning_effort, not a token budget", - "provider", "deepseek", "field", "thinking.budget_tokens", - "budget_tokens", th.BudgetTokens, "model", model) - } - if th.Enable != nil { - block = &openAIThinking{Type: thinkingSwitchValue(*th.Enable)} - } - } - if block != nil && block.Type == thinkingTypeDisabled { - if effort != "" { - log.Warn("reasoning_effort dropped: thinking is explicitly disabled", - "provider", "deepseek", "field", "reasoning_effort", "value", effort, "model", model) - } - return block, "" - } - if effort == effortNone && block != nil { - log.Warn("reasoning_effort none contradicts thinking enabled: thinking disabled wins", - "provider", "deepseek", "field", "reasoning_effort", "value", effort, "model", model) - return &openAIThinking{Type: thinkingTypeDisabled}, "" - } - return block, effort -} - -// openAIThinkingOn reports the effective thinking state of a resolved DeepSeek -// OpenAI-compatible request: an omitted switch leaves the provider default in -// place, and DeepSeek's default is enabled. -func openAIThinkingOn(th *openAIThinking, effort string) bool { - if th != nil { - return th.Type == thinkingTypeEnabled - } - return effort != effortNone -} - // 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. func toolChoiceForces(toolChoice any) bool { @@ -252,40 +201,10 @@ func forcedToolChoiceType(value any) bool { // 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. -func reportForcedToolChoiceWithThinking(log *slog.Logger, model string, toolChoice any, thinkingOn bool) { +func reportForcedToolChoiceWithThinking(log *slog.Logger, provider, model string, toolChoice any, thinkingOn bool) { if !thinkingOn || !toolChoiceForces(toolChoice) { return } - log.Warn("tool_choice forcing cannot be combined with DeepSeek thinking (forcing is rejected with a 400): disable thinking explicitly before forcing a tool call", - "provider", "deepseek", "field", "tool_choice", "model", model) -} - -// reportUnmappedThinking logs why a Thinking directive cannot be carried by a -// non-DeepSeek OpenAI-compatible request, naming the provider and the field that -// was ignored. Qwen3 does have enable_thinking, but the protocol has no portable -// field for it (vLLM wants chat_template_kwargs.enable_thinking, DashScope's -// compatible mode wants a top-level enable_thinking), and sending the wrong shape -// can 400 the request — so this version reports it instead of guessing. DeepSeek's -// switch is mapped by resolveOpenAIReasoning, not here. -func reportUnmappedThinking(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking) { - if th == nil { - return - } - if th.Enable != nil { - switch vendor { - case vendorQwen: - log.Warn("enable_thinking not forwarded: the OpenAI-compatible protocol has no portable field for it", - "provider", "qwen", "field", "extra_body.chat_template_kwargs.enable_thinking", - "enable", *th.Enable, "model", model) - default: - log.Warn("thinking switch ignored: provider has no boolean thinking switch", - "provider", string(vendor), "field", "thinking.enable", "enable", *th.Enable, "model", model) - } - return - } - if th.BudgetTokens > 0 { - log.Warn("thinking budget ignored: OpenAI-compatible providers take reasoning_effort, not a token budget", - "provider", string(vendor), "field", "thinking.budget_tokens", - "budget_tokens", th.BudgetTokens, "model", model) - } + log.Warn("tool_choice forcing cannot be combined with thinking (forcing is rejected with a 400): disable thinking explicitly before forcing a tool call", + "provider", provider, "field", "tool_choice", "model", model) } diff --git a/internal/runtime/model/reasoning_shape.go b/internal/runtime/model/reasoning_shape.go new file mode 100644 index 0000000..a6ac11d --- /dev/null +++ b/internal/runtime/model/reasoning_shape.go @@ -0,0 +1,267 @@ +package model + +import ( + "log/slog" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// 本文件把调用方的推理旋钮映射到**端点自己的** wire 形状(裁决 03.4.2 / 08.4.1: +// 不同提供商的思考开关形状各不相同,必须逐 provider 独立适配,禁止「识别到配置 +// 却只 WARN 不下发」的假适配)。形状由 models.json 组的 `reasoning.kind` 显式声明 +// (配置载体见 internal/config/runtimeconfig),未声明时按模型名/BaseURL 推断厂商, +// 且只在形状**无歧义**时才下发。 +// +// 依据(实现前逐条核对,不猜字段名): +// - DeepSeek:顶层 `thinking:{type:enabled|disabled}` + `reasoning_effort` +// (官方取值 none/low/high/max 与别名表;思考**默认开**)。来源: +// `DevDocs/reference/deepseek/guides-thinking_mode.md`(官方文档本地副本)。 +// - OpenAI 系:只有 `reasoning_effort`,没有布尔思考开关。来源:design/04 §3.2。 +// - vLLM(承载 Qwen3 等):`chat_template_kwargs.enable_thinking`。来源:design/04 +// §3.2 的 Qwen3 行(记作 `extra_body.chat_template_kwargs.enable_thinking`; +// extra_body 是 OpenAI SDK「把键合并进顶层 body」的机制,与 DeepSeek 官方文档 +// 对 `thinking` 的描述同构,故线上形状是顶层 `chat_template_kwargs`)。 +// - DashScope 兼容模式:顶层 `enable_thinking`。来源:design/04 §3.2(「DashScope +// 等互不相同」)+仓库既有实现(reasoning.go 原注释)。⚠️ 本仓库没有 DashScope +// 官方文档本地副本,因此该形状**只在配置显式声明 reasoning.kind=dashscope 时 +// 下发**,绝不从模型名/BaseURL 自动推断(同一模型名可能由 vLLM 或 DashScope +// 承载,形状不同,发错会 400)。 +// - Anthropic:自己的 `thinking:{type,budget_tokens}`,见 anthropic_reasoning.go。 + +// switchShape 描述「只有布尔开关、没有 reasoning_effort」的端点形状。 +type switchShape struct { + // provider 是 WARN 与日志里报告的名字(vllm / dashscope)。 + provider string + // field 是该形状开关的线上字段路径,只用于日志(含配置指引)。 + field string +} + +var ( + // shapeVLLM 是 vLLM 类部署:开关在 chat_template_kwargs 里。 + shapeVLLM = switchShape{provider: "vllm", field: "chat_template_kwargs.enable_thinking"} + // shapeDashScope 是 DashScope 兼容模式:开关在请求体顶层。 + shapeDashScope = switchShape{provider: "dashscope", field: "enable_thinking"} +) + +// openAIReasoning 是一次调用要并入 OpenAI 兼容请求体的推理指令。它是各形状的 +// 并集,但**只会填入该端点形状对应的那一个字段**:形状之间互不替代。 +type openAIReasoning struct { + // thinking 是 DeepSeek 的顶层开关 {"thinking":{"type":...}}。 + thinking *openAIThinking + // effort 是 reasoning_effort(OpenAI 系与 DeepSeek 用)。 + effort string + // template 是 vLLM 的 {"chat_template_kwargs":{"enable_thinking":..}}。 + template *openAIChatTemplateKwargs + // enable 是 DashScope 兼容模式的顶层 enable_thinking。 + enable *bool + // provider 是本次解析归属的 provider 名,供日志定位。 + provider string + // providerDefaultOn 报告该端点**未声明开关时**的思考默认状态。只有 DeepSeek + // 有官方依据(默认 enabled),其余形状为 false:没有依据就不断言其默认。 + providerDefaultOn bool +} + +// thinkingOn 报告该请求的思考是否处于开启状态(显式开关优先,其次是 +// reasoning_effort none 这个官方关闭值,最后才是端点自身默认)。它只用于 +// 提示性 WARN(强制 tool_choice 与思考不兼容),不改变请求。 +func (r openAIReasoning) thinkingOn() bool { + switch { + case r.thinking != nil: + return r.thinking.Type == thinkingTypeEnabled + case r.template != nil && r.template.EnableThinking != nil: + return *r.template.EnableThinking + case r.enable != nil: + return *r.enable + case r.effort != "": + return r.effort != effortNone + } + return r.providerDefaultOn +} + +// setSwitch 把布尔开关按形状挂到对应字段上。 +func (r *openAIReasoning) setSwitch(shape switchShape, enable bool) { + value := enable + switch shape { + case shapeVLLM: + r.template = &openAIChatTemplateKwargs{EnableThinking: &value} + case shapeDashScope: + r.enable = &value + } +} + +// resolveOpenAIReasoning 按 provider 形状解析推理指令。kind 是配置声明的形状 +// (空/"auto" = 推断);vendor 是推断出的厂商,仅在 auto 下参与决策。 +func resolveOpenAIReasoning(log *slog.Logger, kind string, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning { + switch strings.ToLower(strings.TrimSpace(kind)) { + case runtimeconfig.ReasoningKindDeepSeek: + return resolveDeepSeekReasoning(log, model, th, effort) + case runtimeconfig.ReasoningKindOpenAI: + return resolveEffortOnlyReasoning(log, vendorOpenAI, model, th, effort) + case runtimeconfig.ReasoningKindVLLM: + return resolveSwitchReasoning(log, shapeVLLM, model, th, effort) + case runtimeconfig.ReasoningKindDashScope: + return resolveSwitchReasoning(log, shapeDashScope, model, th, effort) + case "", runtimeconfig.ReasoningKindAuto: + return resolveAutoReasoning(log, vendor, model, th, effort) + default: + // 配置里写了一个本版本不认识的形状:保守不发任何字段,但必须报告, + // 否则「配了却不生效」正是本裁决要消灭的假适配。 + log.Warn("reasoning.kind unrecognized: no reasoning field is sent", + "kind", kind, "model", model, "field", "reasoning.kind") + return openAIReasoning{provider: strings.TrimSpace(kind)} + } +} + +// resolveAutoReasoning 处理未声明形状的端点:按推断出的厂商选择**该厂商唯一 +// 无歧义的**形状,厂商认识但形状有歧义(Qwen 既可能是 vLLM 也可能是 DashScope) +// 时不下发开关,只报告并给出配置指引。 +func resolveAutoReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning { + switch vendor { + case vendorDeepSeek: + return resolveDeepSeekReasoning(log, model, th, effort) + case vendorOpenAI: + return resolveEffortOnlyReasoning(log, vendorOpenAI, model, th, effort) + default: + return resolveNoShapeReasoning(log, vendor, model, th, effort) + } +} + +// resolveDeepSeekReasoning 映射 DeepSeek 的 OpenAI 兼容形状:顶层 thinking 开关 +// 与 reasoning_effort。DeepSeek 的思考**默认开启**,所以「不传」不等于「关」; +// 要关只能显式 disabled 或 effort none(官方文档 guides-thinking_mode)。 +func resolveDeepSeekReasoning(log *slog.Logger, model string, th *Thinking, effort string) openAIReasoning { + effort = resolveReasoningEffort(log, vendorDeepSeek, effort, model) + out := openAIReasoning{effort: effort, provider: string(vendorDeepSeek), providerDefaultOn: true} + if th != nil && th.BudgetTokens > 0 { + log.Warn("thinking.budget_tokens ignored: the DeepSeek chat-completions protocol takes reasoning_effort, not a token budget", + "provider", string(vendorDeepSeek), "field", "thinking.budget_tokens", + "budget_tokens", th.BudgetTokens, "model", model) + } + if th == nil || th.Enable == nil { + return out + } + if !*th.Enable { + if effort != "" { + log.Warn("reasoning_effort dropped: thinking is explicitly disabled", + "provider", string(vendorDeepSeek), "field", "reasoning_effort", "value", effort, "model", model) + } + out.thinking = &openAIThinking{Type: thinkingTypeDisabled} + out.effort = "" + return out + } + if effort == effortNone { + // 「关」胜过「开」:provider 未声明优先级,让强度档把思考重新打开正是 + // 本解析要阻止的语义反转(design/04 §3.2 fact 2)。 + log.Warn("reasoning_effort none contradicts thinking enabled: thinking disabled wins", + "provider", string(vendorDeepSeek), "field", "reasoning_effort", "value", effort, "model", model) + out.thinking = &openAIThinking{Type: thinkingTypeDisabled} + out.effort = "" + return out + } + out.thinking = &openAIThinking{Type: thinkingTypeEnabled} + return out +} + +// resolveEffortOnlyReasoning 映射「只有 reasoning_effort」的端点(OpenAI 系): +// 强度档按该厂商白名单校验,布尔开关没有 wire 字段可发,因此只报告并说明 +// 官方替代路径(effort none 才是关闭推理的方式)。 +func resolveEffortOnlyReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning { + effort = resolveReasoningEffort(log, vendor, effort, model) + out := openAIReasoning{effort: effort, provider: string(vendor)} + if th == nil { + return out + } + if th.Enable != nil { + log.Warn("thinking switch ignored: this endpoint has no boolean thinking switch; reasoning_effort none is the documented way to disable reasoning", + "provider", string(vendor), "field", "thinking.enable", "enable", *th.Enable, "model", model) + } + if th.BudgetTokens > 0 { + log.Warn("thinking budget ignored: OpenAI-compatible providers take reasoning_effort, not a token budget", + "provider", string(vendor), "field", "thinking.budget_tokens", + "budget_tokens", th.BudgetTokens, "model", model) + } + return out +} + +// resolveSwitchReasoning 映射「只有布尔开关」的端点(vLLM / DashScope 类): +// 开关按形状下发;该形状没有 reasoning_effort 字段,因此强度档被丢弃并报告—— +// 唯一的例外是官方关闭值 `none`,它被翻译成**显式关思考**(红线:none = 关思考, +// 绝不翻译成任何强度档,那会把「关」变成「开」)。 +func resolveSwitchReasoning(log *slog.Logger, shape switchShape, model string, th *Thinking, effort string) openAIReasoning { + out := openAIReasoning{provider: shape.provider} + var enable *bool + if th != nil { + if th.BudgetTokens > 0 { + log.Warn("thinking budget ignored: this endpoint takes a boolean thinking switch, not a token budget", + "provider", shape.provider, "field", "thinking.budget_tokens", + "budget_tokens", th.BudgetTokens, "model", model, "endpoint_field", shape.field) + } + enable = th.Enable + } + if effort == effortNone { + if enable != nil && *enable { + log.Warn("reasoning_effort none contradicts thinking enabled: thinking disabled wins", + "provider", shape.provider, "field", "reasoning_effort", "value", effort, "model", model) + } else if enable == nil { + log.Warn("reasoning_effort has no field on this endpoint: none is applied as the explicit thinking switch off", + "provider", shape.provider, "field", shape.field, "value", effort, "model", model) + } + out.setSwitch(shape, false) + return out + } + if effort != "" { + dropUnsupportedEffort(log, shape, model, effort) + } + if enable != nil { + out.setSwitch(shape, *enable) + } + return out +} + +// dropUnsupportedEffort 报告该形状没有 reasoning_effort 字段(含配置指引), +// 绝不改写成近似档位:改档会反转调用方意图,正是旧实现踩过的坑。 +func dropUnsupportedEffort(log *slog.Logger, shape switchShape, model, effort string) { + log.Warn("reasoning_effort dropped: this endpoint has no reasoning_effort field", + "provider", shape.provider, "field", "reasoning_effort", "value", effort, + "model", model, "endpoint_field", shape.field) +} + +// resolveNoShapeReasoning 处理「厂商未知或形状有歧义」的端点:不发送任何推理 +// 字段(发错形状会 400),但逐项报告被忽略的旋钮与配置指引(design/04 §3.2: +// 禁止静默无效)。 +func resolveNoShapeReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning { + reportUnmappedThinking(log, vendor, model, th) + if effort != "" { + log.Warn("reasoning_effort ignored: provider does not support it", + "provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model) + } + return openAIReasoning{provider: string(vendor)} +} + +// reportUnmappedThinking 报告一个 Thinking 指令为何无法由该端点承载:厂商认识 +// 但形状有歧义(Qwen 既可能由 vLLM 承载、也可能由 DashScope 承载,两者字段形状 +// 不同),或厂商未知。两条路都给出配置指引,让主人能显式声明形状而不是让我们猜。 +// DeepSeek 的开关由 resolveDeepSeekReasoning 映射,不经过这里。 +func reportUnmappedThinking(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking) { + if th == nil { + return + } + if th.Enable != nil { + switch vendor { + case vendorQwen: + log.Warn("enable_thinking not forwarded: the endpoint's switch shape is ambiguous (vLLM wants chat_template_kwargs.enable_thinking, DashScope wants a top-level enable_thinking) and sending the wrong shape can 400; declare reasoning.kind to choose one", + "provider", "qwen", "field", "extra_body.chat_template_kwargs.enable_thinking", + "enable", *th.Enable, "model", model, "config_field", "reasoning.kind") + default: + log.Warn("thinking switch ignored: provider has no boolean thinking switch", + "provider", string(vendor), "field", "thinking.enable", "enable", *th.Enable, "model", model) + } + return + } + if th.BudgetTokens > 0 { + log.Warn("thinking budget ignored: OpenAI-compatible providers take reasoning_effort, not a token budget", + "provider", string(vendor), "field", "thinking.budget_tokens", + "budget_tokens", th.BudgetTokens, "model", model) + } +} diff --git a/internal/runtime/model/reasoning_shape_test.go b/internal/runtime/model/reasoning_shape_test.go new file mode 100644 index 0000000..8861aa5 --- /dev/null +++ b/internal/runtime/model/reasoning_shape_test.go @@ -0,0 +1,345 @@ +package model + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// 本文件钉住「按提供商独立适配推理开关」(裁决 03.4.2 / 08.4.1):声明了形状的端点 +// 必须真的下发**自己形状**的开关(而不是只 WARN),且绝不发别人的形状——发错形状会 +// 400,比不发更糟。自动模式(未声明形状)保持保守:形状有歧义时不下发。 + +// recvWithin 在超时内从记录通道取一次请求。路由/下发出错时(请求打到了另一个端点 +// 或根本没发),用例必须**立刻失败**并指明哪个端点没收到,而不是挂在通道上把整个包 +// 拖到测试超时——挂死的红是让人看不懂的红。 +func recvWithin[T any](t *testing.T, ch <-chan T, endpoint string) T { + t.Helper() + select { + case got := <-ch: + return got + case <-time.After(2 * time.Second): + var zero T + t.Fatalf("%s did not receive the request within 2s", endpoint) + return zero + } +} + +// newKindAdapter 构造一个 OpenAI 兼容适配器,其配置声明了给定的推理开关形状。 +// 真实端点由 stub server 顶替,请求体被记录供断言。 +func newKindAdapter(t *testing.T, kind string, 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: "endpoint-default", ReasoningKind: kind}, opts...) +} + +// declaredShapeCases 是「声明形状 → 该形状的开关字段」的对照表。model 刻意取一个 +// **会误导自动识别**的名字:显式形状必须压过模型名/BaseURL 的厂商推断。 +var declaredShapeCases = []struct { + name string + kind string + model string + field string +}{ + {"deepseek", runtimeconfig.ReasoningKindDeepSeek, "qwen3-max", "thinking"}, + {"vllm", runtimeconfig.ReasoningKindVLLM, "deepseek-flash", "chat_template_kwargs"}, + {"dashscope", runtimeconfig.ReasoningKindDashScope, "deepseek-flash", "enable_thinking"}, +} + +// allSwitchFields 是全部形状的开关字段,用于断言「只发自己那一个」。 +var allSwitchFields = []string{"thinking", "chat_template_kwargs", "enable_thinking"} + +// switchBool 把一个开关字段读成布尔值(thinking 的开关读嵌套的 type)。 +func switchBool(body map[string]any, field string) (bool, bool) { + value, present := body[field] + if !present { + return false, false + } + switch v := value.(type) { + case bool: + return v, true + case map[string]any: + if field == "thinking" { + return v["type"] == thinkingTypeEnabled, true + } + if nested, ok := v["enable_thinking"].(bool); ok { + return nested, true + } + } + return false, true +} + +// TestOpenAIChat_DeclaredShapeSendsItsOwnSwitch 是本次裁决的主用例:每个 provider +// 声明自己的形状后,关闭思考必须真的下发到该形状的字段上,且请求体不含任何别的形状。 +func TestOpenAIChat_DeclaredShapeSendsItsOwnSwitch(t *testing.T) { + t.Parallel() + for _, tc := range declaredShapeCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + ad := newKindAdapter(t, tc.kind, ch) + if _, err := ad.Chat(context.Background(), Request{ + Model: tc.model, + Messages: []Message{TextMessage(RoleUser, "hi")}, + Thinking: &Thinking{Enable: boolPtr(false)}, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := recvWithin(t, ch, "the declared endpoint").body + got, present := switchBool(body, tc.field) + if !present { + t.Fatalf("body[%q] missing: the declared shape %q was not forwarded: %v", tc.field, tc.kind, body) + } + if got { + t.Fatalf("body[%q] = thinking on, want off", tc.field) + } + for _, other := range allSwitchFields { + if other == tc.field { + continue + } + if _, present := body[other]; present { + t.Errorf("body[%q] = %v, want no other shape's field (shape %q)", other, body[other], tc.kind) + } + } + }) + } +} + +// TestOpenAIChat_DeclaredShapeEnablesThinking pins the on direction as well, so the +// switch cannot be hard-wired off by a refactor. +func TestOpenAIChat_DeclaredShapeEnablesThinking(t *testing.T) { + t.Parallel() + for _, tc := range declaredShapeCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + ad := newKindAdapter(t, tc.kind, ch) + if _, err := ad.Chat(context.Background(), Request{ + Model: tc.model, + Messages: []Message{TextMessage(RoleUser, "hi")}, + Thinking: &Thinking{Enable: boolPtr(true)}, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + got, present := switchBool(recvWithin(t, ch, "the declared endpoint").body, tc.field) + if !present || !got { + t.Fatalf("body[%q] present=%v value=%v, want an explicit on", tc.field, present, got) + } + }) + } +} + +// TestOpenAIChat_DeclaredSwitchShapeDropsEffort 是「该形状没有 reasoning_effort」 +// 的红线用例:强度档被丢弃并报告,绝不改写成近似档(改档会反转调用方意图)。 +func TestOpenAIChat_DeclaredSwitchShapeDropsEffort(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + log, buf := captureLog() + ad := newKindAdapter(t, runtimeconfig.ReasoningKindVLLM, ch, WithOpenAILogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: "qwen3-max", + Messages: []Message{TextMessage(RoleUser, "hi")}, + ReasoningEffort: "high", + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := recvWithin(t, ch, "the declared endpoint").body + if _, present := body["reasoning_effort"]; present { + t.Fatalf("body.reasoning_effort = %v, want the field dropped", body["reasoning_effort"]) + } + if _, present := body["chat_template_kwargs"]; present { + t.Fatalf("body.chat_template_kwargs = %v, want no switch invented from an effort", body["chat_template_kwargs"]) + } + rec := buf.String() + for _, want := range []string{"level=WARN", "reasoning_effort", "high", "vllm", "chat_template_kwargs.enable_thinking"} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } +} + +// TestOpenAIChat_EffortNoneBecomesExplicitSwitchOff 是红线用例:`reasoning_effort: +// none` 的语义是**关思考**。在只有布尔开关的端点上它必须落成显式关闭,绝不能被 +// 翻成任何强度档(那正是旧实现把「关」变成「开」的语义反转)。 +func TestOpenAIChat_EffortNoneBecomesExplicitSwitchOff(t *testing.T) { + t.Parallel() + for _, tc := range declaredShapeCases { + if tc.kind == runtimeconfig.ReasoningKindDeepSeek { + continue // DeepSeek 有 reasoning_effort 字段,none 原样下发(另有用例钉住) + } + t.Run(tc.kind, func(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + log, buf := captureLog() + ad := newKindAdapter(t, tc.kind, ch, WithOpenAILogger(log)) + if _, err := ad.Chat(context.Background(), Request{ + Model: tc.model, + Messages: []Message{TextMessage(RoleUser, "hi")}, + ReasoningEffort: effortNone, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := recvWithin(t, ch, "the declared endpoint").body + got, present := switchBool(body, tc.field) + if !present || got { + t.Fatalf("body[%q] present=%v value=%v, want an explicit off", tc.field, present, got) + } + if _, present := body["reasoning_effort"]; present { + t.Fatalf("body.reasoning_effort = %v, want the field dropped", body["reasoning_effort"]) + } + if rec := buf.String(); !strings.Contains(rec, effortNone) || !strings.Contains(rec, "level=WARN") { + t.Errorf("log %q, want a WARN explaining the none→off translation", rec) + } + }) + } +} + +// TestOpenAIChat_ReasoningKindOpenAIKeepsOpenAITiers 用一个 DeepSeek 风格的模型名 +// 证明显式形状压过厂商推断:`medium` 在 OpenAI 白名单里是合法档,必须原样下发, +// 不能按 DeepSeek 别名表被改写成 high。 +func TestOpenAIChat_ReasoningKindOpenAIKeepsOpenAITiers(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + ad := newKindAdapter(t, runtimeconfig.ReasoningKindOpenAI, ch) + + if _, err := ad.Chat(context.Background(), Request{ + Model: "deepseek-flash", + Messages: []Message{TextMessage(RoleUser, "hi")}, + ReasoningEffort: "medium", + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := recvWithin(t, ch, "the declared endpoint").body + if got := body["reasoning_effort"]; got != "medium" { + t.Fatalf("body.reasoning_effort = %v, want medium (an OpenAI tier, not the DeepSeek alias high)", got) + } + for _, other := range allSwitchFields { + if _, present := body[other]; present { + t.Errorf("body[%q] = %v, want no switch shape on an effort-only endpoint", other, body[other]) + } + } +} + +// 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. +func TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + log, buf := captureLog() + ad := newKindAdapter(t, runtimeconfig.ReasoningKindOpenAI, ch, WithOpenAILogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: "gpt-4o", + Messages: []Message{TextMessage(RoleUser, "hi")}, + Thinking: &Thinking{Enable: boolPtr(false)}, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := recvWithin(t, ch, "the declared endpoint").body + for _, other := range allSwitchFields { + if _, present := body[other]; present { + t.Fatalf("body[%q] present, want nothing sent for a boolean switch on an effort-only endpoint", other) + } + } + rec := buf.String() + for _, want := range []string{"level=WARN", "thinking.enable", "openai", "reasoning_effort"} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } +} + +// 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. +func TestOpenAIChat_UnknownReasoningKindSendsNothing(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + log, buf := captureLog() + ad := newKindAdapter(t, "gemini", ch, WithOpenAILogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: "gpt-4o", + Messages: []Message{TextMessage(RoleUser, "hi")}, + Thinking: &Thinking{Enable: boolPtr(false)}, + ReasoningEffort: "low", + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := recvWithin(t, ch, "the declared endpoint").body + for _, field := range append(append([]string{}, allSwitchFields...), "reasoning_effort") { + if _, present := body[field]; present { + t.Errorf("body[%q] = %v, want nothing sent for an unknown shape", field, body[field]) + } + } + rec := buf.String() + for _, want := range []string{"level=WARN", "reasoning.kind", "gemini"} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } +} + +// 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. +func TestOpenAIChat_AutoQwenShapeStaysConservative(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + log, buf := captureLog() + ad := newKindAdapter(t, "", ch, WithOpenAILogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: "qwen3-max", + Messages: []Message{TextMessage(RoleUser, "hi")}, + Thinking: &Thinking{Enable: boolPtr(false)}, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := recvWithin(t, ch, "the declared endpoint").body + for _, field := range allSwitchFields { + if _, present := body[field]; present { + t.Errorf("body[%q] = %v, want nothing sent while the shape is ambiguous", field, body[field]) + } + } + rec := buf.String() + for _, want := range []string{"level=WARN", "enable_thinking", "qwen", "reasoning.kind"} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } +} + +// TestNewChatChain_CarriesReasoningKind 钉住配置到适配器的贯通:链的每一步都从 +// 同一个 group 配置构建,因此声明过的形状必须随配置抵达适配器(否则 models.json +// 里的声明会静默失效)。 +func TestNewChatChain_CarriesReasoningKind(t *testing.T) { + t.Parallel() + chain, err := NewChatChain(config.LLMConfig{ + Provider: ProviderOpenAI, BaseURL: "http://127.0.0.1:1/v1", Model: "m", Fallback: "fb", + ReasoningKind: runtimeconfig.ReasoningKindDashScope, + }) + if err != nil { + t.Fatalf("NewChatChain: %v", err) + } + if len(chain.steps) != 2 { + t.Fatalf("chain steps = %d, want 2 (primary + fallback)", len(chain.steps)) + } + for i, step := range chain.steps { + ad, ok := step.Adapter.(*OpenAIChatAdapter) + if !ok { + t.Fatalf("step %d adapter is %T, want *OpenAIChatAdapter", i, step.Adapter) + } + if ad.reasoningKind != runtimeconfig.ReasoningKindDashScope { + t.Errorf("step %d reasoningKind = %q, want %q", i, ad.reasoningKind, runtimeconfig.ReasoningKindDashScope) + } + } +} diff --git a/internal/runtime/model/runtime.go b/internal/runtime/model/runtime.go index 3335830..89bac07 100644 --- a/internal/runtime/model/runtime.go +++ b/internal/runtime/model/runtime.go @@ -29,6 +29,11 @@ type runtimeChainSnapshot struct { 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 map[string]*Chain } // RuntimeLLM is the hot-swappable LLM provider stack for the runtime. It @@ -96,11 +101,12 @@ func (m *RuntimeLLM) Model() string { return m.load().group.Model } // Name implements Adapter, returning the active group id. func (m *RuntimeLLM) Name() string { return m.load().group.ID } -// Chat implements Adapter, delegating to the current chain. 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 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. func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Response, error) { - chain := m.load().chain + chain := m.chainFor(req.PersonaID) if chain == nil { return Response{}, ErrNoModel } @@ -109,13 +115,39 @@ func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Respo // ChatStream implements Adapter. func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) { - chain := m.load().chain + chain := m.chainFor(req.PersonaID) if chain == nil { return Response{}, ErrNoModel } 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: +// +// - 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). +// +// 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. +func (m *RuntimeLLM) chainFor(persona string) *Chain { + snap := m.load() + if p := trimKey(persona); p != "" && snap.router != nil { + if _, _, ok := snap.group.PersonaRoute(p); ok { + if c := snap.providerChains[snap.router.RouteProviderForPersona(p)]; c != nil { + return c + } + } + } + return snap.chain +} + // RouteRequest implements ModelRouter, delegating to the current router. func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) { r := m.load().router @@ -196,7 +228,99 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog chain = chain.WithTokenBudget(active.MaxTokens, env.FallbackMaxTokens) } router := NewRouter(DefaultModelForProvider(cfg), personaRouterOptions(active, cfg)...) - return &runtimeChainSnapshot{chain: chain, router: router, group: active, groups: groups}, nil + return &runtimeChainSnapshot{ + chain: chain, + router: router, + group: active, + groups: groups, + providerChains: personaProviderChains(groups, active, env, log), + }, 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. +// +// 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. +func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) map[string]*Chain { + if len(active.PersonaProviders) == 0 { + return nil + } + out := make(map[string]*Chain, len(active.PersonaProviders)) + for persona := range active.PersonaProviders { + model, provider, ok := active.PersonaRoute(persona) + if !ok { + warn(log, "personaProviders entry ignored: the persona binds no model of its own", + "persona", persona, "field", "personaModels") + continue + } + if provider == active.Provider { + continue + } + if _, built := out[provider]; built { + continue + } + g, found := groupForProvider(groups, provider) + if !found { + warn(log, "per-persona provider skipped: no models.json group speaks it", + "persona", persona, "provider", provider) + continue + } + chain := personaChain(g, model, env, log) + if chain != nil { + out[provider] = chain + } + } + if len(out) == 0 { + return nil + } + 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. +func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger) *Chain { + cfg := g.ToLLMConfig() + if cfg.Provider == ProviderAnthropic { + cfg.AnthropicModel = model + } else { + cfg.Model = model + } + cfg.Local = env.Local + chain, err := NewChatChainWithLog(cfg, log) + if err != nil && !errors.Is(err, ErrNoModel) { + warn(log, "per-persona chain skipped", "group", g.ID, "model", model, "error", err) + return nil + } + if chain == nil { + warn(log, "per-persona chain skipped: the provider group has no base url or model", + "group", g.ID, "model", model) + return nil + } + 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. +func groupForProvider(groups []runtimeconfig.Group, provider string) (runtimeconfig.Group, bool) { + for _, g := range groups { + if g.Provider == provider { + return g, true + } + } + return runtimeconfig.Group{}, false } // personaRouterOptions turns the active group's per-persona model/provider