chore: land #1180 P1 LLM Actions mapping (#1190)

* chore: 补齐 LLM Actions 映射与模板校准 P1 (#1180) (#1186)

* chore: align LLM channel Actions mapping (#1180)

* docs: prefer validated OpenRouter default (#1180)

* fix(review-feedback-1190): address latest review comments

---------

Co-authored-by: Alfred <massif0601@gmail.com>
This commit is contained in:
mumu
2026-05-03 21:58:03 +08:00
committed by GitHub
parent 05954d7cd2
commit d0ff7abb14
8 changed files with 212 additions and 5 deletions

View File

@@ -117,6 +117,14 @@ GEMINI_API_KEY=
#
# 常用厂商模板(任选其一复制到 .env完整说明见 docs/llm-providers.md
#
# AIHubmix 聚合平台OpenAI Compatible
# LLM_CHANNELS=aihubmix
# LLM_AIHUBMIX_PROTOCOL=openai
# LLM_AIHUBMIX_BASE_URL=https://aihubmix.com/v1
# LLM_AIHUBMIX_API_KEY=sk-xxx
# LLM_AIHUBMIX_MODELS=gpt-5.5,claude-sonnet-4-6,gemini-3.1-pro-preview
# LITELLM_MODEL=openai/gpt-5.5
#
# OpenAI 官方
# LLM_CHANNELS=openai
# LLM_OPENAI_PROTOCOL=openai
@@ -190,6 +198,24 @@ GEMINI_API_KEY=
# LLM_VOLCENGINE_API_KEY=xxx
# LLM_VOLCENGINE_MODELS=doubao-seed-1-6-251015,doubao-seed-1-6-thinking-251015
# LITELLM_MODEL=openai/doubao-seed-1-6-251015
#
# 硅基流动 SiliconFlowOpenAI Compatible
# 来源:官方模型列表 https://docs.siliconflow.cn/quickstart/models
# LLM_CHANNELS=siliconflow
# LLM_SILICONFLOW_PROTOCOL=openai
# LLM_SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
# LLM_SILICONFLOW_API_KEY=sk-xxx
# LLM_SILICONFLOW_MODELS=deepseek-ai/DeepSeek-V3.2,Qwen/Qwen3-235B-A22B-Thinking-2507
# LITELLM_MODEL=openai/deepseek-ai/DeepSeek-V3.2
#
# OpenRouterOpenAI Compatible
# 来源:官方 Models API https://openrouter.ai/docs/api/api-reference/models/get-models
# LLM_CHANNELS=openrouter
# LLM_OPENROUTER_PROTOCOL=openai
# LLM_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# LLM_OPENROUTER_API_KEY=sk-or-xxx
# LLM_OPENROUTER_MODELS=~anthropic/claude-sonnet-latest,~openai/gpt-latest
# LITELLM_MODEL=openai/~anthropic/claude-sonnet-latest
# 高级:模型路由 YAML 配置(可选,参考 litellm_config.example.yaml
# LITELLM_CONFIG=./litellm_config.yaml

View File

@@ -164,6 +164,54 @@ jobs:
LLM_MOONSHOT_ENABLED: ${{ vars.LLM_MOONSHOT_ENABLED || secrets.LLM_MOONSHOT_ENABLED }}
LLM_MOONSHOT_EXTRA_HEADERS: ${{ vars.LLM_MOONSHOT_EXTRA_HEADERS || secrets.LLM_MOONSHOT_EXTRA_HEADERS }}
LLM_DASHSCOPE_PROTOCOL: ${{ vars.LLM_DASHSCOPE_PROTOCOL || secrets.LLM_DASHSCOPE_PROTOCOL }}
LLM_DASHSCOPE_BASE_URL: ${{ vars.LLM_DASHSCOPE_BASE_URL || secrets.LLM_DASHSCOPE_BASE_URL }}
LLM_DASHSCOPE_API_KEY: ${{ secrets.LLM_DASHSCOPE_API_KEY }}
LLM_DASHSCOPE_API_KEYS: ${{ secrets.LLM_DASHSCOPE_API_KEYS }}
LLM_DASHSCOPE_MODELS: ${{ vars.LLM_DASHSCOPE_MODELS || secrets.LLM_DASHSCOPE_MODELS }}
LLM_DASHSCOPE_ENABLED: ${{ vars.LLM_DASHSCOPE_ENABLED || secrets.LLM_DASHSCOPE_ENABLED }}
LLM_DASHSCOPE_EXTRA_HEADERS: ${{ vars.LLM_DASHSCOPE_EXTRA_HEADERS || secrets.LLM_DASHSCOPE_EXTRA_HEADERS }}
LLM_ZHIPU_PROTOCOL: ${{ vars.LLM_ZHIPU_PROTOCOL || secrets.LLM_ZHIPU_PROTOCOL }}
LLM_ZHIPU_BASE_URL: ${{ vars.LLM_ZHIPU_BASE_URL || secrets.LLM_ZHIPU_BASE_URL }}
LLM_ZHIPU_API_KEY: ${{ secrets.LLM_ZHIPU_API_KEY }}
LLM_ZHIPU_API_KEYS: ${{ secrets.LLM_ZHIPU_API_KEYS }}
LLM_ZHIPU_MODELS: ${{ vars.LLM_ZHIPU_MODELS || secrets.LLM_ZHIPU_MODELS }}
LLM_ZHIPU_ENABLED: ${{ vars.LLM_ZHIPU_ENABLED || secrets.LLM_ZHIPU_ENABLED }}
LLM_ZHIPU_EXTRA_HEADERS: ${{ vars.LLM_ZHIPU_EXTRA_HEADERS || secrets.LLM_ZHIPU_EXTRA_HEADERS }}
LLM_MINIMAX_PROTOCOL: ${{ vars.LLM_MINIMAX_PROTOCOL || secrets.LLM_MINIMAX_PROTOCOL }}
LLM_MINIMAX_BASE_URL: ${{ vars.LLM_MINIMAX_BASE_URL || secrets.LLM_MINIMAX_BASE_URL }}
LLM_MINIMAX_API_KEY: ${{ secrets.LLM_MINIMAX_API_KEY }}
LLM_MINIMAX_API_KEYS: ${{ secrets.LLM_MINIMAX_API_KEYS }}
LLM_MINIMAX_MODELS: ${{ vars.LLM_MINIMAX_MODELS || secrets.LLM_MINIMAX_MODELS }}
LLM_MINIMAX_ENABLED: ${{ vars.LLM_MINIMAX_ENABLED || secrets.LLM_MINIMAX_ENABLED }}
LLM_MINIMAX_EXTRA_HEADERS: ${{ vars.LLM_MINIMAX_EXTRA_HEADERS || secrets.LLM_MINIMAX_EXTRA_HEADERS }}
LLM_VOLCENGINE_PROTOCOL: ${{ vars.LLM_VOLCENGINE_PROTOCOL || secrets.LLM_VOLCENGINE_PROTOCOL }}
LLM_VOLCENGINE_BASE_URL: ${{ vars.LLM_VOLCENGINE_BASE_URL || secrets.LLM_VOLCENGINE_BASE_URL }}
LLM_VOLCENGINE_API_KEY: ${{ secrets.LLM_VOLCENGINE_API_KEY }}
LLM_VOLCENGINE_API_KEYS: ${{ secrets.LLM_VOLCENGINE_API_KEYS }}
LLM_VOLCENGINE_MODELS: ${{ vars.LLM_VOLCENGINE_MODELS || secrets.LLM_VOLCENGINE_MODELS }}
LLM_VOLCENGINE_ENABLED: ${{ vars.LLM_VOLCENGINE_ENABLED || secrets.LLM_VOLCENGINE_ENABLED }}
LLM_VOLCENGINE_EXTRA_HEADERS: ${{ vars.LLM_VOLCENGINE_EXTRA_HEADERS || secrets.LLM_VOLCENGINE_EXTRA_HEADERS }}
LLM_SILICONFLOW_PROTOCOL: ${{ vars.LLM_SILICONFLOW_PROTOCOL || secrets.LLM_SILICONFLOW_PROTOCOL }}
LLM_SILICONFLOW_BASE_URL: ${{ vars.LLM_SILICONFLOW_BASE_URL || secrets.LLM_SILICONFLOW_BASE_URL }}
LLM_SILICONFLOW_API_KEY: ${{ secrets.LLM_SILICONFLOW_API_KEY }}
LLM_SILICONFLOW_API_KEYS: ${{ secrets.LLM_SILICONFLOW_API_KEYS }}
LLM_SILICONFLOW_MODELS: ${{ vars.LLM_SILICONFLOW_MODELS || secrets.LLM_SILICONFLOW_MODELS }}
LLM_SILICONFLOW_ENABLED: ${{ vars.LLM_SILICONFLOW_ENABLED || secrets.LLM_SILICONFLOW_ENABLED }}
LLM_SILICONFLOW_EXTRA_HEADERS: ${{ vars.LLM_SILICONFLOW_EXTRA_HEADERS || secrets.LLM_SILICONFLOW_EXTRA_HEADERS }}
LLM_OPENROUTER_PROTOCOL: ${{ vars.LLM_OPENROUTER_PROTOCOL || secrets.LLM_OPENROUTER_PROTOCOL }}
LLM_OPENROUTER_BASE_URL: ${{ vars.LLM_OPENROUTER_BASE_URL || secrets.LLM_OPENROUTER_BASE_URL }}
LLM_OPENROUTER_API_KEY: ${{ secrets.LLM_OPENROUTER_API_KEY }}
LLM_OPENROUTER_API_KEYS: ${{ secrets.LLM_OPENROUTER_API_KEYS }}
LLM_OPENROUTER_MODELS: ${{ vars.LLM_OPENROUTER_MODELS || secrets.LLM_OPENROUTER_MODELS }}
LLM_OPENROUTER_ENABLED: ${{ vars.LLM_OPENROUTER_ENABLED || secrets.LLM_OPENROUTER_ENABLED }}
LLM_OPENROUTER_EXTRA_HEADERS: ${{ vars.LLM_OPENROUTER_EXTRA_HEADERS || secrets.LLM_OPENROUTER_EXTRA_HEADERS }}
LLM_OLLAMA_PROTOCOL: ${{ vars.LLM_OLLAMA_PROTOCOL || secrets.LLM_OLLAMA_PROTOCOL }}
LLM_OLLAMA_BASE_URL: ${{ vars.LLM_OLLAMA_BASE_URL || secrets.LLM_OLLAMA_BASE_URL }}
LLM_OLLAMA_API_KEY: ${{ secrets.LLM_OLLAMA_API_KEY }}

View File

@@ -99,7 +99,7 @@ export const LLM_PROVIDER_TEMPLATES: LLMProviderTemplate[] = [
label: 'OpenRouter',
protocol: 'openai',
baseUrl: 'https://openrouter.ai/api/v1',
placeholderModels: '~openai/gpt-latest,~anthropic/claude-sonnet-latest',
placeholderModels: '~anthropic/claude-sonnet-latest,~openai/gpt-latest',
capabilities: ['openai-compatible', 'aggregator', 'model-discovery'],
officialSources: [
{ label: 'OpenRouter Models API', url: 'https://openrouter.ai/docs/api/api-reference/models/get-models' },

View File

@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [新功能] EventMonitor 支持 `price_change_percent` 涨跌幅阈值规则,可按上涨或下跌方向触发实时告警。
- [文档] 明确 `price_change_percent` 事件告警仅为配置与运行时规则扩展,未变更模型/provider/base URL/LiteLLM 兼容语义;回退路径为关闭/移除 Event Monitor 配置;兼容验证与回归依据见 `tests/test_multi_agent.py``tests/test_system_config_service.py`
- [chore] 抽出 Web LLM provider preset 单一模板数据源,保持现有配置保存语义不变。
- [改进] 补齐 LLM provider channel 在 GitHub Actions 中的显式映射,并同步 `.env` 示例与配置文档。
## [3.14.2] - 2026-04-30

View File

@@ -245,7 +245,7 @@ model_list:
- 运行时选择:`LLM_CHANNELS`、`LITELLM_MODEL`、`LITELLM_FALLBACK_MODELS`、`AGENT_LITELLM_MODEL`、`VISION_MODEL`、`VISION_PROVIDER_PRIORITY`、`LLM_TEMPERATURE`
- 多 Key`GEMINI_API_KEYS`、`ANTHROPIC_API_KEYS`、`OPENAI_API_KEYS`、`DEEPSEEK_API_KEYS`(当前 workflow 仅从 repository secrets 导入,不会读取同名 Variables
- 常用渠道名:`primary`、`secondary`、`gemini`、`deepseek`、`aihubmix`、`openai`、`anthropic`、`moonshot`、`ollama`
- 常用渠道名:`primary`、`secondary`、`aihubmix`、`deepseek`、`dashscope`、`zhipu`、`moonshot`、`minimax`、`volcengine`、`siliconflow`、`openrouter`、`gemini`、`anthropic`、`openai`、`ollama`
例如在 GitHub Actions 中配置 `LLM_CHANNELS=primary,deepseek` 时,需同步配置 `LLM_PRIMARY_*` / `LLM_DEEPSEEK_*`。其中 `LLM_<NAME>_API_KEY` / `LLM_<NAME>_API_KEYS` 当前也仅从 repository secrets 导入;如果你把这些值放在 Variables运行时不会生效。若使用自定义渠道名如 `my_proxy`GitHub Actions 还必须在 workflow `env:` 中显式新增对应的 `LLM_MY_PROXY_*` 映射;本地 `.env` 和 Docker 不受这个限制。

View File

@@ -222,7 +222,7 @@ The bundled `daily_analysis.yml` explicitly passes the common LLM runtime fields
- Runtime selection: `LLM_CHANNELS`, `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, `VISION_PROVIDER_PRIORITY`, `LLM_TEMPERATURE`
- Multiple keys: `GEMINI_API_KEYS`, `ANTHROPIC_API_KEYS`, `OPENAI_API_KEYS`, `DEEPSEEK_API_KEYS` (the current workflow imports these from repository Secrets only, not from same-named Variables)
- Common channel names: `primary`, `secondary`, `gemini`, `deepseek`, `aihubmix`, `openai`, `anthropic`, `moonshot`, `ollama`
- Common channel names: `primary`, `secondary`, `aihubmix`, `deepseek`, `dashscope`, `zhipu`, `moonshot`, `minimax`, `volcengine`, `siliconflow`, `openrouter`, `gemini`, `anthropic`, `openai`, `ollama`
For example, if you set `LLM_CHANNELS=primary,deepseek` in GitHub Actions, also configure the corresponding `LLM_PRIMARY_*` and `LLM_DEEPSEEK_*` entries. The `LLM_<NAME>_API_KEY` / `LLM_<NAME>_API_KEYS` fields are also imported from repository Secrets only right now, so storing them in Variables will not work at runtime. If you use a custom channel name such as `my_proxy`, GitHub Actions must explicitly add matching `LLM_MY_PROXY_*` mappings in the workflow `env:` block. Local `.env` and Docker runs do not have this limitation.

View File

@@ -38,7 +38,7 @@ LITELLM_MODEL=deepseek/deepseek-v4-flash
| MiniMax | `minimax` | `openai` | `https://api.minimax.io/v1` | `MiniMax-M2.7,MiniMax-M2.7-highspeed` |
| 火山方舟 / 豆包 | `volcengine` | `openai` | `https://ark.cn-beijing.volces.com/api/v3` | `doubao-seed-1-6-251015,doubao-seed-1-6-thinking-251015` |
| 硅基流动 / SiliconFlow | `siliconflow` | `openai` | `https://api.siliconflow.cn/v1` | `deepseek-ai/DeepSeek-V3.2,Qwen/Qwen3-235B-A22B-Thinking-2507` |
| OpenRouter | `openrouter` | `openai` | `https://openrouter.ai/api/v1` | `~openai/gpt-latest,~anthropic/claude-sonnet-latest` |
| OpenRouter | `openrouter` | `openai` | `https://openrouter.ai/api/v1` | `~anthropic/claude-sonnet-latest,~openai/gpt-latest` |
| Ollama | `ollama` | `ollama` | `http://127.0.0.1:11434` | `llama3.2,qwen2.5` |
## 官方来源与兼容性
@@ -55,11 +55,29 @@ LITELLM_MODEL=deepseek/deepseek-v4-flash
| MiniMax | [OpenAI API 兼容](https://platform.minimax.io/docs/api-reference/text-chat)、[获取模型列表](https://platform.minimax.io/docs/api-reference/models/openai/list-models) | 官方 OpenAI-compatible Base URL 为 `https://api.minimax.io/v1`,并列出 `MiniMax-M2.7``MiniMax-M2.7-highspeed`。中国区 Coding 工具场景可能使用 `.com`/Anthropic 专用入口,以控制台为准。 |
| 火山方舟 / 豆包 | [在线推理(常规)](https://www.volcengine.com/docs/82379/2121998)、[模型列表](https://www.volcengine.com/docs/82379/1949118) | 官方示例使用 `https://ark.cn-beijing.volces.com/api/v3``doubao-seed-1-6-251015`;如使用 Coding Plan请改用其专用 Base URL 和模型名,不要套用本表的在线推理模板。 |
| SiliconFlow | [模型列表](https://docs.siliconflow.cn/quickstart/models)、[获取模型列表 API](https://docs.siliconflow.cn/cn/api-reference/models/get-model-list) | 平台模型实时更新且 `/models` 需要 API Key模板只给常见新模型示例保存前建议在 Web 设置页点击「获取模型」确认账号可见性。 |
| OpenRouter | [Models API](https://openrouter.ai/docs/api/api-reference/models/get-models) | OpenRouter 支持 `~openai/gpt-latest``~anthropic/claude-sonnet-latest` 等 latest router alias也可以改成具体模型 ID 固定行为。 |
| OpenRouter | [Models API](https://openrouter.ai/docs/api/api-reference/models/get-models) | OpenRouter 支持 `~anthropic/claude-sonnet-latest``~openai/gpt-latest` 等 latest router alias2026-05-03 的一次手动 live smoke 以 Claude Sonnet latest 作为默认示例通过GPT latest 保留为可按账号权限切换的备选。 |
| LiteLLM | [OpenAI-Compatible Endpoints](https://docs.litellm.ai/docs/providers/openai_compatible) | OpenAI 兼容端点需要把运行时模型写成 `openai/<model>`Base URL 只填到服务商兼容入口,不额外拼接 `/chat/completions`。 |
当前仓库锁定 `litellm>=1.80.10,<1.82.7`(见 `requirements.txt`)。本页预设只保证配置形状与当前依赖的 OpenAI-compatible 路由规则一致;实际连通性仍取决于服务商账号权限、地域、额度和模型开通状态。回退方式:在 Web 设置页删除对应渠道,或从 `.env` 移除 `LLM_MINIMAX_*` / `LLM_VOLCENGINE_*` 并恢复原 `LITELLM_MODEL``LITELLM_FALLBACK_MODELS`
## GitHub Actions 配置
仓库自带 `.github/workflows/daily_analysis.yml` 只会透传 workflow 中显式列出的环境变量。使用渠道模式时,先在 Repository Variables 或 Secrets 中设置 `LLM_CHANNELS`,再按渠道名补齐对应 `LLM_<CHANNEL>_*`
| 字段 | 建议位置 | 说明 |
| --- | --- | --- |
| `LLM_CHANNELS` | Variables 或 Secrets | 逗号分隔渠道名,例如 `deepseek,minimax,volcengine`。 |
| `LLM_<CHANNEL>_PROTOCOL` | Variables 或 Secrets | 非敏感,通常为 `openai``deepseek``gemini``anthropic``ollama`。 |
| `LLM_<CHANNEL>_BASE_URL` | Variables 或 Secrets | 非敏感时优先放 Variables私有网关地址可放 Secrets。 |
| `LLM_<CHANNEL>_MODELS` | Variables 或 Secrets | 非敏感模型列表,逗号分隔。 |
| `LLM_<CHANNEL>_ENABLED` | Variables 或 Secrets | 可选,未配置时默认启用;设为 `false` 可跳过该渠道。 |
| `LLM_<CHANNEL>_API_KEY` / `LLM_<CHANNEL>_API_KEYS` | Secrets | 密钥字段必须放 Repository Secrets同名 Variables 不会被 workflow 读取。 |
| `LLM_<CHANNEL>_EXTRA_HEADERS` | Secrets 或 Variables | JSON 字符串;只要包含鉴权、租户、组织或私有网关信息,就应放 Secrets。 |
默认 workflow 已显式映射 `primary``secondary``aihubmix``deepseek``dashscope``zhipu``moonshot``minimax``volcengine``siliconflow``openrouter``gemini``anthropic``openai``ollama`。如果使用自定义渠道名(如 `my_proxy`),仅在 Repository Secrets / Variables 中新增 `LLM_MY_PROXY_*` 不会自动生效,需要同步扩展 workflow 的 `env:` 映射;本地 `.env`、Docker 和自托管脚本不受这个限制。
Ollama 默认 Base URL `http://127.0.0.1:11434` 主要面向本地、Docker 或能访问该服务的 self-hosted runner。GitHub-hosted runner 通常没有本地 Ollama 服务,直接配置 `LLM_CHANNELS=ollama` 大概率会连接失败。
## 排障要点
- 鉴权失败:检查 API Key 是否填错、复制了空格,或服务商是否要求额外项目权限。

View File

@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""Static checks for LLM provider channel mappings in daily_analysis.yml."""
from __future__ import annotations
import re
from pathlib import Path
import yaml
ROOT_DIR = Path(__file__).resolve().parent.parent
TEMPLATE_PATH = ROOT_DIR / "apps/dsa-web/src/components/settings/llmProviderTemplates.ts"
WORKFLOW_PATH = ROOT_DIR / ".github/workflows/daily_analysis.yml"
ENV_EXAMPLE_PATH = ROOT_DIR / ".env.example"
EXPECTED_TEMPLATE_CHANNELS = {
"aihubmix",
"deepseek",
"dashscope",
"zhipu",
"moonshot",
"minimax",
"volcengine",
"siliconflow",
"openrouter",
"gemini",
"anthropic",
"openai",
"ollama",
}
def _extract_provider_templates() -> dict[str, str]:
content = TEMPLATE_PATH.read_text(encoding="utf-8")
matches = re.findall(
r"channelId:\s*'(?P<channel>[^']+)'.*?baseUrl:\s*'(?P<base_url>[^']*)'",
content,
flags=re.DOTALL,
)
assert matches, "No provider channelId entries were found in llmProviderTemplates.ts"
templates = {channel: base_url for channel, base_url in matches if channel != "custom"}
assert EXPECTED_TEMPLATE_CHANNELS.issubset(templates.keys())
assert "ark" not in templates
return templates
def _load_daily_analysis_env() -> dict[str, str]:
workflow = yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8"))
steps = workflow["jobs"]["analyze"]["steps"]
analyze_step = next((step for step in steps if step.get("name") == "执行股票分析"), None)
available_step_names = [step.get("name", "<unnamed>") for step in steps]
assert analyze_step is not None, (
"Expected daily_analysis.yml job analyze to include a step named "
f"'执行股票分析'; available step names: {available_step_names}"
)
return analyze_step["env"]
def test_daily_analysis_maps_all_provider_template_channels() -> None:
templates = _extract_provider_templates()
env = _load_daily_analysis_env()
for channel in templates:
prefix = f"LLM_{channel.upper()}_"
for suffix in (
"PROTOCOL",
"BASE_URL",
"API_KEY",
"API_KEYS",
"MODELS",
"ENABLED",
"EXTRA_HEADERS",
):
assert f"{prefix}{suffix}" in env
assert not any(key.startswith("LLM_ARK_") for key in env)
def test_daily_analysis_keeps_channel_secrets_in_secrets_context() -> None:
templates = _extract_provider_templates()
env = _load_daily_analysis_env()
for channel in templates:
upper = channel.upper()
for suffix in ("API_KEY", "API_KEYS"):
key = f"LLM_{upper}_{suffix}"
assert env[key] == f"${{{{ secrets.{key} }}}}"
for suffix in ("PROTOCOL", "BASE_URL", "MODELS", "ENABLED", "EXTRA_HEADERS"):
key = f"LLM_{upper}_{suffix}"
assert f"vars.{key}" in env[key]
assert f"secrets.{key}" in env[key]
def test_env_example_includes_provider_template_channel_examples() -> None:
templates = _extract_provider_templates()
env_example = ENV_EXAMPLE_PATH.read_text(encoding="utf-8")
for channel, base_url in templates.items():
upper = channel.upper()
assert f"LLM_CHANNELS={channel}" in env_example
assert f"LLM_{upper}_MODELS=" in env_example
if channel != "ollama":
assert f"LLM_{upper}_API_KEY=" in env_example
if base_url:
assert f"LLM_{upper}_BASE_URL=" in env_example
if channel != "ollama":
assert f"LLM_{upper}_PROTOCOL=" in env_example
assert "LLM_CHANNELS=ark" not in env_example
assert "LLM_ARK_" not in env_example