feat: add Claude Code and OpenCode generation backends (#1850)

Co-authored-by: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
Alfred
2026-06-30 22:33:02 +08:00
committed by GitHub
co-authored by zhulinsen
parent b939f7579f
commit 4c678850e2
36 changed files with 1985 additions and 275 deletions
+7 -3
View File
@@ -116,9 +116,13 @@ STOCK_INDEX_REMOTE_UPDATE_ENABLED=true
# 【进阶】需要多模型 / 多平台 fallback → 配置下方「多渠道」或在 Web 设置页可视化管理。
# ===================================
# 生成后端:默认 litellm;codex_cli 为显式 opt-in 的本地 CLI backend(experimental/limited)。
# 本地 CLI Backend 不等于离线模型,CLI 背后的服务可能处理分析 prompt 和报告草稿。
# 生成后端:默认 litellm;codex_cli / claude_code_cli / opencode_cli 为显式 opt-in 的本地 CLI backend(experimental/limited)。
# OpenCode CLI 使用本机 OpenCode 的默认模型;OPENCODE_CLI_MODEL 只是可选 --model 覆盖。
# 本地 CLI Backend 不等于离线模型,CLI 背后的服务可能处理股票代码、新闻、持仓上下文、分析 prompt 和报告草稿。
# Docker / CI / remote server 不天然拥有桌面 CLI 登录态;DSA 不读取 Claude/OpenCode credential 文件。
# DSA 会用最小 env allowlist + provider credential denylist 降低 API keys / webhook tokens 泄漏风险。
GENERATION_BACKEND=litellm
# OPENCODE_CLI_MODEL=provider/model
# 后端级 fallback;本地 .env 空值禁用 backend-level fallback,litellm -> litellm 会被解析为 no-op。
# 默认 GitHub Actions workflow 未配置该变量时会显式使用 litellm;Actions 中要禁用 fallback 时可设为 primary backend 实现 self no-op。
GENERATION_FALLBACK_BACKEND=litellm
@@ -127,7 +131,7 @@ GENERATION_BACKEND_TIMEOUT_SECONDS=300
GENERATION_BACKEND_MAX_OUTPUT_BYTES=1048576
GENERATION_BACKEND_MAX_CONCURRENCY=1
LOCAL_CLI_BACKEND_MAX_CONCURRENCY=1
# Agent Chat 后端;Web 设置页仅暴露 auto/litellm,手写 codex_cli 会返回 unsupported tool-calling 诊断。
# Agent Chat 后端;Web 设置页仅暴露 auto/litellm,手写 local CLI backend 会返回 unsupported tool-calling 诊断。
AGENT_GENERATION_BACKEND=auto
# --- API Key(填一个即可)---
+1
View File
@@ -12,6 +12,7 @@
# 本地评审与高级路由配置(可能包含敏感信息或临时分析产物)
/.codex
/.tmp
/review.md
/litellm_config.yaml
/litellm_config.*.yaml
@@ -589,7 +589,7 @@ describe('SettingsField', () => {
expect(dialog).not.toHaveTextContent('GENERATION_BACKEND');
expect(dialog).not.toHaveTextContent('配置样例');
expect(dialog).not.toHaveTextContent('Phase 1');
expect(dialog).toHaveTextContent('本机已安装并登录 Codex CLI');
expect(dialog).toHaveTextContent('本机已安装并登录对应 CLI');
expect(dialog).toHaveTextContent('默认模型配置会继续使用现有 API Key');
expect(dialog).not.toHaveTextContent('高级说明');
expect(dialog).not.toHaveTextContent('LiteLLM');
+45 -21
View File
@@ -36,15 +36,15 @@ const settingsHelpZhCN: SettingsHelpMap = {
title: '分析生成方式',
showFieldKey: false,
summary: '决定系统用哪种方式生成个股分析、大盘复盘和普通文本回复。',
usage: '通常保持“默认模型配置”。只有在本机已安装并登录 Codex CLI,且你信任它处理分析内容时,才选择 Codex CLI(实验)。',
usage: '通常保持“默认模型配置”。只有在本机已安装并登录对应 CLI,且你信任它处理分析内容时,才选择本地 CLI 生成方式(实验)。',
valueNotes: [
'Codex CLI 是本机启动的命令行程序,不等于离线模型;它背后的服务可能处理股票代码、新闻、持仓上下文、分析请求和报告草稿。',
'Docker、云服务器、CI 不天然拥有你本机的登录状态;DSA 不读取 Codex 登录凭据文件,但 Codex CLI 自己可能使用它的登录状态。',
'本地 CLI 生成方式是本机启动的命令行程序,不等于离线模型;背后的服务可能处理股票代码、新闻、持仓上下文、分析请求和报告草稿。',
'Docker、云服务器、CI 不天然拥有你本机的登录状态;DSA 不读取 Codex/Claude/OpenCode 登录凭据文件,但对应 CLI 自己可能使用它的登录状态。',
],
impact: ['影响普通分析、大盘复盘和文本生成入口,不改变问股助手的工具执行规则。'],
notes: [
'想恢复默认行为,选择“默认模型配置”并保存配置。',
'Codex CLI 当前仍是实验能力;如果输出不稳定或经常失败,请设回默认模型配置。',
'本地 CLI 生成方式当前仍是实验能力;如果输出不稳定或经常失败,请设回默认模型配置。',
'默认模型配置会继续使用现有 API Key、模型渠道和备用模型设置。',
],
examples: [],
@@ -52,7 +52,7 @@ const settingsHelpZhCN: SettingsHelpMap = {
'settings.ai_model.GENERATION_FALLBACK_BACKEND': {
title: '备用生成方式',
showFieldKey: false,
summary: '决定本地 Codex 生成失败后,是直接报错,还是再尝试默认模型配置。',
summary: '决定本地 CLI 生成失败后,是直接报错,还是再尝试默认模型配置。',
usage: '选择“禁用”表示失败就报错;选择“默认模型配置”表示再尝试你已经配置好的普通模型。',
valueNotes: [
'如果只是想设置主模型失败后的备用模型,请使用“备选模型”,不是这个字段。',
@@ -60,14 +60,26 @@ const settingsHelpZhCN: SettingsHelpMap = {
],
impact: ['不改变现有备用模型顺序,也不会影响渠道编辑器里的模型配置。'],
notes: [
'希望本地 Codex 失败后立刻暴露错误时选择“禁用”;希望继续尝试云端模型时选择“默认模型配置”。',
'希望本地 CLI 失败后立刻暴露错误时选择“禁用”;希望继续尝试云端模型时选择“默认模型配置”。',
],
examples: [],
},
'settings.ai_model.OPENCODE_CLI_MODEL': {
title: 'OpenCode CLI 模型',
showFieldKey: true,
summary: '可选:指定 DSA 调用 OpenCode run 时传给 --model 的模型名。',
usage: '仅在“分析生成方式”选择 OpenCode CLI 时生效。留空时 DSA 不传 --model,使用你本机 OpenCode 的默认模型配置。',
valueNotes: [
'模型是否可用、如何认证由你本机的 OpenCode 配置负责。',
'配置时该值会作为单个 argv 参数传给 OpenCode,不能包含空白或 shell 元字符。',
],
impact: ['影响普通分析、大盘复盘和文本生成的 OpenCode CLI 调用,不影响问股助手。'],
examples: ['OPENCODE_CLI_MODEL=provider/model'],
},
'settings.ai_model.GENERATION_BACKEND_TIMEOUT_SECONDS': {
title: '生成超时(秒)',
summary: '限制一次模型生成最多等待多久。',
usage: '默认 300 秒,主要用于 Codex CLI 这类本地命令行生成方式。',
usage: '默认 300 秒,主要用于本地 CLI 这类命令行生成方式。',
valueNotes: ['超时后会停止本次生成,并在日志里记录明确的超时错误。'],
},
'settings.ai_model.GENERATION_BACKEND_MAX_OUTPUT_BYTES': {
@@ -79,13 +91,13 @@ const settingsHelpZhCN: SettingsHelpMap = {
'settings.ai_model.GENERATION_BACKEND_MAX_CONCURRENCY': {
title: '模型生成最大并发',
summary: '限制同时进行的模型生成任务数量。',
usage: '默认 1。使用 Codex CLI 时,实际并发还会受“本地命令行最大并发”限制。',
usage: '默认 1。使用本地 CLI 生成方式时,实际并发还会受“本地命令行最大并发”限制。',
valueNotes: ['使用默认模型配置时,这个字段不会改变分析任务线程数。'],
},
'settings.ai_model.LOCAL_CLI_BACKEND_MAX_CONCURRENCY': {
title: '本地命令行最大并发',
summary: '限制同时启动多少个本地命令行生成进程。',
usage: '默认 1,避免同时启动多个 Codex CLI 进程导致机器变慢或输出互相干扰。',
usage: '默认 1,避免同时启动多个本地 CLI 进程导致机器变慢或输出互相干扰。',
valueNotes: ['最终并发不会超过“模型生成最大并发”。'],
},
'settings.ai_model.LITELLM_MODEL': {
@@ -798,7 +810,7 @@ const settingsHelpZhCN: SettingsHelpMap = {
valueNotes: [
'如果不确定,选择“自动”即可。',
'只有当你明确要固定使用普通模型配置时,才改为“默认模型配置”。',
'Codex CLI 当前不能直接用于问股助手的数据工具调用;显式选择后会提示不可用,或按配置改用普通模型配置。',
'本地 CLI 生成方式当前不能直接用于问股助手的数据工具调用;显式选择后会提示不可用,或按配置改用普通模型配置。',
],
impact: ['影响问股助手的回复生成和工具调用入口,不改变它能使用哪些工具。'],
notes: [
@@ -1200,15 +1212,15 @@ const settingsHelpEnUS: SettingsHelpMap = {
title: 'Analysis Generation Method',
showFieldKey: false,
summary: 'Chooses how the system generates stock analysis, market reviews, and regular text responses.',
usage: 'Usually keep Default model settings. Choose Codex CLI only when it is installed and logged in on this machine and you trust it to handle analysis content.',
usage: 'Usually keep Default model settings. Choose a local CLI backend only when the corresponding CLI is installed and logged in on this machine and you trust it to handle analysis content.',
valueNotes: [
'Codex CLI is a local command-line program, not an offline model. The service behind it may process stock symbols, news, position context, analysis requests, and report drafts.',
'Docker, cloud servers, and CI do not automatically have your local login state. DSA does not read Codex login credential files, but Codex CLI itself may use its login state.',
'Local CLI backends are local command-line programs, not offline models. The service behind them may process stock symbols, news, position context, analysis requests, and report drafts.',
'Docker, cloud servers, and CI do not automatically have your local login state. DSA does not read Codex/Claude/OpenCode credential files, but the corresponding CLI itself may use its login state.',
],
impact: ['Affects regular analysis, market review, and text generation entry points. It does not change how the ask-stock assistant runs tools.'],
notes: [
'To restore the default behavior, choose “Default model settings” and save.',
'Codex CLI is still experimental. If output is unstable or failures are frequent, switch back to Default model settings.',
'Local CLI backends are still experimental. If output is unstable or failures are frequent, switch back to Default model settings.',
'Default model settings continue to use your existing API keys, model channels, and fallback model settings.',
],
examples: [],
@@ -1216,22 +1228,34 @@ const settingsHelpEnUS: SettingsHelpMap = {
'settings.ai_model.GENERATION_FALLBACK_BACKEND': {
title: 'Fallback Generation Method',
showFieldKey: false,
summary: 'Chooses whether a failed local Codex generation should stop with an error or try Default model settings next.',
summary: 'Chooses whether a failed local CLI generation should stop with an error or try Default model settings next.',
usage: 'Disabled means the local failure is returned immediately. Default model settings means the system tries your configured regular model next.',
valueNotes: [
'Use fallback models for model-to-model fallback; this field only handles local Codex versus Default model settings.',
'Use fallback models for model-to-model fallback; this field only handles local CLI backends versus Default model settings.',
'When the primary generation method is already Default model settings, this field has no extra effect.',
],
impact: ['Affects local CLI failure handling for stock analysis, market review, and free-form text generation.'],
notes: [
'Choose Disabled when you want local Codex failures to be visible immediately, or Default model settings when cloud model recovery is acceptable.',
'Choose Disabled when you want local CLI failures to be visible immediately, or Default model settings when cloud model recovery is acceptable.',
],
examples: [],
},
'settings.ai_model.OPENCODE_CLI_MODEL': {
title: 'OpenCode CLI Model',
showFieldKey: true,
summary: 'Optional model name passed to OpenCode run through --model.',
usage: 'Only applies when Analysis Generation Method is OpenCode CLI. Leave it empty and DSA will not pass --model, so OpenCode uses its local default model configuration.',
valueNotes: [
'Model availability and authentication are handled by your local OpenCode setup.',
'When set, the value is passed as one argv token and must not contain whitespace or shell metacharacters.',
],
impact: ['Affects regular analysis, market review, and text generation through OpenCode CLI. It does not affect the ask-stock assistant.'],
examples: ['OPENCODE_CLI_MODEL=provider/model'],
},
'settings.ai_model.GENERATION_BACKEND_TIMEOUT_SECONDS': {
title: 'Generation Timeout (Seconds)',
summary: 'Limits how long one model generation may wait.',
usage: 'Default is 300 seconds. This mainly applies to local command-line generation such as Codex CLI.',
usage: 'Default is 300 seconds. This mainly applies to local CLI generation.',
valueNotes: ['Timeout stops the generation and records a clear timeout error.'],
},
'settings.ai_model.GENERATION_BACKEND_MAX_OUTPUT_BYTES': {
@@ -1243,13 +1267,13 @@ const settingsHelpEnUS: SettingsHelpMap = {
'settings.ai_model.GENERATION_BACKEND_MAX_CONCURRENCY': {
title: 'Model Generation Max Concurrency',
summary: 'Limits how many model generation jobs may run at the same time.',
usage: 'Default is 1. When using Codex CLI, actual concurrency is also limited by Local Command Max Concurrency.',
usage: 'Default is 1. When using local CLI backends, actual concurrency is also limited by Local Command Max Concurrency.',
valueNotes: ['When using Default model settings, this does not change the number of analysis worker tasks.'],
},
'settings.ai_model.LOCAL_CLI_BACKEND_MAX_CONCURRENCY': {
title: 'Local Command Max Concurrency',
summary: 'Limits how many local command-line generation processes may run at the same time.',
usage: 'Default is 1 to avoid starting multiple Codex CLI processes at once and slowing the machine down.',
usage: 'Default is 1 to avoid starting multiple local CLI processes at once and slowing the machine down.',
valueNotes: ['Final concurrency never exceeds Model Generation Max Concurrency.'],
},
'settings.ai_model.LITELLM_MODEL': {
@@ -1921,7 +1945,7 @@ const settingsHelpEnUS: SettingsHelpMap = {
valueNotes: [
'If you are unsure, choose Auto.',
'Choose “Default model settings” only when you explicitly want to pin the assistant to the regular model configuration.',
'Codex CLI cannot directly run ask-stock assistant data-tool calls right now; explicit manual configuration reports the capability as unavailable.',
'Local CLI backends cannot directly run ask-stock assistant data-tool calls right now; explicit manual configuration reports the capability as unavailable.',
],
impact: ['Affects the assistant reply path and tool entry point. It does not change which tools the assistant can use.'],
notes: [
@@ -711,6 +711,60 @@ describe('SettingsPage', () => {
expect(await screen.findByText(/task-setup-smoke/)).toBeInTheDocument();
});
it('allows brief setup smoke when only the Agent channel is incomplete', async () => {
getSetupStatus.mockResolvedValue({
isComplete: false,
readyForSmoke: true,
requiredMissingKeys: ['llm_agent'],
nextStepKey: 'llm_agent',
checks: [
{
key: 'llm_primary',
title: 'LLM 主渠道',
category: 'ai_model',
required: true,
status: 'configured',
message: '已启用 Claude Code CLI 本地生成 Backend(experimental/limited)。',
nextStep: null,
},
{
key: 'llm_agent',
title: 'Agent 渠道',
category: 'agent',
required: true,
status: 'needs_action',
message: 'Agent 工具调用需要 LiteLLM 模型配置;local CLI 主生成方式不会被自动继承。',
nextStep: '如需使用 Ask-Stock Agent,请配置 LiteLLM 模型。',
},
{
key: 'stock_list',
title: '自选股',
category: 'base',
required: true,
status: 'configured',
message: '已配置 1 只股票。',
nextStep: null,
},
],
});
render(<SettingsPage />);
await screen.findByText('还缺少 1 项:Agent 渠道');
expect(screen.getByRole('button', { name: '简短试跑' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: '简短试跑' }));
await waitFor(() => expect(analyzeAsync).toHaveBeenCalledWith({
stockCode: 'SH600000',
reportType: 'brief',
asyncMode: true,
notify: false,
originalQuery: 'SH600000',
selectionSource: 'manual',
}));
});
it('shows missing setup items and lets the user reopen the setup check', async () => {
getSetupStatus.mockResolvedValue({
isComplete: false,
+9 -5
View File
@@ -76,6 +76,7 @@ const fieldTitleMap: Record<string, string> = {
BIAS_THRESHOLD: 'BIAS 阈值',
GENERATION_BACKEND: '分析生成方式',
GENERATION_FALLBACK_BACKEND: '备用生成方式',
OPENCODE_CLI_MODEL: 'OpenCode CLI 模型',
GENERATION_BACKEND_TIMEOUT_SECONDS: '生成超时(秒)',
GENERATION_BACKEND_MAX_OUTPUT_BYTES: '最大输出大小(字节)',
GENERATION_BACKEND_MAX_CONCURRENCY: '模型生成最大并发',
@@ -239,9 +240,10 @@ const fieldDescriptionMap: Record<string, string> = {
PYTDX_PORT: 'Pytdx 单节点端口,需与主机配置配套。',
PYTDX_SERVERS: 'Pytdx 自定义节点列表,支持 host:port 逗号分隔。',
BIAS_THRESHOLD: 'BIAS 偏离阈值,超过后用于增强超买超卖提示。',
GENERATION_BACKEND: '用于个股分析、大盘复盘和普通文本生成。Codex CLI 需要本机已安装并登录,仍可能调用对应云服务,不是离线模型。',
GENERATION_FALLBACK_BACKEND: '本地 Codex 生成失败后的处理方式:禁用表示直接报错,默认模型配置表示再尝试普通模型。',
GENERATION_BACKEND_TIMEOUT_SECONDS: '单次生成最多等待多少秒,默认 300;主要用于 Codex CLI 这类本地命令行方式。',
GENERATION_BACKEND: '用于个股分析、大盘复盘和普通文本生成。本地 CLI 生成方式需要本机已安装并登录对应 CLI,仍可能调用对应云服务,不是离线模型。',
GENERATION_FALLBACK_BACKEND: '本地 CLI 生成失败后的处理方式:禁用表示直接报错,默认模型配置表示再尝试普通模型。',
OPENCODE_CLI_MODEL: 'OpenCode CLI 的可选模型覆盖;留空时使用本机 OpenCode 默认模型。认证和模型可用性由本机 OpenCode 配置负责。',
GENERATION_BACKEND_TIMEOUT_SECONDS: '单次生成最多等待多少秒,默认 300;主要用于本地 CLI 这类命令行方式。',
GENERATION_BACKEND_MAX_OUTPUT_BYTES: '单次本地命令行生成可读取的输出大小上限,默认 1048576 字节。',
GENERATION_BACKEND_MAX_CONCURRENCY: '同时允许多少个模型生成任务运行,默认 1;使用默认模型配置时不改变分析任务线程数。',
LOCAL_CLI_BACKEND_MAX_CONCURRENCY: '同时允许启动多少个本地命令行生成进程,默认 1;最终不会超过“模型生成最大并发”。',
@@ -412,6 +414,8 @@ const fieldOptionLabelMap: Record<string, Record<string, string>> = {
GENERATION_BACKEND: {
litellm: '默认模型配置',
codex_cli: 'Codex CLI(实验)',
claude_code_cli: 'Claude Code CLI(实验)',
opencode_cli: 'OpenCode CLI(实验)',
},
GENERATION_FALLBACK_BACKEND: {
'': '禁用',
@@ -420,7 +424,6 @@ const fieldOptionLabelMap: Record<string, Record<string, string>> = {
AGENT_GENERATION_BACKEND: {
auto: '自动',
litellm: '默认模型配置',
codex_cli: 'Codex CLI(不支持工具)',
},
LOG_LEVEL: {
debug: '调试',
@@ -489,6 +492,8 @@ const fieldOptionLabelMapEn: Record<string, Record<string, string>> = {
GENERATION_BACKEND: {
litellm: 'Default model settings',
codex_cli: 'Codex CLI (experimental)',
claude_code_cli: 'Claude Code CLI (experimental)',
opencode_cli: 'OpenCode CLI (experimental)',
},
GENERATION_FALLBACK_BACKEND: {
'': 'Disabled',
@@ -497,7 +502,6 @@ const fieldOptionLabelMapEn: Record<string, Record<string, string>> = {
AGENT_GENERATION_BACKEND: {
auto: 'Auto',
litellm: 'Default model settings',
codex_cli: 'Codex CLI (tools unsupported)',
},
LOG_LEVEL: {
debug: 'Debug',
+3
View File
@@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 修复 macOS 桌面端从 Finder/Dock 启动时后端 PATH 看不到 Homebrew Codex CLI 的问题,并明确 Codex CLI 主分析与 Agent LiteLLM 工具调用分流诊断。
- [测试] 台股三大法人 fetcher(TwInstitutionalFetcher)新增真实端点 live-smoke 脚本(tests/tw_institutional_live_smoke.py,非 pytest)与 @pytest.mark.network 漂移检测测试:核对 TWSE T86 / TPEx 核心字段名仍在、解析结果与原始字段一致;仅在非阻断的 network-smoke 定时任务运行,阻断门(pytest -m "not network")不收集,离线 fixtures 无法察觉的上游字段改名/端点变动由此告警。
- [修复] 修复 Web 设置页定时任务“立即执行一次”后台线程未传 `stock_codes` 导致任务崩溃的问题。
- [新功能] #1743 Phase 4 新增 `claude_code_cli` generation-only 本地 CLI backend,保留 LiteLLM 默认路径、Agent 工具调用边界、per-preset extractor、最小 env allowlist 与结构化错误。
- [新功能] #1743 Phase 4 新增 `opencode_cli` generation-only 本地 CLI backend,使用 OpenCode `run --format json --file` prompt-file 路径、JSON event extractor、Agent 边界和 provider credential 不接管约束。
- [文档] #1743 Phase 4 同步本地 CLI backend 隐私/部署边界:local CLI 不是离线模型,Docker/CI/远端需自行安装登录,DSA 不读取 Claude/OpenCode credential 文件。
- [新功能] 台股报告接入三大法人:tw 个股分析报告的 institution 区块改为展示 TWSE T86 / TPEx 三大法人原始买卖超净额(外资/投信/自营/合计,单位:股);tw-only、严格 additive(A股/港股/美股/日韩股 offshore 流程字节不变)、fail-open(取不到数据维持 not_supported,绝不中断分析);不接 Web、不派生 capital_flow_signal、不改评分权重或 schema。
## [3.24.1] - 2026-06-28
+21 -13
View File
@@ -25,9 +25,9 @@
---
## Generation Backend(Phase 2)
## Generation Backend(Phase 4)
Generation backend 是普通分析、大盘复盘和 `generate_text()` 的外层运行时选择。默认仍是 `litellm`,零配置路径与历史行为保持一致;`codex_cli` 是显式 opt-in 的本地 CLI backend,当前标记为 **experimental/limited**。
Generation backend 是普通分析、大盘复盘和 `generate_text()` 的外层运行时选择。默认仍是 `litellm`,零配置路径与历史行为保持一致;`codex_cli` / `claude_code_cli` / `opencode_cli` 是显式 opt-in 的本地 CLI backend,当前标记为 **experimental/limited**。
```env
GENERATION_BACKEND=litellm
@@ -36,28 +36,36 @@ GENERATION_BACKEND_TIMEOUT_SECONDS=300
GENERATION_BACKEND_MAX_OUTPUT_BYTES=1048576
GENERATION_BACKEND_MAX_CONCURRENCY=1
LOCAL_CLI_BACKEND_MAX_CONCURRENCY=1
# 可选:留空时使用本机 OpenCode 默认模型;配置时作为 --model 覆盖值传给 OpenCode。
# OPENCODE_CLI_MODEL=provider/model
AGENT_GENERATION_BACKEND=auto
```
- `GENERATION_BACKEND=litellm|codex_cli`。`codex_cli` 是 generation backend,不是 LiteLLM provider;不要写 `LITELLM_MODEL=codex_cli/...`。
- `GENERATION_BACKEND=litellm|codex_cli|claude_code_cli|opencode_cli`。本地 CLI backend 是 generation backend,不是 LiteLLM provider;不要写 `LITELLM_MODEL=codex_cli/...`、`LITELLM_MODEL=claude_code_cli/...` 或 `LITELLM_MODEL=opencode_cli/...`。
- `GENERATION_BACKEND=opencode_cli` 时默认不传 `--model`,由本机 OpenCode 使用自身默认模型配置;`OPENCODE_CLI_MODEL` 只是可选覆盖值,配置时才作为单个 `--model` 参数传给 OpenCode。provider 认证、账号和模型可用性由本机 OpenCode 自身配置负责;DSA 不接管这些配置。
- `GENERATION_FALLBACK_BACKEND` 未配置时默认 `litellm`;本地 `.env` 显式空值 `GENERATION_FALLBACK_BACKEND=` 表示禁用 backend-level fallback;primary 与 fallback 相同时解析为 no-op。仓库自带 GitHub Actions workflow 未配置该变量时会显式导出 `litellm`,如果要在 Actions 中禁用 backend fallback,请把 fallback 设为 primary backend,例如 `GENERATION_BACKEND=codex_cli` + `GENERATION_FALLBACK_BACKEND=codex_cli`。
- `GENERATION_BACKEND=codex_cli` 且没有 Gemini/OpenAI/Anthropic/DeepSeek API Key 时,普通分析和大盘复盘仍会尝试本地 CLI backend;如果 `codex` executable 不存在,会返回结构化 `command_not_found`,不会报“API Key 未配置”。
- 当前 `codex_cli` preset 使用 `codex exec --output-last-message <temp-file> -` 读取最终响应;Codex CLI 仍会把同一最终响应打印到 stdout,DSA 会从 stdout 诊断预览和输出大小统计中剔除这份重复内容,不参与主分析 JSON 解析。官方依据见 [Codex non-interactive mode](https://developers.openai.com/codex/noninteractive) 与 [Codex CLI command line options](https://developers.openai.com/codex/cli/reference)。本仓库当前只验证 `codex-cli 0.142.0`,不声明更宽最低版本;如果 CLI 版本不支持 preset 参数,DSA 会返回结构化 `non_zero_exit` / `cli_contract_unsupported` 诊断,并在配置 backend fallback 时回退到 `litellm`。
- `codex_cli` 不支持 streaming。请求 stream 时会自动降级为 non-stream,不会因此返回 `capability_unsupported`。
- `GENERATION_BACKEND=codex_cli|claude_code_cli` 且没有 Gemini/OpenAI/Anthropic/DeepSeek API Key 时,普通分析和大盘复盘仍会尝试本地 CLI backend;如果对应 executable 不存在,会返回结构化 `command_not_found`,不会报“API Key 未配置”。
- 当前 `codex_cli` preset 使用 `codex exec --output-last-message <temp-file> -` 读取最终响应;Codex CLI 仍会把同一最终响应打印到 stdout,DSA 会从 stdout 诊断预览和输出大小统计中剔除这份重复内容,不参与主分析 JSON 解析。官方依据见 [Codex non-interactive mode](https://developers.openai.com/codex/noninteractive) 与 [Codex CLI command line options](https://developers.openai.com/codex/cli/reference)。本仓库当前只验证 `codex-cli 0.142.0`,不声明更宽最低版本;如果 CLI 版本不支持 preset 参数,DSA 会返回结构化 `capability_unsupported` / `cli_contract_unsupported` 诊断,并在配置 backend fallback 时回退到 `litellm`。
- 当前 `claude_code_cli` preset 使用 `claude --safe-mode --tools "" --disallowedTools "mcp__*" --strict-mcp-config --no-session-persistence --output-format json -p <static instruction>`,完整 DSA prompt 通过 stdin 传入。DSA 只从 Claude JSON envelope 的 `result/success` 最终字段提取文本;如果后续启用 `--json-schema`,schema mode 必须提取 `structured_output`,并且仍会继续经过 DSA 现有 JSON validator、minimal parser contract、`_parse_response()`、integrity retry、placeholder fill 和 usage telemetry。参数依据见 [Claude Code CLI reference](https://code.claude.com/docs/en/cli-reference);本 PR smoke 验证版本为 `claude 2.1.177 (Claude Code)`,不声明更宽最低版本。
- 当前 `opencode_cli` preset 使用 `opencode --pure run --format json [--model <OPENCODE_CLI_MODEL>] <static instruction> --file <temp prompt file>`;只有显式配置 `OPENCODE_CLI_MODEL` 时才追加 `--model`,完整 DSA prompt 写入权限受控的临时文件,不进入 argv。DSA 只解析 OpenCode JSON event 输出中无工具事件的 `text` 内容,并要求正常 `step_finish`;出现 `tool_use`、`error`、`question`、`permission` 等事件会结构化失败。参数依据见 [OpenCode CLI reference](https://opencode.ai/docs/cli),项目配置合并语义见 [OpenCode config reference](https://opencode.ai/docs/config);本 PR smoke 验证版本为 `opencode 1.17.11`,不声明更宽最低版本。
- 本地 CLI backend 不支持 streaming。请求 stream 时会自动降级为 non-stream,不会因此返回 `capability_unsupported`。
- 本地 CLI usage 通常不可用,系统不会写入 fake 0 token、fake cost 或 fake cache telemetry。
- 本地 CLI 执行上限有硬边界:`GENERATION_BACKEND_TIMEOUT_SECONDS` 最大 `3600`,`GENERATION_BACKEND_MAX_OUTPUT_BYTES` 最大 `33554432`,`GENERATION_BACKEND_MAX_CONCURRENCY` 最大 `16`,`LOCAL_CLI_BACKEND_MAX_CONCURRENCY` 最大 `4`。诊断 stdout/stderr 与最终响应合计超过输出上限时会返回结构化 `output_too_large`;对 `--output-last-message` preset,stdout 中重复打印的最终响应不会重复计入,也不会作为 `stdout_preview` 暴露。
- 本地 CLI 默认并发为 1;有效并发为 `min(LOCAL_CLI_BACKEND_MAX_CONCURRENCY, GENERATION_BACKEND_MAX_CONCURRENCY)`,不继承 `MAX_WORKERS`。
- `AGENT_GENERATION_BACKEND=auto` 不会无条件继承 `GENERATION_BACKEND=codex_cli`;Agent 工具调用继续使用 LiteLLM。Web 设置页仅暴露 `auto|litellm`;手写 `AGENT_GENERATION_BACKEND=codex_cli` 在 Phase 2 不实现 text-only Agent mode,会返回明确 unsupported tool-calling 诊断。
- `AGENT_GENERATION_BACKEND=auto` 不会继承 `GENERATION_BACKEND` 的 local CLI 值;Agent 工具调用继续使用 LiteLLM。Web 设置页仅暴露 `auto|litellm`;手写 `AGENT_GENERATION_BACKEND=codex_cli|claude_code_cli|opencode_cli` 不实现 text-only Agent mode,会返回明确 unsupported tool-calling 诊断。
### Codex CLI 本地 backend 隐私与边界
### Local CLI 本地 backend 隐私与边界
- 本地 CLI Backend 不等于离线模型;Codex CLI 背后的服务可能处理股票代码、新闻、持仓上下文、分析 prompt、报告草稿等内容。
- 本地 CLI Backend 不等于离线模型;Codex / Claude Code / OpenCode 背后的服务可能处理股票代码、新闻、持仓上下文、分析 prompt、报告草稿等内容。
- Docker、云服务器、CI 不天然拥有你本机的 CLI 登录态。
- GitHub Actions 只负责透传配置值,不安装或登录 Codex CLI;如果在 Actions 中 opt-in `GENERATION_BACKEND=codex_cli`,runner 上缺少可执行文件或登录态时应看到结构化失败。
- DSA 不读取 Codex credential 文件,但子进程可能读取 CLI 自身登录态。
- macOS 从 Finder/Dock 启动桌面端时不继承 shell PATH;打包桌面端会在启动后端时补入常见 Homebrew 路径(如 `/opt/homebrew/bin`、`/usr/local/bin`)。如果设置检查仍提示找不到 `codex`,请完全退出并重开 DSA;打开 `codex` 交互窗口不会改变已运行后端的 PATH。
- GitHub Actions 只负责透传配置值,不安装或登录本地 CLI;如果在 Actions 中 opt-in local CLI backend,runner 上缺少可执行文件或登录态时应看到结构化失败。
- DSA 不读取 Codex/Claude/OpenCode credential 文件,但子进程可能读取 CLI 自身登录态。
- macOS 从 Finder/Dock 启动桌面端时不继承 shell PATH;打包桌面端会在启动后端时补入常见 Homebrew 路径(如 `/opt/homebrew/bin`、`/usr/local/bin`)。如果设置检查仍提示找不到 CLI 可执行文件,请完全退出并重开 DSA;打开 CLI 交互窗口不会改变已运行后端的 PATH。
- DSA 默认只继承最小运行环境,并拒绝通配继承 `CLAUDE_*`、`ANTHROPIC_*`、`OPENCODE_*`、`OPENAI_*`、`GOOGLE_*`、`GEMINI_*`、`AWS_*`、`AZURE_*`、`VERTEX_*`、`*_API_KEY`、`*_AUTH_TOKEN`、`*_ACCESS_TOKEN`、`*_SECRET`、`*_PASSWORD`,降低 DSA API keys、provider tokens 和 webhook tokens 泄漏风险。`CODEX_HOME` 是为兼容既有 Codex CLI 登录目录保留的精确例外;不会恢复 `CODEX_CLI_*` 通配。
- `opencode_cli` 会在临时 cwd 写入最小项目 `opencode.json` 以关闭分享、自动更新、快照和常见工具权限,但 OpenCode resolved config 仍可能包含用户本机全局配置;运行时安全边界同时依赖 `--pure`、env denylist、prompt file 权限和 event extractor fail-closed。
- Web 设置页只暴露安全 preset,不允许提交任意 command / argv / shell string。
- `codex_cli` 仍标记为 experimental/limited;如果你的 CLI 版本不支持稳定的 `--output-last-message` 非交互输出,请保持 `GENERATION_BACKEND=litellm`。
- `codex_cli` / `claude_code_cli` / `opencode_cli` 仍标记为 experimental/limited;如果你的 CLI 版本不支持本仓库已验证的非交互输出契约,DSA 会返回结构化 `capability_unsupported`、`cli_contract_unsupported`、`invalid_json`、`schema_validation_failed` 或对应 backend error,并在配置 backend fallback 时回退到 `litellm`。无法接受该版本漂移风险时,请保持 `GENERATION_BACKEND=litellm`。
- `opencode_cli` 不支持 OpenCode serve / web / ACP / MCP / attach / `--dangerously-skip-permissions`;DSA 不把 OpenCode final text 当成 Agent tool success。
## 方式一:极简单模型配置(适合新手)
+21 -13
View File
@@ -18,9 +18,9 @@ If you are choosing a concrete provider, setting up GitHub Actions Secrets / Var
---
## Generation Backend (Phase 2)
## Generation Backend (Phase 4)
The generation backend is the outer runtime selector for regular stock analysis, market review, and `generate_text()`. The default remains `litellm` with zero regression. `codex_cli` is an explicit opt-in local CLI backend and is currently **experimental/limited**.
The generation backend is the outer runtime selector for regular stock analysis, market review, and `generate_text()`. The default remains `litellm` with zero regression. `codex_cli` / `claude_code_cli` / `opencode_cli` are explicit opt-in local CLI backends and are currently **experimental/limited**.
```env
GENERATION_BACKEND=litellm
@@ -29,28 +29,36 @@ GENERATION_BACKEND_TIMEOUT_SECONDS=300
GENERATION_BACKEND_MAX_OUTPUT_BYTES=1048576
GENERATION_BACKEND_MAX_CONCURRENCY=1
LOCAL_CLI_BACKEND_MAX_CONCURRENCY=1
# Optional: leave empty to use the local OpenCode default model; set it only to pass a --model override.
# OPENCODE_CLI_MODEL=provider/model
AGENT_GENERATION_BACKEND=auto
```
- `GENERATION_BACKEND=litellm|codex_cli`. `codex_cli` is a generation backend, not a LiteLLM provider; do not set `LITELLM_MODEL=codex_cli/...`.
- `GENERATION_BACKEND=litellm|codex_cli|claude_code_cli|opencode_cli`. Local CLI backends are generation backends, not LiteLLM providers; do not set `LITELLM_MODEL=codex_cli/...`, `LITELLM_MODEL=claude_code_cli/...`, or `LITELLM_MODEL=opencode_cli/...`.
- With `GENERATION_BACKEND=opencode_cli`, DSA does not pass `--model` by default and lets local OpenCode use its own default model configuration. `OPENCODE_CLI_MODEL` is only an optional override; when set, DSA passes it as one OpenCode `--model` argument. Provider authentication, account state, and model availability are handled by your local OpenCode setup.
- If `GENERATION_FALLBACK_BACKEND` is unset, it defaults to `litellm`. In local `.env`, an explicit empty value disables backend-level fallback. A fallback equal to the primary backend is treated as no-op. The bundled GitHub Actions workflow explicitly exports `litellm` when this variable is not configured; to disable backend fallback there, set the fallback to the primary backend, for example `GENERATION_BACKEND=codex_cli` + `GENERATION_FALLBACK_BACKEND=codex_cli`.
- With `GENERATION_BACKEND=codex_cli`, regular analysis and market review do not require Gemini/OpenAI/Anthropic/DeepSeek API keys. If the `codex` executable is missing, DSA returns structured `command_not_found` instead of “API key not configured”.
- The current `codex_cli` preset reads the final response through `codex exec --output-last-message <temp-file> -`. Codex CLI still prints the same final response to stdout; DSA removes that duplicate from stdout diagnostics previews and output-size accounting, and never uses stdout for main-analysis JSON parsing. Official references: [Codex non-interactive mode](https://developers.openai.com/codex/noninteractive) and [Codex CLI command line options](https://developers.openai.com/codex/cli/reference). This repository currently verifies only `codex-cli 0.142.0` and does not claim a wider minimum version range; if the installed CLI does not support a preset argument, DSA returns structured `non_zero_exit` / `cli_contract_unsupported` diagnostics and falls back to `litellm` when backend fallback is configured.
- `codex_cli` does not support streaming. Stream requests degrade to non-stream and do not return `capability_unsupported`.
- With `GENERATION_BACKEND=codex_cli|claude_code_cli`, regular analysis and market review do not require Gemini/OpenAI/Anthropic/DeepSeek API keys. If the corresponding executable is missing, DSA returns structured `command_not_found` instead of “API key not configured”.
- The current `codex_cli` preset reads the final response through `codex exec --output-last-message <temp-file> -`. Codex CLI still prints the same final response to stdout; DSA removes that duplicate from stdout diagnostics previews and output-size accounting, and never uses stdout for main-analysis JSON parsing. Official references: [Codex non-interactive mode](https://developers.openai.com/codex/noninteractive) and [Codex CLI command line options](https://developers.openai.com/codex/cli/reference). This repository currently verifies only `codex-cli 0.142.0` and does not claim a wider minimum version range; if the installed CLI does not support a preset argument, DSA returns structured `capability_unsupported` / `cli_contract_unsupported` diagnostics and falls back to `litellm` when backend fallback is configured.
- The current `claude_code_cli` preset uses `claude --safe-mode --tools "" --disallowedTools "mcp__*" --strict-mcp-config --no-session-persistence --output-format json -p <static instruction>`, with the full DSA prompt passed through stdin. DSA only extracts the final text from Claude's `result/success` JSON envelope. If `--json-schema` is enabled later, schema mode must extract `structured_output`, and the output still goes through DSA's existing JSON validator, minimal parser contract, `_parse_response()`, integrity retry, placeholder fill, and usage telemetry. The CLI flags are based on the [Claude Code CLI reference](https://code.claude.com/docs/en/cli-reference). This PR smoke-tested `claude 2.1.177 (Claude Code)` and does not claim a wider minimum version range.
- The current `opencode_cli` preset uses `opencode --pure run --format json [--model <OPENCODE_CLI_MODEL>] <static instruction> --file <temp prompt file>`. DSA only appends `--model` when `OPENCODE_CLI_MODEL` is explicitly set. The full DSA prompt is written to a permission-restricted temporary file and is not placed in argv. DSA only extracts text from OpenCode JSON event output that has no tool events and ends with a normal `step_finish`; `tool_use`, `error`, `question`, or `permission` events fail structurally. The CLI flags are based on the [OpenCode CLI reference](https://opencode.ai/docs/cli), and project config merge semantics are documented in the [OpenCode config reference](https://opencode.ai/docs/config). This PR smoke-tested `opencode 1.17.11` and does not claim a wider minimum version range.
- Local CLI backends do not support streaming. Stream requests degrade to non-stream and do not return `capability_unsupported`.
- Local CLI usage is normally unavailable. DSA does not persist fake 0-token, fake cost, or fake cache telemetry.
- Local CLI execution has hard caps: `GENERATION_BACKEND_TIMEOUT_SECONDS` max `3600`, `GENERATION_BACKEND_MAX_OUTPUT_BYTES` max `33554432`, `GENERATION_BACKEND_MAX_CONCURRENCY` max `16`, and `LOCAL_CLI_BACKEND_MAX_CONCURRENCY` max `4`. Diagnostic stdout/stderr plus the final response are counted together; for `--output-last-message` presets, the final response duplicated to stdout is not counted twice and is not exposed in `stdout_preview`.
- Local CLI default concurrency is 1. Effective local CLI concurrency is `min(LOCAL_CLI_BACKEND_MAX_CONCURRENCY, GENERATION_BACKEND_MAX_CONCURRENCY)` and does not inherit `MAX_WORKERS`.
- `AGENT_GENERATION_BACKEND=auto` does not blindly inherit `GENERATION_BACKEND=codex_cli`; Agent tool calling remains on LiteLLM. The Web settings page only exposes `auto|litellm`; a hand-written `AGENT_GENERATION_BACKEND=codex_cli` does not enable Agent text-only mode in Phase 2 and returns an explicit unsupported tool-calling diagnostic.
- `AGENT_GENERATION_BACKEND=auto` does not inherit local CLI values from `GENERATION_BACKEND`; Agent tool calling remains on LiteLLM. The Web settings page only exposes `auto|litellm`; a hand-written `AGENT_GENERATION_BACKEND=codex_cli|claude_code_cli|opencode_cli` does not enable Agent text-only mode and returns an explicit unsupported tool-calling diagnostic.
### Codex CLI Privacy And Boundaries
### Local CLI Privacy And Boundaries
- A local CLI backend is not an offline model. The service behind Codex CLI may process stock symbols, news, position context, analysis prompts, and report drafts.
- A local CLI backend is not an offline model. The service behind Codex / Claude Code / OpenCode may process stock symbols, news, position context, analysis prompts, and report drafts.
- Docker, cloud servers, and CI do not automatically have your local CLI login state.
- GitHub Actions only passes configuration values through; it does not install or log in Codex CLI. If you opt into `GENERATION_BACKEND=codex_cli` in Actions, a runner without the executable or login state should return a structured failure.
- DSA does not read Codex credential files, but the subprocess may use the CLI's own login state.
- On macOS, desktop apps launched from Finder/Dock do not inherit the shell PATH. The packaged desktop app adds common Homebrew directories such as `/opt/homebrew/bin` and `/usr/local/bin` when starting the backend. If setup checks still cannot find `codex`, fully quit and reopen DSA; opening an interactive `codex` window does not change the already-running backend PATH.
- GitHub Actions only passes configuration values through; it does not install or log in local CLIs. If you opt into a local CLI backend in Actions, a runner without the executable or login state should return a structured failure.
- DSA does not read Codex/Claude/OpenCode credential files, but the subprocess may use the CLI's own login state.
- On macOS, desktop apps launched from Finder/Dock do not inherit the shell PATH. The packaged desktop app adds common Homebrew directories such as `/opt/homebrew/bin` and `/usr/local/bin` when starting the backend. If setup checks still cannot find the CLI executable, fully quit and reopen DSA; opening an interactive CLI window does not change the already-running backend PATH.
- DSA only inherits a minimal child environment and denies wildcard inheritance of `CLAUDE_*`, `ANTHROPIC_*`, `OPENCODE_*`, `OPENAI_*`, `GOOGLE_*`, `GEMINI_*`, `AWS_*`, `AZURE_*`, `VERTEX_*`, `*_API_KEY`, `*_AUTH_TOKEN`, `*_ACCESS_TOKEN`, `*_SECRET`, and `*_PASSWORD`, reducing the risk of leaking DSA API keys, provider tokens, or webhook tokens. `CODEX_HOME` is the exact-name exception retained for existing Codex CLI login-directory compatibility; `CODEX_CLI_*` wildcard inheritance is not restored.
- `opencode_cli` writes a minimal project `opencode.json` in the temporary cwd to disable sharing, autoupdate, snapshots, and common tool permissions, but OpenCode's resolved config may still include local global settings. Runtime safety also relies on `--pure`, the env denylist, prompt-file permissions, and the event extractor failing closed.
- The Web settings page only exposes safe presets; it does not accept arbitrary command, argv, or shell strings.
- `codex_cli` remains experimental/limited. If your CLI version does not support stable non-interactive `--output-last-message` output, keep `GENERATION_BACKEND=litellm`.
- `codex_cli` / `claude_code_cli` / `opencode_cli` remain experimental/limited. If your CLI version does not support the non-interactive output contract verified by this repository, DSA returns structured `capability_unsupported`, `cli_contract_unsupported`, `invalid_json`, `schema_validation_failed`, or the corresponding backend error, and falls back to `litellm` when backend fallback is configured. If that version-drift risk is unacceptable, keep `GENERATION_BACKEND=litellm`.
- `opencode_cli` does not support OpenCode serve / web / ACP / MCP / attach / `--dangerously-skip-permissions`, and DSA never treats OpenCode final text as Agent tool success.
## Method 1: Simple Model Config (For Beginners)
+3 -2
View File
@@ -227,13 +227,14 @@ daily_stock_analysis/
| 变量名 | 说明 | 默认值 | 必填 |
|--------|------|--------|:----:|
| `GENERATION_BACKEND` | 普通分析生成后端;支持 `litellm` 或显式 opt-in 的 `codex_cli`(experimental/limited) | `litellm` | 否 |
| `GENERATION_BACKEND` | 普通分析生成后端;支持 `litellm` 或显式 opt-in 的 `codex_cli` / `claude_code_cli` / `opencode_cli`(experimental/limited) | `litellm` | 否 |
| `OPENCODE_CLI_MODEL` | `GENERATION_BACKEND=opencode_cli` 时可选传给 OpenCode `--model` 的模型覆盖;留空则使用本机 OpenCode 默认模型,认证和模型可用性由本机 OpenCode 配置负责 | 空 | 否 |
| `GENERATION_FALLBACK_BACKEND` | backend 级 fallback;未配置默认 `litellm`,空值禁用,self fallback 解析为 no-op | `litellm` | 否 |
| `GENERATION_BACKEND_TIMEOUT_SECONDS` | 单次 generation backend 调用超时秒数,主要用于本地 CLI backend;范围 `1-3600` | `300` | 否 |
| `GENERATION_BACKEND_MAX_OUTPUT_BYTES` | 单次本地 CLI backend 诊断 stdout/stderr 与最终响应捕获总上限;`--output-last-message` 重复打印到 stdout 的最终响应不重复计入;范围 `1-33554432` | `1048576` | 否 |
| `GENERATION_BACKEND_MAX_CONCURRENCY` | generation backend 全局并发上限;范围 `1-16`,不改变 LiteLLM Router / `MAX_WORKERS` 行为 | `1` | 否 |
| `LOCAL_CLI_BACKEND_MAX_CONCURRENCY` | 本地 CLI backend 并发上限;范围 `1-4`,有效并发取它与 `GENERATION_BACKEND_MAX_CONCURRENCY` 的较小值 | `1` | 否 |
| `AGENT_GENERATION_BACKEND` | Agent Chat 生成后端;Web 设置页仅暴露 `auto|litellm`,手写 `codex_cli` 会返回 unsupported tool-calling 诊断 | `auto` | 否 |
| `AGENT_GENERATION_BACKEND` | Agent Chat 生成后端;Web 设置页仅暴露 `auto|litellm`,手写 local CLI backend 会返回 unsupported tool-calling 诊断 | `auto` | 否 |
| `LITELLM_MODEL` | 主模型,格式 `provider/model`(如 `gemini/gemini-3.1-pro-preview`),推荐优先使用 | - | 否 |
| `AGENT_LITELLM_MODEL` | Agent 主模型(可选);留空继承主模型,无 provider 前缀按 `openai/<model>` 解析 | - | 否 |
| `AGENT_CONTEXT_COMPRESSION_ENABLED` | 问股可见对话上下文压缩开关;默认关闭,开启后仅压缩 `session_id` 下 user/assistant 文本历史 | `false` | 否 |
+3 -2
View File
@@ -196,13 +196,14 @@ Default schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.
| Variable | Description | Default | Required |
|--------|------|--------|:----:|
| `GENERATION_BACKEND` | Generation backend for regular analysis. Supports `litellm` or explicit opt-in `codex_cli` (experimental/limited) | `litellm` | No |
| `GENERATION_BACKEND` | Generation backend for regular analysis. Supports `litellm` or explicit opt-in `codex_cli` / `claude_code_cli` / `opencode_cli` (experimental/limited) | `litellm` | No |
| `OPENCODE_CLI_MODEL` | Optional model override passed to OpenCode `--model` when `GENERATION_BACKEND=opencode_cli`; leave empty to use the local OpenCode default model. Authentication and model availability are handled by the local OpenCode setup | Empty | No |
| `GENERATION_FALLBACK_BACKEND` | Backend-level fallback. Unset defaults to `litellm`; an empty value disables fallback; self fallback resolves to no-op | `litellm` | No |
| `GENERATION_BACKEND_TIMEOUT_SECONDS` | Per-call generation backend timeout in seconds, mainly for local CLI backends; range `1-3600` | `300` | No |
| `GENERATION_BACKEND_MAX_OUTPUT_BYTES` | Total captured diagnostic stdout/stderr plus final-response size limit for one local CLI backend call; final responses duplicated to stdout by `--output-last-message` are not counted twice; range `1-33554432` | `1048576` | No |
| `GENERATION_BACKEND_MAX_CONCURRENCY` | Global generation backend concurrency cap; range `1-16`, does not change LiteLLM Router or `MAX_WORKERS` behavior | `1` | No |
| `LOCAL_CLI_BACKEND_MAX_CONCURRENCY` | Local CLI backend concurrency cap; range `1-4`, effective concurrency is the lower of this value and `GENERATION_BACKEND_MAX_CONCURRENCY` | `1` | No |
| `AGENT_GENERATION_BACKEND` | Agent Chat generation backend. Web settings only expose `auto|litellm`; hand-written `codex_cli` returns an unsupported tool-calling diagnostic | `auto` | No |
| `AGENT_GENERATION_BACKEND` | Agent Chat generation backend. Web settings only expose `auto|litellm`; hand-written local CLI backends return an unsupported tool-calling diagnostic | `auto` | No |
| `LITELLM_MODEL` | Primary model, format `provider/model` (e.g. `gemini/gemini-3.1-pro-preview`), recommended | - | No |
| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | - | No |
| `LITELLM_FALLBACK_MODELS` | Fallback models, comma-separated | - | No |
+6 -2
View File
@@ -20,9 +20,13 @@
优先级保持不变:`LITELLM_CONFIG` / `LITELLM_CONFIG_YAML` > `LLM_CHANNELS` > legacy provider keys。P4 只补文档,不迁移、不清空、不静默改写旧配置。
Generation backend 配置是更外层的运行时选择契约。Phase 2 支持 `GENERATION_BACKEND=litellm|codex_cli`,但 `codex_cli` 是本地 CLI backend,不是 LiteLLM provider;不要配置成 `LITELLM_MODEL=codex_cli/...`。`codex_cli` preset 使用 `codex exec --output-last-message <temp-file> -` 读取最终响应;Codex CLI 仍会把同一最终响应打印到 stdout,DSA 会从 stdout 诊断预览和输出大小统计中剔除这份重复内容。诊断 stdout/stderr 与最终响应一起受 `GENERATION_BACKEND_MAX_OUTPUT_BYTES` 总上限约束,超限时返回结构化 `output_too_large`。官方依据见 [Codex non-interactive mode](https://developers.openai.com/codex/noninteractive) 与 [Codex CLI command line options](https://developers.openai.com/codex/cli/reference);本仓库当前只验证 `codex-cli 0.142.0`,不声明更宽最低版本。`GENERATION_FALLBACK_BACKEND=` 空值会在本地 `.env` 禁用 backend-level fallback,未配置时默认回退到 `litellm`;默认 GitHub Actions workflow 未配置该变量时会显式使用 `litellm`,如需禁用 fallback 可设为 primary backend 走 self no-op。Agent 工具调用仍使用 LiteLLM;Web 设置页只暴露 `AGENT_GENERATION_BACKEND=auto|litellm`,手写 `codex_cli` 不会启用 text-only Agent mode,只会返回明确 unsupported tool-calling 诊断。
Generation backend 配置是更外层的运行时选择契约。Phase 4 支持 `GENERATION_BACKEND=litellm|codex_cli|claude_code_cli|opencode_cli`,但本地 CLI backend 不是 LiteLLM provider;不要配置成 `LITELLM_MODEL=codex_cli/...`、`LITELLM_MODEL=claude_code_cli/...` 或 `LITELLM_MODEL=opencode_cli/...`。`codex_cli` preset 使用 `codex exec --output-last-message <temp-file> -` 读取最终响应;`claude_code_cli` preset 使用 `claude --safe-mode --tools "" --disallowedTools "mcp__*" --strict-mcp-config --no-session-persistence --output-format json -p <static instruction>`,完整 DSA prompt 走 stdin,并只从 JSON envelope 的 `result/success` 字段提取最终文本,参数依据见 [Claude Code CLI reference](https://code.claude.com/docs/en/cli-reference);`opencode_cli` preset 使用 `opencode --pure run --format json [--model <OPENCODE_CLI_MODEL>] <static instruction> --file <temp prompt file>`,仅在显式配置 `OPENCODE_CLI_MODEL` 时追加 `--model`,完整 DSA prompt 走权限受控的临时文件,并只从无工具事件的 JSON event text 输出提取最终文本,参数依据见 [OpenCode CLI reference](https://opencode.ai/docs/cli),配置合并语义见 [OpenCode config reference](https://opencode.ai/docs/config)。诊断 stdout/stderr 与最终响应一起受 `GENERATION_BACKEND_MAX_OUTPUT_BYTES` 总上限约束,超限时返回结构化 `output_too_large`。`GENERATION_FALLBACK_BACKEND=` 空值会在本地 `.env` 禁用 backend-level fallback,未配置时默认回退到 `litellm`;默认 GitHub Actions workflow 未配置该变量时会显式使用 `litellm`,如需禁用 fallback 可设为 primary backend 走 self no-op。Agent 工具调用仍使用 LiteLLM;Web 设置页只暴露 `AGENT_GENERATION_BACKEND=auto|litellm`,手写 `codex_cli|claude_code_cli|opencode_cli` 不会启用 text-only Agent mode,只会返回明确 unsupported tool-calling 诊断。
本地 CLI Backend 不等于离线模型。Docker、云服务器和 CI 不天然拥有本机 CLI 登录态;macOS 从 Finder/Dock 启动桌面端时不继承 shell PATH,打包桌面端会在启动后端时补入常见 Homebrew 路径,如果设置检查仍提示找不到 `codex`,需要完全退出并重开 DSA。DSA 不读取 Codex credential 文件,但子进程可能使用 CLI 自身登录态,股票代码、新闻、持仓上下文、分析 prompt 和报告草稿可能被对应 CLI 背后的服务处理。
本 PR smoke 验证版本为 `claude 2.1.177 (Claude Code)` 与 `opencode 1.17.11`,不声明更宽最低版本。如果用户安装的 CLI 不支持这些固定 preset 参数或非交互输出契约,DSA 会返回结构化 `capability_unsupported`、`cli_contract_unsupported`、`invalid_json`、`schema_validation_failed` 或对应 backend error,并在配置 backend fallback 时回退到 `litellm`。
本地 CLI Backend 不等于离线模型。Docker、云服务器和 CI 不天然拥有本机 CLI 登录态;macOS 从 Finder/Dock 启动桌面端时不继承 shell PATH,打包桌面端会在启动后端时补入常见 Homebrew 路径,如果设置检查仍提示找不到 CLI 可执行文件,需要完全退出并重开 DSA。DSA 不读取 Codex/Claude/OpenCode credential 文件,也不为 OpenCode 生成或搬运 provider API key;子进程可能按 CLI 自身机制使用本机登录态或配置,股票代码、新闻、持仓上下文、分析 prompt 和报告草稿可能被对应 CLI 背后的服务处理。DSA 默认只继承最小运行环境,并拒绝通配继承 `CLAUDE_*`、`ANTHROPIC_*`、`OPENCODE_*`、provider API key/token/base-url/model env 和 webhook tokens,降低父进程配置泄漏风险;`CODEX_HOME` 仅作为既有 Codex CLI 登录目录兼容的 exact-name 例外保留。
`opencode_cli` 是 experimental/limited generation backend,不支持 OpenCode serve / web / ACP / MCP / attach / `--dangerously-skip-permissions`。DSA 默认使用本机 OpenCode 的默认模型;`OPENCODE_CLI_MODEL` 只是可选模型覆盖值,配置时才传给 OpenCode `--model`。DSA 会在临时 cwd 写入最小项目 `opencode.json`,但 OpenCode resolved config 仍可能包含用户本机全局配置;运行时安全边界同时依赖 `--pure`、env denylist、prompt file 权限和 event extractor fail-closed。
## Web 设置页路径
+2 -2
View File
@@ -11,7 +11,7 @@ from src.config import (
get_effective_agent_primary_model,
get_configured_llm_models,
)
from src.llm.backend_registry import AUTO_AGENT_BACKEND_ID, CODEX_CLI_BACKEND_ID
from src.llm.backend_registry import AUTO_AGENT_BACKEND_ID, GENERATION_ONLY_BACKEND_IDS
from src.llm.hermes import (
build_route_provenance_map,
filter_non_hermes_deployments,
@@ -55,7 +55,7 @@ def resolve_agent_litellm_route(config: Any) -> AgentLiteLLMRouteResolution:
getattr(config, "agent_generation_backend", AUTO_AGENT_BACKEND_ID)
or AUTO_AGENT_BACKEND_ID
).strip().lower()
if agent_backend == CODEX_CLI_BACKEND_ID:
if agent_backend in GENERATION_ONLY_BACKEND_IDS:
return AgentLiteLLMRouteResolution(False, reason="unsupported_agent_backend")
primary = get_effective_agent_primary_model(config)
+6 -7
View File
@@ -21,7 +21,6 @@ from src.config import (
get_api_keys_for_model,
get_config,
get_configured_llm_models,
get_effective_agent_models_to_try,
get_effective_agent_primary_model,
)
from src.agent.litellm_route_resolution import (
@@ -38,7 +37,7 @@ from src.agent.provider_trace import (
from src.llm.errors import call_litellm_with_param_recovery
from src.llm.backend_registry import (
AUTO_AGENT_BACKEND_ID,
CODEX_CLI_BACKEND_ID,
GENERATION_ONLY_BACKEND_IDS,
LITELLM_BACKEND_ID,
resolve_agent_generation_backend_id,
)
@@ -432,25 +431,25 @@ class LLMToolAdapter:
getattr(config, "agent_generation_backend", AUTO_AGENT_BACKEND_ID)
or AUTO_AGENT_BACKEND_ID
).strip().lower()
if generation_backend == CODEX_CLI_BACKEND_ID and agent_backend == AUTO_AGENT_BACKEND_ID:
if generation_backend in GENERATION_ONLY_BACKEND_IDS and agent_backend == AUTO_AGENT_BACKEND_ID:
self._backend_error = GenerationError(
error_code=GenerationErrorCode.UNSUPPORTED_TOOL_CALLING,
stage="generation",
retryable=False,
fallbackable=False,
backend=CODEX_CLI_BACKEND_ID,
provider=CODEX_CLI_BACKEND_ID,
backend=generation_backend,
provider=generation_backend,
details={
"field": "AGENT_GENERATION_BACKEND",
"requested_backend": AUTO_AGENT_BACKEND_ID,
"generation_backend": CODEX_CLI_BACKEND_ID,
"generation_backend": generation_backend,
"supported_tool_backend": LITELLM_BACKEND_ID,
"reason": "litellm_agent_backend_unavailable",
},
)
logger.error(
"Agent auto backend cannot inherit %s because it does not support tool calling",
CODEX_CLI_BACKEND_ID,
generation_backend,
)
return
logger.warning("Agent LLM: no effective primary model configured")
+17 -18
View File
@@ -35,8 +35,6 @@ from src.config import (
get_api_keys_for_model,
get_config,
get_configured_llm_models,
normalize_litellm_temperature,
resolve_litellm_wire_model,
resolve_news_window_days,
)
from src.llm.hermes import (
@@ -54,7 +52,7 @@ from src.llm.hermes import (
from src.llm.generation_params import apply_litellm_generation_params
from src.llm.errors import call_litellm_with_param_recovery
from src.llm.backend_registry import (
CODEX_CLI_BACKEND_ID,
LOCAL_CLI_GENERATION_BACKEND_IDS,
LITELLM_BACKEND_ID,
resolve_generation_backend_id,
resolve_generation_fallback_backend_id,
@@ -2220,10 +2218,11 @@ class GeminiAnalyzer:
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
except GenerationError:
backend_id = ""
if backend_id == CODEX_CLI_BACKEND_ID:
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
logger.info(
"Analyzer generation backend: codex_cli configured; "
"LiteLLM API keys are not required for stock analysis generation"
"Analyzer generation backend: %s configured; LiteLLM API keys are not "
"required for stock analysis generation",
backend_id,
)
else:
logger.warning("No LLM configured (LITELLM_MODEL / API keys), AI analysis will be unavailable")
@@ -2386,10 +2385,10 @@ class GeminiAnalyzer:
backend_id = resolve_generation_backend_id(config)
except GenerationError:
pass
if backend_id == CODEX_CLI_BACKEND_ID:
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
logger.info(
"Analyzer LiteLLM: LITELLM_MODEL not configured; "
"using codex_cli generation backend"
"Analyzer LiteLLM: LITELLM_MODEL not configured; using %s generation backend",
backend_id,
)
else:
logger.warning("Analyzer LLM: LITELLM_MODEL not configured")
@@ -2489,7 +2488,7 @@ class GeminiAnalyzer:
if backend_error is not None:
return self._can_use_generation_fallback(backend_error)
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
if backend_id == CODEX_CLI_BACKEND_ID:
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
return True
return self._litellm_runtime_available()
@@ -2527,7 +2526,7 @@ class GeminiAnalyzer:
mixed_error = self._get_mixed_hermes_route_error(config, model)
if mixed_error is not None:
return mixed_error
if backend_id == CODEX_CLI_BACKEND_ID:
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
backend = self._get_generation_backend(backend_id)
get_config_error = getattr(backend, "get_config_error", None)
if callable(get_config_error):
@@ -3412,21 +3411,21 @@ class GeminiAnalyzer:
config = self._get_runtime_config()
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
model_name = config.litellm_model or "unknown"
if backend_id == CODEX_CLI_BACKEND_ID:
model_name = CODEX_CLI_BACKEND_ID
legacy_audit_context["transport"] = CODEX_CLI_BACKEND_ID
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
model_name = backend_id
legacy_audit_context["transport"] = backend_id
logger.info(f"========== AI 分析 {name}({code}) ==========")
logger.info(f"[LLM配置] 模型: {model_name}")
logger.info(f"[LLM配置] Prompt 长度: {len(prompt)} 字符")
logger.info(f"[LLM配置] 是否包含新闻: {'是' if news_context else '否'}")
# 本地 CLI backend 是进程执行能力,不记录完整 prompt。
if backend_id == CODEX_CLI_BACKEND_ID:
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
prompt_preview = redact_diagnostic_text(prompt, limit=500)
else:
prompt_preview = prompt[:500] + "..." if len(prompt) > 500 else prompt
logger.info(f"[LLM Prompt 预览]\n{prompt_preview}")
if backend_id != CODEX_CLI_BACKEND_ID:
if backend_id not in LOCAL_CLI_GENERATION_BACKEND_IDS:
logger.debug(f"=== 完整 Prompt ({len(prompt)}字符) ===\n{prompt}\n=== End Prompt ===")
# 设置生成配置
@@ -3473,12 +3472,12 @@ class GeminiAnalyzer:
logger.info(
f"[LLM返回] {model_name} 响应成功, 耗时 {elapsed:.2f}s, 响应长度 {len(response_text)} 字符"
)
if backend_id == CODEX_CLI_BACKEND_ID:
if backend_id in LOCAL_CLI_GENERATION_BACKEND_IDS:
response_preview = redact_diagnostic_text(response_text, limit=300)
else:
response_preview = response_text[:300] + "..." if len(response_text) > 300 else response_text
logger.info(f"[LLM返回 预览]\n{response_preview}")
if backend_id != CODEX_CLI_BACKEND_ID:
if backend_id not in LOCAL_CLI_GENERATION_BACKEND_IDS:
logger.debug(
f"=== {model_name} 完整响应 ({len(response_text)}字符) ===\n{response_text}\n=== End Response ==="
)
+49 -12
View File
@@ -38,9 +38,12 @@ from src.notification_contracts import (
)
from src.llm.backend_registry import (
AUTO_AGENT_BACKEND_ID,
CODEX_CLI_BACKEND_ID,
GENERATION_ONLY_BACKEND_IDS,
LOCAL_CLI_GENERATION_BACKEND_IDS,
LITELLM_BACKEND_ID,
OPENCODE_CLI_BACKEND_ID,
SUPPORTED_AGENT_GENERATION_BACKENDS,
SUPPORTED_AGENT_UI_BACKENDS,
SUPPORTED_GENERATION_BACKENDS,
)
from src.llm.local_cli_backend import (
@@ -739,6 +742,7 @@ class Config:
generation_backend_max_output_bytes: int = DEFAULT_LOCAL_CLI_MAX_OUTPUT_BYTES
generation_backend_max_concurrency: int = DEFAULT_GENERATION_BACKEND_MAX_CONCURRENCY
local_cli_backend_max_concurrency: int = DEFAULT_LOCAL_CLI_BACKEND_MAX_CONCURRENCY
opencode_cli_model: str = ""
# LiteLLM unified model config (provider/model format, e.g. gemini/gemini-3.1-pro-preview)
litellm_model: str = "" # Primary model; must include provider prefix when set explicitly
litellm_fallback_models: List[str] = field(default_factory=list) # Cross-model fallback list
@@ -1472,6 +1476,7 @@ class Config:
minimum=1,
maximum=MAX_LOCAL_CLI_BACKEND_MAX_CONCURRENCY,
)
opencode_cli_model = (os.getenv('OPENCODE_CLI_MODEL', '') or '').strip()
agent_litellm_model = normalize_agent_litellm_model(
os.getenv('AGENT_LITELLM_MODEL', ''),
@@ -1626,6 +1631,7 @@ class Config:
generation_backend_max_output_bytes=generation_backend_max_output_bytes,
generation_backend_max_concurrency=generation_backend_max_concurrency,
local_cli_backend_max_concurrency=local_cli_backend_max_concurrency,
opencode_cli_model=opencode_cli_model,
litellm_model=litellm_model,
litellm_fallback_models=litellm_fallback_models,
llm_temperature=resolve_unified_llm_temperature(litellm_model),
@@ -2665,11 +2671,10 @@ class Config:
still requires a non-Hermes Agent route. Hermes-only deployments cannot
satisfy Agent tool roundtrip support; mixed routes are usable only via
their non-Hermes deployments. ``AGENT_MODE=false`` remains an explicit
kill-switch. Explicit ``AGENT_GENERATION_BACKEND=codex_cli`` is also
unavailable because codex_cli is a text generation backend, not an
Agent tool-calling runtime.
kill-switch. Explicit local CLI Agent backends are unavailable because
they are text generation backends, not Agent tool-calling runtimes.
"""
if (self.agent_generation_backend or AUTO_AGENT_BACKEND_ID).strip().lower() == CODEX_CLI_BACKEND_ID:
if (self.agent_generation_backend or AUTO_AGENT_BACKEND_ID).strip().lower() in GENERATION_ONLY_BACKEND_IDS:
return False
# Phase 3 no longer lets AGENT_MODE=true bypass tool-route safety.
if self._agent_mode_explicit:
@@ -2787,7 +2792,8 @@ class Config:
issues.append(ConfigIssue(
severity="error",
message=(
"GENERATION_BACKEND 当前支持 litellm 或 codex_cli。"
"GENERATION_BACKEND 当前支持 "
f"{'、'.join(sorted(SUPPORTED_GENERATION_BACKENDS))}。"
f"已配置的值为:{generation_backend}。"
),
field="GENERATION_BACKEND",
@@ -2804,24 +2810,55 @@ class Config:
field="GENERATION_FALLBACK_BACKEND",
))
if agent_generation_backend not in SUPPORTED_AGENT_GENERATION_BACKENDS:
agent_ui_backends = "、".join(sorted(SUPPORTED_AGENT_UI_BACKENDS))
local_toolless_backends = "、".join(sorted(GENERATION_ONLY_BACKEND_IDS))
issues.append(ConfigIssue(
severity="error",
message=(
"AGENT_GENERATION_BACKEND 当前支持 auto、litellm;"
"codex_cli 仅作为显式 unsupported diagnostic 保留,不支持 Agent 工具调用。"
f"AGENT_GENERATION_BACKEND 当前支持 {agent_ui_backends};"
f"local CLI backend({local_toolless_backends})仅作为显式 unsupported diagnostic 保留,"
"不支持 Agent 工具调用。"
f"已配置的值为:{agent_generation_backend}。"
),
field="AGENT_GENERATION_BACKEND",
))
if (self.litellm_model or "").strip().lower().startswith(f"{CODEX_CLI_BACKEND_ID}/"):
litellm_model_lower = (self.litellm_model or "").strip().lower()
local_model_prefix = next(
(
backend_id
for backend_id in GENERATION_ONLY_BACKEND_IDS
if litellm_model_lower.startswith(f"{backend_id}/")
),
"",
)
if local_model_prefix:
issues.append(ConfigIssue(
severity="error",
message=(
"codex_cli 是 GENERATION_BACKEND,不是 LiteLLM provider。"
"请不要使用 LITELLM_MODEL=codex_cli/...。"
f"{local_model_prefix} 是 GENERATION_BACKEND,不是 LiteLLM provider。"
f"请不要使用 LITELLM_MODEL={local_model_prefix}/...。"
),
field="LITELLM_MODEL",
))
if generation_backend == OPENCODE_CLI_BACKEND_ID:
opencode_model = (self.opencode_cli_model or "").strip()
unsafe_model = bool(opencode_model) and (
any(ch.isspace() for ch in opencode_model)
or any(
marker in opencode_model
for marker in ("|", ">", "<", ";", "`", "&&", "||", "$")
)
)
if unsafe_model:
issues.append(ConfigIssue(
severity="error",
message=(
"OPENCODE_CLI_MODEL 是可选的 OpenCode 模型覆盖值。"
"配置时会作为单个 --model 参数传给 OpenCode,不能包含空白或 shell 元字符;"
"不配置时 DSA 将使用 OpenCode 自身默认模型。"
),
field="OPENCODE_CLI_MODEL",
))
# --- LLM availability ---
for raw_issue in self.llm_channel_config_issues or []:
@@ -2836,7 +2873,7 @@ class Config:
# Other LiteLLM-native providers (for example cohere/*) run through the
# direct litellm env path and therefore do not populate llm_model_list.
has_direct_env_model = bool(self.litellm_model) and _uses_direct_env_provider(self.litellm_model)
local_generation_backend = generation_backend == CODEX_CLI_BACKEND_ID
local_generation_backend = generation_backend in LOCAL_CLI_GENERATION_BACKEND_IDS
if not local_generation_backend and not self.llm_model_list and not has_direct_env_model:
if self.litellm_config_path:
issues.append(ConfigIssue(
+37 -4
View File
@@ -18,7 +18,7 @@ from src.config import (
from src.notification_noise import NOTIFICATION_SEVERITIES
from src.notification_routing import ROUTABLE_NOTIFICATION_CHANNELS
SCHEMA_VERSION = "2026-06-23-local-cli-backend"
SCHEMA_VERSION = "2026-06-29-claude-code-cli-backend"
_CATEGORY_DEFINITIONS: List[Dict[str, Any]] = [
{
@@ -129,11 +129,44 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"options": [
{"label": "Default model settings", "value": "litellm"},
{"label": "Codex CLI (experimental)", "value": "codex_cli"},
{"label": "Claude Code CLI (experimental)", "value": "claude_code_cli"},
{"label": "OpenCode CLI (experimental)", "value": "opencode_cli"},
],
"validation": {"enum": ["litellm", "codex_cli"]},
"validation": {"enum": ["litellm", "codex_cli", "claude_code_cli", "opencode_cli"]},
"display_order": 0,
"help_key": "settings.ai_model.GENERATION_BACKEND",
"examples": ["GENERATION_BACKEND=litellm", "GENERATION_BACKEND=codex_cli"],
"examples": [
"GENERATION_BACKEND=litellm",
"GENERATION_BACKEND=codex_cli",
"GENERATION_BACKEND=claude_code_cli",
"GENERATION_BACKEND=opencode_cli",
],
"docs": [
{
"label": "LLM 配置指南",
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/LLM_CONFIG_GUIDE.md",
},
],
"warning_codes": [],
},
"OPENCODE_CLI_MODEL": {
"title": "OpenCode CLI Model",
"description": "Optional model override passed to OpenCode CLI when GENERATION_BACKEND=opencode_cli. Leave empty to use OpenCode's default model.",
"category": "ai_model",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "",
"placeholder": "optional provider/model override",
"validation": {"pattern": r"^$|^[^\s|<>;`$]+$"},
"display_order": 1,
"help_key": "settings.ai_model.OPENCODE_CLI_MODEL",
"examples": [
"OPENCODE_CLI_MODEL=provider/model",
"OPENCODE_CLI_MODEL=opencode/model-name",
],
"docs": [
{
"label": "LLM 配置指南",
@@ -144,7 +177,7 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
},
"GENERATION_FALLBACK_BACKEND": {
"title": "Fallback Generation Method",
"description": "Backend-level fallback method. Empty disables backend fallback; litellm can be used as fallback for Codex CLI.",
"description": "Backend-level fallback method. Empty disables backend fallback; litellm can be used as fallback for local CLI generation backends.",
"category": "ai_model",
"data_type": "string",
"ui_control": "select",
+2 -2
View File
@@ -12,7 +12,7 @@ from typing import Any, Optional, Tuple
from src.config import Config
from src.llm.backend_registry import (
CODEX_CLI_BACKEND_ID,
LOCAL_CLI_GENERATION_BACKEND_IDS,
resolve_generation_backend_id,
resolve_generation_fallback_backend_id,
)
@@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
def has_configured_llm_runtime(config: Config) -> bool:
"""Return whether any LLM model configuration is available."""
try:
if resolve_generation_backend_id(config) == CODEX_CLI_BACKEND_ID:
if resolve_generation_backend_id(config) in LOCAL_CLI_GENERATION_BACKEND_IDS:
return True
except GenerationError:
pass
+14
View File
@@ -1,9 +1,16 @@
"""LLM runtime helpers."""
from src.llm.backend_registry import (
AGENT_CAPABLE_BACKEND_IDS,
AUTO_AGENT_BACKEND_ID,
CLAUDE_CODE_CLI_BACKEND_ID,
CODEX_CLI_BACKEND_ID,
GENERATION_ONLY_BACKEND_IDS,
LOCAL_CLI_GENERATION_BACKEND_IDS,
LITELLM_BACKEND_ID,
OPENCODE_CLI_BACKEND_ID,
SUPPORTED_AGENT_GENERATION_BACKENDS,
SUPPORTED_AGENT_UI_BACKENDS,
SUPPORTED_GENERATION_FALLBACK_BACKENDS,
SUPPORTED_GENERATION_BACKENDS,
resolve_agent_generation_backend_id,
@@ -21,14 +28,21 @@ from src.llm.litellm_backend import LiteLLMGenerationBackend
__all__ = [
"AUTO_AGENT_BACKEND_ID",
"AGENT_CAPABLE_BACKEND_IDS",
"CLAUDE_CODE_CLI_BACKEND_ID",
"CODEX_CLI_BACKEND_ID",
"GENERATION_ONLY_BACKEND_IDS",
"GenerationBackend",
"GenerationCapabilities",
"GenerationError",
"GenerationErrorCode",
"GenerationResult",
"LOCAL_CLI_GENERATION_BACKEND_IDS",
"LITELLM_BACKEND_ID",
"LiteLLMGenerationBackend",
"OPENCODE_CLI_BACKEND_ID",
"SUPPORTED_AGENT_GENERATION_BACKENDS",
"SUPPORTED_AGENT_UI_BACKENDS",
"SUPPORTED_GENERATION_FALLBACK_BACKENDS",
"SUPPORTED_GENERATION_BACKENDS",
"resolve_agent_generation_backend_id",
+3 -3
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any, Optional
from src.llm.backend_registry import CODEX_CLI_BACKEND_ID, LITELLM_BACKEND_ID
from src.llm.backend_registry import LOCAL_CLI_GENERATION_BACKEND_IDS, LITELLM_BACKEND_ID
from src.llm.generation_backend import GenerationBackend, GenerationError, GenerationErrorCode
from src.llm.litellm_backend import LiteLLMCallable, LiteLLMGenerationBackend
from src.llm.local_cli_backend import LocalCliGenerationBackend
@@ -32,8 +32,8 @@ def create_generation_backend(
details={"reason": "missing_litellm_completion_callable"},
)
return LiteLLMGenerationBackend(litellm_completion_callable)
if normalized == CODEX_CLI_BACKEND_ID:
return LocalCliGenerationBackend(config, preset_id=CODEX_CLI_BACKEND_ID)
if normalized in LOCAL_CLI_GENERATION_BACKEND_IDS:
return LocalCliGenerationBackend(config, preset_id=normalized)
raise GenerationError(
error_code=GenerationErrorCode.BACKEND_NOT_CONFIGURED,
+23 -4
View File
@@ -10,14 +10,33 @@ from src.llm.generation_backend import GenerationError, GenerationErrorCode
LITELLM_BACKEND_ID = "litellm"
CODEX_CLI_BACKEND_ID = "codex_cli"
CLAUDE_CODE_CLI_BACKEND_ID = "claude_code_cli"
OPENCODE_CLI_BACKEND_ID = "opencode_cli"
AUTO_AGENT_BACKEND_ID = "auto"
SUPPORTED_GENERATION_BACKENDS = frozenset({LITELLM_BACKEND_ID, CODEX_CLI_BACKEND_ID})
LOCAL_CLI_GENERATION_BACKEND_IDS = frozenset({
CODEX_CLI_BACKEND_ID,
CLAUDE_CODE_CLI_BACKEND_ID,
OPENCODE_CLI_BACKEND_ID,
})
AGENT_CAPABLE_BACKEND_IDS = frozenset({LITELLM_BACKEND_ID})
# Phase 4 local CLI backends are generation-only today. Keep this derived so a
# future agent-capable local backend does not remain classified as generation-only.
GENERATION_ONLY_BACKEND_IDS = LOCAL_CLI_GENERATION_BACKEND_IDS - AGENT_CAPABLE_BACKEND_IDS
SUPPORTED_GENERATION_BACKENDS = frozenset({
LITELLM_BACKEND_ID,
*LOCAL_CLI_GENERATION_BACKEND_IDS,
})
SUPPORTED_GENERATION_FALLBACK_BACKENDS = frozenset({LITELLM_BACKEND_ID})
SUPPORTED_AGENT_GENERATION_BACKENDS = frozenset({
AUTO_AGENT_BACKEND_ID,
LITELLM_BACKEND_ID,
CODEX_CLI_BACKEND_ID,
*AGENT_CAPABLE_BACKEND_IDS,
*GENERATION_ONLY_BACKEND_IDS,
})
SUPPORTED_AGENT_UI_BACKENDS = frozenset({
AUTO_AGENT_BACKEND_ID,
*AGENT_CAPABLE_BACKEND_IDS,
})
@@ -101,7 +120,7 @@ def resolve_generation_fallback_backend_id(config: Any) -> Optional[str]:
def resolve_agent_generation_backend_id(config: Any) -> str:
"""Return the Agent tool-calling backend id.
Phase 2 keeps Agent tool-calling on LiteLLM for auto. Explicit local
Phase 4 keeps Agent tool-calling on LiteLLM for auto. Explicit local
backends are returned so the Agent adapter can reject or fallback
explicitly instead of treating text-only output as successful tool use.
"""
+640 -40
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""Local CLI generation backend.
Phase 2 exposes a restricted Codex CLI preset as an opt-in generation backend.
Phase 4 exposes restricted local CLI presets as opt-in generation backends.
It is intentionally process-oriented. Generic safe presets treat stdout as the
model output; the Codex CLI preset reads its final answer from
``--output-last-message`` because stdout includes session diagnostics.
@@ -11,7 +11,8 @@ from __future__ import annotations
from dataclasses import dataclass
import hashlib
from contextlib import contextmanager
from contextlib import ExitStack, contextmanager
import json
import os
from pathlib import Path
import re
@@ -21,10 +22,14 @@ import subprocess
import tempfile
import threading
import time
from typing import Any, Callable, Dict, Mapping, Optional, Sequence
from typing import Any, Callable, Dict, Iterator, Mapping, Optional, Sequence
from urllib.parse import parse_qsl, urlsplit
from src.llm.backend_registry import CODEX_CLI_BACKEND_ID
from src.llm.backend_registry import (
CLAUDE_CODE_CLI_BACKEND_ID,
CODEX_CLI_BACKEND_ID,
OPENCODE_CLI_BACKEND_ID,
)
from src.llm.generation_backend import (
GenerationBackend,
GenerationCapabilities,
@@ -50,13 +55,6 @@ _PROCESS_POLL_INTERVAL_SECONDS = 0.05
_URL_PATTERN = re.compile(r"https?://[^\s,;)\]}]+", re.IGNORECASE)
_SHELL_META_CHARS = ("|", ">", "<", ";", "`")
_SHELL_META_STRINGS = ("&&", "||", "$(")
_PRESET_CONTRACT_ARGS = (
"--output-last-message",
"--skip-git-repo-check",
"--sandbox",
"--color",
"--ephemeral",
)
_UNSUPPORTED_ARG_MARKERS = (
"unknown option",
"unrecognized option",
@@ -84,11 +82,6 @@ _SENSITIVE_URL_KEY_PARTS = {
_SAFE_ENV_EXACT = {
"PATH",
"HOME",
"HOMEDRIVE",
"HOMEPATH",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"TMPDIR",
"TEMP",
"TMP",
@@ -98,19 +91,29 @@ _SAFE_ENV_EXACT = {
"NO_COLOR",
"TERM",
"CODEX_HOME",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"NODE_EXTRA_CA_CERTS",
"HOMEDRIVE",
"HOMEPATH",
"SYSTEMROOT",
"WINDIR",
"PATHEXT",
"COMSPEC",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
}
_SAFE_ENV_PREFIXES = ("CODEX_CLI_",)
_SAFE_ENV_PREFIXES = ("LC_",)
_SENSITIVE_ENV_PATTERNS = (
"ACCESS_TOKEN",
"API_KEY",
"API_KEYS",
"AUTHORIZATION",
"AUTH_TOKEN",
"AWS_",
"AZURE_",
"BASE_URL",
"CLAUDE_",
"COOKIE",
"DATABASE_URL",
"DB_URL",
@@ -119,17 +122,84 @@ _SENSITIVE_ENV_PATTERNS = (
"GITHUB_TOKEN",
"OPENAI",
"ANTHROPIC",
"OPENCODE_",
"DEEPSEEK",
"GOOGLE_",
"MODEL",
"SECRET",
"SESSION",
"TOKEN",
"TUSHARE",
"VERTEX_",
"WEBHOOK",
)
_CLAUDE_CODE_STATIC_INSTRUCTION = (
"Generate the requested DSA analysis output from stdin. "
"Return only the final response content. Do not call tools, read files, "
"use MCP, or ask for interactive approval."
)
_PROMPT_FILE_PLACEHOLDER = "{prompt_file}"
_OPENCODE_STATIC_INSTRUCTION = (
"Generate the requested DSA stock analysis from the attached prompt file. "
"Return only one JSON object that satisfies the DSA parser contract. "
"The JSON must include at least one of sentiment_score, trend_prediction, "
"operation_advice, analysis_summary, or dashboard. Do not use tools, read "
"additional files, browse the web, edit files, ask questions, or request approval."
)
_OPENCODE_ALLOWED_EVENT_TYPES = {"step_start", "text", "step_finish"}
_OPENCODE_BLOCKED_EVENT_TYPES = {
"tool",
"tool_call",
"tool_result",
"tool_use",
"error",
"question",
"permission",
}
_OPENCODE_DISABLED_TOOL_NAMES = (
"bash",
"edit",
"glob",
"grep",
"list",
"lsp",
"patch",
"question",
"read",
"skill",
"task",
"todoread",
"todowrite",
"webfetch",
"websearch",
"write",
)
_CONCURRENCY_CONDITION = threading.Condition()
_CONCURRENCY_ACTIVE = 0
@dataclass(frozen=True)
class LocalCliExecutionResult:
"""Raw subprocess output passed to a preset-specific extractor."""
stdout: str
stderr: str
returncode: int
final_message: str = ""
diagnostics: Optional[Dict[str, Any]] = None
@dataclass(frozen=True)
class LocalCliExtractionError(Exception):
"""Extractor failure mapped to a structured GenerationError by the backend."""
error_code: GenerationErrorCode
reason: str
retryable: bool = True
fallbackable: bool = True
details: Optional[Dict[str, Any]] = None
@dataclass(frozen=True)
class LocalCliPreset:
"""Safe executable preset exposed to Web/API users."""
@@ -138,8 +208,12 @@ class LocalCliPreset:
executable: str
argv: Sequence[str]
display_name: str
experimental: bool = True
output_last_message_arg: Optional[str] = None
extractor: Callable[[LocalCliExecutionResult], str] = lambda result: (
result.final_message or result.stdout
).strip()
contract_args: Sequence[str] = ()
prompt_transport: str = "stdin"
CODEX_CLI_PRESET = LocalCliPreset(
@@ -156,12 +230,79 @@ CODEX_CLI_PRESET = LocalCliPreset(
"-",
),
display_name="Codex CLI",
experimental=True,
output_last_message_arg="--output-last-message",
contract_args=(
"exec",
"--skip-git-repo-check",
"--sandbox",
"read-only",
"--color",
"never",
"--ephemeral",
"--output-last-message",
),
)
CLAUDE_CODE_CLI_PRESET = LocalCliPreset(
preset_id=CLAUDE_CODE_CLI_BACKEND_ID,
executable="claude",
argv=(
"--safe-mode",
"--tools",
"",
"--disallowedTools",
"mcp__*",
"--strict-mcp-config",
"--no-session-persistence",
"--output-format",
"json",
"-p",
_CLAUDE_CODE_STATIC_INSTRUCTION,
),
display_name="Claude Code CLI",
extractor=lambda result: _extract_claude_code_json(result, schema_mode=False),
contract_args=(
"--safe-mode",
"--tools",
"",
"--disallowedTools",
"mcp__*",
"--strict-mcp-config",
"--no-session-persistence",
"--output-format",
"json",
"-p",
),
)
OPENCODE_CLI_PRESET = LocalCliPreset(
preset_id=OPENCODE_CLI_BACKEND_ID,
executable="opencode",
argv=(
"--pure",
"run",
"--format",
"json",
_OPENCODE_STATIC_INSTRUCTION,
"--file",
_PROMPT_FILE_PLACEHOLDER,
),
display_name="OpenCode CLI",
extractor=lambda result: _extract_opencode_json_events(result),
contract_args=(
"--pure",
"run",
"--format",
"json",
"--file",
),
prompt_transport="file",
)
SAFE_LOCAL_CLI_PRESETS = {
CODEX_CLI_BACKEND_ID: CODEX_CLI_PRESET,
CLAUDE_CODE_CLI_BACKEND_ID: CLAUDE_CODE_CLI_PRESET,
OPENCODE_CLI_BACKEND_ID: OPENCODE_CLI_PRESET,
}
@@ -284,12 +425,256 @@ def _has_sensitive_url_params(params_text: str) -> bool:
return False
def _extract_claude_code_json(result: LocalCliExecutionResult, *, schema_mode: bool) -> str:
raw = (result.stdout or "").strip()
if not raw:
raise LocalCliExtractionError(
GenerationErrorCode.EMPTY_OUTPUT,
"empty_output",
)
try:
envelope = json.loads(raw)
except json.JSONDecodeError as exc:
raise LocalCliExtractionError(
GenerationErrorCode.INVALID_JSON,
"invalid_json",
details={"error": redact_diagnostic_text(str(exc), limit=200)},
) from exc
if not isinstance(envelope, dict):
raise LocalCliExtractionError(
GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
"schema_validation_failed",
details={"expected": "object_envelope"},
)
event_type = str(envelope.get("type") or "").strip()
subtype = str(envelope.get("subtype") or "").strip()
if event_type != "result":
raise LocalCliExtractionError(
GenerationErrorCode.CAPABILITY_UNSUPPORTED,
"unexpected_cli_event",
retryable=False,
details={"event_type": event_type or "missing"},
)
if subtype == "error_max_structured_output_retries":
raise LocalCliExtractionError(
GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
"structured_output_retries_exhausted",
)
if envelope.get("is_error") is True:
raise LocalCliExtractionError(
GenerationErrorCode.UNKNOWN_BACKEND_ERROR,
"cli_result_error",
retryable=False,
details={"subtype": subtype or "unknown"},
)
if subtype != "success":
raise LocalCliExtractionError(
GenerationErrorCode.UNKNOWN_BACKEND_ERROR,
"cli_result_not_success",
retryable=False,
details={"subtype": subtype or "missing"},
)
if schema_mode:
if "structured_output" not in envelope:
raise LocalCliExtractionError(
GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
"missing_structured_output",
)
structured_output = envelope.get("structured_output")
return json.dumps(
structured_output,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
text = str(envelope.get("result") or "").strip()
if not text:
raise LocalCliExtractionError(
GenerationErrorCode.EMPTY_OUTPUT,
"empty_result",
)
return text
def _extract_opencode_json_events(result: LocalCliExecutionResult) -> str:
raw = (result.stdout or "").strip()
if not raw:
raise LocalCliExtractionError(
GenerationErrorCode.EMPTY_OUTPUT,
"empty_output",
)
text_parts: list[str] = []
saw_finish = False
finish_reason = ""
for event in _iter_opencode_events(raw):
event_type = str(event.get("type") or "").strip()
event_type_lower = event_type.lower()
blocked_reason = _opencode_blocked_event_reason(event, event_type_lower)
if blocked_reason:
raise LocalCliExtractionError(
GenerationErrorCode.CAPABILITY_UNSUPPORTED,
"capability_unsupported",
retryable=False,
details={
"event_type": event_type or "missing",
"blocked_reason": blocked_reason,
},
)
if event.get("error") or event.get("is_error") is True:
raise LocalCliExtractionError(
GenerationErrorCode.UNKNOWN_BACKEND_ERROR,
"cli_result_error",
retryable=False,
details={"event_type": event_type or "missing"},
)
if event_type_lower not in _OPENCODE_ALLOWED_EVENT_TYPES:
raise LocalCliExtractionError(
GenerationErrorCode.CAPABILITY_UNSUPPORTED,
"unexpected_cli_event",
retryable=False,
details={"event_type": event_type or "missing"},
)
if event_type_lower == "text":
text_value = event.get("text")
if text_value is None and isinstance(event.get("part"), dict):
text_value = event["part"].get("text")
if text_value:
text_parts.append(str(text_value))
continue
if event_type_lower == "step_finish":
saw_finish = True
finish_reason = str(
event.get("reason")
or (
event.get("part", {}).get("reason")
if isinstance(event.get("part"), dict)
else ""
)
or ""
).strip().lower()
if not saw_finish:
raise LocalCliExtractionError(
GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
"missing_step_finish",
)
if finish_reason and finish_reason not in {"stop", "end_turn", "complete", "completed"}:
raise LocalCliExtractionError(
GenerationErrorCode.CAPABILITY_UNSUPPORTED,
"unexpected_finish_reason",
retryable=False,
details={"finish_reason": finish_reason},
)
text = "".join(text_parts).strip()
if not text:
raise LocalCliExtractionError(
GenerationErrorCode.EMPTY_OUTPUT,
"empty_text",
)
return text
def _iter_opencode_events(output_text: str) -> Iterator[Dict[str, Any]]:
"""Yield strict OpenCode JSON events from JSONL, arrays, or raw JSON output."""
raw = str(output_text or "")
decoder = json.JSONDecoder()
index = 0
event_index = 0
length = len(raw)
while index < length:
while index < length and raw[index].isspace():
index += 1
if index >= length:
break
try:
decoded, next_index = decoder.raw_decode(raw, index)
except json.JSONDecodeError as exc:
raise LocalCliExtractionError(
GenerationErrorCode.INVALID_JSON,
"invalid_json",
details={"error": redact_diagnostic_text(str(exc), limit=200)},
) from exc
if next_index <= index:
raise LocalCliExtractionError(
GenerationErrorCode.INVALID_JSON,
"invalid_json",
details={"error": "json_decoder_made_no_progress"},
)
index = next_index
if isinstance(decoded, list):
for item in decoded:
event_index += 1
yield _validate_opencode_event(item, event_index=event_index)
continue
event_index += 1
yield _validate_opencode_event(decoded, event_index=event_index)
def _validate_opencode_event(value: Any, *, event_index: int) -> Dict[str, Any]:
if not isinstance(value, dict):
raise LocalCliExtractionError(
GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
"schema_validation_failed",
details={"event_index": event_index, "expected": "object_event"},
)
event_type = value.get("type")
if not isinstance(event_type, str) or not event_type.strip():
raise LocalCliExtractionError(
GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
"schema_validation_failed",
details={"event_index": event_index, "expected": "event_type"},
)
return value
def _opencode_blocked_event_reason(event: Dict[str, Any], event_type_lower: str) -> str:
if (
event_type_lower in _OPENCODE_BLOCKED_EVENT_TYPES
or any(blocked in event_type_lower for blocked in _OPENCODE_BLOCKED_EVENT_TYPES)
):
return event_type_lower or "blocked_event"
if event_type_lower in _OPENCODE_DISABLED_TOOL_NAMES:
return event_type_lower
for container in (event, event.get("part") if isinstance(event.get("part"), dict) else None):
if not isinstance(container, dict):
continue
for key in ("name", "tool", "tool_name"):
value = container.get(key)
if isinstance(value, str) and value.strip().lower() in _OPENCODE_DISABLED_TOOL_NAMES:
return value.strip().lower()
return ""
def _is_cli_contract_unsupported(output_text: str) -> bool:
text = str(output_text or "").lower()
return (
any(arg in text for arg in _PRESET_CONTRACT_ARGS)
and any(marker in text for marker in _UNSUPPORTED_ARG_MARKERS)
)
return any(marker in text for marker in _UNSUPPORTED_ARG_MARKERS)
def _opencode_output_has_error_event(output_text: str) -> bool:
try:
events = _iter_opencode_events(output_text)
for event in events:
event_type_lower = str(event.get("type") or "").strip().lower()
if (
_opencode_blocked_event_reason(event, event_type_lower)
or bool(event.get("error"))
or event.get("is_error") is True
):
return True
except LocalCliExtractionError:
return False
return False
def resolve_local_cli_preset(preset_id: str) -> LocalCliPreset:
@@ -316,7 +701,6 @@ def resolve_local_cli_preset(preset_id: str) -> LocalCliPreset:
class LocalCliGenerationBackend(GenerationBackend):
"""Restricted subprocess-backed generation backend."""
backend_id = CODEX_CLI_BACKEND_ID
capabilities = GenerationCapabilities(
supports_json=True,
supports_tools=False,
@@ -336,6 +720,10 @@ class LocalCliGenerationBackend(GenerationBackend):
self._config = config
self._preset = preset or resolve_local_cli_preset(preset_id)
@property
def backend_id(self) -> str:
return self._preset.preset_id
@property
def preset_id(self) -> str:
return self._preset.preset_id
@@ -384,6 +772,7 @@ class LocalCliGenerationBackend(GenerationBackend):
diagnostics: Dict[str, Any] = {
"preset_id": self._preset.preset_id,
"executable": executable_summary,
"contract_args": list(self._preset.contract_args),
"stream_degraded": bool(stream),
"timeout_seconds": timeout_seconds,
"max_output_bytes": max_output_bytes,
@@ -399,23 +788,58 @@ class LocalCliGenerationBackend(GenerationBackend):
with _local_cli_concurrency_slot(concurrency_limit):
self._emit_progress(stream_progress_callback, 0)
child_env = build_local_cli_env()
try:
with tempfile.TemporaryDirectory(prefix="dsa-local-cli-") as cwd:
cwd_path = Path(cwd)
try:
cwd_path.chmod(0o700)
except OSError:
pass
diagnostics["cwd_kind"] = "temporary"
command_argv, last_message_path = self._build_runtime_argv(argv, cwd)
prompt_path = Path(cwd) / "prompt.txt"
stdout_path = Path(cwd) / "stdout.txt"
stderr_path = Path(cwd) / "stderr.txt"
child_env = build_local_cli_env()
child_env.update(self._build_preset_child_env(cwd_path, diagnostics))
diagnostics["env_allowlist_names"] = sorted(child_env)
diagnostics["runtime_argv_contract_checked"] = True
prompt_path = cwd_path / "prompt.txt"
stdout_path = cwd_path / "stdout.txt"
stderr_path = cwd_path / "stderr.txt"
prompt_path.write_text(prompt_text, encoding="utf-8")
with (
prompt_path.open("r", encoding="utf-8") as prompt_handle,
stdout_path.open("wb") as stdout_handle,
stderr_path.open("wb") as stderr_handle,
):
try:
prompt_path.chmod(0o600)
except OSError:
pass
self._prepare_preset_runtime_files(cwd_path, prompt_path, diagnostics)
command_argv, last_message_path = self._build_runtime_argv(
argv,
cwd,
prompt_path=prompt_path,
)
with ExitStack() as stack:
if self._preset.prompt_transport == "stdin":
stdin_handle = stack.enter_context(
prompt_path.open("r", encoding="utf-8")
)
elif self._preset.prompt_transport == "file":
stdin_handle = subprocess.DEVNULL
diagnostics["prompt_transport"] = "file"
diagnostics["prompt_file_mode"] = "0600"
else:
raise self._error(
GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
details={
**diagnostics,
"reason": "unsupported_prompt_transport",
"prompt_transport": self._preset.prompt_transport,
},
)
stdout_handle = stack.enter_context(stdout_path.open("wb"))
stderr_handle = stack.enter_context(stderr_path.open("wb"))
process = subprocess.Popen(
[executable, *command_argv],
stdin=prompt_handle,
stdin=stdin_handle,
stdout=stdout_handle,
stderr=stderr_handle,
cwd=cwd,
@@ -619,6 +1043,42 @@ class LocalCliGenerationBackend(GenerationBackend):
},
) from exc
raw_result = LocalCliExecutionResult(
stdout=stdout,
stderr=stderr,
returncode=0,
final_message=text,
diagnostics=diagnostics,
)
try:
text = self._preset.extractor(raw_result)
except LocalCliExtractionError as exc:
raise self._error(
exc.error_code,
stage="validation",
retryable=exc.retryable,
fallbackable=exc.fallbackable,
details={
**diagnostics,
"reason": exc.reason,
**(exc.details or {}),
},
) from exc
except GenerationError:
raise
except Exception as exc:
raise self._error(
GenerationErrorCode.UNKNOWN_BACKEND_ERROR,
stage="validation",
retryable=False,
fallbackable=True,
details={
**diagnostics,
"reason": "extractor_failed",
"error": redact_diagnostic_text(str(exc), limit=200),
},
) from exc
total_output_bytes = stdio_output_bytes + final_output_bytes
if total_output_bytes > max_output_bytes:
raise self._error(
@@ -669,7 +1129,7 @@ class LocalCliGenerationBackend(GenerationBackend):
usage={
"usage_available": False,
"usage_source": "unavailable",
"backend": self.backend_id,
"backend": self._preset.preset_id,
},
raw=None,
diagnostics=diagnostics,
@@ -720,13 +1180,17 @@ class LocalCliGenerationBackend(GenerationBackend):
self,
argv: Sequence[str],
cwd: str,
*,
prompt_path: Optional[Path] = None,
) -> tuple[list[str], Optional[Path]]:
output_arg = self._preset.output_last_message_arg
if not output_arg:
return list(argv), None
runtime_argv = self._replace_runtime_placeholders(list(argv), prompt_path)
self._validate_runtime_contract_args(runtime_argv)
return runtime_argv, None
last_message_path = Path(cwd) / "last-message.txt"
runtime_argv = list(argv)
runtime_argv = self._replace_runtime_placeholders(list(argv), prompt_path)
injected = [output_arg, str(last_message_path)]
if runtime_argv and runtime_argv[-1] == "-":
runtime_argv = [*runtime_argv[:-1], *injected, runtime_argv[-1]]
@@ -742,8 +1206,137 @@ class LocalCliGenerationBackend(GenerationBackend):
fallbackable=False,
details={"reason": "shell_metachar", "token_preview": unsafe},
)
self._validate_runtime_contract_args(runtime_argv)
return runtime_argv, last_message_path
def _replace_runtime_placeholders(
self,
argv: list[str],
prompt_path: Optional[Path],
) -> list[str]:
if self._preset.preset_id != OPENCODE_CLI_BACKEND_ID:
return argv
model = self._get_opencode_cli_model()
if prompt_path is None:
raise self._error(
GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
details={"reason": "missing_prompt_file"},
)
runtime_argv = [
str(prompt_path) if token == _PROMPT_FILE_PLACEHOLDER else token
for token in argv
]
if model:
try:
format_index = runtime_argv.index("--format")
insert_at = format_index + 2
except ValueError:
insert_at = 0
runtime_argv = [
*runtime_argv[:insert_at],
"--model",
model,
*runtime_argv[insert_at:],
]
return runtime_argv
def _get_opencode_cli_model(self) -> str:
model = str(getattr(self._config, "opencode_cli_model", "") or "").strip()
if not model:
return ""
unsafe = _first_unsafe_token([model])
if unsafe or any(ch.isspace() for ch in model) or "$" in model:
raise self._error(
GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
details={
"reason": "unsafe_opencode_cli_model",
"field": "OPENCODE_CLI_MODEL",
"token_preview": unsafe or redact_diagnostic_text(model, limit=120),
},
)
return model
def _build_preset_child_env(
self,
cwd: Path,
diagnostics: Dict[str, Any],
) -> Dict[str, str]:
if self._preset.preset_id != OPENCODE_CLI_BACKEND_ID:
return {}
diagnostics["opencode_child_env_hardened"] = True
diagnostics["opencode_provider_credentials_managed_by_dsa"] = False
return {
"OPENCODE_DISABLE_DEFAULT_PLUGINS": "true",
"OPENCODE_DISABLE_CLAUDE_CODE": "true",
"OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "true",
"OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "true",
"OPENCODE_DISABLE_AUTOUPDATE": "true",
"OPENCODE_DISABLE_LSP_DOWNLOAD": "true",
}
def _prepare_preset_runtime_files(
self,
cwd: Path,
prompt_path: Path,
diagnostics: Dict[str, Any],
) -> None:
if self._preset.preset_id != OPENCODE_CLI_BACKEND_ID:
return
diagnostics["opencode_model_override"] = bool(self._get_opencode_cli_model())
config = {
"$schema": "https://opencode.ai/config.json",
"share": "disabled",
"autoupdate": False,
"snapshot": False,
"mcp": {},
"plugin": [],
"instructions": [],
"tools": {tool_name: False for tool_name in _OPENCODE_DISABLED_TOOL_NAMES},
}
config_path = cwd / "opencode.json"
config_path.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8")
try:
config_path.chmod(0o600)
except OSError:
pass
diagnostics["opencode_project_config_written"] = True
diagnostics["opencode_config_contains_provider_credentials"] = False
diagnostics["opencode_prompt_file"] = prompt_path.name
def _validate_runtime_contract_args(self, runtime_argv: Sequence[str]) -> None:
runtime_tokens = [str(arg) for arg in runtime_argv]
missing_contract_args: list[str] = []
search_start = 0
for contract_arg in self._preset.contract_args:
contract_token = str(contract_arg)
try:
matched_at = runtime_tokens.index(contract_token, search_start)
except ValueError:
missing_contract_args.append(contract_token)
continue
search_start = matched_at + 1
if missing_contract_args:
raise self._error(
GenerationErrorCode.CAPABILITY_UNSUPPORTED,
stage="configuration",
retryable=False,
fallbackable=True,
details={
"reason": "missing_runtime_contract_arg",
"missing_contract_args": [
redact_diagnostic_text(str(arg), limit=120)
for arg in missing_contract_args
],
"preset_id": self._preset.preset_id,
},
)
def _non_zero_exit_error(
self,
returncode: int,
@@ -755,7 +1348,14 @@ class LocalCliGenerationBackend(GenerationBackend):
code = GenerationErrorCode.NON_ZERO_EXIT
reason = "non_zero_exit"
if _is_cli_contract_unsupported(combined):
code = GenerationErrorCode.CAPABILITY_UNSUPPORTED
reason = "cli_contract_unsupported"
elif (
self._preset.preset_id == OPENCODE_CLI_BACKEND_ID
and _opencode_output_has_error_event(f"{stdout}\n{stderr}")
):
code = GenerationErrorCode.UNKNOWN_BACKEND_ERROR
reason = "cli_result_error"
elif "login" in combined or "authentication" in combined or "not authenticated" in combined:
code = GenerationErrorCode.LOGIN_REQUIRED
reason = "login_required"
+2 -2
View File
@@ -7,7 +7,7 @@ from typing import Any, Dict, List
from src.config import get_effective_agent_models_to_try, get_effective_agent_primary_model
from src.agent.litellm_route_resolution import resolve_agent_litellm_route
from src.llm.backend_registry import CODEX_CLI_BACKEND_ID
from src.llm.backend_registry import GENERATION_ONLY_BACKEND_IDS
_PLACEHOLDER_TO_PROVIDER = {
@@ -124,7 +124,7 @@ def _build_legacy_deployments(config) -> List[Dict[str, Any]]:
def list_agent_model_deployments(config) -> List[Dict[str, Any]]:
"""Return configured Agent model deployments without exposing secrets."""
if (getattr(config, "agent_generation_backend", "") or "").strip().lower() == CODEX_CLI_BACKEND_ID:
if (getattr(config, "agent_generation_backend", "") or "").strip().lower() in GENERATION_ONLY_BACKEND_IDS:
return []
deployments = _build_non_legacy_deployments(config)
+31 -13
View File
@@ -13,7 +13,7 @@ import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
from urllib.parse import urljoin, urlparse, urlunparse
from urllib.parse import urlparse, urlunparse
import requests
@@ -60,10 +60,13 @@ from src.llm.errors import call_litellm_with_param_recovery
from src.llm.backend_registry import (
AUTO_AGENT_BACKEND_ID,
CODEX_CLI_BACKEND_ID,
GENERATION_ONLY_BACKEND_IDS,
LOCAL_CLI_GENERATION_BACKEND_IDS,
LITELLM_BACKEND_ID,
normalize_backend_id,
)
from src.llm.generation_params import apply_litellm_generation_params
from src.llm.local_cli_backend import resolve_local_cli_preset
from src.notification_contracts import (
FEISHU_APP_BOT_ENV_GROUP,
FEISHU_WEBHOOK_ENV_GROUP,
@@ -540,9 +543,15 @@ class SystemConfigService:
for check in checks
if check["required"] and check["status"] == "needs_action"
]
smoke_blocking_missing = [
check["key"]
for check in checks
if check["key"] in {"llm_primary", "stock_list"}
and check["status"] == "needs_action"
]
return {
"is_complete": not required_missing,
"ready_for_smoke": not required_missing,
"ready_for_smoke": not smoke_blocking_missing,
"required_missing_keys": required_missing,
"next_step_key": required_missing[0] if required_missing else None,
"checks": checks,
@@ -3180,15 +3189,16 @@ class SystemConfigService:
effective_map.get("GENERATION_BACKEND"),
default=LITELLM_BACKEND_ID,
)
if generation_backend == CODEX_CLI_BACKEND_ID:
if shutil.which("codex"):
if generation_backend in LOCAL_CLI_GENERATION_BACKEND_IDS:
preset = resolve_local_cli_preset(generation_backend)
if shutil.which(preset.executable):
return self._setup_check(
"llm_primary",
"LLM 主渠道",
"ai_model",
True,
"configured",
"已启用 Codex CLI 本地生成 Backend(experimental/limited)。",
f"已启用 {preset.display_name} 本地生成 Backend(experimental/limited)。",
)
return self._setup_check(
"llm_primary",
@@ -3196,10 +3206,18 @@ class SystemConfigService:
"ai_model",
True,
"needs_action",
"已选择 codex_cli,但 DSA 后端进程当前 PATH 中找不到 codex 可执行文件。",
"请确认 Codex CLI 已安装到后端 PATH 可见目录;桌面端请完全退出并重开。"
"打开 Codex CLI 交互窗口不会改变已运行后端的 PATH;若找到后仍失败,再检查 Codex CLI 登录态,"
"或将 GENERATION_BACKEND 设回 litellm。",
(
"已选择 codex_cli,但 DSA 后端进程当前 PATH 中找不到 codex 可执行文件。"
if generation_backend == CODEX_CLI_BACKEND_ID
else f"已选择 {generation_backend},但未找到 {preset.executable} 可执行文件。"
),
(
"请确认 Codex CLI 已安装到后端 PATH 可见目录;桌面端请完全退出并重开。"
"打开 Codex CLI 交互窗口不会改变已运行后端的 PATH;若找到后仍失败,再检查 Codex CLI 登录态,"
"或将 GENERATION_BACKEND 设回 litellm。"
if generation_backend == CODEX_CLI_BACKEND_ID
else "请先安装并登录对应 CLI,或将 GENERATION_BACKEND 设回 litellm。"
),
)
model, source = self._resolve_setup_primary_model(effective_map)
@@ -3241,14 +3259,14 @@ class SystemConfigService:
effective_map.get("AGENT_GENERATION_BACKEND"),
default=AUTO_AGENT_BACKEND_ID,
)
if agent_backend == CODEX_CLI_BACKEND_ID:
if agent_backend in GENERATION_ONLY_BACKEND_IDS:
return self._setup_check(
"llm_agent",
"Agent 渠道",
"agent",
True,
"needs_action",
"Agent 工具调用暂不支持 codex_cli text-only backend。",
f"Agent 工具调用暂不支持 {agent_backend} text-only backend。",
"请将 AGENT_GENERATION_BACKEND 设为 auto 或 litellm,并配置 LiteLLM 工具调用渠道。",
)
@@ -3256,7 +3274,7 @@ class SystemConfigService:
hermes_routes = set(self._collect_hermes_channel_models_from_map(effective_map))
non_hermes_routes = set(self._collect_non_hermes_channel_models_from_map(effective_map))
if not agent_model_raw:
if generation_backend == CODEX_CLI_BACKEND_ID:
if generation_backend in LOCAL_CLI_GENERATION_BACKEND_IDS:
litellm_model, _source = self._resolve_setup_primary_model(effective_map)
if litellm_model:
if litellm_model in hermes_routes and litellm_model not in non_hermes_routes:
@@ -3295,7 +3313,7 @@ class SystemConfigService:
"agent",
True,
"needs_action",
"Agent 工具调用需要 LiteLLM 模型配置;codex_cli 主生成方式不会被自动继承。",
"Agent 工具调用需要 LiteLLM 模型配置;local CLI 主生成方式不会被自动继承。",
"如需使用 Ask-Stock Agent,请配置 LiteLLM 模型,或将 AGENT_GENERATION_BACKEND 固定为 litellm 后补齐模型配置。",
)
if primary_check["status"] == "configured":
+35
View File
@@ -189,6 +189,41 @@ def test_agent_system_prompts_require_phase_decision_contract() -> None:
class TestAgentExecutor(unittest.TestCase):
"""Test the ReAct loop logic."""
def test_unsupported_tool_calling_response_is_not_treated_as_agent_success(self):
executed_calls = []
registry = ToolRegistry()
registry.register(
ToolDefinition(
name="echo",
description="Echoes back the input",
parameters=[
ToolParameter(name="message", type="string", description="Message to echo"),
],
handler=lambda message: executed_calls.append(("echo", message)) or {"echo": message},
)
)
adapter = _make_mock_adapter()
adapter.call_with_tools.return_value = LLMResponse(
content="unsupported_tool_calling: local CLI generation backend does not support tools",
provider="error",
model="error",
tool_calls=[],
usage={},
)
result = run_agent_loop(
messages=[{"role": "user", "content": "请查行情"}],
tool_registry=registry,
llm_adapter=adapter,
max_steps=2,
)
self.assertFalse(result.success)
self.assertEqual(result.content, "")
self.assertIn("unsupported_tool_calling", result.error or "")
self.assertEqual(result.tool_calls_log, [])
self.assertEqual(executed_calls, [])
def test_chat_injects_compressed_history_before_report_context_and_current_user(self):
registry = _make_registry_with_echo()
adapter = _make_mock_adapter()
+18 -9
View File
@@ -4,12 +4,17 @@
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from tests.litellm_stub import ensure_litellm_stub
ensure_litellm_stub()
from src.agent.llm_adapter import LLMToolAdapter
from src.agent.litellm_route_resolution import resolve_agent_litellm_route
from src.llm.backend_registry import LOCAL_CLI_GENERATION_BACKEND_IDS
LOCAL_CLI_BACKENDS = sorted(LOCAL_CLI_GENERATION_BACKEND_IDS)
def _config(**overrides):
@@ -182,9 +187,12 @@ def test_agent_resolver_preserves_direct_model_without_preflight_credentials() -
]
def test_agent_auto_ignores_generation_backend_codex_cli_when_litellm_route_exists() -> None:
@pytest.mark.parametrize("generation_backend", LOCAL_CLI_BACKENDS)
def test_agent_auto_ignores_local_generation_backend_when_litellm_route_exists(
generation_backend: str,
) -> None:
config = _config(
generation_backend="codex_cli",
generation_backend=generation_backend,
agent_generation_backend="auto",
litellm_model="cohere/command-r-plus",
)
@@ -196,11 +204,12 @@ def test_agent_auto_ignores_generation_backend_codex_cli_when_litellm_route_exis
assert resolution.reason == ""
def test_agent_explicit_codex_cli_backend_remains_unsupported() -> None:
@pytest.mark.parametrize("agent_backend", LOCAL_CLI_BACKENDS)
def test_agent_explicit_local_cli_backend_remains_unsupported(agent_backend: str) -> None:
resolution = resolve_agent_litellm_route(
_config(
generation_backend="litellm",
agent_generation_backend="codex_cli",
agent_generation_backend=agent_backend,
litellm_model="cohere/command-r-plus",
)
)
@@ -209,10 +218,13 @@ def test_agent_explicit_codex_cli_backend_remains_unsupported() -> None:
assert resolution.reason == "unsupported_agent_backend"
def test_llm_tool_adapter_available_for_agent_auto_with_generation_codex_cli() -> None:
@pytest.mark.parametrize("generation_backend", LOCAL_CLI_BACKENDS)
def test_llm_tool_adapter_available_for_agent_auto_with_local_generation_backend(
generation_backend: str,
) -> None:
adapter = LLMToolAdapter(
_config(
generation_backend="codex_cli",
generation_backend=generation_backend,
agent_generation_backend="auto",
litellm_model="cohere/command-r-plus",
)
@@ -233,9 +245,6 @@ def test_llm_tool_adapter_unavailable_when_channel_deployments_filter_to_empty()
with patch(
"src.agent.litellm_route_resolution.get_effective_agent_models_to_try",
return_value=["openai/remote-primary"],
), patch(
"src.agent.llm_adapter.get_effective_agent_models_to_try",
return_value=["openai/remote-primary"],
):
adapter = LLMToolAdapter(config)
+15 -12
View File
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
from api.v1.endpoints import agent
from src.config import Config
from src.llm.backend_registry import GENERATION_ONLY_BACKEND_IDS
from src.services.agent_model_service import list_agent_model_deployments
@@ -51,19 +52,21 @@ class AgentModelsApiTestCase(unittest.TestCase):
self.assertTrue(deployments[0]["is_primary"])
self.assertFalse("api_key" in str(deployments))
def test_models_endpoint_does_not_expose_codex_cli_as_litellm_deployment(self) -> None:
config = _build_config(
agent_generation_backend="codex_cli",
llm_models_source="litellm_config",
llm_model_list=[
{
"model_name": "gemini-primary",
"litellm_params": {"model": "gemini/gemini-2.5-flash", "api_key": "secret-1"},
},
],
)
def test_models_endpoint_does_not_expose_local_cli_as_litellm_deployment(self) -> None:
for backend in sorted(GENERATION_ONLY_BACKEND_IDS):
with self.subTest(backend=backend):
config = _build_config(
agent_generation_backend=backend,
llm_models_source="litellm_config",
llm_model_list=[
{
"model_name": "gemini-primary",
"litellm_params": {"model": "gemini/gemini-2.5-flash", "api_key": "secret-1"},
},
],
)
self.assertEqual(list_agent_model_deployments(config), [])
self.assertEqual(list_agent_model_deployments(config), [])
def test_models_endpoint_returns_channel_deployments_with_api_base(self) -> None:
config = _build_config(
+7 -2
View File
@@ -193,9 +193,14 @@ class TestGenerationBackendFieldsRegistered(unittest.TestCase):
self.assertEqual(field["ui_control"], "select")
self.assertEqual(field["default_value"], "litellm")
if key == "GENERATION_BACKEND":
self.assertEqual(field["validation"], {"enum": ["litellm", "codex_cli"]})
self.assertEqual(
field["validation"],
{"enum": ["litellm", "codex_cli", "claude_code_cli", "opencode_cli"]},
)
self.assertIn({"label": "Default model settings", "value": "litellm"}, field["options"])
self.assertIn({"label": "Codex CLI (experimental)", "value": "codex_cli"}, field["options"])
self.assertIn({"label": "Claude Code CLI (experimental)", "value": "claude_code_cli"}, field["options"])
self.assertIn({"label": "OpenCode CLI (experimental)", "value": "opencode_cli"}, field["options"])
else:
self.assertEqual(field["validation"], {"enum": ["", "litellm"]})
self.assertIn({"label": "Disabled", "value": ""}, field["options"])
@@ -234,7 +239,7 @@ class TestGenerationBackendFieldsRegistered(unittest.TestCase):
def test_schema_response_groups_generation_backend_fields(self):
schema = build_schema_response()
self.assertEqual(schema["schema_version"], SCHEMA_VERSION)
self.assertEqual(SCHEMA_VERSION, "2026-06-23-local-cli-backend")
self.assertEqual(SCHEMA_VERSION, "2026-06-29-claude-code-cli-backend")
categories = {
category["category"]: {field["key"] for field in category["fields"]}
+56 -5
View File
@@ -12,6 +12,9 @@ import pytest
from unittest.mock import patch
from src.config import Config, ConfigIssue
from src.llm.backend_registry import LOCAL_CLI_GENERATION_BACKEND_IDS
LOCAL_CLI_BACKENDS = sorted(LOCAL_CLI_GENERATION_BACKEND_IDS)
# ---------------------------------------------------------------------------
@@ -192,9 +195,53 @@ class TestValidateStructuredLLM:
error = next(i for i in issues if i.field == "GENERATION_BACKEND")
assert error.severity == "error"
assert "litellm 或 codex_cli" in error.message
assert "claude_code_cli" in error.message
assert "codex_cli" in error.message
assert "codex" in error.message
def test_opencode_cli_generation_backend_accepts_default_opencode_model(self):
cfg = _make_config(
generation_backend="opencode_cli",
llm_model_list=[],
litellm_model="",
gemini_api_keys=[],
anthropic_api_keys=[],
openai_api_keys=[],
deepseek_api_keys=[],
)
issues = cfg.validate_structured()
assert not [i for i in issues if i.severity == "error"]
def test_opencode_cli_generation_backend_accepts_safe_model_without_litellm_keys(self):
cfg = _make_config(
generation_backend="opencode_cli",
opencode_cli_model="any-provider/model-name",
llm_model_list=[],
litellm_model="",
gemini_api_keys=[],
anthropic_api_keys=[],
openai_api_keys=[],
deepseek_api_keys=[],
)
issues = cfg.validate_structured()
assert not [i for i in issues if i.severity == "error"]
def test_opencode_cli_generation_backend_rejects_unsafe_model_token(self):
for model in ("deepseek/model;rm", "provider/$MODEL"):
cfg = _make_config(
generation_backend="opencode_cli",
opencode_cli_model=model,
)
issues = cfg.validate_structured()
error = next(i for i in issues if i.field == "OPENCODE_CLI_MODEL")
assert error.severity == "error"
def test_unknown_generation_fallback_backend_is_structured_config_error(self):
cfg = _make_config(generation_fallback_backend="claude_code")
@@ -216,9 +263,11 @@ class TestValidateStructuredLLM:
assert "不支持 Agent 工具调用" in error.message
assert "hermes" in error.message
def test_codex_cli_without_litellm_keys_is_not_llm_config_error(self):
@pytest.mark.parametrize("generation_backend", LOCAL_CLI_BACKENDS)
def test_local_cli_without_litellm_keys_is_not_llm_config_error(self, generation_backend):
cfg = _make_config(
generation_backend="codex_cli",
generation_backend=generation_backend,
opencode_cli_model="provider/model" if generation_backend == "opencode_cli" else "",
litellm_model="",
llm_model_list=[],
gemini_api_keys=[],
@@ -231,14 +280,16 @@ class TestValidateStructuredLLM:
assert not any(i.field == "LITELLM_CONFIG" and i.severity == "error" for i in issues)
def test_litellm_model_cannot_pretend_to_be_codex_cli_provider(self):
cfg = _make_config(litellm_model="codex_cli/gpt-5")
@pytest.mark.parametrize("local_backend", LOCAL_CLI_BACKENDS)
def test_litellm_model_cannot_pretend_to_be_local_cli_provider(self, local_backend):
cfg = _make_config(litellm_model=f"{local_backend}/gpt-5")
issues = cfg.validate_structured()
error = next(i for i in issues if i.field == "LITELLM_MODEL")
assert error.severity == "error"
assert "不是 LiteLLM provider" in error.message
assert local_backend in error.message
def test_no_llm_is_error(self):
"""Empty llm_model_list must produce an error regardless of legacy keys."""
+65 -14
View File
@@ -11,7 +11,10 @@ from tests.litellm_stub import ensure_litellm_stub
ensure_litellm_stub()
from src.llm.backend_registry import ( # noqa: E402
AGENT_CAPABLE_BACKEND_IDS,
GENERATION_ONLY_BACKEND_IDS,
LITELLM_BACKEND_ID,
LOCAL_CLI_GENERATION_BACKEND_IDS,
resolve_agent_generation_backend_id,
resolve_generation_backend_id,
resolve_generation_fallback_backend_id,
@@ -177,16 +180,21 @@ def test_litellm_backend_derives_provider_from_model_when_usage_is_empty() -> No
assert result.usage == {}
def test_generation_backend_factory_dispatches_litellm_and_codex_cli() -> None:
def test_generation_backend_factory_dispatches_litellm_and_local_cli_backends() -> None:
litellm_backend = create_generation_backend(
"litellm",
config=_config(),
litellm_completion_callable=lambda _prompt, _cfg, **_kwargs: ("ok", "openai/gpt", {}),
)
codex_backend = create_generation_backend("codex_cli", config=_config(generation_backend="codex_cli"))
assert isinstance(litellm_backend, LiteLLMGenerationBackend)
assert isinstance(codex_backend, LocalCliGenerationBackend)
for backend_id in sorted(LOCAL_CLI_GENERATION_BACKEND_IDS):
local_backend = create_generation_backend(
backend_id,
config=_config(generation_backend=backend_id),
)
assert isinstance(local_backend, LocalCliGenerationBackend)
assert local_backend.preset_id == backend_id
def test_resolvers_default_to_litellm_and_self_fallback_is_noop() -> None:
@@ -227,10 +235,11 @@ def test_explicit_litellm_resolves_for_analysis_and_agent() -> None:
assert resolve_agent_generation_backend_id(config) == "litellm"
def test_agent_auto_does_not_inherit_codex_cli_generation_backend() -> None:
config = _config(generation_backend="codex_cli", agent_generation_backend="auto")
@pytest.mark.parametrize("generation_backend", sorted(LOCAL_CLI_GENERATION_BACKEND_IDS))
def test_agent_auto_does_not_inherit_local_generation_backend(generation_backend: str) -> None:
config = _config(generation_backend=generation_backend, agent_generation_backend="auto")
assert resolve_generation_backend_id(config) == "codex_cli"
assert resolve_generation_backend_id(config) == generation_backend
assert resolve_agent_generation_backend_id(config) == "litellm"
@@ -246,7 +255,12 @@ def test_unknown_generation_backend_raises_structured_config_error() -> None:
assert error.backend == "codex"
assert error.details["field"] == "GENERATION_BACKEND"
assert error.details["requested_backend"] == "codex"
assert error.details["supported_backends"] == ["codex_cli", "litellm"]
assert error.details["supported_backends"] == [
"claude_code_cli",
"codex_cli",
"litellm",
"opencode_cli",
]
def test_codex_cli_generation_backend_can_fallback_to_litellm() -> None:
@@ -256,6 +270,20 @@ def test_codex_cli_generation_backend_can_fallback_to_litellm() -> None:
assert resolve_generation_fallback_backend_id(config) == "litellm"
def test_claude_code_cli_is_supported_generation_backend() -> None:
config = _config(generation_backend="claude_code_cli", generation_fallback_backend="litellm")
assert resolve_generation_backend_id(config) == "claude_code_cli"
assert resolve_generation_fallback_backend_id(config) == "litellm"
def test_opencode_cli_is_supported_generation_backend() -> None:
config = _config(generation_backend="opencode_cli", generation_fallback_backend="litellm")
assert resolve_generation_backend_id(config) == "opencode_cli"
assert resolve_generation_fallback_backend_id(config) == "litellm"
def test_empty_generation_fallback_disables_backend_fallback() -> None:
config = _config(generation_backend="codex_cli", generation_fallback_backend="")
@@ -282,27 +310,50 @@ def test_unknown_agent_backend_raises_structured_config_error() -> None:
assert error.error_code is GenerationErrorCode.BACKEND_NOT_CONFIGURED
assert error.details["field"] == "AGENT_GENERATION_BACKEND"
assert error.details["requested_backend"] == "opencode"
assert error.details["supported_backends"] == ["auto", "codex_cli", "litellm"]
assert error.details["supported_backends"] == [
"auto",
"claude_code_cli",
"codex_cli",
"litellm",
"opencode_cli",
]
def test_llm_tool_adapter_unknown_agent_backend_is_not_silent_litellm_fallback() -> None:
def test_generation_only_backends_are_not_agent_capable() -> None:
assert GENERATION_ONLY_BACKEND_IDS.isdisjoint(AGENT_CAPABLE_BACKEND_IDS)
def test_explicit_local_agent_backends_resolve_to_unsupported_ids() -> None:
for backend_id in sorted(GENERATION_ONLY_BACKEND_IDS):
assert resolve_agent_generation_backend_id(
_config(agent_generation_backend=backend_id)
) == backend_id
@pytest.mark.parametrize("agent_backend", sorted(GENERATION_ONLY_BACKEND_IDS))
def test_llm_tool_adapter_local_agent_backend_is_not_silent_litellm_fallback(
agent_backend: str,
) -> None:
from src.agent.llm_adapter import LLMToolAdapter
with patch("src.agent.llm_adapter.litellm.register_model", create=True):
adapter = LLMToolAdapter(_config(agent_generation_backend="codex_cli"))
adapter = LLMToolAdapter(_config(agent_generation_backend=agent_backend))
assert adapter.is_available is False
response = adapter.call_completion([])
assert response.provider == "error"
assert "unsupported_tool_calling" in (response.content or "")
assert "codex_cli" in (response.content or "")
assert agent_backend in (response.content or "")
def test_agent_auto_with_codex_cli_returns_unsupported_when_litellm_agent_backend_missing() -> None:
@pytest.mark.parametrize("generation_backend", sorted(LOCAL_CLI_GENERATION_BACKEND_IDS))
def test_agent_auto_with_local_generation_backend_returns_unsupported_when_litellm_missing(
generation_backend: str,
) -> None:
from src.agent.llm_adapter import LLMToolAdapter
config = _config(
generation_backend="codex_cli",
generation_backend=generation_backend,
agent_generation_backend="auto",
litellm_model="",
agent_litellm_model="",
@@ -317,4 +368,4 @@ def test_agent_auto_with_codex_cli_returns_unsupported_when_litellm_agent_backen
response = adapter.call_completion([], tools=[{"type": "function"}])
assert response.provider == "error"
assert "unsupported_tool_calling" in (response.content or "")
assert "codex_cli" in (response.content or "")
assert generation_backend in (response.content or "")
+16 -13
View File
@@ -19,6 +19,7 @@ from src.config import (
get_fixed_litellm_temperature,
normalize_litellm_temperature,
)
from src.llm.backend_registry import GENERATION_ONLY_BACKEND_IDS
from src.llm.hermes import open_hermes_no_proxy_client, parse_hermes_channel, route_has_hermes
from src.llm.generation_params import (
apply_litellm_generation_params,
@@ -402,25 +403,27 @@ class LLMChannelConfigTestCase(unittest.TestCase):
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_agent_generation_backend_codex_cli_is_unavailable_even_with_safe_route(
def test_agent_generation_backend_local_cli_is_unavailable_even_with_safe_route(
self,
_mock_parse_yaml,
_mock_setup_env,
) -> None:
env = {
"AGENT_MODE": "true",
"AGENT_GENERATION_BACKEND": "codex_cli",
"LLM_CHANNELS": "remote",
"LLM_REMOTE_PROTOCOL": "openai",
"LLM_REMOTE_BASE_URL": "https://api.example.com/v1",
"LLM_REMOTE_API_KEY": "sk-remote-test-value",
"LLM_REMOTE_MODELS": "gpt-4o-mini",
}
for backend in sorted(GENERATION_ONLY_BACKEND_IDS):
with self.subTest(backend=backend):
env = {
"AGENT_MODE": "true",
"AGENT_GENERATION_BACKEND": backend,
"LLM_CHANNELS": "remote",
"LLM_REMOTE_PROTOCOL": "openai",
"LLM_REMOTE_BASE_URL": "https://api.example.com/v1",
"LLM_REMOTE_API_KEY": "sk-remote-test-value",
"LLM_REMOTE_MODELS": "gpt-4o-mini",
}
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
with patch.dict(os.environ, env, clear=True):
config = Config._load_from_env()
self.assertFalse(config.is_agent_available())
self.assertFalse(config.is_agent_available())
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
+652 -5
View File
@@ -22,8 +22,12 @@ from src.analyzer import GeminiAnalyzer # noqa: E402
from src.llm import local_cli_backend as local_cli_backend_module # noqa: E402
from src.llm.generation_backend import GenerationError, GenerationErrorCode # noqa: E402
from src.llm.local_cli_backend import ( # noqa: E402
CLAUDE_CODE_CLI_PRESET,
LocalCliGenerationBackend,
LocalCliExecutionResult,
LocalCliExtractionError,
LocalCliPreset,
OPENCODE_CLI_PRESET,
build_local_cli_env,
effective_local_cli_concurrency,
redact_diagnostic_text,
@@ -124,6 +128,591 @@ print({final_payload!r})
assert "last_message" not in result.diagnostics["stdout_preview"]
def test_claude_preset_runtime_argv_contains_contract_args(tmp_path: Path) -> None:
argv_path = tmp_path / "argv.json"
script = _script(
tmp_path,
f"""
import json, pathlib, sys
path = pathlib.Path({str(argv_path)!r})
path.write_text(json.dumps(sys.argv[1:]), encoding="utf-8")
print(json.dumps({{"type": "result", "subtype": "success", "result": "{{\\"sentiment_score\\": 77}}"}}))
""",
)
preset = LocalCliPreset(
preset_id="claude_code_cli",
executable=sys.executable,
argv=(script, *CLAUDE_CODE_CLI_PRESET.argv),
display_name="Mock Claude Code CLI",
extractor=CLAUDE_CODE_CLI_PRESET.extractor,
contract_args=CLAUDE_CODE_CLI_PRESET.contract_args,
)
backend = LocalCliGenerationBackend(
_config(generation_backend="claude_code_cli"),
preset=preset,
)
result = backend.generate("prompt", {}, response_validator=lambda text: json.loads(text))
runtime_argv = json.loads(argv_path.read_text(encoding="utf-8"))
assert json.loads(result.text)["sentiment_score"] == 77
for contract_arg in CLAUDE_CODE_CLI_PRESET.contract_args:
assert contract_arg in runtime_argv
def test_missing_contract_arg_is_capability_unsupported(tmp_path: Path) -> None:
preset = LocalCliPreset(
preset_id="claude_code_cli",
executable=sys.executable,
argv=(_script(tmp_path, "print('unused')"), "--safe-mode"),
display_name="Mock Claude Code CLI",
contract_args=("--safe-mode", "--strict-mcp-config"),
)
backend = LocalCliGenerationBackend(
_config(generation_backend="claude_code_cli"),
preset=preset,
)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED
assert exc_info.value.details["reason"] == "missing_runtime_contract_arg"
assert "--strict-mcp-config" in exc_info.value.details["missing_contract_args"]
def test_contract_args_must_keep_preset_order(tmp_path: Path) -> None:
preset = LocalCliPreset(
preset_id="claude_code_cli",
executable=sys.executable,
argv=(
_script(tmp_path, "print('unused')"),
"--strict-mcp-config",
"--safe-mode",
),
display_name="Mock Claude Code CLI",
contract_args=("--safe-mode", "--strict-mcp-config"),
)
backend = LocalCliGenerationBackend(
_config(generation_backend="claude_code_cli"),
preset=preset,
)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED
assert exc_info.value.details["reason"] == "missing_runtime_contract_arg"
assert "--strict-mcp-config" in exc_info.value.details["missing_contract_args"]
def test_claude_extractor_uses_structured_output_in_schema_mode() -> None:
preset = LocalCliPreset(
preset_id="claude_code_cli",
executable="claude",
argv=(),
display_name="Mock Claude Code CLI",
extractor=lambda result: local_cli_backend_module._extract_claude_code_json(
result,
schema_mode=True,
),
)
text = preset.extractor(
LocalCliExecutionResult(
stdout=json.dumps({
"type": "result",
"subtype": "success",
"structured_output": {"sentiment_score": "70"},
}),
stderr="",
returncode=0,
)
)
assert text == '{"sentiment_score":"70"}'
def test_claude_extractor_rejects_tool_event() -> None:
with pytest.raises(LocalCliExtractionError) as exc_info:
local_cli_backend_module._extract_claude_code_json(
LocalCliExecutionResult(
stdout=json.dumps({"type": "tool_use", "result": "should not parse"}),
stderr="",
returncode=0,
),
schema_mode=False,
)
assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED
assert exc_info.value.reason == "unexpected_cli_event"
def test_claude_extractor_requires_result_success_envelope() -> None:
with pytest.raises(LocalCliExtractionError) as missing_type:
local_cli_backend_module._extract_claude_code_json(
LocalCliExecutionResult(
stdout=json.dumps({"subtype": "success", "result": "should not parse"}),
stderr="",
returncode=0,
),
schema_mode=False,
)
with pytest.raises(LocalCliExtractionError) as missing_subtype:
local_cli_backend_module._extract_claude_code_json(
LocalCliExecutionResult(
stdout=json.dumps({"type": "result", "result": "should not parse"}),
stderr="",
returncode=0,
),
schema_mode=False,
)
assert missing_type.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED
assert missing_type.value.reason == "unexpected_cli_event"
assert missing_subtype.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR
assert missing_subtype.value.reason == "cli_result_not_success"
def test_claude_schema_retry_exhaustion_maps_schema_validation_failed() -> None:
with pytest.raises(LocalCliExtractionError) as exc_info:
local_cli_backend_module._extract_claude_code_json(
LocalCliExecutionResult(
stdout=json.dumps({
"type": "result",
"subtype": "error_max_structured_output_retries",
"is_error": True,
}),
stderr="",
returncode=0,
),
schema_mode=True,
)
assert exc_info.value.error_code is GenerationErrorCode.SCHEMA_VALIDATION_FAILED
def test_opencode_preset_uses_prompt_file_and_safe_argv(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-should-not-leak")
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-should-not-leak")
monkeypatch.setenv("OPENCODE_CONFIG_CONTENT", '{"plugin":["leak"]}')
argv_path = tmp_path / "argv.json"
probe_path = tmp_path / "probe.json"
script = _script(
tmp_path,
f"""
import json, os, pathlib, stat, sys
argv = sys.argv[1:]
prompt_path = pathlib.Path(argv[argv.index("--file") + 1])
config_path = pathlib.Path.cwd() / "opencode.json"
probe = {{
"argv": argv,
"prompt": prompt_path.read_text(encoding="utf-8"),
"prompt_mode": stat.S_IMODE(prompt_path.stat().st_mode),
"cwd_mode": stat.S_IMODE(pathlib.Path.cwd().stat().st_mode),
"config": config_path.read_text(encoding="utf-8"),
"env": {{
"DEEPSEEK_API_KEY": os.environ.get("DEEPSEEK_API_KEY"),
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY"),
"OPENCODE_CONFIG_CONTENT": os.environ.get("OPENCODE_CONFIG_CONTENT"),
"OPENCODE_CONFIG_DIR": os.environ.get("OPENCODE_CONFIG_DIR"),
}},
}}
pathlib.Path({str(argv_path)!r}).write_text(json.dumps(argv), encoding="utf-8")
pathlib.Path({str(probe_path)!r}).write_text(json.dumps(probe), encoding="utf-8")
print(json.dumps({{"type": "step_start"}}))
print(json.dumps({{"type": "text", "part": {{"text": "{{\\"sentiment_score\\": 66}}"}}}}))
print(json.dumps({{"type": "step_finish", "reason": "stop"}}))
""",
)
preset = LocalCliPreset(
preset_id="opencode_cli",
executable=sys.executable,
argv=(script, *OPENCODE_CLI_PRESET.argv),
display_name="Mock OpenCode CLI",
extractor=OPENCODE_CLI_PRESET.extractor,
contract_args=OPENCODE_CLI_PRESET.contract_args,
prompt_transport=OPENCODE_CLI_PRESET.prompt_transport,
)
backend = LocalCliGenerationBackend(
_config(generation_backend="opencode_cli"),
preset=preset,
)
result = backend.generate("prompt from dsa", {}, response_validator=lambda text: json.loads(text))
payload = json.loads(result.text)
argv = json.loads(argv_path.read_text(encoding="utf-8"))
probe = json.loads(probe_path.read_text(encoding="utf-8"))
opencode_config = json.loads(probe["config"])
assert payload["sentiment_score"] == 66
assert argv[:4] == ["--pure", "run", "--format", "json"]
assert "--model" not in argv
assert "--file" in argv
assert argv.index("--file") > argv.index("json")
assert "--attach" not in argv
assert "--dangerously-skip-permissions" not in argv
assert probe["prompt"] == "prompt from dsa"
assert probe["prompt_mode"] == 0o600
assert probe["cwd_mode"] == 0o700
for tool_name in local_cli_backend_module._OPENCODE_DISABLED_TOOL_NAMES:
assert opencode_config["tools"][tool_name] is False
assert opencode_config["tools"]["websearch"] is False
assert opencode_config["tools"]["question"] is False
assert opencode_config["tools"]["skill"] is False
assert opencode_config["tools"]["todowrite"] is False
assert opencode_config["tools"]["lsp"] is False
assert "sk-should-not-leak" not in probe["config"]
assert "sk-openai-should-not-leak" not in probe["config"]
assert probe["env"]["DEEPSEEK_API_KEY"] is None
assert probe["env"]["OPENAI_API_KEY"] is None
assert probe["env"]["OPENCODE_CONFIG_CONTENT"] is None
assert probe["env"]["OPENCODE_CONFIG_DIR"] is None
assert result.diagnostics["opencode_project_config_written"] is True
assert "opencode_config_controlled" not in result.diagnostics
assert result.backend == "opencode_cli"
assert result.provider == "opencode_cli"
assert result.model == "opencode_cli"
assert result.usage["backend"] == "opencode_cli"
def test_opencode_model_override_inserts_model_arg(tmp_path: Path) -> None:
argv_path = tmp_path / "argv.json"
script = _script(
tmp_path,
f"""
import json, pathlib, sys
pathlib.Path({str(argv_path)!r}).write_text(json.dumps(sys.argv[1:]), encoding="utf-8")
print(json.dumps({{"type": "step_start"}}))
print(json.dumps({{"type": "text", "part": {{"text": "{{\\"sentiment_score\\": 67}}"}}}}))
print(json.dumps({{"type": "step_finish", "reason": "stop"}}))
""",
)
preset = LocalCliPreset(
preset_id="opencode_cli",
executable=sys.executable,
argv=(script, *OPENCODE_CLI_PRESET.argv),
display_name="Mock OpenCode CLI",
extractor=OPENCODE_CLI_PRESET.extractor,
contract_args=OPENCODE_CLI_PRESET.contract_args,
prompt_transport=OPENCODE_CLI_PRESET.prompt_transport,
)
backend = LocalCliGenerationBackend(
_config(generation_backend="opencode_cli", opencode_cli_model="provider/model"),
preset=preset,
)
result = backend.generate("prompt", {}, response_validator=lambda text: json.loads(text))
argv = json.loads(argv_path.read_text(encoding="utf-8"))
assert json.loads(result.text)["sentiment_score"] == 67
assert argv[:6] == ["--pure", "run", "--format", "json", "--model", "provider/model"]
assert argv.index("--file") > argv.index("provider/model")
def test_opencode_nonzero_json_event_error_maps_unknown_backend_error(tmp_path: Path) -> None:
script = _script(
tmp_path,
"""
import json
print(json.dumps({"type": "error", "error": {"name": "UnknownError"}}))
raise SystemExit(1)
""",
)
preset = LocalCliPreset(
preset_id="opencode_cli",
executable=sys.executable,
argv=(script, *OPENCODE_CLI_PRESET.argv),
display_name="Mock OpenCode CLI",
extractor=OPENCODE_CLI_PRESET.extractor,
contract_args=OPENCODE_CLI_PRESET.contract_args,
prompt_transport=OPENCODE_CLI_PRESET.prompt_transport,
)
backend = LocalCliGenerationBackend(
_config(generation_backend="opencode_cli", opencode_cli_model="provider/model"),
preset=preset,
)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR
assert exc_info.value.details["reason"] == "cli_result_error"
def test_opencode_nonzero_pretty_json_error_maps_unknown_backend_error(tmp_path: Path) -> None:
script = _script(
tmp_path,
"""
import json
print(json.dumps({"type": "error", "error": {"name": "UnknownError"}}, indent=2))
raise SystemExit(1)
""",
)
preset = LocalCliPreset(
preset_id="opencode_cli",
executable=sys.executable,
argv=(script, *OPENCODE_CLI_PRESET.argv),
display_name="Mock OpenCode CLI",
extractor=OPENCODE_CLI_PRESET.extractor,
contract_args=OPENCODE_CLI_PRESET.contract_args,
prompt_transport=OPENCODE_CLI_PRESET.prompt_transport,
)
backend = LocalCliGenerationBackend(
_config(generation_backend="opencode_cli", opencode_cli_model="provider/model"),
preset=preset,
)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR
assert exc_info.value.details["reason"] == "cli_result_error"
@pytest.mark.parametrize(
("event", "stream_name"),
[
({"type": "tool_use", "name": "read"}, "stdout"),
({"type": "websearch", "query": "AAPL"}, "stdout"),
({"type": "tool_result", "part": {"tool_name": "todowrite"}}, "stdout"),
({"type": "lsp", "name": "diagnostics"}, "stdout"),
({"type": "question", "text": "Continue?"}, "stdout"),
({"type": "permission", "name": "read"}, "stdout"),
({"type": "step_finish", "is_error": True}, "stdout"),
({"type": "step_finish", "error": {"name": "StepFailed"}}, "stdout"),
({"type": "error", "error": {"name": "StderrError"}}, "stderr"),
],
)
def test_opencode_nonzero_blocked_or_error_event_maps_unknown_backend_error(
tmp_path: Path,
event: dict,
stream_name: str,
) -> None:
target_stream = "sys.stderr" if stream_name == "stderr" else "sys.stdout"
script = _script(
tmp_path,
f"""
import json, sys
print(json.dumps({event!r}), file={target_stream})
raise SystemExit(1)
""",
)
preset = LocalCliPreset(
preset_id="opencode_cli",
executable=sys.executable,
argv=(script, *OPENCODE_CLI_PRESET.argv),
display_name="Mock OpenCode CLI",
extractor=OPENCODE_CLI_PRESET.extractor,
contract_args=OPENCODE_CLI_PRESET.contract_args,
prompt_transport=OPENCODE_CLI_PRESET.prompt_transport,
)
backend = LocalCliGenerationBackend(
_config(generation_backend="opencode_cli", opencode_cli_model="provider/model"),
preset=preset,
)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR
assert exc_info.value.details["reason"] == "cli_result_error"
def test_opencode_runtime_rejects_unsafe_model_override(tmp_path: Path) -> None:
script = _script(
tmp_path,
"""
print("should not execute")
""",
)
preset = LocalCliPreset(
preset_id="opencode_cli",
executable=sys.executable,
argv=(script, *OPENCODE_CLI_PRESET.argv),
display_name="Mock OpenCode CLI",
extractor=OPENCODE_CLI_PRESET.extractor,
contract_args=OPENCODE_CLI_PRESET.contract_args,
prompt_transport=OPENCODE_CLI_PRESET.prompt_transport,
)
backend = LocalCliGenerationBackend(
_config(generation_backend="opencode_cli", opencode_cli_model="provider/$MODEL"),
preset=preset,
)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.UNSAFE_CONFIG
assert exc_info.value.details["reason"] == "unsafe_opencode_cli_model"
def test_opencode_extractor_rejects_tool_event() -> None:
with pytest.raises(LocalCliExtractionError) as exc_info:
local_cli_backend_module._extract_opencode_json_events(
LocalCliExecutionResult(
stdout="\n".join([
json.dumps({"type": "step_start"}),
json.dumps({"type": "tool_use", "name": "read"}),
json.dumps({"type": "step_finish", "reason": "stop"}),
]),
stderr="",
returncode=0,
)
)
assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED
@pytest.mark.parametrize(
"event",
[
{"type": "websearch", "query": "AAPL"},
{"type": "question", "text": "Continue?"},
{"type": "skill", "name": "default"},
{"type": "todowrite", "items": []},
{"type": "lsp", "name": "diagnostics"},
{"type": "tool_use", "name": "websearch"},
{"type": "tool_result", "part": {"tool_name": "todowrite"}},
],
)
def test_opencode_extractor_rejects_default_tool_events(event: dict) -> None:
with pytest.raises(LocalCliExtractionError) as exc_info:
local_cli_backend_module._extract_opencode_json_events(
LocalCliExecutionResult(
stdout="\n".join([
json.dumps({"type": "step_start"}),
json.dumps(event),
json.dumps({"type": "step_finish", "reason": "stop"}),
]),
stderr="",
returncode=0,
)
)
assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED
def test_opencode_event_iterator_accepts_pretty_single_event_object() -> None:
events = list(local_cli_backend_module._iter_opencode_events(
json.dumps({"type": "step_start"}, indent=2)
))
assert events == [{"type": "step_start"}]
def test_opencode_extractor_accepts_json_array_event_stream() -> None:
result = local_cli_backend_module._extract_opencode_json_events(
LocalCliExecutionResult(
stdout=json.dumps([
{"type": "step_start"},
{"type": "text", "text": '{"sentiment_score":'},
{"type": "text", "part": {"text": " 68}"}},
{"type": "step_finish", "reason": "stop"},
], indent=2),
stderr="",
returncode=0,
)
)
assert json.loads(result)["sentiment_score"] == 68
def test_opencode_extractor_accepts_concatenated_event_stream() -> None:
stdout = "".join([
json.dumps({"type": "step_start"}),
json.dumps({"type": "text", "text": '{"sentiment_score":'}),
json.dumps({"type": "text", "part": {"text": " 69}"}}),
json.dumps({"type": "step_finish", "reason": "end_turn"}),
])
result = local_cli_backend_module._extract_opencode_json_events(
LocalCliExecutionResult(stdout=stdout, stderr="", returncode=0)
)
assert json.loads(result)["sentiment_score"] == 69
def test_opencode_extractor_rejects_trailing_non_json_garbage() -> None:
stdout = json.dumps({"type": "step_start"}) + " trailing"
with pytest.raises(LocalCliExtractionError) as exc_info:
local_cli_backend_module._extract_opencode_json_events(
LocalCliExecutionResult(stdout=stdout, stderr="", returncode=0)
)
assert exc_info.value.error_code is GenerationErrorCode.INVALID_JSON
def test_opencode_extractor_rejects_non_event_json_shape() -> None:
with pytest.raises(LocalCliExtractionError) as exc_info:
local_cli_backend_module._extract_opencode_json_events(
LocalCliExecutionResult(stdout=json.dumps({"message": "not an event"}), stderr="", returncode=0)
)
assert exc_info.value.error_code is GenerationErrorCode.SCHEMA_VALIDATION_FAILED
def test_opencode_extractor_rejects_array_without_step_finish() -> None:
with pytest.raises(LocalCliExtractionError) as exc_info:
local_cli_backend_module._extract_opencode_json_events(
LocalCliExecutionResult(
stdout=json.dumps([
{"type": "step_start"},
{"type": "text", "text": "hello"},
]),
stderr="",
returncode=0,
)
)
assert exc_info.value.error_code is GenerationErrorCode.SCHEMA_VALIDATION_FAILED
assert exc_info.value.reason == "missing_step_finish"
def test_opencode_extractor_rejects_later_error_after_step_finish() -> None:
with pytest.raises(LocalCliExtractionError) as exc_info:
local_cli_backend_module._extract_opencode_json_events(
LocalCliExecutionResult(
stdout=json.dumps([
{"type": "step_start"},
{"type": "text", "text": "hello"},
{"type": "step_finish", "reason": "stop"},
{"type": "error", "error": {"name": "LaterError"}},
]),
stderr="",
returncode=0,
)
)
assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED
def test_opencode_extractor_requires_step_finish_and_text() -> None:
with pytest.raises(LocalCliExtractionError) as missing_finish:
local_cli_backend_module._extract_opencode_json_events(
LocalCliExecutionResult(
stdout=json.dumps({"type": "text", "text": "hello"}),
stderr="",
returncode=0,
)
)
with pytest.raises(LocalCliExtractionError) as empty_text:
local_cli_backend_module._extract_opencode_json_events(
LocalCliExecutionResult(
stdout="\n".join([
json.dumps({"type": "step_start"}),
json.dumps({"type": "step_finish", "reason": "stop"}),
]),
stderr="",
returncode=0,
)
)
assert missing_finish.value.error_code is GenerationErrorCode.SCHEMA_VALIDATION_FAILED
assert empty_text.value.error_code is GenerationErrorCode.EMPTY_OUTPUT
def test_output_last_message_stdout_duplicate_is_not_double_counted(tmp_path: Path) -> None:
final_payload = json.dumps(
{
@@ -547,13 +1136,55 @@ raise SystemExit(2)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.NON_ZERO_EXIT
assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED
assert exc_info.value.fallbackable is True
assert exc_info.value.details["reason"] == "cli_contract_unsupported"
assert exc_info.value.details["returncode"] == 2
assert "--output-last-message" in exc_info.value.details["stderr_preview"]
def test_claude_unknown_contract_arg_is_capability_unsupported_without_retry(tmp_path: Path) -> None:
argv_path = tmp_path / "argv.json"
count_path = tmp_path / "count.txt"
script = _script(
tmp_path,
f"""
import json, pathlib, sys
argv_path = pathlib.Path({str(argv_path)!r})
count_path = pathlib.Path({str(count_path)!r})
count = int(count_path.read_text(encoding="utf-8")) if count_path.exists() else 0
count_path.write_text(str(count + 1), encoding="utf-8")
argv = sys.argv[1:]
argv_path.write_text(json.dumps(argv), encoding="utf-8")
if "--strict-mcp-config" in argv:
print("error: unknown option '--strict-mcp-config'", file=sys.stderr)
raise SystemExit(2)
print(json.dumps({{"type": "result", "subtype": "success", "result": "{{\\"sentiment_score\\": 90}}"}}))
""",
)
preset = LocalCliPreset(
preset_id="claude_code_cli",
executable=sys.executable,
argv=(script, *CLAUDE_CODE_CLI_PRESET.argv),
display_name="Mock Claude Code CLI",
extractor=CLAUDE_CODE_CLI_PRESET.extractor,
contract_args=CLAUDE_CODE_CLI_PRESET.contract_args,
)
backend = LocalCliGenerationBackend(
_config(generation_backend="claude_code_cli"),
preset=preset,
)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
runtime_argv = json.loads(argv_path.read_text(encoding="utf-8"))
assert exc_info.value.error_code is GenerationErrorCode.CAPABILITY_UNSUPPORTED
assert exc_info.value.details["reason"] == "cli_contract_unsupported"
assert "--strict-mcp-config" in runtime_argv
assert count_path.read_text(encoding="utf-8") == "1"
def test_non_zero_exit_mentions_preset_arg_without_unknown_marker_stays_generic(tmp_path: Path) -> None:
preset = LocalCliPreset(
"codex_cli",
@@ -680,7 +1311,14 @@ time.sleep(30)
def test_env_allowlist_and_denylist(monkeypatch) -> None:
monkeypatch.setenv("PATH", "/bin")
monkeypatch.setenv("HOME", "/tmp/home")
monkeypatch.setenv("CODEX_HOME", "/tmp/codex-home")
monkeypatch.setenv("LC_MESSAGES", "C")
monkeypatch.setenv("UNRELATED_VALUE", "leak")
monkeypatch.setenv("CODEX_CLI_TOKEN", "codex-secret")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-secret")
monkeypatch.setenv("ANTHROPIC_MODEL", "claude")
monkeypatch.setenv("CLAUDE_CONFIG_DIR", "/tmp/claude")
monkeypatch.setenv("OPENCODE_CONFIG_CONTENT", "{}")
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret")
monkeypatch.setenv("WEBHOOK_TOKEN", "token")
monkeypatch.setenv("AUTHORIZATION", "Bearer token")
@@ -689,7 +1327,14 @@ def test_env_allowlist_and_denylist(monkeypatch) -> None:
assert child_env["PATH"] == "/bin"
assert child_env["HOME"] == "/tmp/home"
assert child_env["CODEX_HOME"] == "/tmp/codex-home"
assert child_env["LC_MESSAGES"] == "C"
assert "UNRELATED_VALUE" not in child_env
assert "CODEX_CLI_TOKEN" not in child_env
assert "ANTHROPIC_API_KEY" not in child_env
assert "ANTHROPIC_MODEL" not in child_env
assert "CLAUDE_CONFIG_DIR" not in child_env
assert "OPENCODE_CONFIG_CONTENT" not in child_env
assert "OPENAI_API_KEY" not in child_env
assert "WEBHOOK_TOKEN" not in child_env
assert "AUTHORIZATION" not in child_env
@@ -720,12 +1365,12 @@ def test_env_allowlist_preserves_windows_runtime_context() -> None:
"PATHEXT",
"ComSpec",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"HOMEDRIVE",
"HOMEPATH",
):
assert child_env[key] == source[key]
assert "APPDATA" not in child_env
assert "LOCALAPPDATA" not in child_env
assert "OPENAI_API_KEY" not in child_env
assert "UNRELATED_VALUE" not in child_env
@@ -737,13 +1382,13 @@ def test_generate_passes_allowlisted_windows_context_to_child_env(monkeypatch, t
"PATHEXT": ".COM;.EXE;.BAT;.CMD",
"ComSpec": r"C:\Windows\System32\cmd.exe",
"USERPROFILE": r"C:\Users\tester",
"APPDATA": r"C:\Users\tester\AppData\Roaming",
"LOCALAPPDATA": r"C:\Users\tester\AppData\Local",
"HOMEDRIVE": "C:",
"HOMEPATH": r"\Users\tester",
}
for key, value in windows_context.items():
monkeypatch.setenv(key, value)
monkeypatch.setenv("APPDATA", r"C:\Users\tester\AppData\Roaming")
monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\tester\AppData\Local")
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret")
monkeypatch.setenv("UNRELATED_VALUE", "leak")
@@ -773,6 +1418,8 @@ print(json.dumps({key: os.environ.get(key) for key in keys}, ensure_ascii=False)
for key, value in windows_context.items():
assert payload[key] == value
assert payload["APPDATA"] is None
assert payload["LOCALAPPDATA"] is None
assert payload["OPENAI_API_KEY"] is None
assert payload["UNRELATED_VALUE"] is None
+11 -4
View File
@@ -21,7 +21,6 @@ for _mod in ("litellm", "google.generativeai", "google.genai", "anthropic"):
sys.modules[_mod] = MagicMock()
import pytest
from unittest.mock import PropertyMock
@pytest.fixture(autouse=True)
@@ -187,12 +186,20 @@ class TestAnalyzerGenerateText:
assert result == "复盘"
mock_persist.assert_not_called()
def test_codex_cli_is_available_without_litellm_api_keys(self):
@pytest.mark.parametrize(
("generation_backend", "executable_name"),
[
("codex_cli", "codex"),
("claude_code_cli", "claude"),
("opencode_cli", "opencode"),
],
)
def test_local_cli_is_available_without_litellm_api_keys(self, generation_backend, executable_name):
analyzer = self._make_analyzer()
analyzer._litellm_available = False
analyzer._router = None
analyzer._config_override = SimpleNamespace(
generation_backend="codex_cli",
generation_backend=generation_backend,
generation_fallback_backend="",
generation_backend_timeout_seconds=300,
generation_backend_max_output_bytes=1048576,
@@ -200,7 +207,7 @@ class TestAnalyzerGenerateText:
local_cli_backend_max_concurrency=1,
)
with patch("src.llm.local_cli_backend.shutil.which", return_value="/usr/bin/codex"), \
with patch("src.llm.local_cli_backend.shutil.which", return_value=f"/usr/bin/{executable_name}"), \
patch("src.llm.local_cli_backend.os.access", return_value=True):
assert analyzer.get_generation_backend_config_error() is None
assert analyzer.is_available() is True
+35 -30
View File
@@ -11,6 +11,7 @@ ensure_litellm_stub()
from src.core.market_review_runtime import build_market_review_runtime, has_configured_llm_runtime
from src.llm.generation_backend import GenerationError, GenerationErrorCode
from src.llm.backend_registry import LOCAL_CLI_GENERATION_BACKEND_IDS
class _FakeAnalyzer:
@@ -155,33 +156,35 @@ class TestMarketReviewRuntimeCompatibility(unittest.TestCase):
self.assertEqual(analyzer.available_calls, 0)
search_cls.assert_not_called()
def test_build_market_review_runtime_preserves_codex_cli_backend_error_without_api_keys(self) -> None:
config = self._base_config()
config.generation_backend = "codex_cli"
config.generation_fallback_backend = ""
backend_error = GenerationError(
error_code=GenerationErrorCode.COMMAND_NOT_FOUND,
stage="configuration",
retryable=False,
fallbackable=True,
backend="codex_cli",
provider="codex_cli",
details={"reason": "executable_not_found"},
)
notifier = MagicMock()
analyzer = _FakeAnalyzer(backend_error=backend_error, available=False)
def test_build_market_review_runtime_preserves_local_cli_backend_error_without_api_keys(self) -> None:
for backend_id in sorted(LOCAL_CLI_GENERATION_BACKEND_IDS):
with self.subTest(backend_id=backend_id):
config = self._base_config()
config.generation_backend = backend_id
config.generation_fallback_backend = ""
backend_error = GenerationError(
error_code=GenerationErrorCode.COMMAND_NOT_FOUND,
stage="configuration",
retryable=False,
fallbackable=True,
backend=backend_id,
provider=backend_id,
details={"reason": "executable_not_found"},
)
notifier = MagicMock()
analyzer = _FakeAnalyzer(backend_error=backend_error, available=False)
with patch("src.analyzer.GeminiAnalyzer", return_value=analyzer), \
patch("src.notification.NotificationService", return_value=notifier), \
patch("src.search_service.SearchService") as search_cls:
runtime_notifier, runtime_analyzer, runtime_search = build_market_review_runtime(config)
with patch("src.analyzer.GeminiAnalyzer", return_value=analyzer), \
patch("src.notification.NotificationService", return_value=notifier), \
patch("src.search_service.SearchService") as search_cls:
runtime_notifier, runtime_analyzer, runtime_search = build_market_review_runtime(config)
self.assertIs(runtime_notifier, notifier)
self.assertIs(runtime_analyzer, analyzer)
self.assertIsNone(runtime_search)
self.assertEqual(analyzer.backend_error_calls, 1)
self.assertEqual(analyzer.available_calls, 0)
search_cls.assert_not_called()
self.assertIs(runtime_notifier, notifier)
self.assertIs(runtime_analyzer, analyzer)
self.assertIsNone(runtime_search)
self.assertEqual(analyzer.backend_error_calls, 1)
self.assertEqual(analyzer.available_calls, 0)
search_cls.assert_not_called()
def test_build_market_review_runtime_drops_unavailable_analyzer_without_backend_error(self) -> None:
config = self._base_config()
@@ -205,12 +208,14 @@ class TestMarketReviewRuntimeCompatibility(unittest.TestCase):
config = self._base_config()
self.assertFalse(has_configured_llm_runtime(config))
def test_has_configured_llm_runtime_treats_codex_cli_as_runtime_without_api_keys(self) -> None:
config = self._base_config()
config.generation_backend = "codex_cli"
config.generation_fallback_backend = ""
def test_has_configured_llm_runtime_treats_local_cli_as_runtime_without_api_keys(self) -> None:
for backend_id in sorted(LOCAL_CLI_GENERATION_BACKEND_IDS):
with self.subTest(backend_id=backend_id):
config = self._base_config()
config.generation_backend = backend_id
config.generation_fallback_backend = ""
self.assertTrue(has_configured_llm_runtime(config))
self.assertTrue(has_configured_llm_runtime(config))
def test_has_configured_llm_runtime_supports_legacy_fields(self) -> None:
base = self._base_config()
+5
View File
@@ -161,6 +161,11 @@ class SystemConfigApiTestCase(unittest.TestCase):
agent_schema = item_map["AGENT_GENERATION_BACKEND"]["schema"]
self.assertEqual(agent_schema["validation"]["enum"], ["auto", "litellm"])
self.assertNotIn("codex_cli", {option["value"] for option in agent_schema["options"]})
self.assertNotIn("claude_code_cli", {option["value"] for option in agent_schema["options"]})
self.assertNotIn("opencode_cli", {option["value"] for option in agent_schema["options"]})
generation_schema = item_map["GENERATION_BACKEND"]["schema"]
self.assertIn("claude_code_cli", generation_schema["validation"]["enum"])
self.assertIn("opencode_cli", generation_schema["validation"]["enum"])
def test_get_config_schema_includes_notification_noise_fields(self) -> None:
payload = system_config.get_system_config(include_schema=True, service=self.service).model_dump(by_alias=True)
+70 -12
View File
@@ -20,6 +20,7 @@ ensure_litellm_stub()
from src.config import ANSPIRE_LLM_MODEL_DEFAULT, DEFAULT_ALPHASIFT_INSTALL_SPEC, 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, SystemConfigService
@@ -632,20 +633,22 @@ class SystemConfigServiceTestCase(unittest.TestCase):
self.assertTrue(items["REPORT_SHOW_LLM_MODEL"]["raw_value_exists"])
def test_get_config_preserves_manual_agent_codex_cli_value_without_schema_option(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
"AGENT_GENERATION_BACKEND=codex_cli",
)
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"]
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"], "codex_cli")
self.assertNotIn(
"codex_cli",
{option["value"] for option in agent_item["schema"]["options"]},
)
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(
@@ -957,9 +960,33 @@ class SystemConfigServiceTestCase(unittest.TestCase):
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(
@@ -1058,6 +1085,37 @@ class SystemConfigServiceTestCase(unittest.TestCase):
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",