feat(model): 按 provider 形状独立适配推理开关 + per-persona 提供商链真接线(裁决 03.4.2/08.4.1/03.4.1 余项)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增 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 口径冲突,停手报告)
This commit is contained in:
AskaEth committed 2026-10-04 12:57:55 +08:00
1 parent 71fefe5fc3
commit 8890c30bb5
17 files changed
+1496 -134

No files matched your search

+4 -1
View File
@@ -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)
+6 -2
View File
@@ -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.
+10
View File
@@ -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"`
+87 -17
View File
@@ -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)},
}}
}
@@ -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)
}
}
+20
View File
@@ -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
}
@@ -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)
}
}
+9 -1
View File
@@ -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
+15
View File
@@ -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
+15 -5
View File
@@ -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,
+30 -12
View File
@@ -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}
@@ -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"])
}
}
+72
View File
@@ -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)
}
+9 -90
View File
@@ -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)
}
+267
View File
@@ -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)
}
}
@@ -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)
}
}
}
+130 -6
View File
@@ -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