diff --git a/.env.example b/.env.example index 5b4f9ce..4a9567a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 5a7b8ad..9e211f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f4e8385..34e22ab 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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 { diff --git a/internal/config/runtimeconfig/models.go b/internal/config/runtimeconfig/models.go index f66c9ff..4fa27ac 100644 --- a/internal/config/runtimeconfig/models.go +++ b/internal/config/runtimeconfig/models.go @@ -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)}, }} } diff --git a/internal/config/runtimeconfig/multimodal_env_test.go b/internal/config/runtimeconfig/multimodal_env_test.go new file mode 100644 index 0000000..56f559b --- /dev/null +++ b/internal/config/runtimeconfig/multimodal_env_test.go @@ -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 组文件优先") + } +}