Files
AskaEth 4adfc9a2df
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config): 新增 LLM_MULTIMODAL —— 让 env-only 部署能声明「主模型自身能收图」(默认 false,行为不变)
- 起因(Android 会话质询):其测到生效配置 multimodal:false 却与 DeepSeek 视觉文档矛盾。核实结论:
  **既非设计选择也非漏配,而是 env 路径的表达力限制** —— `data/models.json` 不存在 ⇒ 走 `GroupFromEnv`,
  而它此前硬编 false(理由:不得声称端点可能不具备的能力);该标志的正式载体只有 models.json 组(面板写入)。
  后果:env-only 部署里图像**恒走 KindVisionTool**(每回合多一次完整模型调用 + 模型只见文字描述=有损)。
- 修法:`LLMConfig.Multimodal`(`env:"LLM_MULTIMODAL" default:"false"`)+ `GroupFromEnv` 两个 provider 分叉都接入;
  **只对 env 合成组生效**(有有效 models.json 时组文件优先,与 LLM_REASONING_KIND 同口径);非 bool **启动报错**不静默
- 复核:config 包测试绿(5 条:默认不变 / 显式 true 生效且组仍 Validate / 进程 env 端到端 / 非法值 fail-fast /
  组文件优先两方向);**变异 3 条**(分叉写死 false、default 改 true、env 覆盖组文件)均已还原
- 交付给主人的动作:**在 `.env` 写 `LLM_MULTIMODAL=true`** 即可让图直进主模型上下文(改完需重启后端生效)
2026-10-04 16:30:25 +08:00

141 lines
5.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 组文件优先")
}
}