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

@@ -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"])
}
}