- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
`chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
`rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
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 断言流式路径
|
||
// (主聊天路径:loop.streamStep → adapter.ChatStream)解析出与非流式
|
||
// 相同的那条 persona 链——只在两条路径之一生效的 persona
|
||
// 路由只能算一次静默的半修。
|
||
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 钉住那个载体:它让
|
||
// 按 persona 的 agent 能在 loop 不交出自身 ctx 的情况下接好路由,
|
||
// 即作用域化的适配器视图打上 PersonaID 并按该 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 断言未绑定的视图是
|
||
// 直通:空的 persona 不得变成它自己的一个绑定。
|
||
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 覆盖
|
||
// 半声明的配置:只有一条 personaProviders 条目并不能说明该发
|
||
// 哪个模型,所以切换协议会把组的默认模型名推到
|
||
// 另一厂商的端点。此时保留默认链并记录原因。
|
||
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 覆盖已声明
|
||
// 但 models.json 没有对应组的协议:该 persona 保留默认链
|
||
// 而不是让回合失败,并上报这次跳过。
|
||
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 断言声明了
|
||
// 生效组自身协议的 persona 不会构建第二条链:默认链
|
||
// 本就说那个协议,按 persona 的模型名经路由器到达它。
|
||
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"])
|
||
}
|
||
}
|