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`** 即可让图直进主模型上下文(改完需重启后端生效)
This commit is contained in:
1 parent
6c164f920b
commit
4adfc9a2df
5 files changed
+214
-21
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 组文件优先")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user