package runtimeconfig import ( "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) func envCfg() config.LLMConfig { return config.LLMConfig{ Provider: ProviderOpenAI, BaseURL: "https://api.openai.com/v1", APIKey: "sk-env", Model: "gpt-4o", Fallback: "gpt-4o-mini", } } func writeModelsFile(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "models.json") if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("write models.json: %v", err) } return path } func TestLoadModels_FileValid(t *testing.T) { path := writeModelsFile(t, `[ {"id":"openai","provider":"openai","baseUrl":"https://api.openai.com/v1","apiKey":"sk-1","model":"gpt-4o","fallback":"gpt-4o-mini","multimodal":true} ]`) groups, warn := LoadModels(path, envCfg()) if warn != nil { t.Fatalf("warn = %v, want nil", warn) } if len(groups) != 1 { t.Fatalf("len(groups) = %d, want 1", len(groups)) } g := groups[0] if g.ID != "openai" || g.Provider != ProviderOpenAI || g.Model != "gpt-4o" || !g.Multimodal { t.Fatalf("group = %+v, want openai/gpt-4o/multimodal", g) } } func TestLoadModels_MissingFileFallsBackEnv(t *testing.T) { path := filepath.Join(t.TempDir(), "nonexistent.json") groups, warn := LoadModels(path, envCfg()) if warn != nil { t.Fatalf("warn = %v, want nil (missing file is the normal case)", warn) } if len(groups) != 1 || groups[0].Model != "gpt-4o" || groups[0].APIKey != "sk-env" { t.Fatalf("groups = %+v, want env fallback gpt-4o/sk-env", groups) } } func TestLoadModels_InvalidFallsBackEnvWithWarn(t *testing.T) { path := writeModelsFile(t, `{ not json`) groups, warn := LoadModels(path, envCfg()) if warn == nil { t.Fatal("warn = nil, want a warning for an invalid file") } if len(groups) != 1 || groups[0].APIKey != "sk-env" { t.Fatalf("groups = %+v, want env fallback", groups) } } func TestLoadModels_EmptyArrayFallsBackEnv(t *testing.T) { path := writeModelsFile(t, `[]`) groups, warn := LoadModels(path, envCfg()) if warn == nil { t.Fatal("warn = nil, want a warning for an empty file") } if len(groups) != 1 { t.Fatalf("groups = %+v, want env fallback single group", groups) } } func TestValidate(t *testing.T) { valid := Group{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m"} if err := valid.Validate(); err != nil { t.Fatalf("valid group: %v", err) } cases := []Group{ {ID: "", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m"}, {ID: "a", Provider: "bogus", BaseURL: "https://x", Model: "m"}, {ID: "a", Provider: ProviderOpenAI, BaseURL: "", Model: "m"}, {ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", Model: ""}, } for i, c := range cases { if err := c.Validate(); err == nil { t.Fatalf("case %d failed to validate: %+v", i, c) } } } func TestToLLMConfig(t *testing.T) { g := Group{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", APIKey: "sk", Model: "m", Fallback: "f"} cfg := g.ToLLMConfig() if cfg.Provider != ProviderOpenAI || cfg.BaseURL != "https://x" || cfg.APIKey != "sk" || cfg.Model != "m" || cfg.Fallback != "f" { t.Fatalf("ToLLMConfig = %+v", cfg) } ag := Group{ID: "b", Provider: ProviderAnthropic, BaseURL: "https://y", APIKey: "sk2", Model: "claude"} acfg := ag.ToLLMConfig() if acfg.Provider != ProviderAnthropic || acfg.AnthropicBaseURL != "https://y" || acfg.AnthropicModel != "claude" { t.Fatalf("anthropic ToLLMConfig = %+v", acfg) } } func TestMaskedKey(t *testing.T) { empty := Group{APIKey: ""}.MaskedKey() if empty != "" { t.Fatalf("empty key masked = %q, want empty", empty) } short := Group{APIKey: "abc"}.MaskedKey() if short != "****" { t.Fatalf("short key masked = %q, want ****", short) } long := Group{APIKey: "sk-abcdef1234"}.MaskedKey() if long != "****1234" || strings.Contains(long, "sk-") { t.Fatalf("masked = %q, want ****1234 without the prefix", long) } } func TestActivate(t *testing.T) { base := []Group{ {ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", Model: "gpt-4o"}, {ID: "anthropic", Provider: ProviderAnthropic, BaseURL: "https://b", Model: "claude"}, } // New group is prepended (becomes the active index 0). out := Activate(base, Group{ID: "deepseek", Provider: ProviderOpenAI, BaseURL: "https://c", Model: "ds"}) if len(out) != 3 || out[0].ID != "deepseek" || out[1].ID != "openai" || out[2].ID != "anthropic" { t.Fatalf("Activate new = %+v", out) } // An existing group is replaced and promoted to the front. out2 := Activate(out, Group{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a2", Model: "gpt-4o-new"}) if len(out2) != 3 || out2[0].ID != "openai" || out2[0].Model != "gpt-4o-new" || out2[1].ID != "deepseek" { t.Fatalf("Activate existing = %+v", out2) } } func TestSaveLoadRoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), "models.json") in := []Group{ {ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", APIKey: "sk-secret", Model: "gpt-4o", Multimodal: true}, {ID: "anthropic", Provider: ProviderAnthropic, BaseURL: "https://b", APIKey: "sk-ant", Model: "claude"}, } if err := SaveModels(path, in); err != nil { t.Fatalf("SaveModels: %v", err) } groups, warn := LoadModels(path, envCfg()) if warn != nil { t.Fatalf("warn = %v", warn) } if len(groups) != 2 || groups[0].APIKey != "sk-secret" || groups[1].APIKey != "sk-ant" { t.Fatalf("round trip = %+v", groups) } // The key stays in the file (it is the runtime config), but never leaks into // the masked view. if got := groups[0].MaskedKey(); got != "****cret" { t.Fatalf("masked key = %q, want ****cret", got) } } func TestGroupMaxTokensRoundTrip(t *testing.T) { // maxTokens is decoded from a hand-written models.json. path := writeModelsFile(t, `[ {"id":"openai","provider":"openai","baseUrl":"https://a","apiKey":"sk-1","model":"gpt-4o","maxTokens":4096} ]`) groups, warn := LoadModels(path, envCfg()) if warn != nil { t.Fatalf("warn = %v, want nil", warn) } if len(groups) != 1 || groups[0].MaxTokens != 4096 { t.Fatalf("groups = %+v, want one group with maxTokens 4096", groups) } // SaveModels writes it back, and LoadModels reads it again. out := filepath.Join(t.TempDir(), "models.json") in := []Group{ {ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", APIKey: "sk-1", Model: "gpt-4o", MaxTokens: 2048}, {ID: "claude", Provider: ProviderAnthropic, BaseURL: "https://b", APIKey: "sk-2", Model: "claude"}, } if err := SaveModels(out, in); err != nil { t.Fatalf("SaveModels: %v", err) } raw, err := os.ReadFile(out) if err != nil { t.Fatalf("read saved file: %v", err) } if !strings.Contains(string(raw), `"maxTokens": 2048`) { t.Errorf("saved file missing maxTokens: %s", raw) } round, warn := LoadModels(out, envCfg()) if warn != nil { t.Fatalf("load warn = %v", warn) } if round[0].MaxTokens != 2048 || round[1].MaxTokens != 0 { t.Fatalf("round trip maxTokens = (%d,%d), want (2048,0)", round[0].MaxTokens, round[1].MaxTokens) } // Upsert and Activate preserve the field. upserted := Upsert(round, Group{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a2", Model: "gpt-4o", MaxTokens: 3000}) if upserted[0].MaxTokens != 3000 { t.Errorf("Upsert maxTokens = %d, want 3000", upserted[0].MaxTokens) } activated := Activate(round, Group{ID: "claude", Provider: ProviderAnthropic, BaseURL: "https://b", Model: "claude", MaxTokens: 512}) if activated[0].MaxTokens != 512 { t.Errorf("Activate maxTokens = %d, want 512", activated[0].MaxTokens) } // A negative budget is rejected by validation (never silently persisted). if err := (Group{ID: "x", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m", MaxTokens: -1}).Validate(); err == nil { t.Fatal("expected Validate to reject a negative maxTokens") } } func TestSaveModels_RejectsInvalid(t *testing.T) { path := filepath.Join(t.TempDir(), "models.json") if err := SaveModels(path, []Group{{ID: "", Provider: "x", BaseURL: "https://a", Model: "m"}}); err == nil { t.Fatal("expected error saving an invalid group") } if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { t.Fatalf("file should not be written when validation fails") } }