feat(config): 新增 LLM_MULTIMODAL —— 让 env-only 部署能声明「主模型自身能收图」(默认 false,行为不变)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 起因(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`** 即可让图直进主模型上下文(改完需重启后端生效)
This commit is contained in:
AskaEth committed 2026-10-04 16:30:25 +08:00
1 parent 6c164f920b
commit 4adfc9a2df
5 files changed
+214 -21

No files matched your search

+11
View File
@@ -75,10 +75,21 @@ LLM_REASONING_KIND=
# ---- LLM 视觉模型(视觉/OCR 工具用的模型;与「主模型能否收图」无关) ----
# 语义(v0.53 修正):主模型自己能不能收图,看 models.json 生效组的 multimodal 字段(面板可勾选),
# 不是看这里;本节的模型【只供 vision / ocr 工具】调用。都不配置时工具回落用主模型(LLM_MODEL)。
# .env 侧的等价键是下面的 LLM_MULTIMODAL——它同样只作用于 .env 合成的模型组。
# LLM_VISION_MODEL=gpt-4o-mini
# OCR 工具单独用另一个模型(可选):留空 = 复用 LLM_VISION_MODEL。
# LLM_OCR_MODEL=qwen-vl-ocr
# 声明【主模型自身】能否直接接收图片(= models.json 组 multimodal 的 .env 等价物):
# true = 图片直接进主模型上下文(省掉工具那一轮模型调用、不做有损文本摘要);
# 留空 / false(默认)= 图片交给 vision / ocr 工具,由工具返回文本描述。
# 默认 false 与引入本键之前【逐字节一致】——扁平 env 块只有这一个键能声明收图能力,
# 而声称端点可能不具备的能力,比把图交给视觉/OCR 工具更糟(design/06 §0 首条);
# 绝不凭 base URL / 模型名猜测端点是否收图。
# ⚠️ 只作用于 .env 合成的模型组:存在有效 models.json 时以该组的 multimodal 为准(面板可勾选)。
# ⚠️ 非法值(非 bool,如 maybe)= 启动即报错,不静默回退。
# LLM_MULTIMODAL=false
# Provider = anthropic:Anthropic Messages /v1/messages(tool_use.input 为结构化对象,工具循环更稳)
# ANTHROPIC_BASE_URL=https://api.anthropic.com
# ANTHROPIC_API_KEY=sk-ant-xxxxx
+4
View File
@@ -152,6 +152,10 @@ type LLMConfig struct {
// 的「独立视觉模型 + OCR 模型」)。
OCRModel string `env:"LLM_OCR_MODEL"`
// Multimodal 声明**主模型自身**能否收图:models.json 组 `multimodal` 的 env
// 等价物,只作用于 .env 合成的组,默认 false(语义与分工见 GroupFromEnv)。
Multimodal bool `env:"LLM_MULTIMODAL" default:"false"`
// Local is the optional third degradation level (裁决 03.4.3): primary →
// fallback → local. It defaults to off because the current machine cannot run
// a local model comfortably; when off the chain keeps its two-level shape.
+32
View File
@@ -169,6 +169,38 @@ func TestLoad_LlmFallbackMaxTokens(t *testing.T) {
}
}
// TestLoad_LlmMultimodal 钉住 LLM_MULTIMODAL 的解码面(裁决 14.4.1):默认 false
// (= 与引入本键之前逐字节一致)、.env 与进程 env 两条路都能声明、非法值**报错**
// 而不是静默回退成 false(静默的「配了当没配」正是本键要消灭的东西)。
func TestLoad_LlmMultimodal(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
unsetEnvForTest(t, "LLM_MULTIMODAL")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.LLM.Multimodal {
t.Error("LLM.Multimodal = true, want default false")
}
path := writeEnvFile(t, "LLM_MULTIMODAL=true\n")
cfg, err = Load(path)
if err != nil {
t.Fatalf("Load(%q) error: %v", path, err)
}
if !cfg.LLM.Multimodal {
t.Error("LLM.Multimodal = false, want true from .env")
}
t.Setenv("LLM_MULTIMODAL", "maybe")
if _, err := Load(""); err == nil {
t.Fatal("expected an error for LLM_MULTIMODAL=maybe, got nil")
} else if !strings.Contains(err.Error(), "LLM_MULTIMODAL") {
t.Errorf("error should mention LLM_MULTIMODAL, got: %v", err)
}
}
func TestLoad_MissingRequiredFails(t *testing.T) {
// Do not set JWT_SECRET.
if _, err := Load(""); err == nil {
+27 -21
View File
@@ -300,13 +300,17 @@ func loadFile(path string) ([]Group, error) {
// anthropic block when LLM_PROVIDER=anthropic, otherwise the flat OpenAI-compatible
// block. It is the .env fallback used when models.json is absent or invalid.
//
// Multimodal stays false here: the flat env block has no way to declare whether
// the main model accepts image input, and claiming a capability the endpoint may
// not have is worse than routing the image through the vision/OCR tool (which is
// exactly what the tool exists for). The declaration lives on the models.json
// group's `multimodal` flag, which the provider panel writes (design/06 §0 首条;
// 裁决 14.4.1). LLM_VISION_MODEL configures the vision *tool's* model, not the
// main model's ability.
// Multimodal 来自 LLM_MULTIMODAL,默认 false。语义(裁决 14.4.1):它是本组
// `multimodal` 字段的 **env 声明**——「主模型自身能否收图」,而不是「本部署有没有
// 视觉能力」。默认 false 与引入本键之前的行为逐字节一致:
// 扁平 env 块只有这一个键能声明收图能力,而**声称端点可能不具备的能力,比把图
// 交给 vision/OCR 工具更糟**——工具至少会返回一段文本描述(design/06 §0 首条)。
// 因此只有显式写 `LLM_MULTIMODAL=true` 才让主模型直接收图,**绝不凭 baseURL /
// 模型名猜测**(与 reasoning.kind 的口径一致:声明才生效,不推断)。LLM_VISION_MODEL
// / LLM_OCR_MODEL 配的是**视觉/OCR 工具调用的模型**,与本组能否收图互不代表。
//
// 非法值(不是 bool,如 `maybe`)在 config.Load 解码阶段**直接报错**,启动失败,
// 不静默回退成 false——静默的「配了当没配」正是本键要消灭的东西。
//
// Reasoning.Kind is carried through so an env-only deployment can still declare
// its endpoint's switch shape (LLM_REASONING_KIND); its env default is blank,
@@ -314,23 +318,25 @@ func loadFile(path string) ([]Group, error) {
func GroupFromEnv(env config.LLMConfig) []Group {
if env.Provider == ProviderAnthropic {
return []Group{{
ID: ProviderAnthropic,
Provider: ProviderAnthropic,
BaseURL: env.AnthropicBaseURL,
APIKey: env.AnthropicAPIKey,
Model: env.AnthropicModel,
Fallback: env.AnthropicFallback,
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
ID: ProviderAnthropic,
Provider: ProviderAnthropic,
BaseURL: env.AnthropicBaseURL,
APIKey: env.AnthropicAPIKey,
Model: env.AnthropicModel,
Fallback: env.AnthropicFallback,
Multimodal: env.Multimodal,
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
}}
}
return []Group{{
ID: ProviderOpenAI,
Provider: ProviderOpenAI,
BaseURL: env.BaseURL,
APIKey: env.APIKey,
Model: env.Model,
Fallback: env.Fallback,
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
ID: ProviderOpenAI,
Provider: ProviderOpenAI,
BaseURL: env.BaseURL,
APIKey: env.APIKey,
Model: env.Model,
Fallback: env.Fallback,
Multimodal: env.Multimodal,
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
}}
}
@@ -0,0 +1,140 @@
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 组文件优先")
}
}