Files
daily_stock_analysis/tests/test_system_config_service.py
青玉案 b740beea2a fix: 公共 SearXNG 实例发现默认关闭 (#2226)
* fix: 公共 SearXNG 实例发现默认关闭

公共实例普遍存在限流(429)、下线(503)或未开启 JSON 输出的情况,
默认开启会让未配置任何搜索 key 的用户每次分析多耗 30~60 秒,
且新闻面最终为空却不在报告中提示(详见 #2225 的实测数据)。

- .env.example: 默认值改为 false,并说明原因与推荐做法
- docs/full-guide.md、docs/full-guide_EN.md: 同步默认值与说明
- tests: 放宽段落定位断言以适配新默认值,并新增一条测试固化该默认值

* fix: 公共 SearXNG 实例发现改在运行时层面默认关闭

按 review 意见修正:仅改 .env.example 只影响复制新模板的用户,
已有安装与 GitHub Actions(未配置 Variable/Secret 时变量为空)
仍会走 default=True 的运行时分支,继续承受 30~60 秒失败重试。

- src/config.py: parse_env_bool 的 default 由 True 改为 False(真正生效处)
- src/core/config_registry.py: 示例顺序调整为 false 优先
- .github/workflows/00-daily-analysis.yml: 变量未设置时诊断显示「默认关闭」,
  与运行时行为保持一致,不再误报为已开启
- tests: 新增三条回归测试,分别锁住运行时默认值、config.py 源码中的
  default=False、以及工作流诊断文案
- docs/CHANGELOG.md: 在 [Unreleased] 追加条目

显式设为 true 的用户行为不变。27 passed

* fix: align SearXNG public instance defaults

* fix: complete SearXNG opt-in defaults

---------

Co-authored-by: Mach-Chan <zz-b240@zz-b240deMacBook-Air.local>
Co-authored-by: ZhuLinsen <zhuls97@163.com>
2026-08-19 21:59:10 +08:00

4618 lines
200 KiB
Python
Raw Permalink 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.

# -*- coding: utf-8 -*-
"""Unit tests for system configuration service."""
import os
import json
import logging
import tempfile
import unittest
from contextlib import contextmanager
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
from unittest.mock import Mock, patch
import requests
from tests.litellm_stub import ensure_litellm_stub
ensure_litellm_stub()
from src.config import ANSPIRE_LLM_MODEL_DEFAULT, Config
from src.core.config_manager import ConfigManager
from src.llm.backend_registry import GENERATION_ONLY_BACKEND_IDS
from src.services.system_config_service import ConfigConflictError, ConfigImportError, ConfigValidationError, SystemConfigService
class SystemConfigServiceTestCase(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.env_path = Path(self.temp_dir.name) / ".env"
self.env_path.write_text(
"\n".join(
[
"STOCK_LIST=600519,000001",
"GEMINI_API_KEY=secret-key-value",
"SCHEDULE_TIME=18:00",
"LOG_LEVEL=INFO",
]
)
+ "\n",
encoding="utf-8",
)
os.environ["ENV_FILE"] = str(self.env_path)
Config.reset_instance()
self.manager = ConfigManager(env_path=self.env_path)
self.service = SystemConfigService(manager=self.manager)
def tearDown(self) -> None:
Config.reset_instance()
os.environ.pop("ENV_FILE", None)
self.temp_dir.cleanup()
def _rewrite_env(self, *lines: str) -> None:
self.env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
Config.reset_instance()
self.manager = ConfigManager(env_path=self.env_path)
self.service = SystemConfigService(manager=self.manager)
@staticmethod
def _mock_completion_response(content: str = "OK", tool_calls=None):
message = SimpleNamespace(content=content, tool_calls=tool_calls or [])
return SimpleNamespace(choices=[SimpleNamespace(message=message)])
def test_get_config_keeps_regular_sensitive_values_unmasked(self) -> None:
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertIn("openai", payload["llm_model_providers"])
self.assertIn("xai", payload["llm_model_providers"])
self.assertIn("GEMINI_API_KEY", items)
self.assertEqual(items["GEMINI_API_KEY"]["value"], "secret-key-value")
self.assertFalse(items["GEMINI_API_KEY"]["is_masked"])
self.assertTrue(items["GEMINI_API_KEY"]["raw_value_exists"])
def _assert_agent_backend_status_matches_runtime(
self,
*,
saved_backend: str,
runtime_backend: str,
) -> None:
from src.agent.agent_backend import resolve_agent_backend_id
from src.services.agent_backend_status_service import AgentBackendStatusService
self._rewrite_env(
"GEMINI_API_KEY=secret-key-value",
f"AGENT_BACKEND={saved_backend}",
"AGENT_ARCH=single",
)
codex_status = {
"backend": "codex_app_server",
"available": True,
"experimental": True,
"version": "codex-cli test",
"error_code": None,
"message": None,
}
with (
patch.dict(os.environ, {"AGENT_BACKEND": runtime_backend}),
patch.object(
AgentBackendStatusService,
"_codex_cheap_status",
return_value=codex_status,
),
):
Config.reset_instance()
runtime_config = Config.get_instance()
with patch.dict(os.environ, {"AGENT_BACKEND": saved_backend}):
settings_status = self.service.get_agent_backend_status()
chat_status = AgentBackendStatusService(config=runtime_config).get_status()
selected_backend = resolve_agent_backend_id(runtime_config)
preview_baseline = self.service.preview_agent_backend_status(items=[])
preview_draft = self.service.preview_agent_backend_status(
items=[{"key": "AGENT_BACKEND", "value": saved_backend}],
)
self.assertEqual(settings_status["backend"], runtime_backend)
self.assertEqual(chat_status["backend"], runtime_backend)
self.assertEqual(selected_backend, runtime_backend)
self.assertEqual(preview_baseline["backend"], runtime_backend)
self.assertEqual(preview_draft["backend"], saved_backend)
self.assertIs(Config.get_instance(), runtime_config)
def test_agent_backend_status_prefers_runtime_litellm_over_saved_codex(self) -> None:
self._assert_agent_backend_status_matches_runtime(
saved_backend="codex_app_server",
runtime_backend="litellm",
)
def test_agent_backend_status_prefers_runtime_codex_over_saved_litellm(self) -> None:
self._assert_agent_backend_status_matches_runtime(
saved_backend="litellm",
runtime_backend="codex_app_server",
)
def test_agent_backend_empty_preview_uses_runtime_generation_route(self) -> None:
from src.services.agent_backend_status_service import AgentBackendStatusService
self._rewrite_env(
"AGENT_BACKEND=litellm",
"LITELLM_MODEL=",
"OPENAI_API_KEY=",
)
runtime_env = {
"ENV_FILE": str(self.env_path),
"AGENT_BACKEND": "litellm",
"LITELLM_MODEL": "openai/gpt-4o",
"OPENAI_API_KEY": "runtime-key",
}
with patch.dict(os.environ, runtime_env, clear=True):
Config.reset_instance()
runtime_config = Config.get_instance()
settings_status = self.service.get_agent_backend_status()
chat_status = AgentBackendStatusService(config=runtime_config).get_status()
preview_baseline = self.service.preview_agent_backend_status(items=[])
preview_draft = self.service.preview_agent_backend_status(
items=[
{"key": "LITELLM_MODEL", "value": ""},
{"key": "OPENAI_API_KEY", "value": ""},
],
)
self.assertEqual(settings_status, chat_status)
self.assertEqual(preview_baseline, chat_status)
self.assertTrue(chat_status["available"])
self.assertFalse(preview_draft["available"])
self.assertEqual(preview_draft["message"], "no_agent_primary")
def test_get_config_masks_hermes_secret_fields(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LLM_CHANNELS=hermes",
"LLM_HERMES_API_KEY=sk-hermes-secret-value",
"LLM_HERMES_API_KEYS=sk-old-secret-value",
"LLM_HERMES_EXTRA_HEADERS={\"Authorization\":\"Bearer secret\"}",
)
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertEqual(items["LLM_HERMES_API_KEY"]["value"], payload["mask_token"])
self.assertTrue(items["LLM_HERMES_API_KEY"]["is_masked"])
self.assertEqual(items["LLM_HERMES_API_KEYS"]["value"], payload["mask_token"])
self.assertTrue(items["LLM_HERMES_API_KEYS"]["is_masked"])
self.assertEqual(items["LLM_HERMES_EXTRA_HEADERS"]["value"], payload["mask_token"])
self.assertTrue(items["LLM_HERMES_EXTRA_HEADERS"]["is_masked"])
def test_hermes_saved_secret_changed_port_does_not_send_request(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LLM_CHANNELS=hermes",
"LLM_HERMES_PROTOCOL=openai",
"LLM_HERMES_BASE_URL=http://127.0.0.1:8642/v1",
"LLM_HERMES_API_KEY=sk-hermes-secret-value",
)
with patch("src.services.system_config_service.requests.Session") as session_cls:
result = self.service.discover_llm_channel_models(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:9999/v1",
api_key="******",
models=["hermes-agent"],
use_saved_secret=True,
)
self.assertFalse(result["success"])
self.assertEqual(result["error_code"], "saved_secret_scope_mismatch")
session_cls.assert_not_called()
def test_hermes_saved_secret_runtime_env_cannot_rebind_endpoint(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LLM_CHANNELS=hermes",
"LLM_HERMES_PROTOCOL=openai",
"LLM_HERMES_BASE_URL=http://127.0.0.1:8642/v1",
"LLM_HERMES_API_KEY=saved-secret-token",
)
with patch.dict(os.environ, {"LLM_HERMES_BASE_URL": "http://127.0.0.1:9999/v1"}, clear=False), \
patch("src.services.system_config_service.requests.Session") as session_cls:
result = self.service.discover_llm_channel_models(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:9999/v1",
api_key="******",
models=["hermes-agent"],
use_saved_secret=True,
)
self.assertFalse(result["success"])
self.assertEqual(result["error_code"], "saved_secret_scope_mismatch")
self.assertNotIn("saved-secret-token", str(result))
session_cls.assert_not_called()
def test_hermes_model_discovery_uses_no_proxy_session(self) -> None:
observed: Dict[str, Any] = {}
class FakeSession:
def __init__(self) -> None:
self.trust_env = True
def get(self, url: str, **kwargs: Any) -> Any:
observed["url"] = url
observed["trust_env"] = self.trust_env
observed["headers"] = kwargs.get("headers") or {}
return SimpleNamespace(
status_code=200,
ok=True,
json=lambda: {"data": [{"id": "hermes-agent"}]},
)
def close(self) -> None:
observed["closed"] = True
with patch("src.services.system_config_service.requests.Session", side_effect=FakeSession), \
patch("src.services.system_config_service.requests.get") as requests_get:
result = self.service.discover_llm_channel_models(
name="hermes",
protocol="openai",
base_url="http://localhost:8642/v1",
api_key="sk-hermes-secret-value",
models=["hermes-agent"],
)
self.assertTrue(result["success"])
self.assertEqual(observed["url"], "http://127.0.0.1:8642/v1/models")
self.assertFalse(observed["trust_env"])
self.assertEqual(observed["headers"]["Authorization"], "Bearer sk-hermes-secret-value")
self.assertTrue(observed["closed"])
requests_get.assert_not_called()
def test_hermes_model_discovery_invalid_url_fails_before_request(self) -> None:
with patch("src.services.system_config_service.requests.Session") as session_cls:
result = self.service.discover_llm_channel_models(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1?next=proxy",
api_key="saved-secret-token",
models=["hermes-agent"],
)
rendered = json.dumps(result, ensure_ascii=False, default=str)
self.assertFalse(result["success"])
self.assertEqual(result["error_code"], "invalid_config")
self.assertEqual(result["details"]["reason"], "invalid_hermes_url")
self.assertNotIn("saved-secret-token", rendered)
session_cls.assert_not_called()
def test_hermes_model_discovery_http_error_redacts_non_sk_secret(self) -> None:
class FakeSession:
def __init__(self) -> None:
self.trust_env = True
def get(self, *_args: Any, **_kwargs: Any) -> Any:
return SimpleNamespace(
status_code=500,
ok=False,
json=lambda: {"error": {"message": "upstream saw saved-secret-token"}},
text="upstream saw saved-secret-token",
)
def close(self) -> None:
pass
with patch("src.services.system_config_service.requests.Session", side_effect=FakeSession):
result = self.service.discover_llm_channel_models(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1",
api_key="saved-secret-token",
models=["hermes-agent"],
)
rendered = json.dumps(result, ensure_ascii=False, default=str)
self.assertFalse(result["success"])
self.assertNotIn("saved-secret-token", rendered)
self.assertNotIn("Bearer saved-secret-token", rendered)
self.assertIn("[REDACTED]", rendered)
def test_llm_result_redacts_raw_comma_secret_and_segments(self) -> None:
raw_secret = "saved-secret-token,second-part"
redactions = self.service._build_redaction_values(raw_secret)
variants = [
"Bearer saved-secret-token,second-part",
"Bearer saved-secret-token, second-part",
"Bearer saved-secret-token ,second-part",
"Authorization: Bearer saved-secret-token, second-part",
"upstream saw saved-secret-token, second-part",
]
result = self.service._build_llm_channel_result(
success=False,
message="; ".join(variants),
error=" | ".join(variants),
stage="model_discovery",
error_code="network_error",
retryable=False,
details={
"raw": raw_secret,
"variants": variants,
"first": "saved-secret-token",
"second": "second-part",
},
capability_results={
"json": {
"status": "failed",
"message": variants[1],
"details": {"echo": variants[3]},
}
},
resolved_protocol="openai",
models=[],
latency_ms=None,
redaction_values=redactions,
)
rendered = json.dumps(result, ensure_ascii=False, default=str)
self.assertNotIn(raw_secret, rendered)
for variant in variants:
self.assertNotIn(variant, rendered)
self.assertNotIn("saved-secret-token", rendered)
self.assertNotIn("second-part", rendered)
self.assertIn("[REDACTED]", rendered)
def test_llm_result_recursively_redacts_models_details_and_capabilities(self) -> None:
raw_secret = "saved-secret-token,second-part"
redactions = self.service._build_redaction_values(raw_secret)
details = self.service._sanitize_llm_details(
{
"items": [
{"message": "saved-secret-token"},
[{"nested": "Bearer saved-secret-token, second-part"}],
],
"saved-secret-token": "second-part",
},
redaction_values=redactions,
)
result = self.service._build_llm_channel_result(
success=False,
message="upstream saw saved-secret-token, second-part",
error="Authorization: Bearer saved-secret-token, second-part",
stage="model_discovery",
error_code="network_error",
retryable=False,
details=details,
resolved_protocol="openai",
resolved_model="saved-secret-token",
models=["saved-secret-token", ["second-part"]],
capability_results={
"json": {
"status": "failed",
"details": {"items": [{"message": "saved-secret-token"}]},
}
},
redaction_values=redactions,
)
rendered = json.dumps(result, ensure_ascii=False, default=str)
self.assertNotIn(raw_secret, rendered)
self.assertNotIn("saved-secret-token, second-part", rendered)
self.assertNotIn("saved-secret-token", rendered)
self.assertNotIn("second-part", rendered)
self.assertIn("[REDACTED]", rendered)
def test_hermes_model_discovery_request_exception_redacts_response_and_logs(self) -> None:
class FakeSession:
def __init__(self) -> None:
self.trust_env = True
def get(self, *_args: Any, **_kwargs: Any) -> Any:
raise requests.RequestException("proxy saw saved-secret-token")
def close(self) -> None:
pass
with patch("src.services.system_config_service.requests.Session", side_effect=FakeSession), \
self.assertLogs("src.services.system_config_service", level="WARNING") as logs:
result = self.service.discover_llm_channel_models(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1",
api_key="saved-secret-token",
models=["hermes-agent"],
)
rendered = json.dumps(result, ensure_ascii=False, default=str)
log_text = "\n".join(logs.output)
self.assertFalse(result["success"])
self.assertNotIn("saved-secret-token", rendered)
self.assertNotIn("saved-secret-token", log_text)
self.assertIn("[REDACTED]", rendered)
self.assertIn("[REDACTED]", log_text)
def test_hermes_request_exception_redacts_comma_secret_variants_from_logs(self) -> None:
raw_secret = "saved-secret-token,second-part"
variants = [
"Bearer saved-secret-token,second-part",
"Bearer saved-secret-token, second-part",
"Bearer saved-secret-token ,second-part",
"Authorization: Bearer saved-secret-token, second-part",
"upstream saw saved-secret-token, second-part",
]
redactions = self.service._build_redaction_values(raw_secret)
sanitized = self.service._sanitize_llm_error_text(
" | ".join(variants),
redaction_values=redactions,
)
with self.assertLogs("src.services.system_config_service", level="WARNING") as logs:
logging.getLogger("src.services.system_config_service").warning(
"LLM channel model discovery failed for hermes: %s",
sanitized,
)
log_text = "\n".join(logs.output)
self.assertNotIn(raw_secret, log_text)
self.assertNotIn("saved-secret-token, second-part", log_text)
self.assertNotIn("saved-secret-token", log_text)
self.assertNotIn("second-part", log_text)
for variant in variants:
self.assertNotIn(variant, log_text)
self.assertIn("[REDACTED]", log_text)
def test_hermes_channel_test_invalid_url_fails_before_completion(self) -> None:
with patch("src.services.system_config_service.open_hermes_no_proxy_client") as no_proxy_client, \
patch("litellm.completion") as completion:
result = self.service.test_llm_channel(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1#fragment",
api_key="saved-secret-token",
models=["hermes-agent"],
capability_checks=["json"],
)
rendered = json.dumps(result, ensure_ascii=False, default=str)
self.assertFalse(result["success"])
self.assertEqual(result["error_code"], "invalid_config")
self.assertEqual(result["details"]["reason"], "invalid_hermes_url")
self.assertEqual(result["capability_results"]["json"]["status"], "skipped")
self.assertNotIn("saved-secret-token", rendered)
no_proxy_client.assert_not_called()
completion.assert_not_called()
def test_hermes_runtime_only_masked_key_is_not_sent_for_channel_test(self) -> None:
with patch("src.services.system_config_service.open_hermes_no_proxy_client") as no_proxy_client, \
patch("litellm.completion") as completion:
result = self.service.test_llm_channel(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1",
api_key="******",
models=["hermes-agent"],
use_saved_secret=False,
capability_checks=["json"],
)
self.assertFalse(result["success"])
self.assertEqual(result["error_code"], "runtime_secret_not_reusable")
self.assertEqual(result["details"]["reason"], "runtime_secret_not_reusable")
self.assertEqual(result["capability_results"]["json"]["status"], "skipped")
no_proxy_client.assert_not_called()
completion.assert_not_called()
def test_hermes_masked_key_is_not_sent_for_model_discovery(self) -> None:
with patch("src.services.system_config_service.requests.Session") as session_cls:
result = self.service.discover_llm_channel_models(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1",
api_key="******",
models=["hermes-agent"],
use_saved_secret=False,
)
self.assertFalse(result["success"])
self.assertEqual(result["error_code"], "runtime_secret_not_reusable")
self.assertEqual(result["details"]["reason"], "runtime_secret_not_reusable")
session_cls.assert_not_called()
def test_hermes_saved_literal_masked_key_is_not_reused(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LLM_CHANNELS=hermes",
"LLM_HERMES_BASE_URL=http://127.0.0.1:8642/v1",
"LLM_HERMES_API_KEY=******",
)
with patch("src.services.system_config_service.open_hermes_no_proxy_client") as no_proxy_client, \
patch("litellm.completion") as completion:
result = self.service.test_llm_channel(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1",
api_key="******",
models=["hermes-agent"],
use_saved_secret=True,
)
self.assertFalse(result["success"])
self.assertEqual(result["error_code"], "runtime_secret_not_reusable")
no_proxy_client.assert_not_called()
completion.assert_not_called()
def test_hermes_channel_test_rejects_comma_api_key_before_outbound(self) -> None:
raw_key = "key-a,key-b"
with patch("src.services.system_config_service.open_hermes_no_proxy_client") as no_proxy_client, \
patch("litellm.completion") as completion:
result = self.service.test_llm_channel(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1",
api_key=raw_key,
models=["hermes-agent"],
capability_checks=["json"],
)
rendered = json.dumps(result, ensure_ascii=False, default=str)
self.assertFalse(result["success"])
self.assertEqual(result["error_code"], "invalid_config")
self.assertEqual(result["details"]["reason"], "multiple_api_keys")
self.assertNotIn(raw_key, rendered)
self.assertNotIn("key-a", rendered)
self.assertNotIn("key-b", rendered)
no_proxy_client.assert_not_called()
completion.assert_not_called()
def test_hermes_model_discovery_rejects_comma_api_key_before_outbound(self) -> None:
raw_key = "key-a,key-b"
with patch("src.services.system_config_service.requests.Session") as session_cls:
result = self.service.discover_llm_channel_models(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1",
api_key=raw_key,
models=["hermes-agent"],
)
rendered = json.dumps(result, ensure_ascii=False, default=str)
self.assertFalse(result["success"])
self.assertEqual(result["error_code"], "invalid_config")
self.assertEqual(result["details"]["reason"], "multiple_api_keys")
self.assertNotIn(raw_key, rendered)
self.assertNotIn("key-a", rendered)
self.assertNotIn("key-b", rendered)
session_cls.assert_not_called()
def test_hermes_unsupported_capabilities_are_skipped_without_probe(self) -> None:
no_proxy_calls: List[Dict[str, Any]] = []
completion_models: List[str] = []
@contextmanager
def fake_no_proxy_openai_client(**kwargs: Any):
no_proxy_calls.append(kwargs)
yield object()
def fake_completion(**kwargs: Any) -> Any:
completion_models.append(str(kwargs.get("model") or ""))
self.assertFalse(kwargs.get("stream"))
self.assertIn("client", kwargs)
self.assertNotIn("api_key", kwargs)
self.assertNotIn("api_base", kwargs)
return self._mock_completion_response("OK")
with patch("src.services.system_config_service.open_hermes_no_proxy_client", fake_no_proxy_openai_client), \
patch("litellm.completion", side_effect=fake_completion):
result = self.service.test_llm_channel(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1",
api_key="sk-hermes-secret-value",
models=["hermes-agent"],
capability_checks=["tools", "stream", "vision"],
)
self.assertTrue(result["success"])
self.assertEqual(len(no_proxy_calls), 1)
self.assertEqual(completion_models, ["openai/hermes-agent"])
capability_results = result["capability_results"]
self.assertEqual(set(capability_results), {"tools", "stream", "vision"})
for capability in ("tools", "stream", "vision"):
self.assertEqual(capability_results[capability]["status"], "skipped")
self.assertEqual(capability_results[capability]["error_code"], "not_probed")
def test_hermes_failure_redacts_non_sk_secret_from_response_and_logs(self) -> None:
@contextmanager
def fake_no_proxy_openai_client(**_kwargs: Any):
yield object()
def fake_completion(**_kwargs: Any) -> Any:
raise RuntimeError("upstream echoed saved-secret-token")
with patch("src.services.system_config_service.open_hermes_no_proxy_client", fake_no_proxy_openai_client), \
patch("litellm.completion", side_effect=fake_completion), \
self.assertLogs("src.services.system_config_service", level="WARNING") as logs:
result = self.service.test_llm_channel(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1",
api_key="saved-secret-token",
models=["hermes-agent"],
)
self.assertFalse(result["success"])
self.assertNotIn("saved-secret-token", str(result))
self.assertNotIn("saved-secret-token", "\n".join(logs.output))
def test_hermes_json_capability_exception_redacts_non_sk_secret(self) -> None:
@contextmanager
def fake_no_proxy_openai_client(**_kwargs: Any):
yield object()
completion_calls = 0
def fake_completion(**_kwargs: Any) -> Any:
nonlocal completion_calls
completion_calls += 1
if completion_calls == 1:
return self._mock_completion_response("OK")
raise RuntimeError("json capability saw saved-secret-token")
with patch("src.services.system_config_service.open_hermes_no_proxy_client", fake_no_proxy_openai_client), \
patch("litellm.completion", side_effect=fake_completion):
result = self.service.test_llm_channel(
name="hermes",
protocol="openai",
base_url="http://127.0.0.1:8642/v1",
api_key="saved-secret-token",
models=["hermes-agent"],
capability_checks=["json"],
)
rendered = json.dumps(result, ensure_ascii=False, default=str)
self.assertTrue(result["success"])
self.assertEqual(result["capability_results"]["json"]["status"], "failed")
self.assertNotIn("saved-secret-token", rendered)
self.assertIn("[REDACTED]", rendered)
def test_get_config_masks_llm_usage_hmac_secret(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LLM_USAGE_HMAC_SECRET=telemetry-secret",
"LLM_USAGE_HMAC_KEY_VERSION=test-v1",
)
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertEqual(items["LLM_USAGE_HMAC_SECRET"]["value"], payload["mask_token"])
self.assertTrue(items["LLM_USAGE_HMAC_SECRET"]["is_masked"])
self.assertTrue(items["LLM_USAGE_HMAC_SECRET"]["schema"]["is_sensitive"])
self.assertEqual(items["LLM_USAGE_HMAC_KEY_VERSION"]["value"], "test-v1")
self.assertFalse(items["LLM_USAGE_HMAC_KEY_VERSION"]["is_masked"])
def test_get_config_uses_switch_default_for_missing_report_model_toggle(self) -> None:
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertEqual(items["REPORT_SHOW_LLM_MODEL"]["value"], "true")
self.assertFalse(items["REPORT_SHOW_LLM_MODEL"]["raw_value_exists"])
self._rewrite_env(
"STOCK_LIST=600519,000001",
"GEMINI_API_KEY=secret-key-value",
"SCHEDULE_TIME=18:00",
"LOG_LEVEL=INFO",
"REPORT_SHOW_LLM_MODEL=false",
)
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertEqual(items["REPORT_SHOW_LLM_MODEL"]["value"], "false")
self.assertTrue(items["REPORT_SHOW_LLM_MODEL"]["raw_value_exists"])
def test_get_config_defaults_public_searxng_instances_off(self) -> None:
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
public_instances = items["SEARXNG_PUBLIC_INSTANCES_ENABLED"]
self.assertEqual(public_instances["value"], "false")
self.assertFalse(public_instances["raw_value_exists"])
self.assertEqual(public_instances["schema"]["default_value"], "false")
self.assertIn("Default: false", public_instances["schema"]["description"])
def test_get_config_preserves_manual_agent_codex_cli_value_without_schema_option(self) -> None:
for backend in sorted(GENERATION_ONLY_BACKEND_IDS):
with self.subTest(backend=backend):
self._rewrite_env(
"STOCK_LIST=600519,000001",
f"AGENT_GENERATION_BACKEND={backend}",
)
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
agent_item = items["AGENT_GENERATION_BACKEND"]
self.assertEqual(agent_item["value"], backend)
self.assertNotIn(
backend,
{option["value"] for option in agent_item["schema"]["options"]},
)
def test_get_config_preserves_explicit_empty_switch_value(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"GEMINI_API_KEY=secret-key-value",
"SCHEDULE_TIME=18:00",
"LOG_LEVEL=INFO",
"WEBHOOK_VERIFY_SSL=",
)
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertEqual(items["WEBHOOK_VERIFY_SSL"]["value"], "")
self.assertTrue(items["WEBHOOK_VERIFY_SSL"]["raw_value_exists"])
def test_get_config_preserves_explicit_empty_report_show_llm_model_value(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"GEMINI_API_KEY=secret-key-value",
"SCHEDULE_TIME=18:00",
"LOG_LEVEL=INFO",
"REPORT_SHOW_LLM_MODEL=",
)
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertEqual(items["REPORT_SHOW_LLM_MODEL"]["value"], "")
self.assertTrue(items["REPORT_SHOW_LLM_MODEL"]["raw_value_exists"])
def test_get_config_uses_runtime_env_as_display_fallback(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519",
"LOG_LEVEL=INFO",
)
with patch.dict(
os.environ,
{
"STOCK_LIST": "300750",
"LITELLM_MODEL": "openai/gpt-5",
"LLM_CHANNELS": "my_proxy",
"LLM_MY_PROXY_BASE_URL": "https://proxy.example.com/v1",
"LLM_MY_PROXY_MODELS": "gpt-5",
"LLM_UNUSED_API_KEY": "sk-should-not-leak",
"UNRELATED_API_KEY": "sk-should-not-leak",
},
):
payload = self.service.get_config(include_schema=True)
raw_payload = self.service.get_config(include_schema=False)
items = {item["key"]: item for item in payload["items"]}
raw_items = {item["key"]: item for item in raw_payload["items"]}
self.assertEqual(items["STOCK_LIST"]["value"], "600519")
self.assertTrue(items["STOCK_LIST"]["raw_value_exists"])
self.assertEqual(items["LITELLM_MODEL"]["value"], "openai/gpt-5")
self.assertFalse(items["LITELLM_MODEL"]["raw_value_exists"])
self.assertEqual(items["LLM_CHANNELS"]["value"], "my_proxy")
self.assertFalse(items["LLM_CHANNELS"]["raw_value_exists"])
self.assertEqual(items["LLM_MY_PROXY_BASE_URL"]["value"], "https://proxy.example.com/v1")
self.assertFalse(items["LLM_MY_PROXY_BASE_URL"]["raw_value_exists"])
self.assertEqual(items["LLM_MY_PROXY_MODELS"]["value"], "gpt-5")
self.assertFalse(items["LLM_MY_PROXY_MODELS"]["raw_value_exists"])
self.assertNotIn("LLM_UNUSED_API_KEY", items)
self.assertNotIn("UNRELATED_API_KEY", items)
self.assertNotIn("LLM_UNUSED_API_KEY", raw_items)
self.assertNotIn("UNRELATED_API_KEY", raw_items)
def test_get_config_runtime_env_fallback_does_not_persist_llm_fields_on_save(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519",
"LOG_LEVEL=INFO",
)
startup_env = {
"LITELLM_MODEL": "openai/gpt-5",
"LLM_CHANNELS": "my_proxy",
"LLM_MY_PROXY_PROTOCOL": "openai",
"LLM_MY_PROXY_BASE_URL": "https://proxy.example.com/v1",
"LLM_MY_PROXY_API_KEYS": "sk-test-value",
"LLM_MY_PROXY_MODELS": "openai/gpt-5",
}
with patch.dict(os.environ, startup_env, clear=False):
payload_before = self.service.get_config(include_schema=True)
items_before = {item["key"]: item for item in payload_before["items"]}
self.assertEqual(items_before["LITELLM_MODEL"]["value"], "openai/gpt-5")
self.assertFalse(items_before["LITELLM_MODEL"]["raw_value_exists"])
self.assertEqual(
items_before["LLM_MY_PROXY_BASE_URL"]["value"],
"https://proxy.example.com/v1",
)
self.assertFalse(items_before["LLM_MY_PROXY_BASE_URL"]["raw_value_exists"])
self.assertEqual(items_before["LLM_MY_PROXY_MODELS"]["value"], "openai/gpt-5")
self.assertFalse(items_before["LLM_MY_PROXY_MODELS"]["raw_value_exists"])
current_version = self.manager.get_config_version()
response = self.service.update(
config_version=current_version,
items=[{"key": "STOCK_LIST", "value": "300750"}],
reload_now=False,
)
self.assertTrue(response["success"])
current_map = self.manager.read_config_map()
self.assertEqual(current_map["STOCK_LIST"], "300750")
self.assertNotIn("LITELLM_MODEL", current_map)
self.assertNotIn("LLM_MY_PROXY_BASE_URL", current_map)
self.assertNotIn("LLM_MY_PROXY_MODELS", current_map)
payload_after = self.service.get_config(include_schema=True)
items_after = {item["key"]: item for item in payload_after["items"]}
self.assertEqual(items_after["LITELLM_MODEL"]["value"], "openai/gpt-5")
self.assertFalse(items_after["LITELLM_MODEL"]["raw_value_exists"])
self.assertEqual(
items_after["LLM_MY_PROXY_BASE_URL"]["value"],
"https://proxy.example.com/v1",
)
self.assertFalse(items_after["LLM_MY_PROXY_BASE_URL"]["raw_value_exists"])
self.assertEqual(items_after["LLM_MY_PROXY_MODELS"]["value"], "openai/gpt-5")
self.assertFalse(items_after["LLM_MY_PROXY_MODELS"]["raw_value_exists"])
def test_runtime_env_fallback_does_not_override_saved_provider_and_base_url_settings(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519",
"LOG_LEVEL=INFO",
"LITELLM_MODEL=openai/gpt-4o-mini",
"OPENAI_MODEL=gpt-4.1",
)
with patch.dict(
os.environ,
{
"OPENAI_BASE_URL": "https://runtime-openai.v1",
"OPENAI_API_KEY": "runtime-openai-key",
},
clear=False,
):
pre_save = self.service.get_config(include_schema=True)
pre_save_items = {item["key"]: item for item in pre_save["items"]}
self.assertEqual(pre_save_items["OPENAI_BASE_URL"]["value"], "https://runtime-openai.v1")
self.assertFalse(pre_save_items["OPENAI_BASE_URL"]["raw_value_exists"])
self.assertEqual(pre_save_items["OPENAI_API_KEY"]["value"], "runtime-openai-key")
self.assertFalse(pre_save_items["OPENAI_API_KEY"]["raw_value_exists"])
self.assertEqual(pre_save_items["LITELLM_MODEL"]["value"], "openai/gpt-4o-mini")
self.assertTrue(pre_save_items["LITELLM_MODEL"]["raw_value_exists"])
self.assertEqual(pre_save_items["OPENAI_MODEL"]["value"], "gpt-4.1")
self.assertTrue(pre_save_items["OPENAI_MODEL"]["raw_value_exists"])
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "STOCK_LIST", "value": "300750"}],
reload_now=False,
)
self.assertTrue(response["success"])
current_map = self.manager.read_config_map()
self.assertEqual(current_map["STOCK_LIST"], "300750")
self.assertEqual(current_map["LITELLM_MODEL"], "openai/gpt-4o-mini")
self.assertEqual(current_map["OPENAI_MODEL"], "gpt-4.1")
self.assertNotIn("OPENAI_BASE_URL", current_map)
self.assertNotIn("OPENAI_API_KEY", current_map)
def test_validate_uses_runtime_injected_llm_channels_for_support_keys(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519",
"LOG_LEVEL=INFO",
)
with patch.dict(
os.environ,
{
"LLM_CHANNELS": "my_proxy",
"LLM_MY_PROXY_PROTOCOL": "openai",
"LLM_MY_PROXY_API_KEYS": "sk-test-value",
"LLM_MY_PROXY_BASE_URL": "https://proxy.example.com/v1",
"LLM_MY_PROXY_MODELS": "openai/gpt-5",
},
clear=False,
):
validation = self.service.validate(
items=[{"key": "LLM_MY_PROXY_BASE_URL", "value": "not-a-url"}],
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LLM_MY_PROXY_BASE_URL" and issue["code"] == "invalid_url"
for issue in validation["issues"]
)
)
def test_get_config_switch_type_uses_runtime_env_display_fallback(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519",
"LOG_LEVEL=INFO",
)
with patch.dict(os.environ, {"REPORT_SHOW_LLM_MODEL": "false"}, clear=False):
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertEqual(items["REPORT_SHOW_LLM_MODEL"]["value"], "false")
self.assertFalse(items["REPORT_SHOW_LLM_MODEL"]["raw_value_exists"])
def test_get_config_with_schema_hides_unregistered_env_keys(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"DATABASE_PATH=./custom/stock_analysis.db",
"SQLITE_WAL_ENABLED=true",
"USE_PROXY=true",
"PROXY_HOST=127.0.0.1",
"PROXY_PORT=10809",
"LOG_DIR=./logs",
)
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertNotIn("DATABASE_PATH", items)
self.assertNotIn("SQLITE_WAL_ENABLED", items)
self.assertNotIn("USE_PROXY", items)
self.assertNotIn("PROXY_HOST", items)
self.assertNotIn("PROXY_PORT", items)
self.assertIn("LOG_DIR", items)
self.assertEqual(items["LOG_DIR"]["schema"]["help_key"], "settings.system.LOG_DIR")
def test_get_config_with_schema_keeps_declared_llm_channel_support_keys(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LLM_CHANNELS=deepseek,my_proxy",
"LLM_DEEPSEEK_PROTOCOL=deepseek",
"LLM_DEEPSEEK_BASE_URL=https://api.deepseek.com",
"LLM_DEEPSEEK_API_KEY=sk-test-value",
"LLM_DEEPSEEK_MODELS=deepseek-v4-flash,deepseek-v4-pro",
"LLM_MY_PROXY_PROTOCOL=openai",
"LLM_MY_PROXY_API_KEYS=sk-key-1,sk-key-2",
"LLM_MY_PROXY_MODELS=gpt-5.5",
"LLM_UNUSED_API_KEY=sk-should-not-leak",
"DATABASE_PATH=./custom/stock_analysis.db",
)
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertIn("LLM_CHANNELS", items)
self.assertEqual(items["LLM_DEEPSEEK_API_KEY"]["value"], "sk-test-value")
self.assertEqual(items["LLM_DEEPSEEK_MODELS"]["value"], "deepseek-v4-flash,deepseek-v4-pro")
self.assertEqual(items["LLM_MY_PROXY_API_KEYS"]["value"], "sk-key-1,sk-key-2")
self.assertEqual(items["LLM_MY_PROXY_MODELS"]["value"], "gpt-5.5")
self.assertEqual(items["LLM_MY_PROXY_API_KEYS"]["schema"]["category"], "ai_model")
self.assertNotIn("LLM_UNUSED_API_KEY", items)
self.assertNotIn("DATABASE_PATH", items)
def test_get_config_without_schema_keeps_unregistered_env_keys(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"DATABASE_PATH=./custom/stock_analysis.db",
"SQLITE_WAL_ENABLED=true",
)
payload = self.service.get_config(include_schema=False)
items = {item["key"]: item for item in payload["items"]}
self.assertEqual(items["DATABASE_PATH"]["value"], "./custom/stock_analysis.db")
self.assertEqual(items["SQLITE_WAL_ENABLED"]["value"], "true")
self.assertNotIn("schema", items["DATABASE_PATH"])
def test_get_setup_status_reports_required_gaps_for_empty_config(self) -> None:
self._rewrite_env("")
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
self.assertFalse(status["is_complete"])
self.assertFalse(status["ready_for_smoke"])
self.assertEqual(status["next_step_key"], "llm_primary")
self.assertIn("llm_primary", status["required_missing_keys"])
self.assertIn("stock_list", status["required_missing_keys"])
def test_get_setup_status_marks_minimal_config_complete(self) -> None:
self._rewrite_env(
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=secret-key-value",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertTrue(status["is_complete"])
self.assertTrue(status["ready_for_smoke"])
self.assertEqual(checks["llm_primary"]["status"], "configured")
self.assertEqual(checks["llm_agent"]["status"], "inherited")
self.assertEqual(checks["stock_list"]["status"], "configured")
self.assertEqual(checks["notification"]["status"], "optional")
def test_generation_backend_status_preview_uses_draft_backend(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=secret-key-value",
)
with patch("src.llm.local_cli_backend.shutil.which", return_value=None):
payload = self.service.preview_generation_backend_status(
items=[
{"key": "GENERATION_BACKEND", "value": "codex_cli"},
{"key": "GENERATION_FALLBACK_BACKEND", "value": ""},
],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "codex_cli")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["health_status"], "failed")
self.assertEqual(payload["primary"]["last_error_code"], "command_not_found")
def test_generation_backend_status_preserves_masked_saved_secret(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=saved-secret-value",
)
payload = self.service.preview_generation_backend_status(
items=[{"key": "GEMINI_API_KEY", "value": "******"}],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertTrue(payload["primary"]["available"])
def test_generation_backend_status_saved_invalid_numeric_returns_failed_status(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
"GENERATION_BACKEND_TIMEOUT_SECONDS=not-int",
)
payload = self.service.get_generation_backend_status()
self.assertEqual(payload["primary_backend_id"], "codex_cli")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["health_status"], "failed")
self.assertEqual(payload["primary"]["last_error_code"], "unsafe_config")
def test_generation_backend_preview_invalid_numeric_returns_validation_error(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
)
with self.assertRaises(ConfigValidationError) as ctx:
self.service.preview_generation_backend_status(
items=[{"key": "GENERATION_BACKEND_TIMEOUT_SECONDS", "value": "not-int"}],
mask_token="******",
)
self.assertEqual(ctx.exception.issues[0]["key"], "GENERATION_BACKEND_TIMEOUT_SECONDS")
self.assertEqual(ctx.exception.issues[0]["severity"], "error")
def test_generation_backend_status_saved_litellm_invalid_channel_returns_failed_status(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LLM_CHANNELS=remote",
"LLM_REMOTE_PROTOCOL=openai",
"LLM_REMOTE_BASE_URL=http://169.254.169.254/v1",
"LLM_REMOTE_API_KEY=sk-remote",
"LLM_REMOTE_MODELS=gpt-4o-mini",
)
payload = self.service.get_generation_backend_status()
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["health_status"], "failed")
self.assertEqual(payload["primary"]["last_error_code"], "unsafe_config")
def test_generation_backend_status_saved_litellm_model_without_key_returns_failed_status(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
)
with patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True):
payload = self.service.get_generation_backend_status()
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["health_status"], "failed")
self.assertEqual(payload["primary"]["last_error_code"], "unsafe_config")
def test_generation_backend_preview_litellm_model_without_key_returns_validation_error(self) -> None:
self._rewrite_env("GENERATION_BACKEND=litellm")
with patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True):
with self.assertRaises(ConfigValidationError) as ctx:
self.service.preview_generation_backend_status(
items=[{"key": "LITELLM_MODEL", "value": "gemini/gemini-3-flash-preview"}],
mask_token="******",
)
self.assertEqual(ctx.exception.issues[0]["key"], "LITELLM_MODEL")
self.assertEqual(ctx.exception.issues[0]["code"], "missing_runtime_source")
def test_generation_backend_preview_uses_openai_model_draft(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"OPENAI_API_KEY=secret-key-value",
"OPENAI_MODEL=gpt-5.5",
)
with patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True):
payload = self.service.preview_generation_backend_status(
items=[{"key": "OPENAI_MODEL", "value": "gemini/gemini-3-flash-preview"}],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["last_error_code"], "unsafe_config")
def test_generation_backend_preview_uses_gemini_model_draft(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"GEMINI_API_KEY=secret-key-value",
"GEMINI_MODEL=gemini-3.1-pro-preview",
)
with patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True):
payload = self.service.preview_generation_backend_status(
items=[{"key": "GEMINI_MODEL", "value": "openai/gpt-5.5"}],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertFalse(payload["primary"]["available"])
self.assertEqual(payload["primary"]["last_error_code"], "unsafe_config")
def test_generation_backend_status_uses_runtime_provider_key_fallback(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
)
with patch.dict(
os.environ,
{
"ENV_FILE": str(self.env_path),
"GEMINI_API_KEY": "runtime-secret-value",
},
clear=True,
):
payload = self.service.get_generation_backend_status()
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertTrue(payload["primary"]["available"])
self.assertIsNone(payload["primary"]["last_error_code"])
def test_generation_backend_preview_local_cli_ignores_inactive_litellm_model_error(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
)
with patch("src.llm.local_cli_backend.shutil.which", return_value=None):
payload = self.service.preview_generation_backend_status(
items=[
{"key": "GENERATION_BACKEND", "value": "codex_cli"},
{"key": "GENERATION_FALLBACK_BACKEND", "value": ""},
],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "codex_cli")
self.assertEqual(payload["primary"]["last_error_code"], "command_not_found")
def test_generation_backend_preview_ignores_unrelated_draft_errors(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=litellm",
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=secret-key-value",
)
payload = self.service.preview_generation_backend_status(
items=[{"key": "WECHAT_WEBHOOK_URL", "value": "not-a-url"}],
mask_token="******",
)
self.assertEqual(payload["primary_backend_id"], "litellm")
self.assertTrue(payload["primary"]["available"])
def test_generation_backend_status_fallback_error_does_not_fail_primary(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=bad_backend",
)
with patch("src.llm.local_cli_backend.shutil.which", return_value="/usr/bin/codex"), \
patch("src.llm.local_cli_backend.os.access", return_value=True):
payload = self.service.get_generation_backend_status()
self.assertTrue(payload["primary"]["available"])
self.assertEqual(payload["fallback"]["backend_id"], "bad_backend")
self.assertFalse(payload["fallback"]["available"])
def test_get_setup_status_treats_codex_cli_as_primary_runtime_without_api_keys(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value="/usr/bin/codex"):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertFalse(status["is_complete"])
self.assertTrue(status["ready_for_smoke"])
self.assertEqual(checks["llm_primary"]["status"], "configured")
self.assertEqual(checks["llm_agent"]["status"], "needs_action")
self.assertIn("Codex CLI", checks["llm_primary"]["message"])
self.assertNotIn("llm_primary", status["required_missing_keys"])
self.assertIn("llm_agent", status["required_missing_keys"])
def test_get_setup_status_allows_local_cli_primary_smoke_without_agent_model(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=claude_code_cli",
"GENERATION_FALLBACK_BACKEND=",
"STOCK_LIST=AAPL",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value="/usr/bin/claude"):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertFalse(status["is_complete"])
self.assertTrue(status["ready_for_smoke"])
self.assertEqual(checks["llm_primary"]["status"], "configured")
self.assertEqual(checks["stock_list"]["status"], "configured")
self.assertEqual(checks["llm_agent"]["status"], "needs_action")
self.assertIn("local CLI 主生成方式不会被自动继承", checks["llm_agent"]["message"])
self.assertEqual(status["required_missing_keys"], ["llm_agent"])
def test_get_setup_status_codex_cli_missing_reports_backend_path(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value=None):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertEqual(checks["llm_primary"]["status"], "needs_action")
self.assertIn("后端进程当前 PATH", checks["llm_primary"]["message"])
self.assertIn("Codex CLI 交互窗口", checks["llm_primary"]["next_step"])
self.assertNotIn("请先安装并登录", checks["llm_primary"]["next_step"])
def test_get_setup_status_codex_primary_agent_model_explains_litellm_split(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
"AGENT_LITELLM_MODEL=openai/gpt-5.5",
"OPENAI_API_KEY=secret-key-value",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value="/usr/bin/codex"):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertEqual(checks["llm_agent"]["status"], "configured")
self.assertIn("普通分析使用 Codex CLI", checks["llm_agent"]["message"])
self.assertIn("Agent 工具调用仍使用 LiteLLM 主模型", checks["llm_agent"]["message"])
def test_get_setup_status_codex_primary_agent_inherited_model_explains_litellm_split(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
"LITELLM_MODEL=openai/gpt-5.5",
"OPENAI_API_KEY=secret-key-value",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value="/usr/bin/codex"):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertEqual(checks["llm_agent"]["status"], "configured")
self.assertIn(
"普通分析使用 Codex CLIAgent 工具调用仍使用 LiteLLM 主模型: openai/gpt-5.5",
checks["llm_agent"]["message"],
)
def test_get_setup_status_codex_primary_hermes_only_agent_inheritance_needs_action(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
"LLM_CHANNELS=hermes",
"LLM_HERMES_PROTOCOL=openai",
"LLM_HERMES_BASE_URL=http://127.0.0.1:8765/v1",
"LLM_HERMES_API_KEY=test-key",
"LLM_HERMES_MODELS=hermes-agent",
"LITELLM_MODEL=openai/hermes-agent",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value="/usr/bin/codex"):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertEqual(checks["llm_primary"]["status"], "configured")
self.assertEqual(checks["llm_agent"]["status"], "needs_action")
self.assertIn("Hermes", checks["llm_agent"]["message"])
self.assertIn("llm_agent", status["required_missing_keys"])
self.assertNotIn(
"Agent 工具调用仍使用 LiteLLM 主模型",
checks["llm_agent"]["message"],
)
def test_get_setup_status_rejects_agent_codex_cli_tool_backend(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"AGENT_GENERATION_BACKEND=codex_cli",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value="/usr/bin/codex"):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertEqual(checks["llm_agent"]["status"], "needs_action")
self.assertIn("暂不支持 codex_cli", checks["llm_agent"]["message"])
def test_get_setup_status_rejects_agent_claude_and_opencode_tool_backends(self) -> None:
for backend in ("claude_code_cli", "opencode_cli"):
with self.subTest(backend=backend):
self._rewrite_env(
"GENERATION_BACKEND=litellm",
f"AGENT_GENERATION_BACKEND={backend}",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertEqual(checks["llm_agent"]["status"], "needs_action")
self.assertIn(f"暂不支持 {backend}", checks["llm_agent"]["message"])
def test_get_setup_status_accepts_opencode_without_model_override(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=opencode_cli",
"GENERATION_FALLBACK_BACKEND=",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value="/usr/bin/opencode"):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertEqual(checks["llm_primary"]["status"], "configured")
self.assertIn("OpenCode CLI", checks["llm_primary"]["message"])
def test_get_setup_status_agent_litellm_without_model_reports_missing_model(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"AGENT_GENERATION_BACKEND=litellm",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value="/usr/bin/codex"):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertEqual(checks["llm_agent"]["status"], "needs_action")
self.assertIn("未检测到可用 LiteLLM 模型配置", checks["llm_agent"]["message"])
self.assertNotIn("需要 LiteLLM backend", checks["llm_agent"]["message"])
def test_get_setup_status_accepts_anspire_one_key_llm(self) -> None:
self._rewrite_env(
"ANSPIRE_API_KEYS=sk-anspire-test-value",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertTrue(status["is_complete"])
self.assertEqual(checks["llm_primary"]["status"], "configured")
self.assertIn("openai/Doubao-Seed-2.0-lite", checks["llm_primary"]["message"])
def test_get_setup_status_treats_blank_anspire_channel_enabled_as_shared_disable(self) -> None:
self._rewrite_env(
"LLM_CHANNELS=anspire",
"LLM_ANSPIRE_ENABLED=",
"ANSPIRE_LLM_ENABLED=false",
"ANSPIRE_API_KEYS=sk-anspire-test-value",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertFalse(status["is_complete"])
self.assertEqual(checks["llm_primary"]["status"], "needs_action")
self.assertIn("llm_primary", status["required_missing_keys"])
def test_get_setup_status_respects_disabled_anspire_channel_without_legacy_fallback(self) -> None:
self._rewrite_env(
"LLM_CHANNELS=anspire",
"LLM_ANSPIRE_ENABLED=false",
"ANSPIRE_API_KEYS=sk-anspire-test-value",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertFalse(status["is_complete"])
self.assertEqual(checks["llm_primary"]["status"], "needs_action")
self.assertIn("llm_primary", status["required_missing_keys"])
def test_get_setup_status_accepts_direct_env_primary_without_provider_key(self) -> None:
self._rewrite_env(
"LITELLM_MODEL=minimax/MiniMax-M1",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertTrue(status["is_complete"])
self.assertEqual(checks["llm_primary"]["status"], "configured")
self.assertEqual(checks["llm_agent"]["status"], "inherited")
def test_get_setup_status_matches_notification_channel_requirements(self) -> None:
base_lines = [
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=secret-key-value",
"STOCK_LIST=600519",
]
self._rewrite_env(*base_lines, "PUSHOVER_USER_KEY=user-key")
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
pushover_partial = next(check for check in status["checks"] if check["key"] == "notification")
self.assertEqual(pushover_partial["status"], "optional")
self._rewrite_env(*base_lines, "PUSHOVER_USER_KEY=user-key", "PUSHOVER_API_TOKEN=app-token")
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
pushover_complete = next(check for check in status["checks"] if check["key"] == "notification")
self.assertEqual(pushover_complete["status"], "configured")
self._rewrite_env(*base_lines, "SLACK_BOT_TOKEN=xoxb-test", "SLACK_CHANNEL_ID=C123")
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
slack_complete = next(check for check in status["checks"] if check["key"] == "notification")
self.assertEqual(slack_complete["status"], "configured")
self._rewrite_env(*base_lines, "ASTRBOT_URL=https://astrbot.example/webhook")
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
astrbot_complete = next(check for check in status["checks"] if check["key"] == "notification")
self.assertEqual(astrbot_complete["status"], "configured")
self._rewrite_env(*base_lines, "NTFY_URL=https://ntfy.sh/dsa-topic")
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
ntfy_complete = next(check for check in status["checks"] if check["key"] == "notification")
self.assertEqual(ntfy_complete["status"], "configured")
self._rewrite_env(*base_lines, "NTFY_URL=https://ntfy.sh")
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
ntfy_without_topic = next(check for check in status["checks"] if check["key"] == "notification")
self.assertEqual(ntfy_without_topic["status"], "optional")
self._rewrite_env(*base_lines, "GOTIFY_URL=https://gotify.example")
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
gotify_partial = next(check for check in status["checks"] if check["key"] == "notification")
self.assertEqual(gotify_partial["status"], "optional")
self._rewrite_env(*base_lines, "GOTIFY_URL=https://gotify.example", "GOTIFY_TOKEN=app-token")
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
gotify_complete = next(check for check in status["checks"] if check["key"] == "notification")
self.assertEqual(gotify_complete["status"], "configured")
self._rewrite_env(*base_lines, "GOTIFY_URL=https://gotify.example/message", "GOTIFY_TOKEN=app-token")
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
gotify_with_message = next(check for check in status["checks"] if check["key"] == "notification")
self.assertEqual(gotify_with_message["status"], "optional")
def test_get_setup_status_accepts_feishu_app_bot_triad(self) -> None:
self._rewrite_env(
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=secret-key-value",
"STOCK_LIST=600519",
"FEISHU_APP_ID=cli_xxx",
"FEISHU_APP_SECRET=secret_xxx",
"FEISHU_CHAT_ID=oc_xxx",
)
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
notification = next(check for check in status["checks"] if check["key"] == "notification")
self.assertEqual(notification["status"], "configured")
def test_get_setup_status_rejects_partial_feishu_app_bot_triad(self) -> None:
base_lines = [
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=secret-key-value",
"STOCK_LIST=600519",
]
partial_cases = [
("FEISHU_APP_ID=cli_xxx", "FEISHU_APP_SECRET=secret_xxx"),
("FEISHU_APP_ID=cli_xxx", "FEISHU_CHAT_ID=oc_xxx"),
("FEISHU_APP_SECRET=secret_xxx", "FEISHU_CHAT_ID=oc_xxx"),
]
for partial in partial_cases:
with self.subTest(partial=partial):
self._rewrite_env(*base_lines, *partial)
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
notification = next(check for check in status["checks"] if check["key"] == "notification")
self.assertEqual(notification["status"], "optional")
def test_get_setup_status_uses_runtime_env_without_reloading_singletons(self) -> None:
self._rewrite_env("")
with patch.dict(
os.environ,
{
"LITELLM_MODEL": "gemini/gemini-3-flash-preview",
"GEMINI_API_KEY": "runtime-secret",
"STOCK_LIST": "600519",
},
clear=True,
), patch("src.services.system_config_service.Config.reset_instance") as mock_reset, \
patch("src.services.system_config_service.setup_env") as mock_setup_env:
status = self.service.get_setup_status()
self.assertTrue(status["is_complete"])
mock_reset.assert_not_called()
mock_setup_env.assert_not_called()
def test_get_setup_status_storage_check_does_not_create_database_parent(self) -> None:
missing_parent = Path(self.temp_dir.name) / "missing-data"
db_path = missing_parent / "stock_analysis.db"
self._rewrite_env(
"LITELLM_MODEL=gemini/gemini-3-flash-preview",
"GEMINI_API_KEY=secret-key-value",
"STOCK_LIST=600519",
f"DATABASE_PATH={db_path}",
)
with patch.dict(os.environ, {}, clear=True):
status = self.service.get_setup_status()
storage_check = next(check for check in status["checks"] if check["key"] == "storage")
self.assertEqual(storage_check["status"], "configured")
self.assertFalse(missing_parent.exists())
def test_export_desktop_env_returns_raw_text(self) -> None:
self.env_path.write_text(
"# Desktop config\nSTOCK_LIST=600519,000001\n\nGEMINI_API_KEY=secret-key-value\n",
encoding="utf-8",
)
payload = self.service.export_desktop_env()
self.assertEqual(
payload["content"],
"# Desktop config\nSTOCK_LIST=600519,000001\n\nGEMINI_API_KEY=secret-key-value\n",
)
self.assertEqual(payload["config_version"], self.manager.get_config_version())
def test_export_desktop_env_preserves_hidden_web_settings_keys(self) -> None:
self.env_path.write_text(
"STOCK_LIST=600519\nDATABASE_PATH=./custom/stock_analysis.db\nUSE_PROXY=true\n",
encoding="utf-8",
)
payload = self.service.export_desktop_env()
self.assertIn("DATABASE_PATH=./custom/stock_analysis.db\n", payload["content"])
self.assertIn("USE_PROXY=true\n", payload["content"])
def test_import_desktop_env_merges_keys_without_deleting_unspecified_values(self) -> None:
current_version = self.manager.get_config_version()
payload = self.service.import_desktop_env(
config_version=current_version,
content="STOCK_LIST=300750\nCUSTOM_NOTE=desktop backup\n",
reload_now=False,
)
self.assertTrue(payload["success"])
current_map = self.manager.read_config_map()
self.assertEqual(current_map["STOCK_LIST"], "300750")
self.assertEqual(current_map["CUSTOM_NOTE"], "desktop backup")
self.assertEqual(current_map["GEMINI_API_KEY"], "secret-key-value")
def test_import_desktop_env_preserves_hidden_web_settings_keys(self) -> None:
current_version = self.manager.get_config_version()
self.service.import_desktop_env(
config_version=current_version,
content="DATABASE_PATH=./custom/stock_analysis.db\nPROXY_HOST=127.0.0.1\n",
reload_now=False,
)
current_map = self.manager.read_config_map()
self.assertEqual(current_map["DATABASE_PATH"], "./custom/stock_analysis.db")
self.assertEqual(current_map["PROXY_HOST"], "127.0.0.1")
def test_import_desktop_env_treats_mask_token_as_literal_value(self) -> None:
current_version = self.manager.get_config_version()
self.service.import_desktop_env(
config_version=current_version,
content="GEMINI_API_KEY=******\n",
reload_now=False,
)
current_map = self.manager.read_config_map()
self.assertEqual(current_map["GEMINI_API_KEY"], "******")
def test_import_desktop_env_uses_last_duplicate_assignment(self) -> None:
current_version = self.manager.get_config_version()
self.service.import_desktop_env(
config_version=current_version,
content="STOCK_LIST=000001\nSTOCK_LIST=300750\n",
reload_now=False,
)
current_map = self.manager.read_config_map()
self.assertEqual(current_map["STOCK_LIST"], "300750")
def test_import_desktop_env_allows_empty_assignment(self) -> None:
current_version = self.manager.get_config_version()
self.service.import_desktop_env(
config_version=current_version,
content="LOG_LEVEL=\n",
reload_now=False,
)
current_map = self.manager.read_config_map()
self.assertEqual(current_map["LOG_LEVEL"], "")
def test_import_desktop_env_preserves_exported_braced_webhook_template(self) -> None:
template = '{"content":${content_json}}'
save_payload = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "CUSTOM_WEBHOOK_BODY_TEMPLATE", "value": template}],
reload_now=False,
)
self.assertTrue(save_payload["success"])
backup_content = self.service.export_desktop_env()["content"]
self.assertIn(
'CUSTOM_WEBHOOK_BODY_TEMPLATE={"content":$${content_json}}\n',
backup_content,
)
clear_payload = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "CUSTOM_WEBHOOK_BODY_TEMPLATE", "value": ""}],
reload_now=False,
)
self.assertTrue(clear_payload["success"])
restore_payload = self.service.import_desktop_env(
config_version=self.manager.get_config_version(),
content=backup_content,
reload_now=False,
)
self.assertTrue(restore_payload["success"])
self.assertEqual(
self.manager.read_config_map()["CUSTOM_WEBHOOK_BODY_TEMPLATE"],
template,
)
def test_import_desktop_env_rejects_empty_or_comment_only_content(self) -> None:
with self.assertRaises(ConfigImportError):
self.service.import_desktop_env(
config_version=self.manager.get_config_version(),
content=" \n# only comments\n\n",
reload_now=False,
)
def test_import_desktop_env_raises_conflict_for_stale_version(self) -> None:
with self.assertRaises(ConfigConflictError):
self.service.import_desktop_env(
config_version="stale-version",
content="STOCK_LIST=300750\n",
reload_now=False,
)
def test_update_preserves_masked_secret(self) -> None:
old_version = self.manager.get_config_version()
response = self.service.update(
config_version=old_version,
items=[
{"key": "GEMINI_API_KEY", "value": "******"},
{"key": "STOCK_LIST", "value": "600519,300750"},
],
mask_token="******",
reload_now=False,
)
self.assertTrue(response["success"])
self.assertEqual(response["applied_count"], 1)
self.assertEqual(response["skipped_masked_count"], 1)
self.assertIn("STOCK_LIST", response["updated_keys"])
current_map = self.manager.read_config_map()
self.assertEqual(current_map["STOCK_LIST"], "600519,300750")
self.assertEqual(current_map["GEMINI_API_KEY"], "secret-key-value")
def test_update_builtin_screening_enable_does_not_rewrite_llm_fields(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LITELLM_MODEL=openai/gpt-4o-mini",
"AGENT_LITELLM_MODEL=openai/gpt-4o",
"OPENAI_BASE_URL=https://api.openai.com/v1",
"LLM_CHANNELS=openai",
"LLM_OPENAI_PROTOCOL=openai",
"LLM_OPENAI_BASE_URL=https://api.openai.com/v1",
"LLM_OPENAI_API_KEYS=legacy-openai-secret",
"LLM_OPENAI_MODELS=openai/gpt-4o-mini,openai/gpt-4o",
"LITELLM_FALLBACK_MODELS=openai/gpt-4o-mini,openai/gpt-4o",
"SCREENING_ENABLED=false",
"LLM_USAGE_HMAC_SECRET=telemetry-secret",
"LLM_USAGE_HMAC_KEY_VERSION=test-v1",
"GEMINI_API_KEY=legacy-secret",
)
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[
{"key": "SCREENING_ENABLED", "value": "true"},
{"key": "LLM_USAGE_HMAC_SECRET", "value": "******"},
{"key": "GEMINI_API_KEY", "value": "******"},
],
mask_token="******",
reload_now=False,
)
self.assertTrue(response["success"])
self.assertEqual(response["applied_count"], 1)
self.assertIn("SCREENING_ENABLED", response["updated_keys"])
self.assertEqual(response["skipped_masked_count"], 2)
current_map = self.manager.read_config_map()
self.assertEqual(current_map["SCREENING_ENABLED"], "true")
self.assertEqual(current_map["LLM_USAGE_HMAC_SECRET"], "telemetry-secret")
self.assertEqual(current_map["LLM_USAGE_HMAC_KEY_VERSION"], "test-v1")
self.assertEqual(current_map["GEMINI_API_KEY"], "legacy-secret")
self.assertEqual(current_map["LITELLM_MODEL"], "openai/gpt-4o-mini")
self.assertEqual(current_map["AGENT_LITELLM_MODEL"], "openai/gpt-4o")
self.assertEqual(current_map["OPENAI_BASE_URL"], "https://api.openai.com/v1")
self.assertEqual(current_map["LLM_CHANNELS"], "openai")
self.assertEqual(current_map["LLM_OPENAI_PROTOCOL"], "openai")
self.assertEqual(current_map["LLM_OPENAI_BASE_URL"], "https://api.openai.com/v1")
self.assertEqual(current_map["LLM_OPENAI_API_KEYS"], "legacy-openai-secret")
self.assertEqual(current_map["LLM_OPENAI_MODELS"], "openai/gpt-4o-mini,openai/gpt-4o")
self.assertEqual(current_map["LITELLM_FALLBACK_MODELS"], "openai/gpt-4o-mini,openai/gpt-4o")
def test_validate_reports_invalid_time(self) -> None:
validation = self.service.validate(items=[{"key": "SCHEDULE_TIME", "value": "25:70"}])
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_format" for issue in validation["issues"]))
def test_validate_accepts_empty_schedule_times_fallback(self) -> None:
validation = self.service.validate(items=[{"key": "SCHEDULE_TIMES", "value": ""}])
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_validate_reports_invalid_searxng_url(self) -> None:
validation = self.service.validate(items=[{"key": "SEARXNG_BASE_URLS", "value": "searx.local,https://ok.example"}])
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_url" for issue in validation["issues"]))
def test_validate_reports_invalid_public_searxng_toggle(self) -> None:
validation = self.service.validate(
items=[{"key": "SEARXNG_PUBLIC_INSTANCES_ENABLED", "value": "maybe"}]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_type" for issue in validation["issues"]))
def test_validate_reports_invalid_feishu_webhook_url(self) -> None:
validation = self.service.validate(
items=[{"key": "FEISHU_WEBHOOK_URL", "value": "feishu-hook-without-scheme"}]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_url" for issue in validation["issues"]))
def test_validate_reports_ntfy_url_without_topic(self) -> None:
validation = self.service.validate(
items=[{"key": "NTFY_URL", "value": "https://ntfy.sh"}]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "NTFY_URL" and issue["code"] == "invalid_ntfy_url"
for issue in validation["issues"]
)
)
def test_validate_reports_gotify_url_with_message_endpoint(self) -> None:
validation = self.service.validate(
items=[{"key": "GOTIFY_URL", "value": "https://gotify.example/message"}]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "GOTIFY_URL" and issue["code"] == "invalid_gotify_url"
for issue in validation["issues"]
)
)
def test_validate_reports_invalid_notification_route_channel(self) -> None:
validation = self.service.validate(
items=[{"key": "NOTIFICATION_REPORT_CHANNELS", "value": "wechat,not-a-channel,email"}]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "NOTIFICATION_REPORT_CHANNELS"
and issue["code"] == "invalid_allowed_value"
for issue in validation["issues"]
)
)
def test_validate_reports_invalid_notification_quiet_hours(self) -> None:
validation = self.service.validate(
items=[{"key": "NOTIFICATION_QUIET_HOURS", "value": "9:00-18:00"}]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "NOTIFICATION_QUIET_HOURS"
and issue["code"] == "invalid_format"
for issue in validation["issues"]
)
)
def test_validate_reports_invalid_notification_timezone(self) -> None:
validation = self.service.validate(
items=[{"key": "NOTIFICATION_TIMEZONE", "value": "Mars/Olympus"}]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "NOTIFICATION_TIMEZONE"
and issue["code"] == "invalid_timezone"
for issue in validation["issues"]
)
)
def test_validate_reports_invalid_notification_min_severity(self) -> None:
validation = self.service.validate(
items=[{"key": "NOTIFICATION_MIN_SEVERITY", "value": "notice"}]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "NOTIFICATION_MIN_SEVERITY"
and issue["code"] == "invalid_enum"
for issue in validation["issues"]
)
)
def test_validate_warns_daily_digest_is_reserved(self) -> None:
validation = self.service.validate(
items=[{"key": "NOTIFICATION_DAILY_DIGEST_ENABLED", "value": "true"}]
)
self.assertTrue(validation["valid"])
self.assertTrue(
any(
issue["key"] == "NOTIFICATION_DAILY_DIGEST_ENABLED"
and issue["code"] == "reserved_notification_daily_digest"
and issue["severity"] == "warning"
for issue in validation["issues"]
)
)
def test_validate_warns_when_feishu_app_credentials_are_used_without_webhook(self) -> None:
validation = self.service.validate(
items=[
{"key": "FEISHU_APP_ID", "value": "cli_xxx"},
{"key": "FEISHU_APP_SECRET", "value": "secret_xxx"},
]
)
self.assertTrue(validation["valid"])
issue = next(
issue
for issue in validation["issues"]
if issue["code"] == "feishu_mode_mismatch"
and issue["severity"] == "warning"
)
self.assertEqual(issue["key"], "FEISHU_CHAT_ID")
self.assertIn("FEISHU_CHAT_ID", issue["message"])
self.assertIn("static notification:", issue["expected"])
self.assertIn("event subscription:", issue["expected"])
def test_validate_no_warning_when_feishu_cloud_doc_credentials_without_webhook(self) -> None:
validation = self.service.validate(
items=[
{"key": "FEISHU_APP_ID", "value": "cli_xxx"},
{"key": "FEISHU_APP_SECRET", "value": "secret_xxx"},
{"key": "FEISHU_FOLDER_TOKEN", "value": "folder_xxx"},
]
)
self.assertTrue(validation["valid"])
self.assertFalse(
any(
issue["code"] == "feishu_mode_mismatch"
and issue["severity"] == "warning"
for issue in validation["issues"]
)
)
def test_validate_warns_when_only_folder_token_cleared_with_app_credentials(self) -> None:
"""Clearing FEISHU_FOLDER_TOKEN while app credentials remain should trigger mismatch."""
old_version = self.manager.get_config_version()
self.service.update(
config_version=old_version,
items=[
{"key": "FEISHU_APP_ID", "value": "cli_xxx"},
{"key": "FEISHU_APP_SECRET", "value": "secret_xxx"},
],
)
validation = self.service.validate(
items=[
{"key": "FEISHU_FOLDER_TOKEN", "value": ""},
]
)
self.assertTrue(validation["valid"])
self.assertTrue(
any(
issue["code"] == "feishu_mode_mismatch"
and issue["severity"] == "warning"
for issue in validation["issues"]
)
)
def test_update_persists_public_searxng_toggle(self) -> None:
old_version = self.manager.get_config_version()
response = self.service.update(
config_version=old_version,
items=[{"key": "SEARXNG_PUBLIC_INSTANCES_ENABLED", "value": "false"}],
reload_now=False,
)
self.assertTrue(response["success"])
current_map = self.manager.read_config_map()
self.assertEqual(current_map["SEARXNG_PUBLIC_INSTANCES_ENABLED"], "false")
def test_validate_reports_invalid_llm_channel_definition(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LLM_PRIMARY_API_KEY", "value": ""},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "missing_api_key" for issue in validation["issues"]))
def test_validate_rejects_unknown_llm_api_surface(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_SURFACE", "value": "automatic"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_api_surface" for issue in validation["issues"]))
def test_validate_rejects_unknown_anspire_llm_api_surface(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "anspire"},
{"key": "LLM_ANSPIRE_API_SURFACE", "value": "respones"},
{"key": "ANSPIRE_API_KEYS", "value": "sk-anspire-test-value"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LLM_ANSPIRE_API_SURFACE"
and issue["code"] == "invalid_api_surface"
for issue in validation["issues"]
)
)
def test_validate_requires_openai_protocol_for_responses_surface(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "deepseek"},
{"key": "LLM_PRIMARY_API_SURFACE", "value": "responses"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "deepseek-v4-flash"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(issue["code"] == "responses_requires_openai_protocol" for issue in validation["issues"])
)
def test_validate_rejects_non_openai_model_provider_for_responses_surface(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_SURFACE", "value": "responses"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "anthropic/claude-sonnet-4-6"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LLM_PRIMARY_MODELS"
and issue["code"] == "responses_requires_openai_model_provider"
for issue in validation["issues"]
)
)
def test_validate_rejects_litellm_direct_provider_for_responses_surface(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_SURFACE", "value": "responses"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "xai/grok-beta"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LLM_PRIMARY_MODELS"
and issue["code"] == "responses_requires_openai_model_provider"
for issue in validation["issues"]
)
)
def test_validate_rejects_duplicate_route_alias_with_mixed_surfaces(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "chat,responses"},
{"key": "LLM_CHAT_PROTOCOL", "value": "openai"},
{"key": "LLM_CHAT_API_KEY", "value": "sk-chat"},
{"key": "LLM_CHAT_MODELS", "value": "gpt-5.6-sol"},
{"key": "LLM_RESPONSES_PROTOCOL", "value": "openai"},
{"key": "LLM_RESPONSES_API_SURFACE", "value": "responses"},
{"key": "LLM_RESPONSES_API_KEY", "value": "sk-responses"},
{"key": "LLM_RESPONSES_MODELS", "value": "gpt-5.6-sol"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LLM_CHANNELS"
and issue["code"] == "mixed_api_surfaces_for_route"
and "openai/gpt-5.6-sol" in issue["message"]
for issue in validation["issues"]
)
)
def test_validate_rejects_responses_surface_for_hermes_channel(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "hermes"},
{"key": "LLM_HERMES_API_SURFACE", "value": "responses"},
{"key": "LLM_HERMES_API_KEY", "value": "sk-test-value"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(issue["code"] == "hermes_responses_unsupported" for issue in validation["issues"])
)
def test_validate_skips_stale_responses_surface_for_disabled_hermes_channel(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "hermes"},
{"key": "LLM_HERMES_ENABLED", "value": "false"},
{"key": "LLM_HERMES_API_SURFACE", "value": "responses"},
]
)
self.assertTrue(validation["valid"], validation["issues"])
self.assertFalse(
any(issue["key"] == "LLM_HERMES_API_SURFACE" for issue in validation["issues"])
)
def test_validate_preserves_model_based_protocol_inference_for_ollama_channel(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "lab"},
{"key": "LLM_LAB_MODELS", "value": "ollama/llama3"},
{"key": "LLM_LAB_API_KEY", "value": ""},
]
)
self.assertTrue(validation["valid"], validation["issues"])
self.assertEqual(validation["issues"], [])
def test_validate_reports_unknown_primary_model_for_channels(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LITELLM_MODEL", "value": "openai/gpt-4o"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["key"] == "LITELLM_MODEL" and issue["code"] == "unknown_model" for issue in validation["issues"]))
def test_validate_rejects_bare_primary_when_channel_route_is_openai_canonical(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LITELLM_MODEL", "value": "gpt-4o-mini"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["key"] == "LITELLM_MODEL" and issue["code"] == "unknown_model" for issue in validation["issues"]))
def test_validate_rejects_bare_fallback_when_channel_route_is_openai_canonical(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LITELLM_MODEL", "value": "openai/gpt-4o-mini"},
{"key": "LITELLM_FALLBACK_MODELS", "value": "gpt-4o-mini"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LITELLM_FALLBACK_MODELS"
and issue["code"] == "unknown_model"
for issue in validation["issues"]
)
)
def test_validate_reports_bare_vision_when_channel_route_is_openai_canonical(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "VISION_MODEL", "value": "gpt-4o-mini"},
]
)
self.assertTrue(
any(
issue["key"] == "VISION_MODEL"
and issue["code"] == "unknown_model"
for issue in validation["issues"]
),
validation["issues"],
)
def test_validate_accepts_deepseek_v4_primary_model_for_channel(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "deepseek"},
{"key": "LLM_DEEPSEEK_PROTOCOL", "value": "deepseek"},
{"key": "LLM_DEEPSEEK_BASE_URL", "value": "https://api.deepseek.com"},
{"key": "LLM_DEEPSEEK_API_KEY", "value": "sk-test-value"},
{"key": "LLM_DEEPSEEK_MODELS", "value": "deepseek-v4-flash,deepseek-v4-pro"},
{"key": "LITELLM_MODEL", "value": "deepseek/deepseek-v4-flash"},
]
)
self.assertTrue(validation["valid"], validation["issues"])
self.assertEqual(validation["issues"], [])
def test_validate_reports_unknown_agent_primary_model_for_channels(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "AGENT_LITELLM_MODEL", "value": "openai/gpt-4o"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["key"] == "AGENT_LITELLM_MODEL" and issue["code"] == "unknown_model" for issue in validation["issues"]))
def test_validate_accepts_unprefixed_agent_model_when_channel_declares_openai_model(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "AGENT_LITELLM_MODEL", "value": "gpt-4o-mini"},
]
)
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_validate_rejects_explicit_hermes_only_agent_model(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "hermes"},
{"key": "LLM_HERMES_API_KEY", "value": "sk-hermes-test-value"},
{"key": "LLM_HERMES_MODELS", "value": "hermes-agent"},
{"key": "AGENT_LITELLM_MODEL", "value": "openai/hermes-agent"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "AGENT_LITELLM_MODEL"
and issue["code"] == "explicit_agent_model_no_safe_deployment"
for issue in validation["issues"]
)
)
def test_validate_allows_explicit_mixed_agent_model(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "hermes,remote"},
{"key": "LLM_HERMES_API_KEY", "value": "sk-hermes-test-value"},
{"key": "LLM_HERMES_MODELS", "value": "shared-route"},
{"key": "LLM_REMOTE_PROTOCOL", "value": "openai"},
{"key": "LLM_REMOTE_BASE_URL", "value": "https://api.example.com/v1"},
{"key": "LLM_REMOTE_API_KEY", "value": "sk-remote-test-value"},
{"key": "LLM_REMOTE_MODELS", "value": "shared-route"},
{"key": "AGENT_LITELLM_MODEL", "value": "openai/shared-route"},
]
)
self.assertFalse(
any(
issue["key"] == "AGENT_LITELLM_MODEL"
and issue["code"] == "explicit_agent_model_no_safe_deployment"
for issue in validation["issues"]
),
validation["issues"],
)
def test_validate_rejects_mixed_generation_primary_and_fallback(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "hermes,remote"},
{"key": "LLM_HERMES_API_KEY", "value": "sk-hermes-test-value"},
{"key": "LLM_HERMES_MODELS", "value": "shared-route"},
{"key": "LLM_REMOTE_PROTOCOL", "value": "openai"},
{"key": "LLM_REMOTE_BASE_URL", "value": "https://api.example.com/v1"},
{"key": "LLM_REMOTE_API_KEY", "value": "sk-remote-test-value"},
{"key": "LLM_REMOTE_MODELS", "value": "shared-route"},
{"key": "LITELLM_MODEL", "value": "openai/shared-route"},
{"key": "LITELLM_FALLBACK_MODELS", "value": "openai/shared-route"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LITELLM_MODEL"
and issue["code"] == "mixed_hermes_route_unsupported"
for issue in validation["issues"]
)
)
self.assertTrue(
any(
issue["key"] == "LITELLM_FALLBACK_MODELS"
and issue["code"] == "mixed_hermes_route_unsupported"
for issue in validation["issues"]
)
)
def test_validate_rejects_bare_mixed_generation_primary_and_fallback(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "hermes,remote"},
{"key": "LLM_HERMES_API_KEY", "value": "sk-hermes-test-value"},
{"key": "LLM_HERMES_MODELS", "value": "shared-route"},
{"key": "LLM_REMOTE_PROTOCOL", "value": "openai"},
{"key": "LLM_REMOTE_BASE_URL", "value": "https://api.example.com/v1"},
{"key": "LLM_REMOTE_API_KEY", "value": "sk-remote-test-value"},
{"key": "LLM_REMOTE_MODELS", "value": "shared-route"},
{"key": "LITELLM_MODEL", "value": "shared-route"},
{"key": "LITELLM_FALLBACK_MODELS", "value": "shared-route"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "LITELLM_MODEL"
and issue["code"] == "mixed_hermes_route_unsupported"
for issue in validation["issues"]
)
)
self.assertTrue(
any(
issue["key"] == "LITELLM_FALLBACK_MODELS"
and issue["code"] == "mixed_hermes_route_unsupported"
for issue in validation["issues"]
)
)
def test_validate_rejects_bare_hermes_vision_model(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "hermes"},
{"key": "LLM_HERMES_API_KEY", "value": "sk-hermes-test-value"},
{"key": "LLM_HERMES_MODELS", "value": "hermes-agent"},
{"key": "VISION_MODEL", "value": "hermes-agent"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(
issue["key"] == "VISION_MODEL"
and issue["code"] == "hermes_vision_unsupported"
for issue in validation["issues"]
)
)
@patch.object(
Config,
"_parse_litellm_yaml",
return_value=[
{
"model_name": "gpt4o",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test-value"},
}
],
)
def test_validate_accepts_unprefixed_agent_model_when_yaml_declares_alias(self, _mock_parse_yaml) -> None:
validation = self.service.validate(
items=[
{"key": "LITELLM_CONFIG", "value": "/tmp/litellm.yaml"},
{"key": "AGENT_LITELLM_MODEL", "value": "gpt4o"},
]
)
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
@patch.object(
Config,
"_parse_litellm_yaml",
return_value=[{"model_name": "gemini/gemini-2.5-flash", "litellm_params": {"model": "gemini/gemini-2.5-flash"}}],
)
def test_validate_skips_channel_checks_when_litellm_yaml_is_active(self, _mock_parse_yaml) -> None:
validation = self.service.validate(
items=[
{"key": "LITELLM_CONFIG", "value": "/tmp/litellm.yaml"},
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LLM_PRIMARY_API_KEY", "value": ""},
{"key": "LITELLM_MODEL", "value": "gemini/gemini-2.5-flash"},
]
)
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_get_config_preserves_labeled_select_options_and_enum_validation(self) -> None:
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
agent_arch_schema = items["AGENT_ARCH"]["schema"]
self.assertEqual(agent_arch_schema["options"][0]["value"], "single")
self.assertEqual(agent_arch_schema["options"][1]["label"], "Multi Agent (Orchestrator)")
self.assertEqual(agent_arch_schema["validation"]["enum"], ["single", "multi"])
report_language_schema = items["REPORT_LANGUAGE"]["schema"]
self.assertEqual(report_language_schema["validation"]["enum"], ["zh", "en", "ko"])
self.assertEqual(report_language_schema["options"][1]["value"], "en")
self.assertEqual(report_language_schema["options"][2]["value"], "ko")
self.assertEqual(items["AGENT_ORCHESTRATOR_TIMEOUT_S"]["schema"]["default_value"], "600")
self.assertTrue(items["AGENT_DEEP_RESEARCH_BUDGET"]["schema"]["is_editable"])
self.assertTrue(items["AGENT_EVENT_MONITOR_ENABLED"]["schema"]["is_editable"])
context_profile_schema = items["AGENT_CONTEXT_COMPRESSION_PROFILE"]["schema"]
self.assertEqual(
[option["label"] for option in context_profile_schema["options"]],
["成本优先", "均衡推荐", "长上下文原文优先"],
)
self.assertEqual(
context_profile_schema["validation"]["enum"],
["cost", "balanced", "long_context_raw_first"],
)
market_review_schema = items["MARKET_REVIEW_REGION"]["schema"]
self.assertEqual(
market_review_schema["validation"]["allowed_values"],
["cn", "hk", "us", "jp", "kr", "both"],
)
self.assertEqual(market_review_schema["validation"]["delimiter"], ",")
self.assertEqual(
items["AGENT_CONTEXT_COMPRESSION_TRIGGER_TOKENS"]["schema"]["default_value"],
"",
)
self.assertEqual(
items["AGENT_CONTEXT_PROTECTED_TURNS"]["schema"]["default_value"],
"",
)
def test_validate_reports_invalid_select_option(self) -> None:
validation = self.service.validate(items=[{"key": "AGENT_ARCH", "value": "invalid-mode"}])
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_enum" for issue in validation["issues"]))
def test_validate_rejects_codex_backend_with_multi_agent_architecture(self) -> None:
validation = self.service.validate(
items=[
{"key": "AGENT_BACKEND", "value": "codex_app_server"},
{"key": "AGENT_ARCH", "value": "multi"},
]
)
self.assertFalse(validation["valid"])
issue = next(
issue
for issue in validation["issues"]
if issue["code"] == "unsupported_agent_arch"
)
self.assertEqual(issue["key"], "AGENT_ARCH")
self.assertEqual(issue["expected"], "single")
def test_validate_rejects_disabled_timeout_for_codex_only(self) -> None:
codex = self.service.validate(
items=[
{"key": "AGENT_BACKEND", "value": "codex_app_server"},
{"key": "AGENT_ORCHESTRATOR_TIMEOUT_S", "value": "0"},
]
)
litellm = self.service.validate(
items=[
{"key": "AGENT_BACKEND", "value": "litellm"},
{"key": "AGENT_ORCHESTRATOR_TIMEOUT_S", "value": "0"},
]
)
self.assertFalse(codex["valid"])
self.assertTrue(
any(issue["code"] == "codex_timeout_required" for issue in codex["issues"])
)
self.assertTrue(litellm["valid"])
def test_validate_reports_generation_backend_numeric_maximum(self) -> None:
validation = self.service.validate(
items=[
{"key": "GENERATION_BACKEND_TIMEOUT_SECONDS", "value": "3601"},
{"key": "GENERATION_BACKEND_MAX_OUTPUT_BYTES", "value": "33554433"},
{"key": "GENERATION_BACKEND_MAX_CONCURRENCY", "value": "17"},
{"key": "LOCAL_CLI_BACKEND_MAX_CONCURRENCY", "value": "5"},
]
)
self.assertFalse(validation["valid"])
issues = {issue["key"]: issue for issue in validation["issues"]}
self.assertEqual(issues["GENERATION_BACKEND_TIMEOUT_SECONDS"]["expected"], "<=3600")
self.assertEqual(issues["GENERATION_BACKEND_MAX_OUTPUT_BYTES"]["expected"], "<=33554432")
self.assertEqual(issues["GENERATION_BACKEND_MAX_CONCURRENCY"]["expected"], "<=16")
self.assertEqual(issues["LOCAL_CLI_BACKEND_MAX_CONCURRENCY"]["expected"], "<=4")
def test_validate_accepts_report_language_english(self) -> None:
validation = self.service.validate(items=[{"key": "REPORT_LANGUAGE", "value": "en"}])
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_validate_accepts_report_language_korean(self) -> None:
validation = self.service.validate(items=[{"key": "REPORT_LANGUAGE", "value": "ko"}])
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_validate_accepts_comma_separated_market_review_region(self) -> None:
validation = self.service.validate(
items=[{"key": "MARKET_REVIEW_REGION", "value": "cn,jp,us"}]
)
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_validate_accepts_blank_context_compression_preset_fields(self) -> None:
validation = self.service.validate(
items=[
{"key": "AGENT_CONTEXT_COMPRESSION_TRIGGER_TOKENS", "value": ""},
{"key": "AGENT_CONTEXT_PROTECTED_TURNS", "value": ""},
]
)
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_validate_reports_invalid_context_compression_profile(self) -> None:
validation = self.service.validate(
items=[{"key": "AGENT_CONTEXT_COMPRESSION_PROFILE", "value": "invalid"}]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_enum" for issue in validation["issues"]))
def test_config_loads_context_compression_preset_when_numeric_values_are_blank(self) -> None:
self.env_path.write_text(
"\n".join(
[
"AGENT_CONTEXT_COMPRESSION_PROFILE=cost",
"AGENT_CONTEXT_COMPRESSION_TRIGGER_TOKENS=",
"AGENT_CONTEXT_PROTECTED_TURNS=",
]
)
+ "\n",
encoding="utf-8",
)
with patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True):
config = Config._load_from_env()
self.assertEqual(config.agent_context_compression_profile, "cost")
self.assertEqual(config.agent_context_compression_trigger_tokens, 6000)
self.assertEqual(config.agent_context_protected_turns, 2)
self.env_path.write_text(
"AGENT_CONTEXT_COMPRESSION_PROFILE=long_context_raw_first\n",
encoding="utf-8",
)
with patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True):
config = Config._load_from_env()
self.assertEqual(config.agent_context_compression_profile, "long_context_raw_first")
self.assertEqual(config.agent_context_compression_trigger_tokens, 24000)
self.assertEqual(config.agent_context_protected_turns, 6)
self.env_path.write_text(
"\n".join(
[
"AGENT_CONTEXT_COMPRESSION_PROFILE=bad-profile",
"AGENT_CONTEXT_COMPRESSION_TRIGGER_TOKENS=bad-int",
"AGENT_CONTEXT_PROTECTED_TURNS=0",
]
)
+ "\n",
encoding="utf-8",
)
with patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True):
config = Config._load_from_env()
self.assertEqual(config.agent_context_compression_profile, "balanced")
self.assertEqual(config.agent_context_compression_trigger_tokens, 12000)
self.assertEqual(config.agent_context_protected_turns, 4)
def test_validate_reports_invalid_json(self) -> None:
validation = self.service.validate(items=[{"key": "AGENT_EVENT_ALERT_RULES_JSON", "value": "[invalid"}])
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_json" for issue in validation["issues"]))
def test_validate_accepts_blank_optional_json(self) -> None:
validation = self.service.validate(items=[{"key": "AGENT_EVENT_ALERT_RULES_JSON", "value": ""}])
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_validate_accepts_multiline_json(self) -> None:
validation = self.service.validate(items=[{
"key": "AGENT_EVENT_ALERT_RULES_JSON",
"value": (
"[\n"
' {"stock_code":"600519","alert_type":"price_cross","direction":"above","price":1800}\n'
"]"
),
}])
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_update_minifies_multiline_json_before_storage(self) -> None:
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{
"key": "AGENT_EVENT_ALERT_RULES_JSON",
"value": (
"[\n"
' {"stock_code":"600519","alert_type":"price_cross","direction":"above","price":1800}\n'
"]"
),
}],
reload_now=False,
)
self.assertTrue(response["success"])
current_map = self.manager.read_config_map()
self.assertEqual(
current_map["AGENT_EVENT_ALERT_RULES_JSON"],
'[{"stock_code":"600519","alert_type":"price_cross","direction":"above","price":1800}]',
)
def test_validate_accepts_legacy_agent_orchestrator_mode_alias(self) -> None:
validation = self.service.validate(items=[{"key": "AGENT_ORCHESTRATOR_MODE", "value": "strategy"}])
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_get_config_projects_legacy_strategy_aliases_onto_skill_fields(self) -> None:
self._rewrite_env(
"AGENT_STRATEGY_DIR=legacy-strategies",
"AGENT_STRATEGY_AUTOWEIGHT=false",
"AGENT_STRATEGY_ROUTING=manual",
)
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertEqual(items["AGENT_SKILL_DIR"]["value"], "legacy-strategies")
self.assertEqual(items["AGENT_SKILL_AUTOWEIGHT"]["value"], "false")
self.assertEqual(items["AGENT_SKILL_ROUTING"]["value"], "manual")
self.assertNotIn("AGENT_STRATEGY_DIR", items)
self.assertNotIn("AGENT_STRATEGY_AUTOWEIGHT", items)
self.assertNotIn("AGENT_STRATEGY_ROUTING", items)
def test_get_config_respects_empty_canonical_skill_field_over_legacy_alias(self) -> None:
self._rewrite_env(
"AGENT_SKILL_DIR=",
"AGENT_STRATEGY_DIR=legacy-strategies",
)
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertEqual(items["AGENT_SKILL_DIR"]["value"], "")
def test_get_config_normalizes_legacy_orchestrator_mode_for_ui(self) -> None:
self._rewrite_env("AGENT_ORCHESTRATOR_MODE=strategy")
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertEqual(items["AGENT_ORCHESTRATOR_MODE"]["value"], "specialist")
self.assertEqual(
items["AGENT_ORCHESTRATOR_MODE"]["schema"]["validation"]["enum"],
["quick", "standard", "full", "specialist", "strategy", "skill"],
)
@patch.object(
Config,
"_parse_litellm_yaml",
return_value=[{"model_name": "gemini/gemini-2.5-flash", "litellm_params": {"model": "gemini/gemini-2.5-flash"}}],
)
def test_validate_reports_unknown_primary_model_for_litellm_yaml(self, _mock_parse_yaml) -> None:
validation = self.service.validate(
items=[
{"key": "LITELLM_CONFIG", "value": "/tmp/litellm.yaml"},
{"key": "LITELLM_MODEL", "value": "openai/gpt-4o-mini"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["key"] == "LITELLM_MODEL" and issue["code"] == "unknown_model" for issue in validation["issues"]))
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_validate_keeps_channel_checks_when_litellm_yaml_has_no_models(self, _mock_parse_yaml) -> None:
validation = self.service.validate(
items=[
{"key": "LITELLM_CONFIG", "value": "/tmp/litellm.yaml"},
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LLM_PRIMARY_API_KEY", "value": ""},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "missing_api_key" for issue in validation["issues"]))
def test_validate_reports_stale_primary_model_when_all_channels_disabled(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LLM_PRIMARY_ENABLED", "value": "false"},
{"key": "LITELLM_MODEL", "value": "openai/gpt-4o-mini"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["key"] == "LITELLM_MODEL" and issue["code"] == "missing_runtime_source" for issue in validation["issues"]))
def test_validate_accepts_minimax_model_as_direct_env_provider(self) -> None:
"""minimax is NOT a managed key provider; it uses LiteLLM direct-env routing."""
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "minimax/MiniMax-M1"},
{"key": "LLM_PRIMARY_ENABLED", "value": "false"},
{"key": "LITELLM_MODEL", "value": "minimax/MiniMax-M1"},
]
)
self.assertFalse(any(issue.get("key") == "LITELLM_MODEL" and issue["code"] == "missing_runtime_source" for issue in validation.get("issues", [])))
def test_validate_accepts_cohere_model_as_direct_env_provider(self) -> None:
"""cohere is NOT a managed key provider; it also uses LiteLLM direct-env routing."""
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LLM_PRIMARY_ENABLED", "value": "false"},
{"key": "LITELLM_MODEL", "value": "cohere/command-r-plus"},
]
)
self.assertFalse(any(issue.get("key") == "LITELLM_MODEL" and issue["code"] == "missing_runtime_source" for issue in validation.get("issues", [])))
def test_validate_accepts_google_model_as_direct_env_provider(self) -> None:
"""google prefix is not managed by project key buckets and is kept as direct provider routing."""
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LLM_PRIMARY_ENABLED", "value": "false"},
{"key": "LITELLM_MODEL", "value": "google/gemini-2.5-flash"},
]
)
self.assertFalse(any(issue.get("key") == "LITELLM_MODEL" and issue["code"] == "missing_runtime_source" for issue in validation.get("issues", [])))
def test_validate_accepts_xai_model_as_direct_env_provider(self) -> None:
"""xai is not a managed provider key and is also preserved as direct runtime source."""
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LLM_PRIMARY_ENABLED", "value": "false"},
{"key": "LITELLM_MODEL", "value": "xai/grok-beta"},
]
)
self.assertFalse(any(issue.get("key") == "LITELLM_MODEL" and issue["code"] == "missing_runtime_source" for issue in validation.get("issues", [])))
def test_validate_reports_stale_agent_primary_model_when_all_channels_disabled(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LLM_PRIMARY_ENABLED", "value": "false"},
{"key": "AGENT_LITELLM_MODEL", "value": "openai/gpt-4o-mini"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["key"] == "AGENT_LITELLM_MODEL" and issue["code"] == "missing_runtime_source" for issue in validation["issues"]))
def test_validate_allows_primary_model_when_all_channels_disabled_but_legacy_key_exists(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test-value"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LLM_PRIMARY_ENABLED", "value": "false"},
{"key": "OPENAI_API_KEY", "value": "sk-legacy-value"},
{"key": "LITELLM_MODEL", "value": "openai/gpt-4o-mini"},
]
)
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_validate_allows_anspire_channel_with_shared_key_defaults(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "anspire"},
{"key": "ANSPIRE_API_KEYS", "value": "sk-anspire-test-value"},
]
)
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_validate_treats_blank_anspire_channel_enabled_as_shared_disable(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "anspire"},
{"key": "LLM_ANSPIRE_ENABLED", "value": " "},
{"key": "ANSPIRE_LLM_ENABLED", "value": "false"},
]
)
self.assertTrue(validation["valid"], validation["issues"])
self.assertEqual(validation["issues"], [])
def test_validate_excludes_blank_disabled_anspire_channel_from_runtime_models(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "anspire"},
{"key": "LLM_ANSPIRE_ENABLED", "value": " "},
{"key": "ANSPIRE_LLM_ENABLED", "value": "false"},
{"key": "ANSPIRE_API_KEYS", "value": "sk-anspire-test-value"},
{"key": "LITELLM_MODEL", "value": f"openai/{ANSPIRE_LLM_MODEL_DEFAULT}"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["key"] == "LITELLM_MODEL" and issue["code"] == "missing_runtime_source" for issue in validation["issues"]))
def test_validate_excludes_disabled_anspire_channel_from_legacy_runtime_source(self) -> None:
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "anspire"},
{"key": "LLM_ANSPIRE_ENABLED", "value": "false"},
{"key": "ANSPIRE_API_KEYS", "value": "sk-anspire-test-value"},
{"key": "LITELLM_MODEL", "value": f"openai/{ANSPIRE_LLM_MODEL_DEFAULT}"},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["key"] == "LITELLM_MODEL" and issue["code"] == "missing_runtime_source" for issue in validation["issues"]))
@staticmethod
def _mock_http_response(status_code: int, json_body: Optional[Dict[str, Any]] = None):
response = Mock()
response.status_code = status_code
response.text = "ok" if status_code == 200 else "error"
response.json.return_value = json_body or {"errcode": 0}
return response
def _notification_test_env(self):
return patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=True)
@patch("src.notification_sender.wechat_sender.requests.post")
def test_test_notification_channel_uses_temporary_items_without_persisting(self, mock_post) -> None:
mock_post.return_value = self._mock_http_response(200, {"errcode": 0})
with self._notification_test_env():
before_instance = Config.get_instance()
payload = self.service.test_notification_channel(
channel="wechat",
items=[{"key": "WECHAT_WEBHOOK_URL", "value": "https://qyapi.example.com/cgi-bin/webhook/send?key=secret"}],
title="Test title",
content="hello",
timeout_seconds=3,
)
self.assertIs(Config.get_instance(), before_instance)
self.assertTrue(payload["success"])
self.assertEqual(payload["attempts"][0]["latency_ms"] >= 0, True)
self.assertIn("key=***", payload["attempts"][0]["target"])
self.assertNotIn("WECHAT_WEBHOOK_URL", self.env_path.read_text(encoding="utf-8"))
self.assertEqual(mock_post.call_args.kwargs["timeout"], 3)
def test_test_notification_channel_reports_missing_config(self) -> None:
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="telegram",
items=[{"key": "TELEGRAM_BOT_TOKEN", "value": "token"}],
title="Test title",
content="hello",
timeout_seconds=3,
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], "config_missing")
self.assertIn("TELEGRAM_CHAT_ID", payload["message"])
def test_test_notification_channel_reports_nearest_feishu_app_bot_missing_key(self) -> None:
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="feishu",
items=[
{"key": "FEISHU_APP_ID", "value": "cli_xxx"},
{"key": "FEISHU_APP_SECRET", "value": "secret_xxx"},
],
title="Test title",
content="hello",
timeout_seconds=3,
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], "config_missing")
self.assertIn("FEISHU_CHAT_ID", payload["message"])
self.assertNotIn("FEISHU_WEBHOOK_URL", payload["message"])
def test_test_notification_channel_feishu_domain_draft_builds_isolated_config(self) -> None:
captured: Dict[str, Any] = {}
def fake_dispatch(**kwargs):
captured.update(kwargs)
return {
"success": True,
"message": "ok",
"error_code": None,
"stage": "notification_send",
"retryable": False,
"latency_ms": 0,
"attempts": [],
}
with self._notification_test_env(), patch.object(
SystemConfigService,
"_dispatch_notification_test",
side_effect=fake_dispatch,
):
payload = self.service.test_notification_channel(
channel="feishu",
items=[
{"key": "FEISHU_APP_ID", "value": "cli_xxx"},
{"key": "FEISHU_APP_SECRET", "value": "secret_xxx"},
{"key": "FEISHU_CHAT_ID", "value": "oc_xxx"},
{"key": "FEISHU_DOMAIN", "value": "lark"},
],
title="Test title",
content="hello",
timeout_seconds=3,
)
self.assertTrue(payload["success"])
self.assertEqual(captured["config"].feishu_domain, "lark")
@patch("src.notification_sender.wechat_sender.requests.post")
def test_test_notification_channel_skips_masked_secret_overwrite(self, mock_post) -> None:
self._rewrite_env("WECHAT_WEBHOOK_URL=https://saved.example.com/hook?key=savedsecret")
mock_post.return_value = self._mock_http_response(200, {"errcode": 0})
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="wechat",
items=[{"key": "WECHAT_WEBHOOK_URL", "value": "******"}],
mask_token="******",
title="Test title",
content="hello",
timeout_seconds=3,
)
self.assertTrue(payload["success"])
self.assertEqual(mock_post.call_args[0][0], "https://saved.example.com/hook?key=savedsecret")
@patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_test_notification_channel_returns_custom_webhook_attempts(self, mock_post) -> None:
mock_post.side_effect = [
self._mock_http_response(500),
self._mock_http_response(200),
]
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="custom",
items=[
{
"key": "CUSTOM_WEBHOOK_URLS",
"value": (
"https://example.com/robot/send?access_token=first,"
"https://example.com/verylongsecrettoken1234567890"
),
}
],
title="Test title",
content="hello",
timeout_seconds=4,
)
self.assertTrue(payload["success"])
self.assertIn("部分成功", payload["message"])
self.assertIn("1/2", payload["message"])
self.assertEqual(len(payload["attempts"]), 2)
self.assertFalse(payload["attempts"][0]["success"])
self.assertTrue(payload["attempts"][1]["success"])
self.assertIn("access_token=***", payload["attempts"][0]["target"])
self.assertNotIn("verylongsecrettoken1234567890", payload["attempts"][1]["target"])
self.assertNotIn("access_token=first", str(payload))
self.assertEqual(mock_post.call_args_list[0].kwargs["timeout"], 4)
@patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_test_notification_channel_custom_webhook_all_failures_are_retryable(self, mock_post) -> None:
mock_post.side_effect = [
self._mock_http_response(500),
self._mock_http_response(429),
]
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="custom",
items=[
{
"key": "CUSTOM_WEBHOOK_URLS",
"value": (
"https://example.com/robot/send?access_token=first,"
"https://example.com/robot/send?token=second"
),
}
],
title="Test title",
content="hello",
timeout_seconds=4,
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], "send_failed")
self.assertTrue(payload["retryable"])
self.assertIn("失败", payload["message"])
self.assertIn("0/2", payload["message"])
self.assertEqual(len(payload["attempts"]), 2)
self.assertTrue(all(attempt["retryable"] for attempt in payload["attempts"]))
self.assertNotIn("access_token=first", str(payload))
self.assertNotIn("token=second", str(payload))
@patch("src.notification_sender.ntfy_sender.requests.post")
def test_test_notification_channel_supports_ntfy_and_masks_topic_target(self, mock_post) -> None:
mock_post.return_value = self._mock_http_response(200)
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="ntfy",
items=[
{"key": "NTFY_URL", "value": "https://ntfy.sh/private-topic"},
{"key": "NTFY_TOKEN", "value": "secret-token"},
],
title="Test title",
content="hello",
timeout_seconds=4,
)
self.assertTrue(payload["success"])
self.assertEqual(mock_post.call_args.args[0], "https://ntfy.sh")
self.assertEqual(mock_post.call_args.kwargs["json"]["topic"], "private-topic")
self.assertEqual(mock_post.call_args.kwargs["headers"]["Authorization"], "Bearer secret-token")
self.assertEqual(mock_post.call_args.kwargs["timeout"], 4)
self.assertIn("https://ntfy.sh/***", payload["attempts"][0]["target"])
self.assertNotIn("private-topic", str(payload))
self.assertNotIn("NTFY_URL", self.env_path.read_text(encoding="utf-8"))
@patch("src.notification_sender.ntfy_sender.requests.post")
def test_test_notification_channel_rejects_ntfy_url_without_topic(self, mock_post) -> None:
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="ntfy",
items=[{"key": "NTFY_URL", "value": "https://ntfy.sh"}],
title="Test title",
content="hello",
timeout_seconds=4,
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], "config_invalid")
self.assertEqual(payload["stage"], "config_validation")
self.assertIn("NTFY_URL", payload["message"])
mock_post.assert_not_called()
@patch("src.notification_sender.gotify_sender.requests.post")
def test_test_notification_channel_supports_gotify_and_keeps_token_out_of_url(self, mock_post) -> None:
mock_post.return_value = self._mock_http_response(200)
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="gotify",
items=[
{"key": "GOTIFY_URL", "value": "https://gotify.example"},
{"key": "GOTIFY_TOKEN", "value": "secret-token"},
],
title="Test title",
content="hello",
timeout_seconds=4,
)
self.assertTrue(payload["success"])
self.assertEqual(mock_post.call_args.args[0], "https://gotify.example/message")
self.assertEqual(mock_post.call_args.kwargs["headers"]["X-Gotify-Key"], "secret-token")
self.assertEqual(mock_post.call_args.kwargs["timeout"], 4)
self.assertEqual(payload["attempts"][0]["target"], "https://gotify.example")
self.assertNotIn("secret-token", str(payload))
self.assertNotIn("GOTIFY_URL", self.env_path.read_text(encoding="utf-8"))
@patch("src.notification_sender.gotify_sender.requests.post")
def test_test_notification_channel_rejects_gotify_message_endpoint(self, mock_post) -> None:
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="gotify",
items=[
{"key": "GOTIFY_URL", "value": "https://gotify.example/message"},
{"key": "GOTIFY_TOKEN", "value": "secret-token"},
],
title="Test title",
content="hello",
timeout_seconds=4,
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], "config_invalid")
self.assertEqual(payload["stage"], "config_validation")
self.assertIn("GOTIFY_URL", payload["message"])
mock_post.assert_not_called()
@patch(
"src.notification_sender.WechatSender.send_to_wechat",
side_effect=requests.exceptions.Timeout(
"timeout for https://qyapi.example.com/cgi-bin/webhook/send?key=secret token=abc123"
),
)
def test_test_notification_channel_classifies_escaped_timeout(self, _mock_send) -> None:
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="wechat",
items=[
{
"key": "WECHAT_WEBHOOK_URL",
"value": "https://qyapi.example.com/cgi-bin/webhook/send?key=secret",
}
],
title="Test title",
content="hello",
timeout_seconds=3,
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], "timeout")
self.assertTrue(payload["retryable"])
self.assertEqual(payload["attempts"][0]["error_code"], "timeout")
self.assertIn("key=***", payload["attempts"][0]["target"])
self.assertNotIn("key=secret", str(payload))
self.assertNotIn("abc123", str(payload))
@patch("src.notification_sender.telegram_sender.requests.post")
def test_test_notification_channel_masks_short_sensitive_target(self, mock_post) -> None:
mock_post.return_value = self._mock_http_response(200, {"ok": True})
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="telegram",
items=[
{"key": "TELEGRAM_BOT_TOKEN", "value": "tok123"},
{"key": "TELEGRAM_CHAT_ID", "value": "chat-id"},
],
title="Test title",
content="hello",
timeout_seconds=3,
)
self.assertTrue(payload["success"])
self.assertEqual(payload["attempts"][0]["target"], "***")
self.assertNotIn("tok123", str(payload))
@patch("src.notification_sender.wechat_sender.requests.post")
def test_test_notification_channel_strips_url_userinfo_from_target(self, mock_post) -> None:
mock_post.return_value = self._mock_http_response(200, {"errcode": 0})
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="wechat",
items=[
{
"key": "WECHAT_WEBHOOK_URL",
"value": "https://user:password@example.com/cgi-bin/webhook/send?key=secret",
}
],
title="Test title",
content="hello",
timeout_seconds=3,
)
self.assertTrue(payload["success"])
target = payload["attempts"][0]["target"]
self.assertIn("https://example.com/cgi-bin/webhook/send?key=***", target)
self.assertNotIn("user", target)
self.assertNotIn("password", target)
@patch("src.notification_sender.discord_sender.requests.post")
def test_test_notification_channel_prefers_discord_main_channel_alias(self, mock_post) -> None:
mock_post.return_value = self._mock_http_response(200)
with self._notification_test_env():
payload = self.service.test_notification_channel(
channel="discord",
items=[
{"key": "DISCORD_BOT_TOKEN", "value": "bot-token"},
{"key": "DISCORD_MAIN_CHANNEL_ID", "value": "main-channel"},
{"key": "DISCORD_CHANNEL_ID", "value": "legacy-channel"},
],
title="Test title",
content="hello",
timeout_seconds=3,
)
self.assertTrue(payload["success"])
self.assertIn("/channels/main-channel/messages", mock_post.call_args[0][0])
@patch("litellm.completion")
def test_test_llm_channel_returns_success_payload(self, mock_completion) -> None:
mock_completion.return_value = type(
"MockResponse",
(),
{
"choices": [type("Choice", (), {"message": type("Message", (), {"content": "OK"})()})()],
},
)()
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.deepseek.com/v1",
api_key="sk-test-value",
models=["deepseek-chat"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["resolved_protocol"], "openai")
self.assertEqual(payload["resolved_model"], "openai/deepseek-chat")
self.assertEqual(payload["capability_results"], {})
self.assertEqual(mock_completion.call_count, 1)
@patch("litellm.completion")
def test_test_llm_channel_falls_back_to_message_content_when_content_blocks_empty(
self,
mock_completion,
) -> None:
mock_completion.return_value = type(
"MockResponse",
(),
{
"choices": [
type(
"Choice",
(),
{
"content_blocks": [],
"message": type("Message", (), {"content": "OK"})(),
},
)(),
]
},
)()
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.deepseek.com/v1",
api_key="sk-test-value",
models=["deepseek-chat"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["resolved_model"], "openai/deepseek-chat")
@patch("litellm.completion")
def test_test_llm_channel_allows_ollama_prefix_without_explicit_protocol(self, mock_completion) -> None:
mock_completion.return_value = type(
"MockResponse",
(),
{
"choices": [type("Choice", (), {"message": type("Message", (), {"content": "OK"})()})()],
},
)()
payload = self.service.test_llm_channel(
name="lab",
protocol="",
base_url="http://localhost:11434/v1",
api_key="",
models=["ollama/llama3"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["resolved_protocol"], "ollama")
self.assertEqual(payload["resolved_model"], "ollama/llama3")
@patch("litellm.completion")
def test_test_llm_channel_normalizes_kimi_temperature(self, mock_completion) -> None:
mock_completion.return_value = type(
"MockResponse",
(),
{
"choices": [type("Choice", (), {"message": type("Message", (), {"content": "OK"})()})()],
},
)()
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.moonshot.cn/v1",
api_key="sk-test-value",
models=["kimi-k2.6"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["resolved_model"], "openai/kimi-k2.6")
self.assertEqual(mock_completion.call_args.kwargs["temperature"], 1.0)
def test_update_switching_to_kimi_does_not_rewrite_saved_llm_temperature(self) -> None:
self._rewrite_env(
"LITELLM_MODEL=openai/gpt-4o-mini",
"LLM_TEMPERATURE=0.42",
)
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "LITELLM_MODEL", "value": "openai/kimi-k2.6"}],
reload_now=False,
)
self.assertTrue(response["success"])
current_map = self.manager.read_config_map()
self.assertEqual(current_map["LITELLM_MODEL"], "openai/kimi-k2.6")
self.assertEqual(current_map["LLM_TEMPERATURE"], "0.42")
def test_update_runtime_model_cleanup_does_not_rewrite_temperature(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LLM_CHANNELS=deepseek",
"LLM_DEEPSEEK_PROTOCOL=deepseek",
"LLM_DEEPSEEK_BASE_URL=https://api.deepseek.com",
"LLM_DEEPSEEK_API_KEY=sk-test-value",
"LLM_DEEPSEEK_MODELS=deepseek-chat,deepseek-v4-flash",
"LITELLM_MODEL=deepseek/deepseek-chat",
"AGENT_LITELLM_MODEL=deepseek/deepseek-v4-flash",
"LLM_TEMPERATURE=0.42",
"LITELLM_FALLBACK_MODELS=deepseek/deepseek-v4-flash,cohere/command-r-plus",
"VISION_MODEL=deepseek/deepseek-chat",
)
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[
{"key": "LLM_DEEPSEEK_MODELS", "value": "deepseek-v4-flash"},
{"key": "LITELLM_MODEL", "value": ""},
{"key": "AGENT_LITELLM_MODEL", "value": ""},
{"key": "LITELLM_FALLBACK_MODELS", "value": "deepseek/deepseek-v4-flash"},
{"key": "VISION_MODEL", "value": ""},
],
reload_now=False,
)
self.assertTrue(response["success"])
current_map = self.manager.read_config_map()
self.assertEqual(current_map["LLM_TEMPERATURE"], "0.42")
self.assertEqual(current_map["LITELLM_MODEL"], "")
self.assertEqual(current_map["AGENT_LITELLM_MODEL"], "")
self.assertEqual(current_map["VISION_MODEL"], "")
self.assertEqual(
current_map["LITELLM_FALLBACK_MODELS"],
"deepseek/deepseek-v4-flash",
)
def test_update_warns_when_clearing_unsupported_hermes_keys(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LLM_CHANNELS=hermes",
"LLM_HERMES_PROTOCOL=openai",
"LLM_HERMES_BASE_URL=http://127.0.0.1:8642/v1",
"LLM_HERMES_API_KEY=sk-hermes-test-value",
"LLM_HERMES_API_KEYS=sk-old-a,sk-old-b",
'LLM_HERMES_EXTRA_HEADERS={"X":"Y"}',
"LLM_HERMES_MODELS=hermes-agent",
)
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[
{"key": "LLM_HERMES_API_KEYS", "value": ""},
{"key": "LLM_HERMES_EXTRA_HEADERS", "value": ""},
],
reload_now=False,
)
self.assertTrue(response["success"])
joined = " | ".join(response["warnings"])
self.assertIn("Hermes Phase 3 不支持", joined)
self.assertIn("LLM_HERMES_API_KEYS", joined)
self.assertIn("LLM_HERMES_EXTRA_HEADERS", joined)
self.assertIn("LLM_HERMES_API_KEY", joined)
self.assertIn(".env 备份", joined)
current_map = self.manager.read_config_map()
self.assertEqual(current_map["LLM_HERMES_API_KEYS"], "")
self.assertEqual(current_map["LLM_HERMES_EXTRA_HEADERS"], "")
@patch("litellm.completion")
def test_test_llm_channel_does_not_persist_normalized_kimi_temperature(self, mock_completion) -> None:
self._rewrite_env("LLM_TEMPERATURE=0.42")
mock_completion.return_value = type(
"MockResponse",
(),
{
"choices": [type("Choice", (), {"message": type("Message", (), {"content": "OK"})()})()],
},
)()
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.moonshot.cn/v1",
api_key="sk-test-value",
models=["kimi-k2.6"],
)
self.assertTrue(payload["success"])
self.assertEqual(mock_completion.call_args.kwargs["temperature"], 1.0)
self.assertEqual(self.manager.read_config_map()["LLM_TEMPERATURE"], "0.42")
@patch("litellm.completion")
def test_test_llm_channel_omits_temperature_for_gpt5_family(self, mock_completion) -> None:
mock_completion.return_value = type(
"MockResponse",
(),
{
"choices": [type("Choice", (), {"message": type("Message", (), {"content": "OK"})()})()],
},
)()
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["gpt5.5-ferr"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["resolved_model"], "openai/gpt5.5-ferr")
self.assertNotIn("temperature", mock_completion.call_args.kwargs)
@patch("litellm.completion")
@patch("src.services.system_config_service.Config._load_from_env")
def test_test_llm_channel_recovers_from_unsupported_temperature(
self,
mock_load_config,
mock_completion,
) -> None:
from src.llm.generation_params import clear_litellm_generation_param_recovery_cache
clear_litellm_generation_param_recovery_cache()
mock_load_config.return_value = SimpleNamespace(llm_temperature=0.42)
mock_completion.side_effect = [
RuntimeError("Unsupported parameter: temperature is not supported"),
type(
"MockResponse",
(),
{
"choices": [type("Choice", (), {"message": type("Message", (), {"content": "OK"})()})()],
},
)(),
]
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["custom-temp-locked-settings"],
)
self.assertTrue(payload["success"])
self.assertEqual(mock_completion.call_args_list[0].kwargs["temperature"], 0.42)
self.assertNotIn("temperature", mock_completion.call_args_list[1].kwargs)
@patch("litellm.completion")
def test_test_llm_channel_routes_responses_surface_through_litellm_bridge(
self,
mock_completion,
) -> None:
mock_completion.return_value = self._mock_completion_response("OK")
payload = self.service.test_llm_channel(
name="anspire",
protocol="openai",
api_surface="responses",
base_url="https://open-gateway.anspire.cn/v6",
api_key="sk-test-value",
models=["gpt-5.6-sol"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["stage"], "responses")
self.assertEqual(payload["resolved_api_surface"], "responses")
self.assertEqual(payload["resolved_model"], "openai/gpt-5.6-sol")
self.assertEqual(
mock_completion.call_args.kwargs["model"],
"openai/responses/gpt-5.6-sol",
)
@patch("litellm.completion")
def test_test_llm_channel_rejects_non_openai_model_before_network_call(
self,
mock_completion,
) -> None:
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
api_surface="responses",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["anthropic/claude-sonnet-4-6"],
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], "invalid_config")
self.assertEqual(payload["details"]["issue_code"], "responses_requires_openai_model_provider")
mock_completion.assert_not_called()
@patch("litellm.completion")
@patch("src.services.system_config_service.Config._load_from_env")
def test_test_llm_channel_uses_runtime_temperature_for_non_kimi_models(
self,
mock_load_config,
mock_completion,
) -> None:
mock_load_config.return_value = SimpleNamespace(llm_temperature=0.42)
mock_completion.return_value = type(
"MockResponse",
(),
{
"choices": [type("Choice", (), {"message": type("Message", (), {"content": "OK"})()})()],
},
)()
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["gpt-4o-mini"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["resolved_model"], "openai/gpt-4o-mini")
self.assertEqual(mock_completion.call_args.kwargs["temperature"], 0.42)
@patch("litellm.completion")
def test_test_llm_channel_classifies_common_failure_scenarios(self, mock_completion) -> None:
cases = [
(PermissionError("401 Unauthorized Bearer sk-secret-value"), "auth", "chat_completion", False),
(TimeoutError("request timed out"), "timeout", "chat_completion", True),
(Exception("404 model not found: gpt-4o-mini"), "model_not_found", "chat_completion", False),
(Exception("The model `gpt-4o-mini` does not exist"), "model_not_found", "chat_completion", False),
(Exception("404 Not Found: page not found"), "network_error", "chat_completion", False),
(
type("MockResponse", (), {"choices": [type("Choice", (), {"message": type("Message", (), {"content": ""})()})()]})(),
"empty_response",
"response_parse",
False,
),
(object(), "format_error", "response_parse", False),
]
for response_or_exc, error_code, stage, retryable in cases:
with self.subTest(error_code=error_code):
mock_completion.reset_mock()
if isinstance(response_or_exc, Exception):
mock_completion.side_effect = response_or_exc
mock_completion.return_value = None
else:
mock_completion.side_effect = None
mock_completion.return_value = response_or_exc
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-secret-value",
models=["gpt-4o-mini"],
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], error_code)
self.assertEqual(payload["stage"], stage)
self.assertEqual(payload["retryable"], retryable)
if error_code == "auth":
self.assertNotIn("sk-secret-value", payload["error"])
if error_code == "format_error":
self.assertIn("choices", payload["error"])
@patch("litellm.completion")
def test_test_llm_channel_marks_requested_capabilities_skipped_when_base_fails(self, mock_completion) -> None:
mock_completion.side_effect = PermissionError("401 Unauthorized Bearer sk-secret-value")
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-secret-value",
models=["gpt-4o-mini"],
capability_checks=["json", "tools"],
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], "auth")
self.assertEqual(payload["details"]["reason"], "api_key_rejected")
self.assertEqual(payload["capability_results"]["json"]["status"], "skipped")
self.assertEqual(payload["capability_results"]["tools"]["details"]["reason"], "base_test_failed")
self.assertEqual(mock_completion.call_count, 1)
@patch("litellm.completion")
def test_test_llm_channel_runs_json_and_tools_capability_checks(self, mock_completion) -> None:
tool_call = SimpleNamespace(function=SimpleNamespace(name="dsa_probe_echo"))
mock_completion.side_effect = [
self._mock_completion_response("OK"),
self._mock_completion_response('{"status":"ok"}'),
self._mock_completion_response("", tool_calls=[tool_call]),
]
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["gpt-4o-mini"],
capability_checks=["tools", "json", "tools"],
)
self.assertTrue(payload["success"])
self.assertEqual(list(payload["capability_results"].keys()), ["json", "tools"])
self.assertEqual(payload["capability_results"]["json"]["status"], "passed")
self.assertEqual(payload["capability_results"]["tools"]["status"], "passed")
self.assertEqual(mock_completion.call_count, 3)
self.assertEqual(mock_completion.call_args_list[1].kwargs["response_format"], {"type": "json_object"})
self.assertEqual(mock_completion.call_args_list[2].kwargs["tool_choice"]["function"]["name"], "dsa_probe_echo")
@patch("litellm.completion")
def test_test_llm_channel_json_capability_ignores_minimax_reasoning_blocks(self, mock_completion) -> None:
mock_completion.side_effect = [
self._mock_completion_response("OK"),
{
"choices": [
{
"message": {
"content": None,
"content_blocks": [
{"type": "reasoning", "content": "Internal reasoning"},
{"type": "text", "text": '{"status":"ok"}'},
],
}
}
]
},
]
payload = self.service.test_llm_channel(
name="minimax",
protocol="openai",
base_url="https://api.minimax.io/v1",
api_key="sk-test-value",
models=["MiniMax-M3"],
capability_checks=["json"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["capability_results"]["json"]["status"], "passed")
@patch("litellm.completion")
def test_test_llm_channel_json_capability_strips_minimax_think_wrapper(self, mock_completion) -> None:
mock_completion.side_effect = [
self._mock_completion_response("OK"),
self._mock_completion_response('<think>Internal reasoning</think>{"status":"ok"}'),
]
payload = self.service.test_llm_channel(
name="minimax",
protocol="openai",
base_url="https://api.minimax.io/v1",
api_key="sk-test-value",
models=["MiniMax-M3"],
capability_checks=["json"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["capability_results"]["json"]["status"], "passed")
@patch("litellm.completion")
def test_test_llm_channel_reports_json_capability_failures(self, mock_completion) -> None:
mock_completion.side_effect = [
self._mock_completion_response("OK"),
self._mock_completion_response("not json"),
]
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["gpt-4o-mini"],
capability_checks=["json"],
)
self.assertTrue(payload["success"])
result = payload["capability_results"]["json"]
self.assertEqual(result["status"], "failed")
self.assertEqual(result["error_code"], "format_error")
self.assertEqual(result["details"]["reason"], "non_json")
@patch("litellm.completion")
def test_test_llm_channel_runs_stream_capability_check_and_closes_stream(self, mock_completion) -> None:
class _Stream:
def __init__(self):
self.closed = False
def __iter__(self):
yield SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="OK"))])
def close(self):
self.closed = True
stream = _Stream()
mock_completion.side_effect = [
self._mock_completion_response("OK"),
stream,
]
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["gpt-4o-mini"],
capability_checks=["stream"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["capability_results"]["stream"]["status"], "passed")
self.assertTrue(stream.closed)
self.assertTrue(mock_completion.call_args_list[1].kwargs["stream"])
@patch("litellm.completion")
def test_test_llm_channel_ignores_stream_close_failures(self, mock_completion) -> None:
class _Stream:
def __init__(self):
self.close_attempted = False
def __iter__(self):
yield SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content="OK"))])
def close(self):
self.close_attempted = True
raise RuntimeError("transport already closed")
stream = _Stream()
mock_completion.side_effect = [
self._mock_completion_response("OK"),
stream,
]
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["gpt-4o-mini"],
capability_checks=["stream"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["capability_results"]["stream"]["status"], "passed")
self.assertTrue(stream.close_attempted)
@patch("litellm.completion")
def test_test_llm_channel_runs_vision_capability_check(self, mock_completion) -> None:
mock_completion.side_effect = [
self._mock_completion_response("OK"),
self._mock_completion_response("OK"),
]
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["gpt-4o-mini"],
capability_checks=["vision"],
)
self.assertTrue(payload["success"])
self.assertEqual(payload["capability_results"]["vision"]["status"], "passed")
vision_content = mock_completion.call_args_list[1].kwargs["messages"][0]["content"]
self.assertEqual(vision_content[1]["type"], "image_url")
self.assertTrue(vision_content[1]["image_url"]["url"].startswith("data:image/png;base64,"))
@patch("litellm.completion")
def test_test_llm_channel_classifies_capability_unsupported(self, mock_completion) -> None:
mock_completion.side_effect = [
self._mock_completion_response("OK"),
Exception("response_format is not supported"),
]
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["gpt-4o-mini"],
capability_checks=["json"],
)
result = payload["capability_results"]["json"]
self.assertEqual(result["status"], "failed")
self.assertEqual(result["error_code"], "capability_unsupported")
self.assertEqual(result["details"]["reason"], "capability_unsupported")
@patch("litellm.completion")
def test_test_llm_channel_adds_focused_diagnostic_reasons(self, mock_completion) -> None:
class RateLimitError(Exception):
pass
cases = [
(Exception("account balance insufficient"), "quota", "insufficient_balance"),
(RateLimitError("account balance insufficient"), "quota", "insufficient_balance"),
(RateLimitError("insufficient_quota"), "quota", "quota_exceeded"),
(Exception("account balance insufficient; your request was blocked"), "quota", "insufficient_balance"),
(RateLimitError("rate limit: your request was blocked by policy"), "quota", "rate_limit"),
(Exception("DNS lookup failed"), "network_error", "dns_error"),
(Exception("TLS certificate verify failed"), "network_error", "tls_error"),
(Exception("Connection refused"), "network_error", "connection_refused"),
(Exception("connection request was blocked by firewall"), "network_error", "network_error"),
(Exception("connection blocked by policy"), "network_error", "network_error"),
(Exception("request blocked by firewall"), "network_error", "network_error"),
(Exception("blocked"), "network_error", "unknown_error"),
(Exception("model gpt-4o is not authorized for this account"), "model_not_found", "model_access_denied"),
(Exception("litellm.APIError: APIError: OpenAIException - Model disabled."), "model_not_found", "model_access_denied"),
(Exception("Model is disabled for this account"), "model_not_found", "model_access_denied"),
(
Exception("litellm.APIError: APIError: OpenAIException - Your request was blocked."),
"request_blocked",
"provider_blocked",
),
(Exception("Forbidden: your request was blocked by content policy"), "request_blocked", "provider_blocked"),
(Exception("blocked by policy"), "request_blocked", "provider_blocked"),
(Exception("moderation_blocked"), "request_blocked", "provider_blocked"),
(Exception("LLM Provider NOT provided for model foo"), "model_not_found", "provider_prefix_mismatch"),
]
for exc, error_code, reason in cases:
with self.subTest(reason=reason):
mock_completion.reset_mock()
mock_completion.side_effect = exc
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key="sk-test-value",
models=["gpt-4o-mini"],
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], error_code)
self.assertEqual(payload["details"]["reason"], reason)
if reason in {"model_access_denied", "provider_blocked"}:
self.assertFalse(payload["retryable"])
self.assertEqual(payload["details"]["model"], "openai/gpt-4o-mini")
self.assertEqual(payload["resolved_model"], "openai/gpt-4o-mini")
def test_test_llm_channel_reports_comma_only_api_key_as_missing(self) -> None:
payload = self.service.test_llm_channel(
name="primary",
protocol="openai",
base_url="https://api.example.com/v1",
api_key=", ,",
models=["gpt-4o-mini"],
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], "invalid_config")
self.assertEqual(payload["details"]["reason"], "missing_api_key")
@patch("src.services.system_config_service.requests.get")
def test_discover_llm_channel_models_returns_deduped_ids(self, mock_get) -> None:
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.json.return_value = {
"data": [
{"id": "qwen-plus"},
{"id": "qwen-plus"},
{"id": "qwen-turbo"},
]
}
mock_get.return_value = mock_response
payload = self.service.discover_llm_channel_models(
name="dashscope",
protocol="openai",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key="sk-test-value",
)
self.assertTrue(payload["success"])
self.assertEqual(payload["resolved_protocol"], "openai")
self.assertEqual(payload["models"], ["qwen-plus", "qwen-turbo"])
mock_get.assert_called_once()
self.assertEqual(
mock_get.call_args.args[0],
"https://dashscope.aliyuncs.com/compatible-mode/v1/models",
)
self.assertEqual(
mock_get.call_args.kwargs["headers"]["Authorization"],
"Bearer sk-test-value",
)
self.assertFalse(mock_get.call_args.kwargs["allow_redirects"])
@patch("src.services.system_config_service.requests.get")
def test_discover_llm_channel_models_classifies_error_scenarios(self, mock_get) -> None:
auth_response = Mock(ok=False, status_code=401, text="invalid api key sk-secret-value")
auth_response.json.return_value = {"error": {"message": "invalid api key sk-secret-value"}}
not_found_response = Mock(ok=False, status_code=404, text="not found")
not_found_response.json.return_value = {"error": {"message": "not found"}}
billing_response = Mock(ok=False, status_code=402, text="account balance insufficient")
billing_response.json.return_value = {"error": {"message": "account balance insufficient"}}
billing_rate_limit_response = Mock(ok=False, status_code=429, text="account balance insufficient")
billing_rate_limit_response.json.return_value = {"error": {"message": "account balance insufficient"}}
quota_exceeded_response = Mock(ok=False, status_code=429, text="insufficient_quota")
quota_exceeded_response.json.return_value = {"error": {"message": "insufficient_quota"}}
quota_blocked_response = Mock(ok=False, status_code=403, text="account balance insufficient; your request was blocked")
quota_blocked_response.json.return_value = {"error": {"message": "account balance insufficient; your request was blocked"}}
rate_limit_response = Mock(ok=False, status_code=429, text="too many requests")
rate_limit_response.json.return_value = {"error": {"message": "too many requests"}}
blocked_response = Mock(ok=False, status_code=403, text="Forbidden: your request was blocked by content policy")
blocked_response.json.return_value = {"error": {"message": "Forbidden: your request was blocked by content policy"}}
connection_blocked_response = Mock(ok=False, status_code=403, text="connection blocked by policy")
connection_blocked_response.json.return_value = {"error": {"message": "connection blocked by policy"}}
invalid_json_response = Mock(ok=True, status_code=200, text="<html>bad gateway</html>")
invalid_json_response.json.side_effect = ValueError("invalid json")
for response, error_code, stage, retryable, reason in [
(auth_response, "auth", "model_discovery", False, "api_key_rejected"),
(not_found_response, "network_error", "model_discovery", False, "endpoint_not_found"),
(billing_response, "quota", "model_discovery", True, "insufficient_balance"),
(billing_rate_limit_response, "quota", "model_discovery", True, "insufficient_balance"),
(quota_exceeded_response, "quota", "model_discovery", True, "quota_exceeded"),
(quota_blocked_response, "quota", "model_discovery", True, "insufficient_balance"),
(rate_limit_response, "quota", "model_discovery", True, "rate_limit"),
(blocked_response, "request_blocked", "model_discovery", False, "provider_blocked"),
(connection_blocked_response, "network_error", "model_discovery", True, "network_error"),
(invalid_json_response, "format_error", "response_parse", False, "non_json"),
]:
with self.subTest(error_code=error_code):
mock_get.return_value = response
payload = self.service.discover_llm_channel_models(
name="dashscope",
protocol="openai",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key="sk-secret-value",
)
self.assertFalse(payload["success"])
self.assertEqual(payload["error_code"], error_code)
self.assertEqual(payload["stage"], stage)
self.assertEqual(payload["retryable"], retryable)
self.assertEqual(payload["details"]["reason"], reason)
if error_code == "auth":
self.assertNotIn("sk-secret-value", payload["error"])
@patch("src.services.system_config_service.requests.get")
def test_discover_llm_channel_models_rejects_redirect_responses(self, mock_get) -> None:
mock_response = Mock()
mock_response.ok = True
mock_response.status_code = 302
mock_get.return_value = mock_response
payload = self.service.discover_llm_channel_models(
name="dashscope",
protocol="openai",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key="sk-test-value",
)
self.assertFalse(payload["success"])
self.assertEqual(payload["message"], "Model discovery request was redirected")
self.assertIn("Redirect responses are not allowed", payload["error"])
self.assertFalse(mock_get.call_args.kwargs["allow_redirects"])
def test_discover_llm_channel_models_requires_base_url(self) -> None:
payload = self.service.discover_llm_channel_models(
name="primary",
protocol="openai",
base_url="",
api_key="sk-test-value",
)
self.assertFalse(payload["success"])
self.assertIn("base URL", payload["error"])
self.assertEqual(payload["models"], [])
def test_discover_llm_channel_models_rejects_unsupported_protocol(self) -> None:
payload = self.service.discover_llm_channel_models(
name="gemini",
protocol="gemini",
base_url="https://example.com/v1",
api_key="sk-test-value",
)
self.assertFalse(payload["success"])
self.assertEqual(payload["resolved_protocol"], "gemini")
self.assertIn("does not support /models discovery yet", payload["error"])
def test_build_llm_models_url_strips_query_and_fragment(self) -> None:
models_url = SystemConfigService._build_llm_models_url(
"https://example.com/v1/chat/completions?api-version=1#frag"
)
self.assertEqual(models_url, "https://example.com/v1/models")
def test_build_llm_models_url_supports_deepseek_root_base_url(self) -> None:
models_url = SystemConfigService._build_llm_models_url("https://api.deepseek.com")
self.assertEqual(models_url, "https://api.deepseek.com/models")
def test_validate_reports_invalid_event_rule_semantics(self) -> None:
validation = self.service.validate(items=[{
"key": "AGENT_EVENT_ALERT_RULES_JSON",
"value": '[{"stock_code":"600519","alert_type":"price_cross","status":"bad","direction":"above","price":1800}]',
}])
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_event_rule" for issue in validation["issues"]))
def test_validate_accepts_price_change_percent_event_rule(self) -> None:
validation = self.service.validate(items=[{
"key": "AGENT_EVENT_ALERT_RULES_JSON",
"value": (
'[{"stock_code":"300750","alert_type":"price_change_percent",'
'"direction":"down","change_pct":3.0}]'
),
}])
self.assertTrue(validation["valid"])
self.assertEqual(validation["issues"], [])
def test_validate_rejects_unsupported_event_rule_type(self) -> None:
validation = self.service.validate(items=[{
"key": "AGENT_EVENT_ALERT_RULES_JSON",
"value": '[{"stock_code":"600519","alert_type":"sentiment_shift"}]',
}])
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_event_rule" for issue in validation["issues"]))
@patch.object(SystemConfigService, "_reload_runtime_singletons")
def test_update_with_reload_resets_runtime_singletons(
self,
mock_reload_runtime_singletons,
) -> None:
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "STOCK_LIST", "value": "600519"}],
reload_now=True,
)
self.assertTrue(response["success"])
mock_reload_runtime_singletons.assert_called_once()
def test_update_with_reload_applies_updated_env_file_when_process_env_is_stale(self) -> None:
os.environ["STOCK_LIST"] = "600519,000001"
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "STOCK_LIST", "value": "300750,TSLA"}],
reload_now=True,
)
self.assertTrue(response["success"])
self.assertEqual(Config.get_instance().stock_list, ["300750", "TSLA"])
@patch.object(SystemConfigService, "_reload_runtime_singletons")
def test_update_escapes_custom_webhook_template_and_runtime_reads_literals(
self,
_mock_reload_runtime_singletons,
) -> None:
template = '{"title":$title_json,"content":$content_json}'
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "CUSTOM_WEBHOOK_BODY_TEMPLATE", "value": template}],
reload_now=True,
)
self.assertTrue(response["success"])
self.assertIn(
'CUSTOM_WEBHOOK_BODY_TEMPLATE={"title":$$title_json,"content":$$content_json}\n',
self.env_path.read_text(encoding="utf-8"),
)
self.assertEqual(Config.get_instance().custom_webhook_body_template, template)
items = {
item["key"]: item
for item in self.service.get_config(include_schema=True)["items"]
}
self.assertEqual(items["CUSTOM_WEBHOOK_BODY_TEMPLATE"]["value"], template)
@patch.object(SystemConfigService, "_reload_runtime_singletons")
def test_update_escapes_braced_custom_webhook_template_and_runtime_reads_literals(
self,
_mock_reload_runtime_singletons,
) -> None:
template = '{"content":${content_json}}'
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "CUSTOM_WEBHOOK_BODY_TEMPLATE", "value": template}],
reload_now=True,
)
self.assertTrue(response["success"])
self.assertIn(
'CUSTOM_WEBHOOK_BODY_TEMPLATE={"content":$${content_json}}\n',
self.env_path.read_text(encoding="utf-8"),
)
self.assertEqual(Config.get_instance().custom_webhook_body_template, template)
items = {
item["key"]: item
for item in self.service.get_config(include_schema=True)["items"]
}
self.assertEqual(items["CUSTOM_WEBHOOK_BODY_TEMPLATE"]["value"], template)
def test_update_raises_conflict_for_stale_version(self) -> None:
with self.assertRaises(ConfigConflictError):
self.service.update(
config_version="stale-version",
items=[{"key": "STOCK_LIST", "value": "600519"}],
reload_now=False,
)
def test_update_appends_news_window_explainability_warning(self) -> None:
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[
{"key": "NEWS_STRATEGY_PROFILE", "value": "ultra_short"},
{"key": "NEWS_MAX_AGE_DAYS", "value": "7"},
],
reload_now=False,
)
self.assertTrue(response["success"])
joined = " | ".join(response["warnings"])
self.assertIn("effective_days=1", joined)
self.assertIn("min(profile_days, NEWS_MAX_AGE_DAYS)", joined)
def test_update_appends_max_workers_warning(self) -> None:
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "MAX_WORKERS", "value": "1"}],
reload_now=False,
)
self.assertTrue(response["success"])
joined = " | ".join(response["warnings"])
self.assertIn("MAX_WORKERS=1", joined)
self.assertIn("reload_now=false", joined)
def test_update_appends_mode_specific_startup_warnings(self) -> None:
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[
{"key": "RUN_IMMEDIATELY", "value": "false"},
{"key": "SCHEDULE_ENABLED", "value": "true"},
{"key": "SCHEDULE_RUN_IMMEDIATELY", "value": "true"},
],
reload_now=True,
)
self.assertTrue(response["success"])
run_warning = next(
warning
for warning in response["warnings"]
if "RUN_IMMEDIATELY 已写入 .env" in warning
)
schedule_warning = next(
warning
for warning in response["warnings"]
if "SCHEDULE_ENABLED" in warning
)
schedule_run_warning = next(
warning
for warning in response["warnings"]
if "SCHEDULE_RUN_IMMEDIATELY" in warning
)
self.assertIn("非 schedule 模式", run_warning)
self.assertNotIn("以 schedule 模式", run_warning)
self.assertIn("runtime scheduler", schedule_warning)
self.assertIn("CLI schedule", schedule_warning)
self.assertIn("SCHEDULE_RUN_IMMEDIATELY", schedule_run_warning)
self.assertIn("不会因为本次保存启动、停止或重建 scheduler", schedule_run_warning)
self.assertIn("以 schedule 模式重新启动后生效", schedule_run_warning)
self.assertNotIn("它属于启动期单次运行配置", schedule_run_warning)
def test_update_appends_schedule_time_runtime_rebind_warning(self) -> None:
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "SCHEDULE_TIME", "value": "09:30"}],
reload_now=True,
)
self.assertTrue(response["success"])
schedule_time_warning = next(
warning
for warning in response["warnings"]
if "SCHEDULE_TIME=09:30 已写入 .env" in warning
)
self.assertIn("已经以 schedule 模式运行", schedule_time_warning)
self.assertIn("自动重建 daily job", schedule_time_warning)
self.assertIn("不会启动 scheduler", schedule_time_warning)
self.assertNotIn("重启当前进程", schedule_time_warning)
self.assertNotIn("不会因为本次保存启动、停止或重建 scheduler", schedule_time_warning)
def test_update_schedule_time_blank_warning_reports_effective_default(self) -> None:
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "SCHEDULE_TIME", "value": " "}],
reload_now=True,
)
self.assertTrue(response["success"])
self.assertTrue(
any("SCHEDULE_TIME=18:00 已写入 .env" in warning for warning in response["warnings"]),
response["warnings"],
)
def test_update_appends_webui_bind_restart_warning(self) -> None:
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[
{"key": "WEBUI_HOST", "value": "0.0.0.0"},
{"key": "WEBUI_PORT", "value": "18000"},
],
reload_now=True,
)
self.assertTrue(response["success"])
bind_warning = next(
warning
for warning in response["warnings"]
if "WEBUI_HOST" in warning and "WEBUI_PORT" in warning
)
self.assertIn("启动期监听配置", bind_warning)
self.assertIn("不会因为本次保存重新绑定监听地址或端口", bind_warning)
self.assertIn("重启当前进程、Docker 容器或服务管理器后生效", bind_warning)
def test_update_warns_when_runtime_model_references_are_cleared(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LLM_CHANNELS=deepseek",
"LLM_DEEPSEEK_PROTOCOL=deepseek",
"LLM_DEEPSEEK_BASE_URL=https://api.deepseek.com",
"LLM_DEEPSEEK_API_KEY=sk-test-value",
"LLM_DEEPSEEK_MODELS=deepseek-chat,deepseek-v4-flash,deepseek-v4-pro",
"LITELLM_MODEL=deepseek/deepseek-chat",
"AGENT_LITELLM_MODEL=deepseek/deepseek-v4-pro",
"LITELLM_FALLBACK_MODELS=deepseek/deepseek-v4-pro,deepseek/deepseek-chat,cohere/command-r-plus",
"VISION_MODEL=deepseek/deepseek-v4-flash",
)
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[
{"key": "LLM_DEEPSEEK_MODELS", "value": "deepseek-v4-flash,deepseek-v4-pro"},
{"key": "LITELLM_MODEL", "value": ""},
{"key": "AGENT_LITELLM_MODEL", "value": ""},
{"key": "LITELLM_FALLBACK_MODELS", "value": "deepseek/deepseek-v4-pro,cohere/command-r-plus"},
{"key": "VISION_MODEL", "value": ""},
],
reload_now=False,
)
self.assertTrue(response["success"])
warning = next(
warning
for warning in response["warnings"]
if "已同步清理失效的运行时模型引用" in warning
)
self.assertIn("主模型 / Agent 主模型 / Vision 模型 / 备选模型中的失效项", warning)
self.assertIn("桌面端导出备份", warning)
def test_update_market_review_region_does_not_trigger_runtime_model_cleanup(self) -> None:
litellm_config_path = Path(self.temp_dir.name) / "litellm_config.yaml"
litellm_config_path.write_text("model_list: []\n", encoding="utf-8")
self._rewrite_env(
"MARKET_REVIEW_REGION=cn",
"LITELLM_MODEL=openai/gpt-4o-mini",
"AGENT_LITELLM_MODEL=openai/gpt-4o",
"LITELLM_FALLBACK_MODELS=openai/gpt-4o-mini,openai/gpt-4o",
"VISION_MODEL=openai/gpt-4o",
f"LITELLM_CONFIG={litellm_config_path}",
"LLM_CHANNELS=openai",
"LLM_OPENAI_PROTOCOL=openai",
"LLM_OPENAI_BASE_URL=https://llm-openai.example.com/v1",
"LLM_OPENAI_API_KEYS=legacy-openai-secret",
"LLM_OPENAI_MODELS=openai/gpt-4o-mini,openai/gpt-4o",
"OPENAI_BASE_URL=https://openai.example.com/v1",
"OPENAI_API_KEY=sk-openai",
"OPENAI_MODEL=gpt-4.1",
"ANTHROPIC_MODEL=claude-sonnet-4-6",
)
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "MARKET_REVIEW_REGION", "value": "both"}],
reload_now=False,
)
self.assertTrue(response["success"])
self.assertIn("MARKET_REVIEW_REGION", response["updated_keys"])
current_map = self.manager.read_config_map()
self.assertEqual(current_map["MARKET_REVIEW_REGION"], "both")
self.assertEqual(current_map["LITELLM_MODEL"], "openai/gpt-4o-mini")
self.assertEqual(current_map["AGENT_LITELLM_MODEL"], "openai/gpt-4o")
self.assertEqual(current_map["LITELLM_FALLBACK_MODELS"], "openai/gpt-4o-mini,openai/gpt-4o")
self.assertEqual(current_map["VISION_MODEL"], "openai/gpt-4o")
self.assertEqual(current_map["LITELLM_CONFIG"], str(litellm_config_path))
self.assertEqual(current_map["LLM_CHANNELS"], "openai")
self.assertEqual(current_map["LLM_OPENAI_PROTOCOL"], "openai")
self.assertEqual(current_map["LLM_OPENAI_BASE_URL"], "https://llm-openai.example.com/v1")
self.assertEqual(current_map["LLM_OPENAI_API_KEYS"], "legacy-openai-secret")
self.assertEqual(current_map["LLM_OPENAI_MODELS"], "openai/gpt-4o-mini,openai/gpt-4o")
self.assertEqual(current_map["OPENAI_BASE_URL"], "https://openai.example.com/v1")
self.assertEqual(current_map["OPENAI_API_KEY"], "sk-openai")
self.assertEqual(current_map["OPENAI_MODEL"], "gpt-4.1")
self.assertEqual(current_map["ANTHROPIC_MODEL"], "claude-sonnet-4-6")
self.assertFalse(
any("已同步清理失效的运行时模型引用" in warning for warning in response["warnings"]),
response["warnings"],
)
def test_update_market_review_region_accepts_comma_separated_regions(self) -> None:
response = self.service.update(
config_version=self.manager.get_config_version(),
items=[{"key": "MARKET_REVIEW_REGION", "value": "cn,jp,us"}],
reload_now=False,
)
self.assertTrue(response["success"])
self.assertIn("MARKET_REVIEW_REGION", response["updated_keys"])
current_map = self.manager.read_config_map()
self.assertEqual(current_map["MARKET_REVIEW_REGION"], "cn,jp,us")
def test_import_env_market_review_region_accepts_comma_separated_regions(self) -> None:
response = self.service.import_env(
config_version=self.manager.get_config_version(),
content="MARKET_REVIEW_REGION=jp,kr\n",
reload_now=False,
)
self.assertTrue(response["success"])
current_map = self.manager.read_config_map()
self.assertEqual(current_map["MARKET_REVIEW_REGION"], "jp,kr")
def test_import_desktop_env_restores_runtime_models_after_cleanup(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LLM_CHANNELS=deepseek",
"LLM_DEEPSEEK_PROTOCOL=deepseek",
"LLM_DEEPSEEK_BASE_URL=https://api.deepseek.com",
"LLM_DEEPSEEK_API_KEY=sk-test-value",
"LLM_DEEPSEEK_MODELS=deepseek-chat,deepseek-v4-flash,deepseek-v4-pro",
"LITELLM_MODEL=deepseek/deepseek-chat",
"AGENT_LITELLM_MODEL=deepseek/deepseek-v4-pro",
"LITELLM_FALLBACK_MODELS=deepseek/deepseek-v4-pro,deepseek/deepseek-chat,cohere/command-r-plus",
"VISION_MODEL=deepseek/deepseek-v4-flash",
)
backup_content = self.service.export_desktop_env()["content"]
pre_clear_map = dict(self.manager.read_config_map())
clear_response = self.service.update(
config_version=self.manager.get_config_version(),
items=[
{"key": "LLM_DEEPSEEK_MODELS", "value": "deepseek-v4-flash"},
{"key": "LITELLM_MODEL", "value": ""},
{"key": "AGENT_LITELLM_MODEL", "value": ""},
{"key": "LITELLM_FALLBACK_MODELS", "value": "deepseek/deepseek-v4-flash"},
{"key": "VISION_MODEL", "value": ""},
],
reload_now=False,
)
self.assertTrue(clear_response["success"])
cleared_map = self.manager.read_config_map()
self.assertEqual(cleared_map["LITELLM_MODEL"], "")
self.assertEqual(cleared_map["AGENT_LITELLM_MODEL"], "")
self.assertEqual(cleared_map["VISION_MODEL"], "")
self.assertEqual(cleared_map["LITELLM_FALLBACK_MODELS"], "deepseek/deepseek-v4-flash")
restore_payload = self.service.import_desktop_env(
config_version=self.manager.get_config_version(),
content=backup_content,
reload_now=False,
)
self.assertTrue(restore_payload["success"])
restored_map = self.manager.read_config_map()
self.assertEqual(restored_map["LITELLM_MODEL"], pre_clear_map["LITELLM_MODEL"])
self.assertEqual(restored_map["AGENT_LITELLM_MODEL"], pre_clear_map["AGENT_LITELLM_MODEL"])
self.assertEqual(restored_map["VISION_MODEL"], pre_clear_map["VISION_MODEL"])
self.assertEqual(restored_map["LITELLM_FALLBACK_MODELS"], pre_clear_map["LITELLM_FALLBACK_MODELS"])
def test_import_desktop_env_restores_provider_and_base_url_after_provider_cleanup(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"LITELLM_MODEL=openai/gpt-4o-mini",
"OPENAI_MODEL=gpt-4.1",
"OPENAI_BASE_URL=https://openai.example.com/v1",
"OPENAI_API_KEY=legacy-openai-key",
)
backup_content = self.service.export_desktop_env()["content"]
pre_clear_map = dict(self.manager.read_config_map())
clear_response = self.service.update(
config_version=self.manager.get_config_version(),
items=[
{"key": "LITELLM_MODEL", "value": ""},
{"key": "OPENAI_MODEL", "value": ""},
{"key": "OPENAI_BASE_URL", "value": ""},
{"key": "OPENAI_API_KEY", "value": ""},
],
reload_now=False,
)
self.assertTrue(clear_response["success"])
cleared_map = self.manager.read_config_map()
self.assertEqual(cleared_map["LITELLM_MODEL"], "")
self.assertEqual(cleared_map["OPENAI_MODEL"], "")
self.assertEqual(cleared_map["OPENAI_BASE_URL"], "")
self.assertEqual(cleared_map["OPENAI_API_KEY"], "")
restore_payload = self.service.import_desktop_env(
config_version=self.manager.get_config_version(),
content=backup_content,
reload_now=False,
)
self.assertTrue(restore_payload["success"])
restored_map = self.manager.read_config_map()
self.assertEqual(restored_map["LITELLM_MODEL"], pre_clear_map["LITELLM_MODEL"])
self.assertEqual(restored_map["OPENAI_MODEL"], pre_clear_map["OPENAI_MODEL"])
self.assertEqual(restored_map["OPENAI_BASE_URL"], pre_clear_map["OPENAI_BASE_URL"])
self.assertEqual(restored_map["OPENAI_API_KEY"], pre_clear_map["OPENAI_API_KEY"])
def test_validate_rejects_comma_only_api_key(self) -> None:
"""Whitespace/comma-only api_key must fail validation (P2: parsed-segment check)."""
for bad_key in (",", " , ", " , , "):
with self.subTest(api_key=bad_key):
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LLM_PRIMARY_API_KEY", "value": bad_key},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(issue["code"] == "missing_api_key" for issue in validation["issues"]),
f"Expected missing_api_key for api_key={bad_key!r}, got: {validation['issues']}",
)
def test_validate_rejects_ssrf_metadata_base_url(self) -> None:
"""base_url pointing to cloud metadata service must be blocked (P1: SSRF guard)."""
for bad_url in (
"http://169.254.169.254/latest/meta-data/",
"http://metadata.google.internal/computeMetadata/v1/",
"http://100.100.100.200/latest/meta-data/",
):
with self.subTest(base_url=bad_url):
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "primary"},
{"key": "LLM_PRIMARY_PROTOCOL", "value": "openai"},
{"key": "LLM_PRIMARY_MODELS", "value": "gpt-4o-mini"},
{"key": "LLM_PRIMARY_API_KEY", "value": "sk-test"},
{"key": "LLM_PRIMARY_BASE_URL", "value": bad_url},
]
)
self.assertFalse(validation["valid"])
self.assertTrue(
any(issue["code"] == "ssrf_blocked" for issue in validation["issues"]),
f"Expected ssrf_blocked for base_url={bad_url!r}, got: {validation['issues']}",
)
def test_validate_allows_localhost_base_url(self) -> None:
"""localhost/LAN base_url must not be blocked (legitimate Ollama endpoints)."""
validation = self.service.validate(
items=[
{"key": "LLM_CHANNELS", "value": "local"},
{"key": "LLM_LOCAL_PROTOCOL", "value": "ollama"},
{"key": "LLM_LOCAL_MODELS", "value": "llama3"},
{"key": "LLM_LOCAL_API_KEY", "value": ""},
{"key": "LLM_LOCAL_BASE_URL", "value": "http://localhost:11434"},
]
)
self.assertFalse(any(issue["code"] == "ssrf_blocked" for issue in validation["issues"]))
if __name__ == "__main__":
unittest.main()