package runtimeconfig import ( "encoding/json" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // 本文件钉住 LLM_MULTIMODAL —— .env 合成组的「**主模型自身**能否收图」声明 // (= models.json 组 multimodal 的 env 等价物,裁决 14.4.1 / design/06 §0): // // - 默认(不设该键)= false,与引入本键之前逐字节一致; // - 显式 true 才让主模型直接收图(否则图片恒走 vision/ocr 工具的文本描述); // - 有有效 models.json 时本键不生效(组文件优先,与 LLM_REASONING_KIND 同口径)。 // TestGroupFromEnvMultimodalDefaultsFalse 是「行为不变」的硬证据:零值 LLMConfig // (= 键未设)与「配好了模型但没声明收图能力」两种 env 都合成 Multimodal == false, // 两条 provider 分叉都一样,且缺失的 models.json 走 env 兜底时同样 false。 func TestGroupFromEnvMultimodalDefaultsFalse(t *testing.T) { t.Parallel() for _, provider := range []string{ProviderOpenAI, ProviderAnthropic} { if got := GroupFromEnv(config.LLMConfig{Provider: provider})[0].Multimodal; got { t.Fatalf("provider %s: 键未声明时 Multimodal = true, want false(默认必须保守)", provider) } } full := envCfg() if full.Multimodal { t.Fatal("envCfg() 不该声明 Multimodal(它是「配好模型但未声明收图」的基线)") } if got := GroupFromEnv(full)[0].Multimodal; got { t.Fatal("env-only 部署未声明收图能力时 Multimodal = true, want false") } path := filepath.Join(t.TempDir(), "nonexistent.json") groups, warn := LoadModels(path, full) if warn != nil { t.Fatalf("warn = %v, want nil(文件缺失是常态)", warn) } if groups[0].Multimodal { t.Fatal("models.json 缺失走 env 兜底时 Multimodal = true, want false") } } // TestGroupFromEnvMultimodalDeclaredTrue 覆盖显式声明:LLM_MULTIMODAL=true 必须 // 真的抵达合成组(否则「配了却不生效」正是本键要消灭的假适配),并且该组是有效组、 // 序列化后带 multimodal:true —— 面板读的正是 GroupFromEnv 的产物,键丢了面板就看不到。 func TestGroupFromEnvMultimodalDeclaredTrue(t *testing.T) { t.Parallel() for _, provider := range []string{ProviderOpenAI, ProviderAnthropic} { env := envCfg() env.Provider = provider if provider == ProviderAnthropic { env.AnthropicBaseURL, env.AnthropicAPIKey, env.AnthropicModel = "https://api.anthropic.com", "sk-a", "claude" } env.Multimodal = true groups := GroupFromEnv(env) if len(groups) != 1 { t.Fatalf("provider %s: groups = %d, want 1", provider, len(groups)) } g := groups[0] if !g.Multimodal { t.Fatalf("provider %s: 显式声明 true 后 Multimodal = false, want true", provider) } if err := g.Validate(); err != nil { t.Fatalf("provider %s: 合成组必须有效: %v", provider, err) } raw, err := json.Marshal(g) if err != nil { t.Fatalf("Marshal: %v", err) } if !strings.Contains(string(raw), `"multimodal":true`) { t.Fatalf("provider %s: marshal = %s, want multimodal:true(面板据此勾选)", provider, raw) } } } // TestGroupFromEnvMultimodalFromProcessEnv 端到端走一遍真实读路径: // 进程 env → config.Load → cfg.LLM.Multimodal → 合成组。空串(`.env.example` // 里注释掉本键时的等价形态)与显式 false 都退化为默认 false;大小写不敏感。 // // 本测试用 t.Setenv,故不与并行测试同跑(config 包同口径)。 func TestGroupFromEnvMultimodalFromProcessEnv(t *testing.T) { t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) for _, tc := range []struct { raw string want bool }{ {raw: "", want: false}, {raw: "false", want: false}, {raw: "true", want: true}, {raw: "TRUE", want: true}, } { t.Setenv("LLM_MULTIMODAL", tc.raw) cfg, err := config.Load("") if err != nil { t.Fatalf("LLM_MULTIMODAL=%q: Load: %v", tc.raw, err) } if got := cfg.LLM.Multimodal; got != tc.want { t.Fatalf("LLM_MULTIMODAL=%q: cfg.LLM.Multimodal = %v, want %v", tc.raw, got, tc.want) } if got := GroupFromEnv(cfg.LLM)[0].Multimodal; got != tc.want { t.Fatalf("LLM_MULTIMODAL=%q: 合成组 Multimodal = %v, want %v", tc.raw, got, tc.want) } } } // TestLoadModelsFileBeatsEnvMultimodal 是回归:models.json 存在且有效时,该文件的 // 组是唯一权威(与 LLM_REASONING_KIND 同口径)——env 的 LLM_MULTIMODAL 两个方向都 // 不得影响结果,否则面板保存的勾选会被 .env 顶掉。 func TestLoadModelsFileBeatsEnvMultimodal(t *testing.T) { t.Parallel() off := writeModelsFile(t, `[ {"id":"openai","provider":"openai","baseUrl":"https://api.openai.com/v1","apiKey":"sk-1","model":"gpt-4o"} ]`) on := writeModelsFile(t, `[ {"id":"openai","provider":"openai","baseUrl":"https://api.openai.com/v1","apiKey":"sk-1","model":"gpt-4o","multimodal":true} ]`) // 组文件没有该键 + env 声明 true ⇒ 仍是 false(组文件优先)。 envOn := envCfg() envOn.Multimodal = true groups, warn := LoadModels(off, envOn) if warn != nil { t.Fatalf("LoadModels: warn = %v, want nil", warn) } if groups[0].Multimodal { t.Fatal("env 的 LLM_MULTIMODAL=true 覆盖了 models.json 组的声明, want 组文件优先") } // 反方向:组文件声明 true + env 未声明 ⇒ 仍是 true。 groups, warn = LoadModels(on, envCfg()) if warn != nil { t.Fatalf("LoadModels: warn = %v, want nil", warn) } if !groups[0].Multimodal { t.Fatal("models.json 组声明的 multimodal=true 被 env 默认值顶掉了, want 组文件优先") } }