Files
Cyrene_v2/internal/runtime/model/persona_provider_test.go
T
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

255 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"])
}
}