Files
AskaEth 3dc1232bc2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`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
2026-10-04 16:27:54 +08:00

255 lines
11 KiB
Go
Raw Permalink 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 断言流式路径
// (主聊天路径: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"])
}
}