Files
ZNJJ-api-server/src/core/config.py
T
Eric Wang e5d2a78886 feat(config): named LLM provider list via env vars, runtime selection
- Config.llm_profiles: LLM_PROVIDERS declares names, each group defined by
  LLM_<NAME>_BASE_URL/_API_KEY/_MODEL (required) and optional _TIMEOUT_SECONDS/
  _MAX_RETRIES/_TEMPERATURE/_EXTRA_BODY; JSON-dict form of LLM_PROVIDERS also
  supported. LLM_PROVIDER selects the active group at runtime; empty/fake =
  offline scripted model
- removed fixed openai/kimi groups (AGENT_LLM_PROVIDER, KIMI_LLM_*)
- validate(): incomplete groups and unknown LLM_PROVIDER fail fast with
  the configured-name list
- OpenAILLMService: http client trust_env from LLM_HTTP_TRUST_ENV (default
  False - local gateways must bypass the Windows system proxy)
- build_llm_service(provider): explicit name or LLM_PROVIDER default
- .env/.env.example: deepseek + kimi groups side by side, switch by
  LLM_PROVIDER=kimi (thinking off via EXTRA_BODY reasoning_effort=none)
- eval probe scripts updated to the new config interface

Verified: 172 unit tests; live calls succeed on both providers
(deepseek 28s, kimi 6.6s, both emitting valid <state> prefixes)
2026-10-22 13:59:18 +08:00

162 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
PROJECT_NAME = "Flexible Employment Analysis API"
API_V1_STR = "/api/v1"
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key")
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./test.db")
DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "t")
# FastGPT Configuration
FASTGPT_API_KEY = os.getenv("ANALYSIS_AUTH_TOKEN")
FASTGPT_BASE_URL = os.getenv("ANALYSIS_SERVICE_URL")
FASTGPT_APP_ID = os.getenv("APP_ID")
# Chat backend switch(作业 2)
# "fastgpt" 保持现有行为;"langgraph" 使用内存图 fake 回复。
AGENT_BACKEND = os.getenv("AGENT_BACKEND", "fastgpt")
# 命名 LLM 提供商列表(均为 OpenAI 兼容接口),两种写法二选一:
# ① JSON 字典(推荐,单变量管理):
# LLM_PROVIDERS={"deepseek": {"base_url": "https://...", "api_key": "...",
# "model": "deepseek-v4.1-flash"},
# "kimi": {"base_url": "http://127.0.0.1:3425/v1",
# "api_key": "magpie", "model": "kimi-code-cn/kimi-for-coding",
# "extra_body": {"reasoning_effort": "none"}}}
# 每组 base_url/api_key/model 必填;timeout_seconds/max_retries/
# temperature 可选;extra_body 为对象时直接合并进请求体(思考开关等)。
# ② 逗号分隔名单 + 分散变量:
# LLM_PROVIDERS=deepseek,kimi + LLM_DEEPSEEK_BASE_URL/_API_KEY/_MODEL 等。
# 运行时选择:LLM_PROVIDER=<名>;缺省或 fake = 离线脚本模型。
LLM_PROVIDERS = os.getenv("LLM_PROVIDERS", "")
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "")
# LLM 客户端是否信任系统代理(Windows 注册表)。缺省 False:
# 本地网关(127.0.0.1)必须直连;外部网关若需代理设为 true。
LLM_HTTP_TRUST_ENV = os.getenv("LLM_HTTP_TRUST_ENV", "false").lower() in (
"true", "1", "t",
)
@classmethod
def _declared_provider_names(cls) -> list[str]:
"""LLM_PROVIDERS 中声明的提供商名(JSON 字典取键,名单取逗号项)。"""
raw = cls.LLM_PROVIDERS.strip()
if not raw:
return []
if raw.startswith("{"):
import json
data = json.loads(raw)
return [str(k).strip().lower() for k in data]
return [n.strip().lower() for n in raw.split(",") if n.strip()]
@classmethod
def llm_profiles(cls) -> dict[str, dict]:
"""解析命名提供商列表为统一结构;声明了但缺必填项的组不收录
(由 validate() 统一报错)。extra_body 统一为 JSON 字符串。"""
raw = cls.LLM_PROVIDERS.strip()
if not raw:
return {}
if raw.startswith("{"):
return cls._profiles_from_json(raw)
return cls._profiles_from_prefixed(raw)
@classmethod
def _profiles_from_json(cls, raw: str) -> dict[str, dict]:
import json
try:
data = json.loads(raw)
except ValueError as exc:
raise ValueError(f"LLM_PROVIDERS is not valid JSON: {exc}") from exc
if not isinstance(data, dict):
raise ValueError("LLM_PROVIDERS JSON must be an object keyed by provider name")
profiles: dict[str, dict] = {}
for key, spec in data.items():
name = str(key).strip().lower()
if not name or not isinstance(spec, dict):
continue
base_url = str(spec.get("base_url", ""))
api_key = str(spec.get("api_key", ""))
model = str(spec.get("model", ""))
if not (base_url and api_key and model):
continue
extra_body = spec.get("extra_body", "")
if isinstance(extra_body, dict):
extra_body = json.dumps(extra_body, ensure_ascii=False)
temperature = spec.get("temperature")
profiles[name] = {
"base_url": base_url,
"api_key": api_key,
"model": model,
"timeout_seconds": float(spec.get("timeout_seconds", 30)),
"max_retries": int(spec.get("max_retries", 2)),
"temperature": float(temperature) if temperature is not None else None,
"extra_body": str(extra_body),
}
return profiles
@classmethod
def _profiles_from_prefixed(cls, raw: str) -> dict[str, dict]:
profiles: dict[str, dict] = {}
for name in cls._declared_provider_names():
prefix = f"LLM_{name.upper()}_"
base_url = os.getenv(prefix + "BASE_URL", "")
api_key = os.getenv(prefix + "API_KEY", "")
model = os.getenv(prefix + "MODEL", "")
if not (base_url and api_key and model):
continue
temperature = os.getenv(prefix + "TEMPERATURE")
profiles[name] = {
"base_url": base_url,
"api_key": api_key,
"model": model,
"timeout_seconds": float(os.getenv(prefix + "TIMEOUT_SECONDS", "30")),
"max_retries": int(os.getenv(prefix + "MAX_RETRIES", "2")),
"temperature": float(temperature) if temperature else None,
"extra_body": os.getenv(prefix + "EXTRA_BODY", ""),
}
return profiles
@classmethod
def llm_profile(cls, name: str) -> dict | None:
return cls.llm_profiles().get(name.strip().lower())
@classmethod
def validate(cls):
"""Validate required configuration."""
# TODO(作业 2): 语音管线仍依赖 FastGPT。当前仅按文本后端开关校验;
# 接入语音开关后,需要单独校验语音路径所需配置。
if cls.AGENT_BACKEND not in ("fastgpt", "langgraph"):
raise ValueError(
f"AGENT_BACKEND must be 'fastgpt' or 'langgraph', got {cls.AGENT_BACKEND!r}"
)
profiles = cls.llm_profiles() # JSON 非法时在此抛出明确错误
incomplete = [n for n in cls._declared_provider_names() if n not in profiles]
if incomplete:
raise ValueError(
"LLM providers missing BASE_URL/API_KEY/MODEL: "
+ ", ".join(incomplete)
)
if (
cls.LLM_PROVIDER
and cls.LLM_PROVIDER != "fake"
and cls.LLM_PROVIDER.strip().lower() not in profiles
):
raise ValueError(
f"Unknown LLM_PROVIDER {cls.LLM_PROVIDER!r}; "
f"configured: {sorted(profiles)}"
)
if cls.AGENT_BACKEND != "fastgpt":
return
required = [
("FASTGPT_BASE_URL (ANALYSIS_SERVICE_URL)", cls.FASTGPT_BASE_URL),
("FASTGPT_API_KEY (ANALYSIS_AUTH_TOKEN)", cls.FASTGPT_API_KEY),
("FASTGPT_APP_ID (APP_ID)", cls.FASTGPT_APP_ID),
]
missing = [name for name, value in required if not value]
if missing:
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")