feat(model): 按 provider 形状独立适配推理开关 + per-persona 提供商链真接线(裁决 03.4.2/08.4.1/03.4.1 余项)
- 新增 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:
1 parent
71fefe5fc3
commit
8890c30bb5
17 files changed
+1496
-134
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user