- 起因(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`** 即可让图直进主模型上下文(改完需重启后端生效)
141 lines
5.5 KiB
Go
141 lines
5.5 KiB
Go
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 组文件优先")
|
||
}
|
||
}
|