Files
AskaEth 8890c30bb5
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 口径冲突,停手报告)
2026-10-04 12:57:55 +08:00

127 lines
4.7 KiB
Go

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)
}
}