package model import ( "context" "path/filepath" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) func rtEnv() config.LLMConfig { // env 降级符合「openai 默认」(提供商不是 anthropic)。 return config.LLMConfig{ Provider: ProviderOpenAI, BaseURL: "https://api.openai.com/v1", APIKey: "sk-env", Model: "gpt-4o", } } func writeRTFile(t *testing.T, groups []runtimeconfig.Group) string { t.Helper() path := filepath.Join(t.TempDir(), "models.json") if err := runtimeconfig.SaveModels(path, groups); err != nil { t.Fatalf("SaveModels: %v", err) } return path } func TestRuntimeLLM_EnvFallback(t *testing.T) { // 文件缺失 → env 降级的提供商/模型。 m := NewRuntimeLLM(filepath.Join(t.TempDir(), "nope.json"), rtEnv(), nil) if m.Provider() != ProviderOpenAI || m.Model() != "gpt-4o" { t.Fatalf("fallback Provider=%q Model=%q, want openai/gpt-4o", m.Provider(), m.Model()) } chain, router := m.Current() if router == nil { t.Fatal("router is nil after env fallback") } if chain == nil { // 没有真实端点没关系;适配器应报 ErrNoModel,而不是 panic。 if _, err := m.Chat(context.Background(), Request{Messages: nil}, Options{}); err != ErrNoModel { t.Fatalf("Chat on empty config err = %v, want ErrNoModel", err) } } } func TestRuntimeLLM_FileLoad(t *testing.T) { path := writeRTFile(t, []runtimeconfig.Group{ {ID: "deepseek", Provider: ProviderOpenAI, BaseURL: "https://api.deepseek.com/v1", APIKey: "sk-ds", Model: "deepseek-flash"}, }) m := NewRuntimeLLM(path, rtEnv(), nil) if m.Provider() != ProviderOpenAI || m.Model() != "deepseek-flash" { t.Fatalf("Provider=%q Model=%q, want openai/deepseek-flash", m.Provider(), m.Model()) } if m.Group().ID != "deepseek" { t.Fatalf("Group().ID = %q, want deepseek", m.Group().ID) } } func TestRuntimeLLM_ReloadReflectsNewProviderModel(t *testing.T) { path := writeRTFile(t, []runtimeconfig.Group{ {ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://api.openai.com/v1", APIKey: "sk-1", Model: "gpt-4o"}, }) m := NewRuntimeLLM(path, rtEnv(), nil) if m.Model() != "gpt-4o" { t.Fatalf("before reload Model = %q, want gpt-4o", m.Model()) } // 用 anthropic 提供商/模型重写该文件,然后重载。 if err := runtimeconfig.SaveModels(path, []runtimeconfig.Group{ {ID: "claude", Provider: ProviderAnthropic, BaseURL: "https://api.anthropic.com", APIKey: "sk-ant", Model: "claude-sonnet-4"}, }); err != nil { t.Fatalf("SaveModels: %v", err) } if err := m.Reload(); err != nil { t.Fatalf("Reload: %v", err) } if m.Provider() != ProviderAnthropic || m.Model() != "claude-sonnet-4" { t.Fatalf("after reload Provider=%q Model=%q, want anthropic/claude-sonnet-4", m.Provider(), m.Model()) } chain, router := m.Current() if router == nil || chain == nil { t.Fatal("after reload Current() returned nil chain/router") } } func TestRuntimeLLM_ImplementsAdapterAndRouter(t *testing.T) { path := writeRTFile(t, []runtimeconfig.Group{ {ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", APIKey: "sk", Model: "m"}, }) m := NewRuntimeLLM(path, rtEnv(), nil) var _ Adapter = m var _ ModelRouter = m if m.Name() != "openai" { t.Fatalf("Name = %q, want openai", m.Name()) } modelName, _ := m.RouteRequest(Request{}) if modelName != "m" { t.Fatalf("RouteRequest = %q, want the active model m", modelName) } } func TestRuntimeLLM_ConcurrentReadDuringReloadNoPanic(t *testing.T) { path := writeRTFile(t, []runtimeconfig.Group{ {ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://api.openai.com/v1", APIKey: "sk-1", Model: "gpt-4o"}, }) m := NewRuntimeLLM(path, rtEnv(), nil) const readers = 8 var wg sync.WaitGroup stop := make(chan struct{}) for i := 0; i < readers; i++ { wg.Add(1) go func() { defer wg.Done() for { select { case <-stop: return default: } _ = m.Provider() _ = m.Model() chain, router := m.Current() if router == nil { t.Error("router is nil during concurrent read") return } if chain == nil { // nil 链意味着配置里没有 base/model;我们的测试文件里有, // 所以一次换入必须总能产出链。仍然加一道守护。 t.Error("chain is nil during concurrent read") return } } }() } // 在读方自旋期间重载若干次。 for i := 0; i < 200; i++ { if err := m.Reload(); err != nil { t.Fatalf("reload %d: %v", i, err) } } close(stop) wg.Wait() }