Files
Cyrene_v2/internal/config/config_test.go
T
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

419 lines
13 KiB
Go
Raw 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 config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func writeEnvFile(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, ".env")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write env file: %v", err)
}
return path
}
func TestLoad_DefaultsWhenUnset(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Env != "development" {
t.Errorf("Env = %q, want development", cfg.Env)
}
if cfg.Server.Host != "0.0.0.0" || cfg.Server.Port != 8080 {
t.Errorf("Server = %+v, want 0.0.0.0:8080", cfg.Server)
}
if cfg.LogLevel != "debug" {
t.Errorf("LogLevel = %q, want debug", cfg.LogLevel)
}
if cfg.DB.SQLitePath != "./data/cyrene.db" {
t.Errorf("SQLitePath = %q, want ./data/cyrene.db", cfg.DB.SQLitePath)
}
want := []string{"http://localhost:5173", "http://localhost:3000"}
if !reflect.DeepEqual(cfg.AllowedOrigins, want) {
t.Errorf("AllowedOrigins = %v, want %v", cfg.AllowedOrigins, want)
}
}
func TestLoad_EnvOverridesDefault(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("PORT", "9090")
t.Setenv("LOG_LEVEL", "warn")
t.Setenv("IOT_PROVIDER", "none")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Server.Port != 9090 {
t.Errorf("Port = %d, want 9090", cfg.Server.Port)
}
if cfg.LogLevel != "warn" {
t.Errorf("LogLevel = %q, want warn", cfg.LogLevel)
}
if cfg.IoT.Provider != "none" {
t.Errorf("IoT.Provider = %q, want none", cfg.IoT.Provider)
}
}
func TestLoad_PushOutletEnv(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("PUSH_EXPORTER_ENABLED", "true")
t.Setenv("PUSH_EXPORTER_URL", "https://push.example.com/hook")
t.Setenv("PUSH_EXPORTER_TOKEN", "tok")
t.Setenv("PUSH_EXPORTER_TIMEOUT_SEC", "9")
t.Setenv("GETUI_APP_ID", "app-id")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if !cfg.PushOutlet.Enabled {
t.Error("PushOutlet.Enabled = false, want true")
}
if cfg.PushOutlet.URL != "https://push.example.com/hook" {
t.Errorf("PushOutlet.URL = %q, want the configured endpoint", cfg.PushOutlet.URL)
}
if cfg.PushOutlet.Token != "tok" {
t.Errorf("PushOutlet.Token = %q, want tok", cfg.PushOutlet.Token)
}
if cfg.PushOutlet.TimeoutSec != 9 {
t.Errorf("PushOutlet.TimeoutSec = %d, want 9", cfg.PushOutlet.TimeoutSec)
}
if cfg.PushOutlet.GetuiAppID != "app-id" {
t.Errorf("PushOutlet.GetuiAppID = %q, want app-id", cfg.PushOutlet.GetuiAppID)
}
}
func TestLoad_DotEnvThenProcessEnv(t *testing.T) {
path := writeEnvFile(t, `PORT=7000
LOG_LEVEL=error
# comment line
IOT_PROVIDER=none
`)
// Process env overrides .env for PORT but leaves LOG_LEVEL from .env.
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("PORT", "8000")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load(%q) error: %v", path, err)
}
if cfg.Server.Port != 8000 {
t.Errorf("Port = %d, want 8000 (process env wins)", cfg.Server.Port)
}
if cfg.LogLevel != "error" {
t.Errorf("LogLevel = %q, want error from .env", cfg.LogLevel)
}
if cfg.IoT.Provider != "none" {
t.Errorf("IoT.Provider = %q, want none from .env", cfg.IoT.Provider)
}
}
// unsetEnvForTest clears key for the test and restores the original value (or
// absence) on cleanup, so a .env file is not shadowed by an ambient process env
// variable. config tests are not parallel, so this is safe.
func unsetEnvForTest(t *testing.T, key string) {
t.Helper()
if v, ok := os.LookupEnv(key); ok {
t.Cleanup(func() { _ = os.Setenv(key, v) })
} else {
t.Cleanup(func() { _ = os.Unsetenv(key) })
}
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
func TestLoad_LlmFallbackMaxTokens(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
unsetEnvForTest(t, "LLM_FALLBACK_MAX_TOKENS")
// Default: the last-resort fallback budget is a meaningful 8192.
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.LLM.FallbackMaxTokens != 8192 {
t.Errorf("LLM.FallbackMaxTokens = %d, want default 8192", cfg.LLM.FallbackMaxTokens)
}
// A .env file is parsed into the field.
path := writeEnvFile(t, "LLM_FALLBACK_MAX_TOKENS=4096\n")
cfg, err = Load(path)
if err != nil {
t.Fatalf("Load(%q) error: %v", path, err)
}
if cfg.LLM.FallbackMaxTokens != 4096 {
t.Errorf("LLM.FallbackMaxTokens = %d, want 4096 from .env", cfg.LLM.FallbackMaxTokens)
}
// Process env wins over .env.
t.Setenv("LLM_FALLBACK_MAX_TOKENS", "2048")
cfg, err = Load(path)
if err != nil {
t.Fatalf("Load(%q) error: %v", path, err)
}
if cfg.LLM.FallbackMaxTokens != 2048 {
t.Errorf("LLM.FallbackMaxTokens = %d, want 2048 (process env wins)", cfg.LLM.FallbackMaxTokens)
}
}
// 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 {
t.Fatal("expected error when JWT_SECRET missing, got nil")
} else if !strings.Contains(err.Error(), "JWT_SECRET") {
t.Errorf("error should mention JWT_SECRET, got: %v", err)
}
}
func TestLoad_ValidationMinLength(t *testing.T) {
t.Setenv("JWT_SECRET", "short")
if _, err := Load(""); err == nil {
t.Fatal("expected error for JWT_SECRET shorter than 32, got nil")
} else if !strings.Contains(err.Error(), "at least 32") {
t.Errorf("error should mention min length, got: %v", err)
}
}
func TestLoad_InvalidPortFails(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("PORT", "not-a-number")
if _, err := Load(""); err == nil {
t.Fatal("expected error for invalid PORT, got nil")
} else if !strings.Contains(err.Error(), "PORT") {
t.Errorf("error should mention PORT, got: %v", err)
}
}
func TestLoad_MissingDotEnvIsTolerated(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
// A non-existent .env path must not error.
cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.env"))
if err != nil {
t.Fatalf("Load(nonexistent) error: %v", err)
}
if cfg.Server.Port != 8080 {
t.Errorf("Port = %d, want default 8080", cfg.Server.Port)
}
}
func TestLoad_AuthRefreshDefaults(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Auth.RefreshTTL != 720*time.Hour {
t.Errorf("Auth.RefreshTTL = %v, want 720h", cfg.Auth.RefreshTTL)
}
if cfg.Auth.RefreshRotateDisabled {
t.Errorf("Auth.RefreshRotateDisabled = true, want false (rotation on by default)")
}
}
func TestLoad_AuthRefreshEnvOverrides(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("AUTH_REFRESH_TTL", "30m")
t.Setenv("AUTH_REFRESH_ROTATE_DISABLED", "true")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Auth.RefreshTTL != 30*time.Minute {
t.Errorf("Auth.RefreshTTL = %v, want 30m", cfg.Auth.RefreshTTL)
}
if !cfg.Auth.RefreshRotateDisabled {
t.Errorf("Auth.RefreshRotateDisabled = false, want true")
}
}
func TestLoad_CapacityDefaults(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Capacity.Enabled {
t.Errorf("Capacity.Enabled = true, want false (default, opt-in)")
}
if cfg.Capacity.MinConfidence != 0.6 {
t.Errorf("Capacity.MinConfidence = %v, want 0.6", cfg.Capacity.MinConfidence)
}
}
func TestLoad_CapacityEnvOverrides(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("CAPACITY_ENABLED", "true")
t.Setenv("CAPACITY_MIN_CONFIDENCE", "0.75")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if !cfg.Capacity.Enabled {
t.Errorf("Capacity.Enabled = false, want true")
}
if cfg.Capacity.MinConfidence != 0.75 {
t.Errorf("Capacity.MinConfidence = %v, want 0.75", cfg.Capacity.MinConfidence)
}
}
func TestLoad_VoiceDefaults(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Voice.DashscopeBaseURL != "https://dashscope.aliyuncs.com/api/v1" {
t.Errorf("DashscopeBaseURL = %q, want the default Bailian URL", cfg.Voice.DashscopeBaseURL)
}
if cfg.Voice.STTModel != "qwen-audio-3.0-asr-flash" {
t.Errorf("STTModel = %q, want qwen-audio-3.0-asr-flash", cfg.Voice.STTModel)
}
if cfg.Voice.STTLanguage != "zh" {
t.Errorf("STTLanguage = %q, want zh", cfg.Voice.STTLanguage)
}
if cfg.Voice.TTSModel != "cosyvoice-v3-flash" {
t.Errorf("TTSModel = %q, want cosyvoice-v3-flash", cfg.Voice.TTSModel)
}
if cfg.Voice.TTSVoiceID != "longxiaochun_v3" {
t.Errorf("TTSVoiceID = %q, want longxiaochun_v3", cfg.Voice.TTSVoiceID)
}
if cfg.Voice.TTSFormat != "wav" {
t.Errorf("TTSFormat = %q, want wav", cfg.Voice.TTSFormat)
}
if cfg.Voice.TTSSampleRate != 24000 {
t.Errorf("TTSSampleRate = %d, want 24000", cfg.Voice.TTSSampleRate)
}
if cfg.Voice.VoiceTTSInstruction != "" {
t.Errorf("VoiceTTSInstruction = %q, want empty (default)", cfg.Voice.VoiceTTSInstruction)
}
if cfg.Voice.VoiceClonePrefix != "cyrene" {
t.Errorf("VoiceClonePrefix = %q, want cyrene (default)", cfg.Voice.VoiceClonePrefix)
}
}
func TestLoad_VoiceEnvOverrides(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("VOICE_TTS_VOICE_ID", "custom-voice-1")
t.Setenv("VOICE_STT_MODEL", "qwen-audio-3.0-asr-flash")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Voice.TTSVoiceID != "custom-voice-1" {
t.Errorf("TTSVoiceID = %q, want custom-voice-1", cfg.Voice.TTSVoiceID)
}
if cfg.Voice.STTModel != "qwen-audio-3.0-asr-flash" {
t.Errorf("STTModel = %q, want qwen-audio-3.0-asr-flash", cfg.Voice.STTModel)
}
}
func TestLoad_VoiceCloneInstructionEnvOverrides(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("VOICE_TTS_INSTRUCTION", "语气开朗俏皮,像女朋友撒娇")
t.Setenv("VOICE_CLONE_PREFIX", "xilian")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Voice.VoiceTTSInstruction != "语气开朗俏皮,像女朋友撒娇" {
t.Errorf("VoiceTTSInstruction = %q, want the env instruction", cfg.Voice.VoiceTTSInstruction)
}
if cfg.Voice.VoiceClonePrefix != "xilian" {
t.Errorf("VoiceClonePrefix = %q, want xilian", cfg.Voice.VoiceClonePrefix)
}
}
func TestParseDotEnv_QuotedAndExport(t *testing.T) {
path := writeEnvFile(t, `export FOO=bar
BAZ='quoted value'
EMPTY=
`)
m, err := parseDotEnv(path)
if err != nil {
t.Fatalf("parseDotEnv error: %v", err)
}
if m["FOO"] != "bar" {
t.Errorf("FOO = %q, want bar", m["FOO"])
}
if m["BAZ"] != "quoted value" {
t.Errorf("BAZ = %q, want 'quoted value'", m["BAZ"])
}
if v, ok := m["EMPTY"]; !ok || v != "" {
t.Errorf("EMPTY = %q (present=%v), want empty", v, ok)
}
}
// TestLoad_SubagentTimeout 验证子代理时限配置(design/05 §7 裁决 02.4.4:
// agent_timeout 可配置,默认 10min):缺省 600 秒,环境变量可覆盖。
func TestLoad_SubagentTimeout(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Subagent.TimeoutSec != 600 {
t.Errorf("Subagent.TimeoutSec = %d, want the 600s (10min) default", cfg.Subagent.TimeoutSec)
}
t.Setenv("SUBAGENT_TIMEOUT_SEC", "90")
cfg, err = Load("")
if err != nil {
t.Fatalf("Load() with override error: %v", err)
}
if cfg.Subagent.TimeoutSec != 90 {
t.Errorf("Subagent.TimeoutSec = %d, want 90 from the environment", cfg.Subagent.TimeoutSec)
}
}