Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
64 KiB
JSON

{"repo": "666hanrui/eduresource-agent-", "files": {"apps/interactive-classroom/.env.example": {"daac49a62c905a9aca13d8beec4a0517ea9eba04": {"t": 1785746753.4944477, "n": 6663, "h": "4cc65e0d40e8863e", "c": "# =============================================================================\n# OpenMAIC Environment Variables\n# Copy this file to .env.local and fill in the values you need.\n# All variables are optional — only configure the providers you want to use.\n# You can also use server-providers.yml for configuration (see docs).\n# =============================================================================\n\n# --- LLM Providers -----------------------------------------------------------\n# Format: {PROVIDER}_API_KEY, {PROVIDER}_BASE_URL (optional), {PROVIDER}_MODELS (optional, comma-separated)\n\nOPENAI_API_KEY=\nOPENAI_BASE_URL=\nOPENAI_MODELS=\n\nANTHROPIC_API_KEY=\nANTHROPIC_BASE_URL=\nANTHROPIC_MODELS=\n\nGOOGLE_API_KEY=\nGOOGLE_BASE_URL=\nGOOGLE_MODELS=\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_BASE_URL=\n# Example: deepseek-v4-pro,deepseek-v4-flash\nDEEPSEEK_MODELS=\n\nQWEN_API_KEY=\nQWEN_BASE_URL=\nQWEN_MODELS=\n\nKIMI_API_KEY=\nKIMI_BASE_URL=\nKIMI_MODELS=\n\nMINIMAX_API_KEY=\n# MiniMax Anthropic-compatible endpoint for the built-in Anthropic SDK integration\nMINIMAX_BASE_URL=https://api.minimaxi.com/anthropic/v1\n# Example: MiniMax-M2.7-highspeed,MiniMax-M2.7,MiniMax-M2.5-highspeed,MiniMax-M2.5\nMINIMAX_MODELS=\n\nGLM_API_KEY=\nGLM_BASE_URL=\nGLM_MODELS=\n\nSILICONFLOW_API_KEY=\nSILICONFLOW_BASE_URL=\nSILICONFLOW_MODELS=\n\nDOUBAO_API_KEY=\nDOUBAO_BASE_URL=\nDOUBAO_MODELS=\n\nOPENROUTER_API_KEY=\nOPENROUTER_BASE_URL=https://openrouter.ai/api/v1\n# Example: deepseek/deepseek-v4-pro,deepseek/deepseek-v4-flash\nOPENROUTER_MODELS=\n\nGROK_API_KEY=\nGROK_BASE_URL=\nGROK_MODELS=\n\nTENCENT_API_KEY=\n# Tencent TokenHub OpenAI-compatible endpoint. Hy3 is a model ID, not an env prefix.\n# TENCENT_HUNYUAN_* is also accepted as an alias.\nTENCENT_BASE_URL=https://tokenhub.tencentmaas.com/v1\n# Example: hy3-preview,hunyuan-2.0-thinking-20251109,hunyuan-2.0-instruct-20251111\nTENCENT_MODELS=\n\nXIAOMI_API_KEY=\n# MIMO_* is also accepted as an alias. Use tp-... keys only with Token Plan URLs.\nXIAOMI_BASE_URL=https://api.xiaomimimo.com/v1\n# Token Plan regional examples:\n# XIAOMI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1\n# XIAOMI_BASE_URL=https://token-plan-sgp.xiaomimimo.com/v1\n# XIAOMI_BASE_URL=https://token-plan-ams.xiaomimimo.com/v1\n# Example: mimo-v2.5-pro,mimo-v2-pro,mimo-v2.5,mimo-v2-omni,mimo-v2-flash\nXIAOMI_MODELS=\n\n# --- Ollama (Local Models) ---------------------------------------------------\n# No API key needed. Configure BASE_URL here (server-side) so it bypasses SSRF\n# protection automatically. Client-supplied localhost URLs are blocked in production.\n# OLLAMA_BASE_URL=http://localhost:11434/v1\n# OLLAMA_MODELS=llama3.3,llama3.2,qwen2.5,mistral,gemma3\n\n# Lemonade local server (OpenAI-compatible, no API key required)\n# LEMONADE_BASE_URL=http://localhost:13305/v1\n# LEMONADE_MODELS=Qwen3-0.6B-GGUF,Llama-3.2-1B-Instruct-Hybrid,Qwen2.5-VL-7B-Instruct\n\n# --- TTS (Text-to-Speech) ----------------------------------------------------\n\nTTS_OPENAI_API_KEY=\nTTS_OPENAI_BASE_URL=\n\nTTS_AZURE_API_KEY=\nTTS_AZURE_BASE_URL=\n\nTTS_GLM_API_KEY=\nTTS_GLM_BASE_URL=\n\nTTS_QWEN_API_KEY=\nTTS_QWEN_BASE_URL=\n\nTTS_MINIMAX_API_KEY=\n# MiniMax TTS endpoint (speech-2.8 / 2.6 / 02 / 01 series)\nTTS_MINIMAX_BASE_URL=https://api.minimaxi.com\nTTS_ELEVENLABS_API_KEY=\nTTS_ELEVENLABS_BASE_URL=\n\n# Lemonade TTS (local, no API key required)\n# TTS_LEMONADE_BASE_URL=http://localhost:13305/v1\n\n# --- ASR (Automatic Speech Recognition) --------------------------------------\n\nASR_OPENAI_API_KEY=\nASR_OPENAI_BASE_URL=\n\nASR_QWEN_API_KEY=\nASR_QWEN_BASE_URL=\n\nASR_AZURE_API_KEY=\nASR_AZURE_BASE_URL=https://{region}.api.cognitive.microsoft.com\n\n# Lemonade ASR (local, WAV input only, no API key required)\n# ASR_LEMONADE_BASE_URL=http://localhost:13305/v1\n\n\n# --- PDF Processing -----------------------------------------------------------\n\nPDF_UNPDF_API_KEY=\nPDF_UNPDF_BASE_URL=\n\nPDF_MINERU_API_KEY=\nPDF_MINERU_BASE_URL=\n\n# --- Image Generation ---------------------------------------------------------\n\nIMAGE_OPENAI_API_KEY=\nIMAGE_OPENAI_BASE_URL=https://api.openai.com/v1\n\nIMAGE_SEEDREAM_API_KEY=\nIMAGE_SEEDREAM_BASE_URL=\n\nIMAGE_QWEN_IMAGE_API_KEY=\nIMAGE_QWEN_IMAGE_BASE_URL=\n\nIMAGE_NANO_BANANA_API_KEY=\nIMAGE_NANO_BANANA_BASE_URL=\n\nIMAGE_MINIMAX_API_KEY=\n# Example models: image-01, image-01-live\nIMAGE_MINIMAX_BASE_URL=https://api.minimaxi.com\n\nIMAGE_GROK_API_KEY=\nIMAGE_GROK_BASE_URL=\n\n# Lemonade image generation (local, no API key required)\n# IMAGE_LEMONADE_BASE_URL=http://localhost:13305/v1\n\n# --- Video Generation ---------------------------------------------------------\n\nVIDEO_SEEDANCE_API_KEY=\nVIDEO_SEEDANCE_BASE_URL=\n\nVIDEO_KLING_API_KEY=\nVIDEO_KLING_BASE_URL=\n\nVIDEO_VEO_API_KEY=\nVIDEO_VEO_BASE_URL=\n\nVIDEO_SORA_API_KEY=\nVIDEO_SORA_BASE_URL=\n\nVIDEO_MINIMAX_API_KEY=\n# Example models: MiniMax-Hailuo-2.3, MiniMax-Hailuo-2.3-Fast, MiniMax-Hailuo-02\nVIDEO_MINIMAX_BASE_URL=https://api.minimaxi.com\n\nVIDEO_GROK_API_KEY=\nVIDEO_GROK_BASE_URL=\n\nVIDEO_HAPPYHORSE_API_KEY=\nVIDEO_HAPPYHORSE_BASE_URL=https://dashscope.aliyuncs.com\n\n# --- Web Search ---------------------------------------------------------------\n# Note: Grok (xAI) web search is available via chat completions + search tools,\n# not as a standalone search API. Use Grok LLM provider with search_parameters\n# in chat requests. See: https://docs.x.ai/docs/guides/tools/search-tools\n\nTAVILY_API_KEY=\nBOCHA_API_KEY=\nBOCHA_BASE_URL=https://api.bocha.cn\nBRAVE_API_KEY=\nBAIDU_API_KEY=\nBAIDU_BASE_URL=https://qianfan.baidubce.com\n# Dedicated MiniMax web-search vars avoid conflicting with the LLM MINIMAX_* endpoint.\nWEB_SEARCH_MINIMAX_API_KEY=\nWEB_SEARCH_MINIMAX_BASE_URL=https://api.minimaxi.com\n\n# --- Proxy (optional) --------------------------------------------------------\n\n# HTTP_PROXY=\n# HTTPS_PROXY=\n\n# --- Misc ---------------------------------------------------------------------\n\n# Optional server-side default model for API routes like /api/generate-classroom\n# Example: anthropic:claude-3-5-haiku-20241022 or google:gemini-3-flash-preview\n# OpenAI example: openai:gpt-5.5\n# MiniMax example: minimax:MiniMax-M2.7-highspeed\nDEFAULT_MODEL=\n\n# LOG_LEVEL=info\n# LOG_FORMAT=pretty\n# LLM_THINKING_DISABLED=false\n\n# --- Local/Self-hosted Deployment ---------------------------------------------\n# Set to \"true\" to allow private/local network URLs (e.g. localhost, 192.168.x.x).\n# Required for self-hosted models like Ollama. Do NOT enable on public deployments.\n# ALLOW_LOCAL_NETWORKS=true\n\n# --- Access Control -----------------------------------------------------------\n# Set a password to restrict site access. When set, users must enter this code\n# before using the app. Leave empty or remove to disable access control.\n# ACCESS_CODE=your-secret-code\n"}, "f11f40e7de001604a00c01e3525bdb32b4498063": {"t": 1785767480.9415774, "n": 6663, "h": "4cc65e0d40e8863e", "c": "# =============================================================================\n# OpenMAIC Environment Variables\n# Copy this file to .env.local and fill in the values you need.\n# All variables are optional — only configure the providers you want to use.\n# You can also use server-providers.yml for configuration (see docs).\n# =============================================================================\n\n# --- LLM Providers -----------------------------------------------------------\n# Format: {PROVIDER}_API_KEY, {PROVIDER}_BASE_URL (optional), {PROVIDER}_MODELS (optional, comma-separated)\n\nOPENAI_API_KEY=\nOPENAI_BASE_URL=\nOPENAI_MODELS=\n\nANTHROPIC_API_KEY=\nANTHROPIC_BASE_URL=\nANTHROPIC_MODELS=\n\nGOOGLE_API_KEY=\nGOOGLE_BASE_URL=\nGOOGLE_MODELS=\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_BASE_URL=\n# Example: deepseek-v4-pro,deepseek-v4-flash\nDEEPSEEK_MODELS=\n\nQWEN_API_KEY=\nQWEN_BASE_URL=\nQWEN_MODELS=\n\nKIMI_API_KEY=\nKIMI_BASE_URL=\nKIMI_MODELS=\n\nMINIMAX_API_KEY=\n# MiniMax Anthropic-compatible endpoint for the built-in Anthropic SDK integration\nMINIMAX_BASE_URL=https://api.minimaxi.com/anthropic/v1\n# Example: MiniMax-M2.7-highspeed,MiniMax-M2.7,MiniMax-M2.5-highspeed,MiniMax-M2.5\nMINIMAX_MODELS=\n\nGLM_API_KEY=\nGLM_BASE_URL=\nGLM_MODELS=\n\nSILICONFLOW_API_KEY=\nSILICONFLOW_BASE_URL=\nSILICONFLOW_MODELS=\n\nDOUBAO_API_KEY=\nDOUBAO_BASE_URL=\nDOUBAO_MODELS=\n\nOPENROUTER_API_KEY=\nOPENROUTER_BASE_URL=https://openrouter.ai/api/v1\n# Example: deepseek/deepseek-v4-pro,deepseek/deepseek-v4-flash\nOPENROUTER_MODELS=\n\nGROK_API_KEY=\nGROK_BASE_URL=\nGROK_MODELS=\n\nTENCENT_API_KEY=\n# Tencent TokenHub OpenAI-compatible endpoint. Hy3 is a model ID, not an env prefix.\n# TENCENT_HUNYUAN_* is also accepted as an alias.\nTENCENT_BASE_URL=https://tokenhub.tencentmaas.com/v1\n# Example: hy3-preview,hunyuan-2.0-thinking-20251109,hunyuan-2.0-instruct-20251111\nTENCENT_MODELS=\n\nXIAOMI_API_KEY=\n# MIMO_* is also accepted as an alias. Use tp-... keys only with Token Plan URLs.\nXIAOMI_BASE_URL=https://api.xiaomimimo.com/v1\n# Token Plan regional examples:\n# XIAOMI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1\n# XIAOMI_BASE_URL=https://token-plan-sgp.xiaomimimo.com/v1\n# XIAOMI_BASE_URL=https://token-plan-ams.xiaomimimo.com/v1\n# Example: mimo-v2.5-pro,mimo-v2-pro,mimo-v2.5,mimo-v2-omni,mimo-v2-flash\nXIAOMI_MODELS=\n\n# --- Ollama (Local Models) ---------------------------------------------------\n# No API key needed. Configure BASE_URL here (server-side) so it bypasses SSRF\n# protection automatically. Client-supplied localhost URLs are blocked in production.\n# OLLAMA_BASE_URL=http://localhost:11434/v1\n# OLLAMA_MODELS=llama3.3,llama3.2,qwen2.5,mistral,gemma3\n\n# Lemonade local server (OpenAI-compatible, no API key required)\n# LEMONADE_BASE_URL=http://localhost:13305/v1\n# LEMONADE_MODELS=Qwen3-0.6B-GGUF,Llama-3.2-1B-Instruct-Hybrid,Qwen2.5-VL-7B-Instruct\n\n# --- TTS (Text-to-Speech) ----------------------------------------------------\n\nTTS_OPENAI_API_KEY=\nTTS_OPENAI_BASE_URL=\n\nTTS_AZURE_API_KEY=\nTTS_AZURE_BASE_URL=\n\nTTS_GLM_API_KEY=\nTTS_GLM_BASE_URL=\n\nTTS_QWEN_API_KEY=\nTTS_QWEN_BASE_URL=\n\nTTS_MINIMAX_API_KEY=\n# MiniMax TTS endpoint (speech-2.8 / 2.6 / 02 / 01 series)\nTTS_MINIMAX_BASE_URL=https://api.minimaxi.com\nTTS_ELEVENLABS_API_KEY=\nTTS_ELEVENLABS_BASE_URL=\n\n# Lemonade TTS (local, no API key required)\n# TTS_LEMONADE_BASE_URL=http://localhost:13305/v1\n\n# --- ASR (Automatic Speech Recognition) --------------------------------------\n\nASR_OPENAI_API_KEY=\nASR_OPENAI_BASE_URL=\n\nASR_QWEN_API_KEY=\nASR_QWEN_BASE_URL=\n\nASR_AZURE_API_KEY=\nASR_AZURE_BASE_URL=https://{region}.api.cognitive.microsoft.com\n\n# Lemonade ASR (local, WAV input only, no API key required)\n# ASR_LEMONADE_BASE_URL=http://localhost:13305/v1\n\n\n# --- PDF Processing -----------------------------------------------------------\n\nPDF_UNPDF_API_KEY=\nPDF_UNPDF_BASE_URL=\n\nPDF_MINERU_API_KEY=\nPDF_MINERU_BASE_URL=\n\n# --- Image Generation ---------------------------------------------------------\n\nIMAGE_OPENAI_API_KEY=\nIMAGE_OPENAI_BASE_URL=https://api.openai.com/v1\n\nIMAGE_SEEDREAM_API_KEY=\nIMAGE_SEEDREAM_BASE_URL=\n\nIMAGE_QWEN_IMAGE_API_KEY=\nIMAGE_QWEN_IMAGE_BASE_URL=\n\nIMAGE_NANO_BANANA_API_KEY=\nIMAGE_NANO_BANANA_BASE_URL=\n\nIMAGE_MINIMAX_API_KEY=\n# Example models: image-01, image-01-live\nIMAGE_MINIMAX_BASE_URL=https://api.minimaxi.com\n\nIMAGE_GROK_API_KEY=\nIMAGE_GROK_BASE_URL=\n\n# Lemonade image generation (local, no API key required)\n# IMAGE_LEMONADE_BASE_URL=http://localhost:13305/v1\n\n# --- Video Generation ---------------------------------------------------------\n\nVIDEO_SEEDANCE_API_KEY=\nVIDEO_SEEDANCE_BASE_URL=\n\nVIDEO_KLING_API_KEY=\nVIDEO_KLING_BASE_URL=\n\nVIDEO_VEO_API_KEY=\nVIDEO_VEO_BASE_URL=\n\nVIDEO_SORA_API_KEY=\nVIDEO_SORA_BASE_URL=\n\nVIDEO_MINIMAX_API_KEY=\n# Example models: MiniMax-Hailuo-2.3, MiniMax-Hailuo-2.3-Fast, MiniMax-Hailuo-02\nVIDEO_MINIMAX_BASE_URL=https://api.minimaxi.com\n\nVIDEO_GROK_API_KEY=\nVIDEO_GROK_BASE_URL=\n\nVIDEO_HAPPYHORSE_API_KEY=\nVIDEO_HAPPYHORSE_BASE_URL=https://dashscope.aliyuncs.com\n\n# --- Web Search ---------------------------------------------------------------\n# Note: Grok (xAI) web search is available via chat completions + search tools,\n# not as a standalone search API. Use Grok LLM provider with search_parameters\n# in chat requests. See: https://docs.x.ai/docs/guides/tools/search-tools\n\nTAVILY_API_KEY=\nBOCHA_API_KEY=\nBOCHA_BASE_URL=https://api.bocha.cn\nBRAVE_API_KEY=\nBAIDU_API_KEY=\nBAIDU_BASE_URL=https://qianfan.baidubce.com\n# Dedicated MiniMax web-search vars avoid conflicting with the LLM MINIMAX_* endpoint.\nWEB_SEARCH_MINIMAX_API_KEY=\nWEB_SEARCH_MINIMAX_BASE_URL=https://api.minimaxi.com\n\n# --- Proxy (optional) --------------------------------------------------------\n\n# HTTP_PROXY=\n# HTTPS_PROXY=\n\n# --- Misc ---------------------------------------------------------------------\n\n# Optional server-side default model for API routes like /api/generate-classroom\n# Example: anthropic:claude-3-5-haiku-20241022 or google:gemini-3-flash-preview\n# OpenAI example: openai:gpt-5.5\n# MiniMax example: minimax:MiniMax-M2.7-highspeed\nDEFAULT_MODEL=\n\n# LOG_LEVEL=info\n# LOG_FORMAT=pretty\n# LLM_THINKING_DISABLED=false\n\n# --- Local/Self-hosted Deployment ---------------------------------------------\n# Set to \"true\" to allow private/local network URLs (e.g. localhost, 192.168.x.x).\n# Required for self-hosted models like Ollama. Do NOT enable on public deployments.\n# ALLOW_LOCAL_NETWORKS=true\n\n# --- Access Control -----------------------------------------------------------\n# Set a password to restrict site access. When set, users must enter this code\n# before using the app. Leave empty or remove to disable access control.\n# ACCESS_CODE=your-secret-code\n"}}, "apps/interactive-classroom/tests/store/settings-server-sync.test.ts": {"daac49a62c905a9aca13d8beec4a0517ea9eba04": {"t": 1786068368.9072034, "n": 49740, "h": "4b18853822e76a39", "c": "/**\n * Tests for fetchServerProviders() — verifying that the settings store\n * correctly reflects server-side provider availability changes.\n *\n * Core invariant: after server sync, the set of models/providers a user\n * can select in the UI must match what the server currently supports.\n */\n\nimport { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';\nimport { isProviderUsable } from '@/lib/store/settings-validation';\n\n// ---------------------------------------------------------------------------\n// Mocks — must be defined before importing the store\n// ---------------------------------------------------------------------------\n\n// Minimal built-in provider registry used by the store\nvi.mock('@/lib/ai/providers', () => ({\n PROVIDERS: {\n openai: {\n id: 'openai',\n name: 'OpenAI',\n type: 'openai',\n defaultBaseUrl: 'https://api.openai.com/v1',\n requiresApiKey: true,\n icon: '/logos/openai.svg',\n models: [\n { id: 'gpt-4o', name: 'GPT-4o' },\n { id: 'gpt-4o-mini', name: 'GPT-4o Mini' },\n { id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },\n ],\n },\n anthropic: {\n id: 'anthropic',\n name: 'Anthropic',\n type: 'anthropic',\n defaultBaseUrl: 'https://api.anthropic.com',\n requiresApiKey: true,\n icon: '/logos/anthropic.svg',\n models: [\n { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },\n { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },\n ],\n },\n deepseek: {\n id: 'deepseek',\n name: 'DeepSeek',\n type: 'openai',\n defaultBaseUrl: 'https://api.deepseek.com/v1',\n requiresApiKey: true,\n icon: '/logos/deepseek.svg',\n models: [\n { id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },\n { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },\n ],\n },\n },\n}));\n\nvi.mock('@/lib/audio/constants', () => ({\n TTS_PROVIDERS: {\n 'openai-tts': {\n id: 'openai-tts',\n name: 'OpenAI TTS',\n requiresApiKey: true,\n defaultModelId: 'gpt-4o-mini-tts',\n models: [{ id: 'gpt-4o-mini-tts', name: 'GPT-4o Mini TTS' }],\n voices: [{ id: 'alloy', name: 'Alloy', language: 'en', gender: 'neutral' }],\n supportedFormats: ['mp3'],\n },\n 'azure-tts': {\n id: 'azure-tts',\n name: 'Azure TTS',\n requiresApiKey: true,\n defaultModelId: '',\n models: [],\n voices: [{ id: 'zh-CN-XiaoxiaoNeural', name: 'Xiaoxiao', language: 'zh-CN' }],\n supportedFormats: ['mp3'],\n },\n 'browser-native-tts': {\n id: 'browser-native-tts',\n name: 'Browser Native TTS',\n requiresApiKey: false,\n defaultModelId: '',\n models: [],\n voices: [{ id: 'default', name: 'Default', language: 'en', gender: 'neutral' }],\n supportedFormats: ['browser'],\n speedRange: { min: 0.1, max: 10, default: 1 },\n },\n },\n ASR_PROVIDERS: {\n 'openai-whisper': {\n id: 'openai-whisper',\n name: 'OpenAI Whisper',\n requiresApiKey: true,\n defaultModelId: 'gpt-4o-mini-transcribe',\n models: [{ id: 'gpt-4o-mini-transcribe', name: 'GPT-4o Mini Transcribe' }],\n supportedLanguages: ['auto', 'zh'],\n supportedFormats: ['webm'],\n },\n 'browser-native': {\n id: 'browser-native',\n name: 'Browser Native ASR',\n requiresApiKey: false,\n defaultModelId: '',\n models: [],\n supportedLanguages: ['zh'],\n supportedFormats: ['browser'],\n },\n },\n DEFAULT_TTS_VOICES: {\n 'openai-tts': 'alloy',\n 'browser-native-tts': 'default',\n },\n}));\n\nvi.mock('@/lib/audio/types', () => ({\n isCustomTTSProvider: (id: string) => id.startsWith('custom-tts-'),\n isCustomASRProvider: (id: string) => id.startsWith('custom-asr-'),\n}));\n\nvi.mock('@/lib/pdf/constants', () => ({\n PDF_PROVIDERS: {\n unpdf: { id: 'unpdf', requiresApiKey: false },\n mineru: { id: 'mineru', requiresApiKey: false },\n },\n}));\n\nvi.mock('@/lib/media/image-providers', () => ({\n IMAGE_PROVIDERS: {\n seedream: {\n id: 'seedream',\n requiresApiKey: true,\n models: [{ id: 'doubao-seedream-5-0-260128', name: 'Seedream 5.0' }],\n },\n 'qwen-image': {\n id: 'qwen-image',\n requiresApiKey: true,\n models: [{ id: 'qwen-image-max', name: 'Qwen Image Max' }],\n },\n },\n}));\n\nvi.mock('@/lib/media/video-providers', () => ({\n VIDEO_PROVIDERS: {\n seedance: {\n id: 'seedance',\n requiresApiKey: true,\n models: [{ id: 'doubao-seedance-1-5-pro-251215', name: 'Seedance 1.5 Pro' }],\n },\n kling: {\n id: 'kling',\n requiresApiKey: true,\n models: [{ id: 'kling-v2-6', name: 'Kling V2' }],\n },\n },\n}));\n\nvi.mock('@/lib/logger', () => ({\n createLogger: () => ({\n info: vi.fn(),\n warn: vi.fn(),\n error: vi.fn(),\n debug: vi.fn(),\n }),\n}));\n\n// Stub global fetch\nconst mockFetch = vi.fn() as Mock;\nvi.stubGlobal('fetch', mockFetch);\n\n// Stub localStorage\nconst storage = new Map<string, string>();\nconst localStorageStub = {\n getItem: (key: string) => storage.get(key) ?? null,\n setItem: (key: string, value: string) => storage.set(key, value),\n removeItem: (key: string) => storage.delete(key),\n};\nvi.stubGlobal('localStorage', localStorageStub);\nvi.stubGlobal('window', { localStorage: localStorageStub });\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Full server response shape */\ninterface MockServerResponse {\n providers?: Record<string, { models?: string[]; baseUrl?: string }>;\n tts?: Record<string, { baseUrl?: string }>;\n asr?: Record<string, { baseUrl?: string }>;\n pdf?: Record<string, { baseUrl?: string }>;\n image?: Record<string, { baseUrl?: string }>;\n video?: Record<string, { baseUrl?: string }>;\n webSearch?: Record<string, { baseUrl?: string }>;\n}\n\nfunction mockServerResponse(overrides: MockServerResponse = {}) {\n mockFetch.mockResolvedValueOnce({\n ok: true,\n json: async () => ({\n providers: {},\n tts: {},\n asr: {},\n pdf: {},\n image: {},\n video: {},\n webSearch: {},\n ...overrides,\n }),\n });\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\ndescribe('settings rehydrate — built-in provider models', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n async function getStore() {\n const { useSettingsStore } = await import('@/lib/store/settings');\n return useSettingsStore;\n }\n\n it('reorders persisted built-in models to registry order while preserving custom models', async () => {\n storage.set(\n 'settings-storage',\n JSON.stringify({\n state: {\n providerId: 'openai',\n modelId: 'gpt-4o-mini',\n providersConfig: {\n openai: {\n apiKey: '',\n baseUrl: '',\n models: [\n { id: 'custom-earlier', name: 'Custom Earlier' },\n { id: 'gpt-4-turbo', name: 'Old GPT-4 Turbo' },\n { id: 'gpt-4o-mini', name: 'Old GPT-4o Mini' },\n { id: 'custom-later', name: 'Custom Later' },\n { id: 'gpt-4o', name: 'Old GPT-4o' },\n ],\n name: 'OpenAI',\n type: 'openai',\n defaultBaseUrl: 'https://api.openai.com/v1',\n icon: '/logos/openai.svg',\n requiresApiKey: true,\n isBuiltIn: true,\n },\n },\n },\n version: 2,\n }),\n );\n\n const store = await getStore();\n const models = store.getState().providersConfig.openai.models;\n\n expect(models.map((m) => m.id)).toEqual([\n 'gpt-4o',\n 'gpt-4o-mini',\n 'gpt-4-turbo',\n 'custom-earlier',\n 'custom-later',\n ]);\n expect(models[0].name).toBe('GPT-4o');\n expect(models[3].name).toBe('Custom Earlier');\n });\n\n it('strips a legacy serverBaseUrl from persisted provider configs on rehydrate (#620)', async () => {\n storage.set(\n 'settings-storage',\n JSON.stringify({\n state: {\n providerId: 'openai',\n modelId: 'gpt-4o',\n providersConfig: {\n openai: {\n apiKey: '',\n baseUrl: '',\n models: [{ id: 'gpt-4o', name: 'GPT-4o' }],\n name: 'OpenAI',\n type: 'openai',\n defaultBaseUrl: 'https://api.openai.com/v1',\n requiresApiKey: true,\n isBuiltIn: true,\n isServerConfigured: true,\n serverBaseUrl: 'https://internal-gateway.local/v1',\n },\n },\n webSearchProvidersConfig: {\n bocha: {\n apiKey: '',\n baseUrl: '',\n enabled: true,\n requiresApiKey: true,\n isServerConfigured: true,\n serverBaseUrl: 'https://api.bocha.cn',\n },\n },\n },\n version: 2,\n }),\n );\n\n const store = await getStore();\n const openai = store.getState().providersConfig.openai as unknown as Record<string, unknown>;\n const bocha = store.getState().webSearchProvidersConfig.bocha as unknown as Record<\n string,\n unknown\n >;\n\n // The removed field must not linger in persisted client state...\n expect('serverBaseUrl' in openai).toBe(false);\n expect('serverBaseUrl' in bocha).toBe(false);\n // ...while the managed flag itself is preserved.\n expect(openai.isServerConfigured).toBe(true);\n });\n});\n\ndescribe('fetchServerProviders — provider availability sync', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n async function getStore() {\n const { useSettingsStore } = await import('@/lib/store/settings');\n return useSettingsStore;\n }\n\n // ---- Server model list filtering ----\n\n it('filters models to only those the server allows', async () => {\n const store = await getStore();\n mockServerResponse({\n providers: {\n openai: { models: ['gpt-4o'] },\n },\n });\n\n await store.getState().fetchServerProviders();\n\n const config = store.getState().providersConfig.openai;\n const modelIds = config.models.map((m) => m.id);\n expect(modelIds).toEqual(['gpt-4o']);\n expect(modelIds).not.toContain('gpt-4o-mini');\n expect(modelIds).not.toContain('gpt-4-turbo');\n });\n\n it('preserves custom server model IDs in server order', async () => {\n const store = await getStore();\n mockServerResponse({\n providers: {\n openai: { models: ['gpt-5.5', 'gpt-4o'] },\n },\n });\n\n await store.getState().fetchServerProviders();\n\n const models = store.getState().providersConfig.openai.models;\n expect(models.map((m) => m.id)).toEqual(['gpt-5.5', 'gpt-4o']);\n expect(models[0].name).toBe('gpt-5.5');\n expect(models[1].name).toBe('GPT-4o');\n });\n\n it('keeps all models when server provides no model restriction', async () => {\n const store = await getStore();\n mockServerResponse({\n providers: {\n openai: {}, // no models field = no restriction\n },\n });\n\n await store.getState().fetchServerProviders();\n\n const modelIds = store.getState().providersConfig.openai.models.map((m) => m.id);\n expect(modelIds).toContain('gpt-4o');\n expect(modelIds).toContain('gpt-4o-mini');\n expect(modelIds).toContain('gpt-4-turbo');\n });\n\n it('removes a model when server drops it from the allowed list', async () => {\n const store = await getStore();\n\n // Round 1: server allows two models\n mockServerResponse({\n providers: {\n openai: { models: ['gpt-4o', 'gpt-4o-mini'] },\n },\n });\n await store.getState().fetchServerProviders();\n expect(store.getState().providersConfig.openai.models.map((m) => m.id)).toEqual([\n 'gpt-4o',\n 'gpt-4o-mini',\n ]);\n\n // Round 2: server removes gpt-4o-mini\n mockServerResponse({\n providers: {\n openai: { models: ['gpt-4o'] },\n },\n });\n await store.getState().fetchServerProviders();\n const modelIds = store.getState().providersConfig.openai.models.map((m) => m.id);\n expect(modelIds).toEqual(['gpt-4o']);\n expect(modelIds).not.toContain('gpt-4o-mini');\n });\n\n // ---- Provider availability flags ----\n\n it('marks provider as server-configured when present in response', async () => {\n const store = await getStore();\n mockServerResponse({\n providers: {\n openai: { models: ['gpt-4o'] },\n },\n });\n\n await store.getState().fetchServerProviders();\n\n expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);\n });\n\n it('resets isServerConfigured when provider disappears from response', async () => {\n const store = await getStore();\n\n // Round 1: openai is server-configured\n mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });\n await store.getState().fetchServerProviders();\n expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);\n\n // Round 2: openai is no longer in server response\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n expect(store.getState().providersConfig.openai.isServerConfigured).toBe(false);\n });\n\n it('provider without client key and not server-configured has no usable path', async () => {\n const store = await getStore();\n mockServerResponse({}); // no server providers\n\n await store.getState().fetchServerProviders();\n\n const config = store.getState().providersConfig.openai;\n // No client key, not server-configured → provider should not be \"ready\"\n expect(config.apiKey).toBe('');\n expect(config.isServerConfigured).toBe(false);\n // This is the condition model-selector uses to decide if a provider is usable:\n const isUsable = isProviderUsable(config);\n expect(isUsable).toBe(false);\n });\n\n // ---- Multiple providers ----\n\n it('handles mixed provider state: one configured, one not', async () => {\n const store = await getStore();\n mockServerResponse({\n providers: {\n openai: { models: ['gpt-4o'] },\n // anthropic not in response\n },\n });\n\n await store.getState().fetchServerProviders();\n\n expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);\n expect(store.getState().providersConfig.anthropic.isServerConfigured).toBe(false);\n });\n\n // ---- serverModels metadata ----\n\n it('stores serverModels metadata for downstream filtering', async () => {\n const store = await getStore();\n mockServerResponse({\n providers: {\n openai: { models: ['gpt-4o', 'gpt-4o-mini'] },\n },\n });\n\n await store.getState().fetchServerProviders();\n\n expect(store.getState().providersConfig.openai.serverModels).toEqual(['gpt-4o', 'gpt-4o-mini']);\n });\n\n it('clears serverModels when provider removed from server', async () => {\n const store = await getStore();\n\n mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });\n await store.getState().fetchServerProviders();\n expect(store.getState().providersConfig.openai.serverModels).toEqual(['gpt-4o']);\n\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n expect(store.getState().providersConfig.openai.serverModels).toBeUndefined();\n });\n\n // ---- Stale selection consistency ----\n\n // BUG: fetchServerProviders() updates providersConfig.models but never\n // validates the current modelId/providerId selection against the new list.\n // These tests document the desired fix — remove .fails() once implemented.\n\n it('clears modelId when server removes the selected model', async () => {\n const store = await getStore();\n\n // User selects gpt-4o-mini while it's available\n store.getState().setModel('openai', 'gpt-4o-mini');\n expect(store.getState().modelId).toBe('gpt-4o-mini');\n\n // Server drops gpt-4o-mini\n mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });\n await store.getState().fetchServerProviders();\n\n // modelId should be cleared, not silently kept as a stale value\n expect(store.getState().modelId).toBe('gpt-4o');\n });\n\n it('clears providerId when entire provider loses server config and has no client key', async () => {\n const store = await getStore();\n\n // User on a server-only provider (no client key)\n store.getState().setModel('openai', 'gpt-4o');\n mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });\n await store.getState().fetchServerProviders();\n expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);\n\n // Server removes openai entirely — no client key either\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n // Provider is unusable → selection should be cleared\n expect(store.getState().providerId).toBe('');\n expect(store.getState().modelId).toBe('');\n });\n\n it('clears modelId when server narrows model list and selected model is excluded', async () => {\n const store = await getStore();\n\n // Round 1: user picks gpt-4-turbo\n mockServerResponse({\n providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo'] } },\n });\n await store.getState().fetchServerProviders();\n store.getState().setModel('openai', 'gpt-4-turbo');\n\n // Round 2: server narrows to gpt-4o only\n mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });\n await store.getState().fetchServerProviders();\n\n // Selection should be cleared, not left pointing to unavailable model\n expect(store.getState().modelId).toBe('gpt-4o');\n });\n\n it('keeps modelId when selected model is still available after server sync', async () => {\n const store = await getStore();\n\n store.getState().setModel('openai', 'gpt-4o');\n mockServerResponse({ providers: { openai: { models: ['gpt-4o', 'gpt-4o-mini'] } } });\n await store.getState().fetchServerProviders();\n\n // gpt-4o is still available — selection should be preserved\n expect(store.getState().providerId).toBe('openai');\n expect(store.getState().modelId).toBe('gpt-4o');\n });\n\n it('selects the server LLM model when provider fallback replaces the default provider', async () => {\n const store = await getStore();\n\n expect(store.getState().providerId).toBe('openai');\n expect(store.getState().modelId).toBe('');\n\n mockServerResponse({\n providers: {\n deepseek: { models: ['deepseek-chat'] },\n },\n });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().providerId).toBe('deepseek');\n expect(store.getState().modelId).toBe('deepseek-chat');\n });\n\n // ---- Error handling ----\n\n it('does not modify state when fetch returns non-ok response', async () => {\n const store = await getStore();\n\n // First, set up a known state\n mockServerResponse({ providers: { openai: { models: ['gpt-4o'] } } });\n await store.getState().fetchServerProviders();\n expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);\n\n // Now fetch returns an error\n mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });\n await store.getState().fetchServerProviders();\n\n // State should be unchanged — the failed fetch should not wipe existing config\n expect(store.getState().providersConfig.openai.isServerConfigured).toBe(true);\n });\n\n it('does not throw when fetch rejects (network error)', async () => {\n const store = await getStore();\n\n mockFetch.mockRejectedValueOnce(new Error('Network error'));\n\n // Should not throw — server providers are optional\n await expect(store.getState().fetchServerProviders()).resolves.not.toThrow();\n });\n});\n\ndescribe('fetchServerProviders — TTS stale selection', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n async function getStore() {\n const { useSettingsStore } = await import('@/lib/store/settings');\n return useSettingsStore;\n }\n\n it('falls back to browser-native-tts when selected TTS provider loses server config', async () => {\n const store = await getStore();\n\n mockServerResponse({ tts: { 'openai-tts': {} } });\n await store.getState().fetchServerProviders();\n store.getState().setTTSProvider('openai-tts');\n expect(store.getState().ttsProviderId).toBe('openai-tts');\n\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n expect(store.getState().ttsProviderId).toBe('browser-native-tts');\n });\n\n it('falls back to remaining server TTS provider when selected one is removed', async () => {\n const store = await getStore();\n\n mockServerResponse({ tts: { 'openai-tts': {}, 'azure-tts': {} } });\n await store.getState().fetchServerProviders();\n store.getState().setTTSProvider('openai-tts');\n\n mockServerResponse({ tts: { 'azure-tts': {} } });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().ttsProviderId).toBe('azure-tts');\n });\n\n it('keeps TTS provider when it is still server-configured', async () => {\n const store = await getStore();\n\n mockServerResponse({ tts: { 'openai-tts': {} } });\n await store.getState().fetchServerProviders();\n store.getState().setTTSProvider('openai-tts');\n\n mockServerResponse({ tts: { 'openai-tts': {} } });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().ttsProviderId).toBe('openai-tts');\n });\n});\n\ndescribe('fetchServerProviders — ASR stale selection', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n async function getStore() {\n const { useSettingsStore } = await import('@/lib/store/settings');\n return useSettingsStore;\n }\n\n it('falls back to browser-native when selected ASR provider loses server config', async () => {\n const store = await getStore();\n\n mockServerResponse({ asr: { 'openai-whisper': {} } });\n await store.getState().fetchServerProviders();\n store.getState().setASRProvider('openai-whisper');\n expect(store.getState().asrProviderId).toBe('openai-whisper');\n\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n expect(store.getState().asrProviderId).toBe('browser-native');\n });\n\n it('keeps ASR provider when it is still server-configured', async () => {\n const store = await getStore();\n\n mockServerResponse({ asr: { 'openai-whisper': {} } });\n await store.getState().fetchServerProviders();\n store.getState().setASRProvider('openai-whisper');\n\n mockServerResponse({ asr: { 'openai-whisper': {} } });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().asrProviderId).toBe('openai-whisper');\n });\n});\n\ndescribe('fetchServerProviders — Web Search provider sync', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n async function getStore() {\n const { useSettingsStore } = await import('@/lib/store/settings');\n return useSettingsStore;\n }\n\n it('marks Bocha as server-configured without storing a server base URL', async () => {\n const store = await getStore();\n mockServerResponse({\n webSearch: {\n bocha: {},\n },\n });\n\n await store.getState().fetchServerProviders();\n\n const bocha = store.getState().webSearchProvidersConfig.bocha;\n expect(bocha.isServerConfigured).toBe(true);\n // The server base URL is never exposed to / stored on the client.\n expect((bocha as Record<string, unknown>).serverBaseUrl).toBeUndefined();\n });\n\n it('falls back to Bocha when selected Tavily loses server config and has no client key', async () => {\n const store = await getStore();\n\n mockServerResponse({\n webSearch: {\n tavily: { baseUrl: 'https://api.tavily.com' },\n bocha: { baseUrl: 'https://api.bocha.cn' },\n },\n });\n await store.getState().fetchServerProviders();\n store.getState().setWebSearchProvider('tavily');\n\n mockServerResponse({\n webSearch: {\n bocha: { baseUrl: 'https://api.bocha.cn' },\n },\n });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().webSearchProviderId).toBe('bocha');\n });\n\n it('keeps Bocha selected when it is still server-configured', async () => {\n const store = await getStore();\n\n mockServerResponse({\n webSearch: {\n bocha: { baseUrl: 'https://api.bocha.cn' },\n },\n });\n await store.getState().fetchServerProviders();\n store.getState().setWebSearchProvider('bocha');\n\n mockServerResponse({\n webSearch: {\n bocha: { baseUrl: 'https://api.bocha.cn' },\n },\n });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().webSearchProviderId).toBe('bocha');\n });\n\n it('stores Baidu sub-source toggles and prevents disabling every source', async () => {\n const store = await getStore();\n\n expect(store.getState().baiduSubSources).toEqual({\n webSearch: true,\n baike: true,\n scholar: true,\n });\n\n store.getState().setBaiduSubSources({ webSearch: false, scholar: false });\n expect(store.getState().baiduSubSources).toEqual({\n webSearch: false,\n baike: true,\n scholar: false,\n });\n\n store.getState().setBaiduSubSources({ baike: false });\n expect(store.getState().baiduSubSources).toEqual({\n webSearch: false,\n baike: true,\n scholar: false,\n });\n });\n});\n\ndescribe('fetchServerProviders — PDF stale selection', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n async function getStore() {\n const { useSettingsStore } = await import('@/lib/store/settings');\n return useSettingsStore;\n }\n\n it('falls back to unpdf when mineru loses server config', async () => {\n const store = await getStore();\n\n mockServerResponse({ pdf: { mineru: {} } });\n await store.getState().fetchServerProviders();\n store.getState().setPDFProvider('mineru');\n\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n expect(store.getState().pdfProviderId).toBe('unpdf');\n });\n});\n\ndescribe('fetchServerProviders — Image stale selection', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n async function getStore() {\n const { useSettingsStore } = await import('@/lib/store/settings');\n return useSettingsStore;\n }\n\n it('clears imageProviderId and imageModelId when provider loses server config', async () => {\n const store = await getStore();\n\n mockServerResponse({ image: { seedream: {} } });\n await store.getState().fetchServerProviders();\n store.getState().setImageProvider('seedream');\n store.getState().setImageModelId('doubao-seedream-5-0-260128');\n\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n expect(store.getState().imageProviderId).toBe('');\n expect(store.getState().imageModelId).toBe('');\n });\n\n it('disables imageGenerationEnabled when no image provider is usable', async () => {\n const store = await getStore();\n\n // Server configures seedream, user enables image generation\n mockServerResponse({ image: { seedream: {} } });\n await store.getState().fetchServerProviders();\n store.getState().setImageProvider('seedream');\n store.getState().setImageGenerationEnabled(true);\n expect(store.getState().imageGenerationEnabled).toBe(true);\n\n // Server removes all image providers\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n expect(store.getState().imageGenerationEnabled).toBe(false);\n });\n\n it('prevents enabling image generation when no image provider is usable', async () => {\n const store = await getStore();\n\n // No server image providers\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n // User tries to enable image generation\n store.getState().setImageGenerationEnabled(true);\n expect(store.getState().imageGenerationEnabled).toBe(false);\n });\n\n it('preserves user-disabled image generation across server syncs', async () => {\n const store = await getStore();\n\n // Server has seedream, auto-enabled on first sync\n mockServerResponse({ image: { seedream: {} } });\n await store.getState().fetchServerProviders();\n expect(store.getState().imageGenerationEnabled).toBe(true);\n\n // User intentionally disables\n store.getState().setImageGenerationEnabled(false);\n expect(store.getState().imageGenerationEnabled).toBe(false);\n\n // Next server sync — same config, should NOT re-enable\n mockServerResponse({ image: { seedream: {} } });\n await store.getState().fetchServerProviders();\n expect(store.getState().imageGenerationEnabled).toBe(false);\n });\n\n it('falls back to another server-configured image provider', async () => {\n const store = await getStore();\n\n mockServerResponse({ image: { seedream: {}, 'qwen-image': {} } });\n await store.getState().fetchServerProviders();\n store.getState().setImageProvider('seedream');\n store.getState().setImageModelId('doubao-seedream-5-0-260128');\n\n mockServerResponse({ image: { 'qwen-image': {} } });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().imageProviderId).toBe('qwen-image');\n expect(store.getState().imageModelId).toBe('qwen-image-max');\n });\n\n it('auto-selects provider and model when server adds image provider after empty state', async () => {\n const store = await getStore();\n\n // Start with no image providers — selection is empty, generation disabled\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n expect(store.getState().imageProviderId).toBe('');\n expect(store.getState().imageModelId).toBe('');\n expect(store.getState().imageGenerationEnabled).toBe(false);\n\n // Server adds seedream\n mockServerResponse({ image: { seedream: {} } });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().imageProviderId).toBe('seedream');\n expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128');\n // Provider recovered but generation stays off — user enables manually\n expect(store.getState().imageGenerationEnabled).toBe(false);\n });\n\n it('auto-enables image generation on first load when server has image provider', async () => {\n const store = await getStore();\n\n // First ever fetchServerProviders — server has seedream\n // Default state: imageProviderId='seedream', imageGenerationEnabled=false, autoConfigApplied=false\n mockServerResponse({ image: { seedream: {} } });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().imageGenerationEnabled).toBe(true);\n expect(store.getState().imageProviderId).toBe('seedream');\n expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128');\n });\n\n it('does not force-enable when provider is already set but generation was disabled', async () => {\n const store = await getStore();\n\n // autoConfigApplied=true, provider already set, generation off (user choice)\n mockServerResponse({});\n await store.getState().fetchServerProviders(); // sets autoConfigApplied=true\n\n store.setState({\n imageProviderId: 'seedream',\n imageModelId: '',\n imageGenerationEnabled: false,\n });\n\n // Server has seedream — should NOT force-enable (provider was already set)\n mockServerResponse({ image: { seedream: {} } });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().imageGenerationEnabled).toBe(false);\n // But model should be auto-filled\n expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128');\n });\n});\n\ndescribe('fetchServerProviders — Video stale selection', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n async function getStore() {\n const { useSettingsStore } = await import('@/lib/store/settings');\n return useSettingsStore;\n }\n\n it('clears videoProviderId and videoModelId when provider loses server config', async () => {\n const store = await getStore();\n\n mockServerResponse({ video: { seedance: {} } });\n await store.getState().fetchServerProviders();\n store.getState().setVideoProvider('seedance');\n store.getState().setVideoModelId('doubao-seedance-1-5-pro-251215');\n\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n expect(store.getState().videoProviderId).toBe('');\n expect(store.getState().videoModelId).toBe('');\n });\n\n it('disables videoGenerationEnabled when no video provider is usable', async () => {\n const store = await getStore();\n\n mockServerResponse({ video: { seedance: {} } });\n await store.getState().fetchServerProviders();\n store.getState().setVideoProvider('seedance');\n store.getState().setVideoGenerationEnabled(true);\n expect(store.getState().videoGenerationEnabled).toBe(true);\n\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n expect(store.getState().videoGenerationEnabled).toBe(false);\n });\n\n it('prevents enabling video generation when no video provider is usable', async () => {\n const store = await getStore();\n\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n store.getState().setVideoGenerationEnabled(true);\n expect(store.getState().videoGenerationEnabled).toBe(false);\n });\n\n it('falls back to another server-configured video provider', async () => {\n const store = await getStore();\n\n mockServerResponse({ video: { seedance: {}, kling: {} } });\n await store.getState().fetchServerProviders();\n store.getState().setVideoProvider('seedance');\n store.getState().setVideoModelId('doubao-seedance-1-5-pro-251215');\n\n mockServerResponse({ video: { kling: {} } });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().videoProviderId).toBe('kling');\n expect(store.getState().videoModelId).toBe('kling-v2-6');\n });\n\n it('auto-selects provider and model when server adds video provider after empty state', async () => {\n const store = await getStore();\n\n // Start with no video providers — generation disabled\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n expect(store.getState().videoProviderId).toBe('');\n expect(store.getState().videoModelId).toBe('');\n expect(store.getState().videoGenerationEnabled).toBe(false);\n\n // Server adds seedance\n mockServerResponse({ video: { seedance: {} } });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().videoProviderId).toBe('seedance');\n expect(store.getState().videoModelId).toBe('doubao-seedance-1-5-pro-251215');\n // Provider recovered but generation stays off — user enables manually\n expect(store.getState().videoGenerationEnabled).toBe(false);\n });\n});\n\ndescribe('fetchServerProviders — LLM cross-provider fallback', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n async function getStore() {\n const { useSettingsStore } = await import('@/lib/store/settings');\n return useSettingsStore;\n }\n\n it('falls back to another server-configured LLM provider when current becomes unusable', async () => {\n const store = await getStore();\n\n mockServerResponse({\n providers: {\n openai: { models: ['gpt-4o'] },\n anthropic: { models: ['claude-sonnet-4-6'] },\n },\n });\n await store.getState().fetchServerProviders();\n store.getState().setModel('openai', 'gpt-4o');\n\n mockServerResponse({\n providers: {\n anthropic: { models: ['claude-sonnet-4-6'] },\n },\n });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().providerId).toBe('anthropic');\n expect(store.getState().modelId).toBe('claude-sonnet-4-6');\n });\n});\n\ndescribe('usable provider ⇒ concrete model invariant (#580)', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n async function getStore() {\n const { useSettingsStore } = await import('@/lib/store/settings');\n return useSettingsStore;\n }\n\n it('server sync: LLM provider usable via client API key resolves a concrete model (not empty)', async () => {\n const store = await getStore();\n\n // First sync establishes autoConfigApplied; server offers nothing.\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n // Illegal state #580 targets: a usable provider (client key) with empty model.\n store.setState({\n providerId: 'openai',\n modelId: '',\n providersConfig: {\n ...store.getState().providersConfig,\n openai: { ...store.getState().providersConfig.openai, apiKey: 'sk-client' },\n },\n });\n\n // Server still offers nothing — openai is usable ONLY via the client key.\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n\n expect(store.getState().providerId).toBe('openai');\n expect(store.getState().modelId).not.toBe('');\n expect(store.getState().modelId).toBe('gpt-4o');\n });\n\n it('first load: server-restricted model list is preferred over the built-in first model', async () => {\n const store = await getStore();\n\n // First ever sync, nothing selected, server restricts openai to a model\n // that is NOT the built-in first ('gpt-4o').\n mockServerResponse({ providers: { openai: { models: ['gpt-4o-mini'] } } });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().providerId).toBe('openai');\n expect(store.getState().modelId).toBe('gpt-4o-mini');\n });\n\n it('first load: server-configured LLM provider auto-selects provider and a concrete model', async () => {\n const store = await getStore();\n\n mockServerResponse({ providers: { anthropic: { models: ['claude-sonnet-4-6'] } } });\n await store.getState().fetchServerProviders();\n\n expect(store.getState().providerId).toBe('anthropic');\n expect(store.getState().modelId).toBe('claude-sonnet-4-6');\n });\n\n it('API-key entry resolves a concrete model atomically (no waiting for next server sync)', async () => {\n const store = await getStore();\n\n // openai is the active provider but not yet usable (no key) and has no\n // model selected — the illegal interim state #580 must not persist.\n store.setState({ providerId: 'openai', modelId: '' });\n\n store.getState().setProviderConfig('openai', {\n apiKey: 'sk-client',\n baseUrl: '',\n requiresApiKey: true,\n });\n\n expect(store.getState().providerId).toBe('openai');\n expect(store.getState().modelId).toBe('gpt-4o');\n });\n\n it('configuring a non-active provider does not hijack the current selection', async () => {\n const store = await getStore();\n\n mockServerResponse({});\n await store.getState().fetchServerProviders();\n store.setState({\n providerId: 'anthropic',\n modelId: 'claude-sonnet-4-6',\n providersConfig: {\n ...store.getState().providersConfig,\n anthropic: { ...store.getState().providersConfig.anthropic, apiKey: 'sk-a' },\n },\n });\n\n store.getState().setProviderConfig('openai', {\n apiKey: 'sk-o',\n baseUrl: '',\n requiresApiKey: true,\n });\n\n expect(store.getState().providerId).toBe('anthropic');\n expect(store.getState().modelId).toBe('claude-sonnet-4-6');\n });\n\n it('switching image provider resolves the new provider model (not a stale one)', async () => {\n const store = await getStore();\n\n // Start on seedream with a stale/foreign model id selected.\n store.setState({ imageProviderId: 'seedream', imageModelId: 'stale-model' });\n store.getState().setImageProvider('qwen-image');\n\n expect(store.getState().imageProviderId).toBe('qwen-image');\n expect(store.getState().imageModelId).toBe('qwen-image-max');\n });\n\n it('switching video provider resolves the new provider model (not a stale one)', async () => {\n const store = await getStore();\n\n // Default state is seedance/doubao-seedance…; switch to kling.\n store.getState().setVideoProvider('kling');\n\n expect(store.getState().videoProviderId).toBe('kling');\n expect(store.getState().videoModelId).toBe('kling-v2-6');\n });\n\n it('deleting the selected custom image model resolves back to a valid model', async () => {\n const store = await getStore();\n\n store.getState().setImageProvider('seedream');\n store.getState().setImageProviderConfig('seedream', {\n customModels: [{ id: 'my-custom-image', name: 'Custom' }],\n });\n store.getState().setImageModelId('my-custom-image');\n expect(store.getState().imageModelId).toBe('my-custom-image');\n\n // Delete the selected custom model — selection must not stay stale.\n store.getState().setImageProviderConfig('seedream', { customModels: [] });\n\n expect(store.getState().imageModelId).toBe('doubao-seedream-5-0-260128');\n });\n\n it('deleting the selected custom video model resolves back to a valid model', async () => {\n const store = await getStore();\n\n store.getState().setVideoProvider('seedance');\n store.getState().setVideoProviderConfig('seedance', {\n customModels: [{ id: 'my-custom-video', name: 'Custom' }],\n });\n store.getState().setVideoModelId('my-custom-video');\n expect(store.getState().videoModelId).toBe('my-custom-video');\n\n store.getState().setVideoProviderConfig('seedance', { customModels: [] });\n\n expect(store.getState().videoModelId).toBe('doubao-seedance-1-5-pro-251215');\n });\n\n it('deleting the selected provider (bulk setProvidersConfig) does not keep an invalid selection', async () => {\n const store = await getStore();\n const base = store.getState().providersConfig;\n\n // Built-ins require a key and have none → unusable. A custom provider is\n // the only usable one and is the active selection.\n const stripped = Object.fromEntries(\n Object.entries(base).map(([id, c]) => [id, { ...c, apiKey: '', isServerConfigured: false }]),\n ) as typeof base;\n const withCustom = {\n ...stripped,\n 'custom-tencent': {\n ...base.openai,\n apiKey: 'sk-t',\n baseUrl: 'https://tencent.example/v1',\n models: [{ id: 'hy3-preview', name: 'Hy3' }],\n name: 'Tencent',\n requiresApiKey: true,\n },\n } as typeof base;\n\n store.setState({\n providersConfig: withCustom,\n providerId: 'custom-tencent',\n modelId: 'hy3-preview',\n });\n\n // Delete it via the real delete path (the bulk config setter).\n store.getState().setProvidersConfig(stripped);\n\n // No usable provider remains ⇒ State A. Selection must NOT point at the\n // deleted provider, nor at an unusable built-in (e.g. openai + a model).\n expect(store.getState().providerId).not.toBe('custom-tencent');\n expect(store.getState().providerId).toBe('');\n expect(store.getState().modelId).toBe('');\n });\n\n it('clearing the selected provider API key (it becomes invalid) drops the stale selection', async () => {\n const store = await getStore();\n const base = store.getState().providersConfig;\n\n const stripped = Object.fromEntries(\n Object.entries(base).map(([id, c]) => [id, { ...c, apiKey: '', isServerConfigured: false }]),\n ) as typeof base;\n const withCustom = {\n ...stripped,\n 'custom-tencent': {\n ...base.openai,\n apiKey: 'sk-t',\n baseUrl: 'https://tencent.example/v1',\n models: [{ id: 'hy3-preview', name: 'Hy3' }],\n name: 'Tencent',\n requiresApiKey: true,\n },\n } as typeof base;\n\n store.setState({\n providersConfig: withCustom,\n providerId: 'custom-tencent',\n modelId: 'hy3-preview',\n });\n\n // The realistic case: user clears the selected provider's key in Settings\n // → it becomes invalid. The selection must NOT stay on it.\n store.getState().setProviderConfig('custom-tencent', { apiKey: '' });\n\n expect(store.getState().providerId).not.toBe('custom-tencent');\n expect(store.getState().providerId).toBe('');\n expect(store.getState().modelId).toBe('');\n });\n\n it('clearing a non-selected provider key keeps the still-usable current selection', async () => {\n const store = await getStore();\n const base = store.getState().providersConfig;\n\n const stripped = Object.fromEntries(\n Object.entries(base).map(([id, c]) => [id, { ...c, apiKey: '', isServerConfigured: false }]),\n ) as typeof base;\n const withTwo = {\n ...stripped,\n 'custom-a': {\n ...base.openai,\n apiKey: 'sk-a',\n baseUrl: 'https://a.example/v1',\n models: [{ id: 'a-1', name: 'A1' }],\n name: 'A',\n requiresApiKey: true,\n },\n 'custom-b': {\n ...base.openai,\n apiKey: 'sk-b',\n baseUrl: 'https://b.example/v1',\n models: [{ id: 'b-1', name: 'B1' }],\n name: 'B',\n requiresApiKey: true,\n },\n } as typeof base;\n\n store.setState({\n providersConfig: withTwo,\n providerId: 'custom-a',\n modelId: 'a-1',\n });\n\n store.getState().setProviderConfig('custom-b', { apiKey: '' });\n\n expect(store.getState().providerId).toBe('custom-a');\n expect(store.getState().modelId).toBe('a-1');\n });\n\n it('deleting a non-selected provider keeps the still-usable current selection', async () => {\n const store = await getStore();\n const base = store.getState().providersConfig;\n\n const stripped = Object.fromEntries(\n Object.entries(base).map(([id, c]) => [id, { ...c, apiKey: '', isServerConfigured: false }]),\n ) as typeof base;\n const withTwoCustom = {\n ...stripped,\n 'custom-a': {\n ...base.openai,\n apiKey: 'sk-a',\n baseUrl: 'https://a.example/v1',\n models: [{ id: 'a-1', name: 'A1' }],\n name: 'A',\n requiresApiKey: true,\n },\n 'custom-b': {\n ...base.openai,\n apiKey: 'sk-b',\n baseUrl: 'https://b.example/v1',\n models: [{ id: 'b-1', name: 'B1' }],\n name: 'B',\n requiresApiKey: true,\n },\n } as typeof base;\n\n store.setState({\n providersConfig: withTwoCustom,\n providerId: 'custom-a',\n modelId: 'a-1',\n });\n\n const withoutB = { ...withTwoCustom };\n delete (withoutB as Record<string, unknown>)['custom-b'];\n store.getState().setProvidersConfig(withoutB as typeof base);\n\n expect(store.getState().providerId).toBe('custom-a');\n expect(store.getState().modelId).toBe('a-1');\n });\n});\n\ndescribe('settings merge migration — custom provider baseUrl', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n it('promotes defaultBaseUrl into baseUrl for legacy custom providers', async () => {\n const { promoteLegacyCustomProviderBaseUrls } = await import('@/lib/store/settings');\n const state = {\n providersConfig: {\n 'custom-123': {\n apiKey: '',\n baseUrl: '',\n models: [{ id: 'test-model', name: 'Test Model' }],\n name: 'Legacy Custom',\n type: 'openai',\n defaultBaseUrl: 'https://example.com/v1',\n requiresApiKey: true,\n isBuiltIn: false,\n },\n },\n };\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentionally partial for unit test\n promoteLegacyCustomProviderBaseUrls(state as any);\n\n expect(state.providersConfig['custom-123'].baseUrl).toBe('https://example.com/v1');\n expect(state.providersConfig['custom-123'].defaultBaseUrl).toBe('https://example.com/v1');\n });\n\n it('does not promote defaultBaseUrl for built-in providers', async () => {\n const { promoteLegacyCustomProviderBaseUrls } = await import('@/lib/store/settings');\n const state = {\n providersConfig: {\n openai: {\n apiKey: '',\n baseUrl: '',\n models: [{ id: 'gpt-4o', name: 'GPT-4o' }],\n name: 'OpenAI',\n type: 'openai',\n defaultBaseUrl: 'https://persisted-openai.example/v1',\n requiresApiKey: true,\n isBuiltIn: true,\n },\n },\n };\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentionally partial for unit test\n promoteLegacyCustomProviderBaseUrls(state as any);\n\n expect(state.providersConfig.openai.baseUrl).toBe('');\n expect(state.providersConfig.openai.defaultBaseUrl).toBe('https://persisted-openai.example/v1');\n });\n});\n\ndescribe('settings store — outline review preference', () => {\n beforeEach(() => {\n vi.resetModules();\n storage.clear();\n mockFetch.mockReset();\n });\n\n async function getStore() {\n const { useSettingsStore } = await import('@/lib/store/settings');\n return useSettingsStore;\n }\n\n it('defaults reviewOutlineEnabled to false', async () => {\n const store = await getStore();\n\n expect(store.getState().reviewOutlineEnabled).toBe(false);\n });\n\n it('toggles reviewOutlineEnabled', async () => {\n const store = await getStore();\n\n store.getState().setReviewOutlineEnabled(true);\n\n expect(store.getState().reviewOutlineEnabled).toBe(true);\n });\n\n it('rehydrates older persisted settings without the outline flag to false', async () => {\n storage.set(\n 'settings-storage',\n JSON.stringify({\n state: {\n providerId: 'openai',\n modelId: 'gpt-4o',\n autoConfigApplied: true,\n },\n version: 2,\n }),\n );\n\n const store = await getStore();\n\n expect(store.getState().reviewOutlineEnabled).toBe(false);\n });\n});\n"}}}}