- 只翻注释/文档字符串:`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
188 lines
6.4 KiB
Go
188 lines
6.4 KiB
Go
package model
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"sync"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// recordingServer 是一个 OpenAI 兼容的 /chat/completions 端点,记录
|
||
// 每个请求的模型名,并或失败或成功。它支撑
|
||
// 本地级测试(裁决 03.4.3):该级的意义在于请求以本地模型名
|
||
// 到达本地端点,而该级关闭时绝不如此。
|
||
type recordingServer struct {
|
||
mu sync.Mutex
|
||
models []string
|
||
}
|
||
|
||
func (s *recordingServer) modelsSeen() []string {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return append([]string(nil), s.models...)
|
||
}
|
||
|
||
func newRecordingServer(t *testing.T, fail bool) (*httptest.Server, *recordingServer) {
|
||
t.Helper()
|
||
rec := &recordingServer{}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Model string `json:"model"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||
rec.mu.Lock()
|
||
rec.models = append(rec.models, body.Model)
|
||
rec.mu.Unlock()
|
||
if fail {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = io.WriteString(w, `{"error":"down"}`)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = io.WriteString(w, okCompletion)
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
return srv, rec
|
||
}
|
||
|
||
// TestNewChatChainWithLog_LocalRungAppended 验证第三级降级
|
||
// (primary → fallback → local)只在启用且完全
|
||
// 配置好时才加入链。
|
||
func TestNewChatChainWithLog_LocalRungAppended(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
local config.LocalLLMConfig
|
||
want int
|
||
}{
|
||
{"off", config.LocalLLMConfig{BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 2},
|
||
{"enabled but no model", config.LocalLLMConfig{Enabled: true, BaseURL: "http://127.0.0.1:1/v1"}, 2},
|
||
{"enabled but no base url", config.LocalLLMConfig{Enabled: true, Model: "local"}, 2},
|
||
{"enabled and configured", config.LocalLLMConfig{Enabled: true, BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 3},
|
||
{"enabled with unknown provider", config.LocalLLMConfig{Enabled: true, Provider: "gemini", BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 2},
|
||
}
|
||
for _, c := range cases {
|
||
c := c
|
||
t.Run(c.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||
Provider: ProviderOpenAI, BaseURL: "http://127.0.0.1:1/v1", Model: "main", Fallback: "fb",
|
||
Local: c.local,
|
||
}, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewChatChainWithLog: %v", err)
|
||
}
|
||
if len(chain.steps) != c.want {
|
||
t.Fatalf("steps = %d, want %d", len(chain.steps), c.want)
|
||
}
|
||
if c.want == 3 && chain.steps[2].Model != "local" {
|
||
t.Errorf("steps[2].Model = %q, want local", chain.steps[2].Model)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestChain_Chat_LocalRungServesAfterFallback 验证本地级在主
|
||
// 步骤与降级步骤都失败后真的接管请求,且线上用
|
||
// 本地模型名与本地端点自己的 base URL/API key(而非组的)。
|
||
func TestChain_Chat_LocalRungServesAfterFallback(t *testing.T) {
|
||
t.Parallel()
|
||
mainSrv, _ := newRecordingServer(t, true)
|
||
localSrv, localRec := newRecordingServer(t, false)
|
||
|
||
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||
Provider: ProviderOpenAI, BaseURL: mainSrv.URL, APIKey: "sk-main", Model: "main", Fallback: "fb",
|
||
Local: config.LocalLLMConfig{Enabled: true, BaseURL: localSrv.URL, APIKey: "sk-local", Model: "local-model"},
|
||
}, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewChatChainWithLog: %v", err)
|
||
}
|
||
resp, err := chain.Chat(context.Background(),
|
||
Request{Messages: []Message{TextMessage(RoleUser, "hi")}, Purpose: "chat"}, Options{})
|
||
if err != nil {
|
||
t.Fatalf("Chain.Chat: %v", err)
|
||
}
|
||
if resp.Content != "Hello!" {
|
||
t.Errorf("resp.Content = %q, want Hello!", resp.Content)
|
||
}
|
||
seen := localRec.modelsSeen()
|
||
if len(seen) != 1 || seen[0] != "local-model" {
|
||
t.Fatalf("local endpoint models = %v, want [local-model]", seen)
|
||
}
|
||
}
|
||
|
||
// TestChain_Chat_LocalRungOffNeverCalled 钉住默认关闭的红线:该级
|
||
// 禁用时绝不能触碰本地端点,即便其它所有
|
||
// 步骤都失败。
|
||
func TestChain_Chat_LocalRungOffNeverCalled(t *testing.T) {
|
||
t.Parallel()
|
||
mainSrv, _ := newRecordingServer(t, true)
|
||
localSrv, localRec := newRecordingServer(t, false)
|
||
|
||
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||
Provider: ProviderOpenAI, BaseURL: mainSrv.URL, Model: "main", Fallback: "fb",
|
||
Local: config.LocalLLMConfig{BaseURL: localSrv.URL, Model: "local-model"},
|
||
}, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewChatChainWithLog: %v", err)
|
||
}
|
||
if _, err := chain.Chat(context.Background(),
|
||
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); !errors.Is(err, ErrUpstream) {
|
||
t.Fatalf("err = %v, want ErrUpstream", err)
|
||
}
|
||
if seen := localRec.modelsSeen(); len(seen) != 0 {
|
||
t.Fatalf("local endpoint called %v, want never called while the rung is off", seen)
|
||
}
|
||
}
|
||
|
||
// TestChain_Chat_PrimaryStepKeepsRoutedModel 钉住链不会丢弃
|
||
// 路由器的决策:主步骤发送请求中被路由的模型,而
|
||
// 降级步骤仍钉住自己配置的模型(裁决 03.4.1)。
|
||
func TestChain_Chat_PrimaryStepKeepsRoutedModel(t *testing.T) {
|
||
t.Parallel()
|
||
// 一个端点同时服务两个步骤(工厂让每一步都针对
|
||
// 组的 base URL 构建):除降级模型外它都失败,所以
|
||
// 记录下来的模型名显示了每一步实际发送了哪个模型。
|
||
rec := &recordingServer{}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Model string `json:"model"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||
rec.mu.Lock()
|
||
rec.models = append(rec.models, body.Model)
|
||
rec.mu.Unlock()
|
||
if body.Model != "group-fb" {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
_, _ = io.WriteString(w, `{"error":"down"}`)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = io.WriteString(w, okCompletion)
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
|
||
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||
Provider: ProviderOpenAI, BaseURL: srv.URL, Model: "group-main", Fallback: "group-fb",
|
||
}, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewChatChainWithLog: %v", err)
|
||
}
|
||
if _, err := chain.Chat(context.Background(), Request{
|
||
Model: "persona-main",
|
||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||
}, Options{}); err != nil {
|
||
t.Fatalf("Chain.Chat: %v", err)
|
||
}
|
||
seen := rec.modelsSeen()
|
||
if len(seen) != 2 || seen[0] != "persona-main" || seen[1] != "group-fb" {
|
||
t.Fatalf("models seen = %v, want [persona-main group-fb] (routed model first, step model on fallback)", seen)
|
||
}
|
||
}
|