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