- 新增 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 口径冲突,停手报告)
255 lines
11 KiB
Go
255 lines
11 KiB
Go
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"])
|
||
}
|
||
}
|