feat: 新增 codex_cli 本地生成后端 Phase2 (#1769)

* feat: add codex cli generation backend

* fix: harden local CLI backend phase 2

* fix: harden local cli output file handling

* fix: tighten codex cli backend contracts

* fix: support codex cli windows smoke path

* fix: make local cli tests portable on windows

* fix: avoid duplicate codex final output accounting
This commit is contained in:
Alfred
2026-06-24 20:19:19 +08:00
committed by GitHub
parent cca629ff24
commit 1f91024dec
36 changed files with 3886 additions and 326 deletions

View File

@@ -113,12 +113,18 @@ STOCK_INDEX_REMOTE_UPDATE_ENABLED=true
# 【进阶】需要多模型 / 多平台 fallback → 配置下方「多渠道」或在 Web 设置页可视化管理。
# ===================================
# 生成后端Phase 1 仅支持 litellm
# 非 litellm 值会作为配置错误处理,不会静默回退到 LiteLLM
# 生成后端:默认 litellmcodex_cli 为显式 opt-in 的本地 CLI backendexperimental/limited
# 本地 CLI Backend 不等于离线模型CLI 背后的服务可能处理分析 prompt 和报告草稿
GENERATION_BACKEND=litellm
# 后端级 fallbacklitellm -> litellm 会被解析为 no-op,模型 fallback 仍由 LiteLLM 配置负责
# 后端级 fallback本地 .env 空值禁用 backend-level fallbacklitellm -> litellm 会被解析为 no-op。
# 默认 GitHub Actions workflow 未配置该变量时会显式使用 litellmActions 中要禁用 fallback 时可设为 primary backend 实现 self no-op。
GENERATION_FALLBACK_BACKEND=litellm
# Agent Chat 后端auto 在 Phase 1 中等价于现有 LiteLLM tool-calling 后端
# 本地 CLI backend 执行上限timeout 最大 3600输出最大 33554432 bytes并发最大分别为 16 / 4
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_GENERATION_BACKEND=auto
# --- API Key填一个即可---

View File

@@ -61,6 +61,13 @@ jobs:
# ==========================================
# AI 配置
# ==========================================
GENERATION_BACKEND: ${{ vars.GENERATION_BACKEND || secrets.GENERATION_BACKEND }}
GENERATION_FALLBACK_BACKEND: ${{ vars.GENERATION_FALLBACK_BACKEND || secrets.GENERATION_FALLBACK_BACKEND || 'litellm' }}
GENERATION_BACKEND_TIMEOUT_SECONDS: ${{ vars.GENERATION_BACKEND_TIMEOUT_SECONDS || secrets.GENERATION_BACKEND_TIMEOUT_SECONDS }}
GENERATION_BACKEND_MAX_OUTPUT_BYTES: ${{ vars.GENERATION_BACKEND_MAX_OUTPUT_BYTES || secrets.GENERATION_BACKEND_MAX_OUTPUT_BYTES }}
GENERATION_BACKEND_MAX_CONCURRENCY: ${{ vars.GENERATION_BACKEND_MAX_CONCURRENCY || secrets.GENERATION_BACKEND_MAX_CONCURRENCY }}
LOCAL_CLI_BACKEND_MAX_CONCURRENCY: ${{ vars.LOCAL_CLI_BACKEND_MAX_CONCURRENCY || secrets.LOCAL_CLI_BACKEND_MAX_CONCURRENCY }}
AGENT_GENERATION_BACKEND: ${{ vars.AGENT_GENERATION_BACKEND || secrets.AGENT_GENERATION_BACKEND }}
# LITELLM_CONFIG
LITELLM_CONFIG: ${{ vars.LITELLM_CONFIG || secrets.LITELLM_CONFIG }}
LITELLM_CONFIG_YAML: ${{ vars.LITELLM_CONFIG_YAML || secrets.LITELLM_CONFIG_YAML }}

View File

@@ -488,8 +488,10 @@ describe('SettingsField', () => {
expect(dialog).not.toHaveTextContent('GENERATION_BACKEND');
expect(dialog).not.toHaveTextContent('配置样例');
expect(dialog).not.toHaveTextContent('Phase 1');
expect(dialog).toHaveTextContent('高级说明');
expect(dialog).toHaveTextContent('LiteLLM');
expect(dialog).toHaveTextContent('本机已安装并登录 Codex CLI');
expect(dialog).toHaveTextContent('默认模型配置会继续使用现有 API Key');
expect(dialog).not.toHaveTextContent('高级说明');
expect(dialog).not.toHaveTextContent('LiteLLM');
});
it('describes agent auto generation without exposing implementation labels as the primary UI copy', () => {
@@ -511,7 +513,7 @@ describe('SettingsField', () => {
isEditable: true,
options: [
{ label: 'Auto', value: 'auto' },
{ label: 'Default model tool calling', value: 'litellm' },
{ label: 'Default model settings', value: 'litellm' },
],
validation: { enum: ['auto', 'litellm'] },
displayOrder: 1,
@@ -528,10 +530,11 @@ describe('SettingsField', () => {
fireEvent.click(screen.getByRole('button', { name: '查看 问股生成方式 配置说明' }));
const dialog = screen.getByRole('dialog', { name: '问股生成方式' });
expect(dialog).toHaveTextContent('系统会选择当前可用的模型工具调用方式');
expect(dialog).toHaveTextContent('系统会选择当前可用的方式');
expect(dialog).toHaveTextContent('如果不确定,选择“自动”即可');
expect(dialog).toHaveTextContent('高级说明');
expect(dialog).toHaveTextContent('LiteLLM');
expect(dialog).toHaveTextContent('这项设置只影响问股助手');
expect(dialog).not.toHaveTextContent('高级说明');
expect(dialog).not.toHaveTextContent('LiteLLM');
expect(dialog).not.toHaveTextContent('优先选择当前可用');
});

View File

@@ -36,34 +36,58 @@ const settingsHelpZhCN: SettingsHelpMap = {
title: '分析生成方式',
showFieldKey: false,
summary: '决定系统用哪种方式生成个股分析、大盘复盘和普通文本回复。',
usage: '通常保持“默认模型配置”。系统会继续使用你在本页配置的主模型、备选模型、渠道和用量记录。',
usage: '通常保持“默认模型配置”。只有在本机已安装并登录 Codex CLI且你信任它处理分析内容时才选择 Codex CLI实验。',
valueNotes: [
'当前页面只开放“默认模型配置”作为可选方式,看到更多选项前无需调整。',
'如果通过环境变量手动填写了其他值,系统会提示配置错误,避免误以为已经切换成功。',
'Codex CLI 是本机启动的命令行程序,不等于离线模型;它背后的服务可能处理股票代码、新闻、持仓上下文、分析请求和报告草稿。',
'Docker、云服务器、CI 不天然拥有你本机的登录状态DSA 不读取 Codex 登录凭据文件,但 Codex CLI 自己可能使用它的登录状态。',
],
impact: ['影响普通分析、大盘复盘和文本生成入口,不改变问股助手的工具执行规则。'],
notes: [
'想恢复默认行为,选择“默认模型配置”并保存配置。',
'高级说明:当前默认模型配置内部由 LiteLLM 兼容层执行;普通使用无需了解或修改该内部值。',
'Codex CLI 当前仍是实验能力;如果输出不稳定或经常失败,请设回默认模型配置。',
'默认模型配置会继续使用现有 API Key、模型渠道和备用模型设置。',
],
examples: [],
},
'settings.ai_model.GENERATION_FALLBACK_BACKEND': {
title: '备用生成方式(预留)',
title: '备用生成方式',
showFieldKey: false,
summary: '为以后多个生成方式之间的备用切换预留;当前主要用于明确保持默认行为。',
usage: '通常保持“默认模型配置”。主生成方式也是默认模型配置时,不会额外重复调用一次。',
summary: '决定本地 Codex 生成失败后,是直接报错,还是再尝试默认模型配置。',
usage: '选择“禁用”表示失败就报错;选择“默认模型配置”表示再尝试你已经配置好的普通模型。',
valueNotes: [
'如果只是想设置主模型失败后的备用模型,请使用“备选模型”,不是这个字段。',
'当前页面只开放“默认模型配置”,保存默认值即可。',
'主生成方式本身就是默认模型配置时,这个字段不会额外生效。',
],
impact: ['不改变现有模型备用顺序,也不会影响渠道编辑器里的模型配置。'],
impact: ['不改变现有备用模型顺序,也不会影响渠道编辑器里的模型配置。'],
notes: [
'想恢复默认行为,选择“默认模型配置”并保存配置。',
'高级说明:当前默认模型配置内部由 LiteLLM 兼容层执行;普通使用无需了解或修改该内部值。',
'希望本地 Codex 失败后立刻暴露错误时选择“禁用”;希望继续尝试云端模型时选择“默认模型配置”。',
],
examples: [],
},
'settings.ai_model.GENERATION_BACKEND_TIMEOUT_SECONDS': {
title: '生成超时(秒)',
summary: '限制一次模型生成最多等待多久。',
usage: '默认 300 秒,主要用于 Codex CLI 这类本地命令行生成方式。',
valueNotes: ['超时后会停止本次生成,并在日志里记录明确的超时错误。'],
},
'settings.ai_model.GENERATION_BACKEND_MAX_OUTPUT_BYTES': {
title: '最大输出大小(字节)',
summary: '限制一次本地命令行生成可读取的输出大小。',
usage: '默认 1048576 字节。超过限制时会停止解析,并记录“输出过大”错误。',
valueNotes: ['日志只展示脱敏后的片段,不展示完整分析内容、环境变量、密钥或本机路径。'],
},
'settings.ai_model.GENERATION_BACKEND_MAX_CONCURRENCY': {
title: '模型生成最大并发',
summary: '限制同时进行的模型生成任务数量。',
usage: '默认 1。使用 Codex CLI 时,实际并发还会受“本地命令行最大并发”限制。',
valueNotes: ['使用默认模型配置时,这个字段不会改变分析任务线程数。'],
},
'settings.ai_model.LOCAL_CLI_BACKEND_MAX_CONCURRENCY': {
title: '本地命令行最大并发',
summary: '限制同时启动多少个本地命令行生成进程。',
usage: '默认 1避免同时启动多个 Codex CLI 进程导致机器变慢或输出互相干扰。',
valueNotes: ['最终并发不会超过“模型生成最大并发”。'],
},
'settings.ai_model.LITELLM_MODEL': {
title: '主模型',
summary: '指定普通分析流程默认使用的 LLM 模型。',
@@ -738,15 +762,16 @@ const settingsHelpZhCN: SettingsHelpMap = {
title: '问股生成方式',
showFieldKey: false,
summary: '决定问股助手用哪种方式生成回复,并配合工具查询行情、新闻和历史数据。',
usage: '通常保持“自动”。系统会选择当前可用的模型工具调用方式;如果没有明确要固定方式,无需调整。',
usage: '通常保持“自动”。系统会选择当前可用的方式来回答问题并调用数据工具;如果没有明确要固定方式,无需调整。',
valueNotes: [
'如果不确定,选择“自动”即可。',
'只有当你明确要固定使用当前默认模型工具调用时,才改为“默认模型工具调用”。',
'只有当你明确要固定使用普通模型配置时,才改为“默认模型配置”。',
'Codex CLI 当前不能直接用于问股助手的数据工具调用;显式选择后会提示不可用,或按配置改用普通模型配置。',
],
impact: ['影响问股助手的回复生成和工具调用入口,不改变它能使用哪些工具。'],
notes: [
'想恢复默认行为,选择“自动”并保存配置。',
'高级说明:当前默认模型工具调用内部由 LiteLLM 兼容层执行;普通使用无需了解或修改该内部值。',
'这项设置只影响问股助手,不会改变普通个股分析和大盘复盘的生成方式。',
],
examples: [],
},
@@ -1142,34 +1167,58 @@ 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. The system will continue to use the primary model, fallback models, channels, and usage tracking configured on this page.',
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.',
valueNotes: [
'The settings page currently exposes “Default model settings” as the available method, so most users do not need to change this.',
'If another value is set manually through environment variables, the system reports a configuration error instead of pretending the switch worked.',
'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.',
],
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.',
'Advanced note: the default model settings are currently executed through the LiteLLM compatibility layer; regular users do not need to understand or change that internal value.',
'Codex CLI is 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: [],
},
'settings.ai_model.GENERATION_FALLBACK_BACKEND': {
title: 'Fallback Generation Method (reserved)',
title: 'Fallback Generation Method',
showFieldKey: false,
summary: 'Reserved for switching between multiple generation methods later; today it mainly records the default behavior explicitly.',
usage: 'Usually keep “Default model settings”. When the main method is already the default model settings, the system does not make an extra duplicate call.',
summary: 'Chooses whether a failed local Codex 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: [
'If you want a backup model after the primary model fails, use “Fallback models” instead of this field.',
'The settings page currently exposes “Default model settings” only, so saving the default is enough.',
'Use fallback models for model-to-model fallback; this field only handles local Codex versus Default model settings.',
'When the primary generation method is already Default model settings, this field has no extra effect.',
],
impact: ['Does not change the current fallback model order or the model-channel editor configuration.'],
impact: ['Affects local CLI failure handling for stock analysis, market review, and free-form text generation.'],
notes: [
'To restore the default behavior, choose “Default model settings” and save.',
'Advanced note: the default model settings are currently executed through the LiteLLM compatibility layer; regular users do not need to understand or change that internal value.',
'Choose Disabled when you want local Codex failures to be visible immediately, or Default model settings when cloud model recovery is acceptable.',
],
examples: [],
},
'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.',
valueNotes: ['Timeout stops the generation and records a clear timeout error.'],
},
'settings.ai_model.GENERATION_BACKEND_MAX_OUTPUT_BYTES': {
title: 'Maximum Output Size (Bytes)',
summary: 'Limits how much output one local command-line generation may read.',
usage: 'Default is 1048576 bytes. Oversized output stops parsing and records an output-too-large error.',
valueNotes: ['Logs only show redacted snippets, not full analysis content, environment variables, secrets, or local paths.'],
},
'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.',
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.',
valueNotes: ['Final concurrency never exceeds Model Generation Max Concurrency.'],
},
'settings.ai_model.LITELLM_MODEL': {
title: 'Primary Model',
summary: 'Selects the default LLM model for regular analysis flows.',
@@ -1803,15 +1852,16 @@ const settingsHelpEnUS: SettingsHelpMap = {
title: 'Ask-Stock Generation Method',
showFieldKey: false,
summary: 'Chooses how the ask-stock assistant generates replies and queries market, news, and history tools.',
usage: 'Usually keep Auto. The system chooses the currently available model tool-calling method; change it only when you need to pin the assistant method.',
usage: 'Usually keep Auto. The system chooses the currently available method to answer questions and call data tools; change it only when you need to pin the assistant method.',
valueNotes: [
'If you are unsure, choose Auto.',
'Choose “Default model tool calling” only when you explicitly want to pin the assistant to the current default model tool-calling method.',
'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.',
],
impact: ['Affects the assistant reply path and tool entry point. It does not change which tools the assistant can use.'],
notes: [
'To restore the default behavior, choose Auto and save.',
'Advanced note: the current default model tool-calling method is executed through the LiteLLM compatibility layer; regular users do not need to understand or change that internal value.',
'This setting only affects the ask-stock assistant. It does not change regular stock analysis or market review generation.',
],
examples: [],
},

View File

@@ -71,7 +71,11 @@ const fieldTitleMap: Record<string, string> = {
PYTDX_SERVERS: 'Pytdx 服务器列表',
BIAS_THRESHOLD: 'BIAS 阈值',
GENERATION_BACKEND: '分析生成方式',
GENERATION_FALLBACK_BACKEND: '备用生成方式(预留)',
GENERATION_FALLBACK_BACKEND: '备用生成方式',
GENERATION_BACKEND_TIMEOUT_SECONDS: '生成超时(秒)',
GENERATION_BACKEND_MAX_OUTPUT_BYTES: '最大输出大小(字节)',
GENERATION_BACKEND_MAX_CONCURRENCY: '模型生成最大并发',
LOCAL_CLI_BACKEND_MAX_CONCURRENCY: '本地命令行最大并发',
LITELLM_MODEL: '主模型',
AGENT_LITELLM_MODEL: 'Agent 主模型',
LITELLM_FALLBACK_MODELS: '备选模型',
@@ -227,8 +231,12 @@ const fieldDescriptionMap: Record<string, string> = {
PYTDX_PORT: 'Pytdx 单节点端口,需与主机配置配套。',
PYTDX_SERVERS: 'Pytdx 自定义节点列表,支持 host:port 逗号分隔。',
BIAS_THRESHOLD: 'BIAS 偏离阈值,超过后用于增强超买超卖提示。',
GENERATION_BACKEND: '用于个股分析、大盘复盘和普通文本生成。通常保持“默认模型配置”,即可沿用当前模型渠道、备用模型、用量记录和审计设置。',
GENERATION_FALLBACK_BACKEND: '为未来多个生成方式之间的备用切换预留。当前保持“默认模型配置”即可;主模型失败后的备用模型仍在“备选模型”或渠道配置里设置。',
GENERATION_BACKEND: '用于个股分析、大盘复盘和普通文本生成。Codex CLI 需要本机已安装并登录,仍可能调用对应云服务,不是离线模型。',
GENERATION_FALLBACK_BACKEND: '本地 Codex 生成失败后的处理方式:禁用表示直接报错,默认模型配置表示再尝试普通模型。',
GENERATION_BACKEND_TIMEOUT_SECONDS: '单次生成最多等待多少秒,默认 300主要用于 Codex CLI 这类本地命令行方式。',
GENERATION_BACKEND_MAX_OUTPUT_BYTES: '单次本地命令行生成可读取的输出大小上限,默认 1048576 字节。',
GENERATION_BACKEND_MAX_CONCURRENCY: '同时允许多少个模型生成任务运行,默认 1使用默认模型配置时不改变分析任务线程数。',
LOCAL_CLI_BACKEND_MAX_CONCURRENCY: '同时允许启动多少个本地命令行生成进程,默认 1最终不会超过“模型生成最大并发”。',
LITELLM_MODEL: '主模型,格式 provider/model如 gemini/gemini-2.5-flash。配置渠道后自动推断。',
AGENT_LITELLM_MODEL: 'Agent 专用主模型。留空时继承主模型;无 provider 前缀时会按 openai/<model> 解析。',
LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',
@@ -333,7 +341,7 @@ const fieldDescriptionMap: Record<string, string> = {
WEBUI_AUTO_BUILD: '后端启动 WebUI 前是否自动检查并构建前端静态产物;关闭前需确认产物已预构建,保存后需重启生效。',
WEBUI_PORT: 'Web 页面服务监听端口。',
AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',
AGENT_GENERATION_BACKEND: '用于问股助手生成回复并调用行情、新闻和历史数据工具。通常保持“自动”,系统会选择当前可用的模型工具调用方式。',
AGENT_GENERATION_BACKEND: '用于问股助手生成回复并调用行情、新闻和历史数据工具。通常保持“自动”,系统会选择当前可用的方式。',
AGENT_MAX_STEPS: 'Agent 最大推理步数上限。保持默认 10 时,各子 Agent 按自身预设步数运行;调高到高于默认值时,所有子 Agent 统一采用该值;调低到低于某子 Agent 默认值时,该 Agent 会被封顶。',
AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill内置默认是 bull_trend也可填写 all 启用全部策略。',
AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',
@@ -395,13 +403,16 @@ const fieldOptionLabelMap: Record<string, Record<string, string>> = {
},
GENERATION_BACKEND: {
litellm: '默认模型配置',
codex_cli: 'Codex CLI实验',
},
GENERATION_FALLBACK_BACKEND: {
'': '禁用',
litellm: '默认模型配置',
},
AGENT_GENERATION_BACKEND: {
auto: '自动',
litellm: '默认模型工具调用',
litellm: '默认模型配置',
codex_cli: 'Codex CLI不支持工具',
},
LOG_LEVEL: {
debug: '调试',
@@ -475,13 +486,16 @@ const fieldOptionLabelMapEn: Record<string, Record<string, string>> = {
},
GENERATION_BACKEND: {
litellm: 'Default model settings',
codex_cli: 'Codex CLI (experimental)',
},
GENERATION_FALLBACK_BACKEND: {
'': 'Disabled',
litellm: 'Default model settings',
},
AGENT_GENERATION_BACKEND: {
auto: 'Auto',
litellm: 'Default model tool calling',
litellm: 'Default model settings',
codex_cli: 'Codex CLI (tools unsupported)',
},
LOG_LEVEL: {
debug: 'Debug',

View File

@@ -14,6 +14,10 @@ const requiredLocalizedKeys = [
'BIAS_THRESHOLD',
'GENERATION_BACKEND',
'GENERATION_FALLBACK_BACKEND',
'GENERATION_BACKEND_TIMEOUT_SECONDS',
'GENERATION_BACKEND_MAX_OUTPUT_BYTES',
'GENERATION_BACKEND_MAX_CONCURRENCY',
'LOCAL_CLI_BACKEND_MAX_CONCURRENCY',
'LLM_PROMPT_CACHE_TELEMETRY_ENABLED',
'LLM_PROMPT_CACHE_HINTS_ENABLED',
'LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL',
@@ -144,7 +148,7 @@ describe('systemConfigI18n option label localization', () => {
['GENERATION_BACKEND', 'litellm', undefined, '默认模型配置'],
['GENERATION_FALLBACK_BACKEND', 'litellm', undefined, '默认模型配置'],
['AGENT_GENERATION_BACKEND', 'auto', 'Auto', '自动'],
['AGENT_GENERATION_BACKEND', 'litellm', undefined, '默认模型工具调用'],
['AGENT_GENERATION_BACKEND', 'litellm', undefined, '默认模型配置'],
['AGENT_ARCH', 'single', 'Single Agent', '单 Agent'],
['AGENT_ARCH', 'multi', 'Multi Agent (Orchestrator)', '多 Agent编排'],
['AGENT_ORCHESTRATOR_MODE', 'quick', 'Quick', '快速'],
@@ -193,6 +197,14 @@ describe('generation backend settings help contract', () => {
getFieldDescriptionZh('GENERATION_BACKEND', ''),
getFieldTitleZh('GENERATION_FALLBACK_BACKEND', ''),
getFieldDescriptionZh('GENERATION_FALLBACK_BACKEND', ''),
getFieldTitleZh('GENERATION_BACKEND_TIMEOUT_SECONDS', ''),
getFieldDescriptionZh('GENERATION_BACKEND_TIMEOUT_SECONDS', ''),
getFieldTitleZh('GENERATION_BACKEND_MAX_OUTPUT_BYTES', ''),
getFieldDescriptionZh('GENERATION_BACKEND_MAX_OUTPUT_BYTES', ''),
getFieldTitleZh('GENERATION_BACKEND_MAX_CONCURRENCY', ''),
getFieldDescriptionZh('GENERATION_BACKEND_MAX_CONCURRENCY', ''),
getFieldTitleZh('LOCAL_CLI_BACKEND_MAX_CONCURRENCY', ''),
getFieldDescriptionZh('LOCAL_CLI_BACKEND_MAX_CONCURRENCY', ''),
getFieldTitleZh('AGENT_GENERATION_BACKEND', ''),
getFieldDescriptionZh('AGENT_GENERATION_BACKEND', ''),
].join('\n');
@@ -244,8 +256,12 @@ describe('generation backend settings help contract', () => {
].join('\n');
expect(zhBackend?.title).toBe('分析生成方式');
expect(zhFallback?.title).toBe('备用生成方式(预留)');
expect(zhFallback?.title).toBe('备用生成方式');
expect(zhAgent?.title).toBe('问股生成方式');
expect(getFieldTitleZh('GENERATION_BACKEND_TIMEOUT_SECONDS', '')).toBe('生成超时(秒)');
expect(getFieldTitleZh('GENERATION_BACKEND_MAX_OUTPUT_BYTES', '')).toBe('最大输出大小(字节)');
expect(getFieldTitleZh('GENERATION_BACKEND_MAX_CONCURRENCY', '')).toBe('模型生成最大并发');
expect(getFieldTitleZh('LOCAL_CLI_BACKEND_MAX_CONCURRENCY', '')).toBe('本地命令行最大并发');
expect(zhBackend?.showFieldKey).toBe(false);
expect(zhFallback?.showFieldKey).toBe(false);
expect(zhAgent?.showFieldKey).toBe(false);
@@ -254,33 +270,53 @@ describe('generation backend settings help contract', () => {
expect(zhAgent?.examples).toEqual([]);
expect(zhInlineText).toContain('个股分析');
expect(zhInlineText).toContain('问股助手');
expect(zhInlineText).toContain('当前可用的模型工具调用方式');
expect(zhInlineText).toContain('当前可用的方式');
expect(zhInlineText).not.toContain('沿用当前可用的模型通道');
expect(zhText).toContain('个股分析');
expect(zhText).toContain('大盘复盘');
expect(zhText).toContain('自动');
expect(zhBackend?.usage).toContain('默认模型配置');
expect(zhFallback?.usage).toContain('默认模型配置');
expect(zhAgent?.usage).toContain('当前可用的模型工具调用方式');
expect(zhAgent?.usage).toContain('当前可用的方式');
expect(zhAgent?.valueNotes).toContain('如果不确定,选择“自动”即可。');
expect(zhBackend?.notes?.join('\n')).toContain('高级说明');
expect(zhBackend?.notes?.join('\n')).toContain('LiteLLM');
expect(zhText).not.toContain('优先选择当前可用');
expect(zhText).not.toContain('unsupported_tool_calling');
expect(zhText).not.toContain('run_agent_loop');
[
'Backend',
'backend',
'backend-level',
'generation backend',
'self fallback',
'stdout',
'stderr',
'contract',
'MAX_WORKERS',
'Router',
'diagnostics',
'executable',
'coding-agent',
'experimental/limited',
'fail-fast',
'LiteLLM',
].forEach((term) => {
expect(zhInlineText).not.toContain(term);
expect(zhText).not.toContain(term);
});
expect(enBackend?.title).toBe('Analysis Generation Method');
expect(enFallback?.title).toBe('Fallback Generation Method (reserved)');
expect(enFallback?.title).toBe('Fallback Generation Method');
expect(enAgent?.title).toBe('Ask-Stock Generation Method');
expect(enText).toContain('stock analysis');
expect(enText).toContain('market reviews');
expect(enText).toContain('Auto');
expect(enBackend?.usage).toContain('Default model settings');
expect(enFallback?.usage).toContain('Default model settings');
expect(enAgent?.usage).toContain('currently available model tool-calling method');
expect(enAgent?.usage).toContain('currently available method');
expect(enAgent?.valueNotes).toContain('If you are unsure, choose Auto.');
expect(enBackend?.notes?.join('\n')).toContain('Advanced note');
expect(enBackend?.notes?.join('\n')).toContain('LiteLLM');
expect(enBackend?.notes?.join('\n')).toContain('Default model settings continue');
expect(enBackend?.notes?.join('\n')).not.toContain('Advanced note');
expect(enBackend?.notes?.join('\n')).not.toContain('LiteLLM');
expect(enText).not.toContain('current available model channel');
expect(enText).not.toContain('unsupported_tool_calling');
expect(enText).not.toContain('run_agent_loop');

View File

@@ -24,6 +24,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] Web/API runtime scheduler 接管 `--serve --schedule` 后保留 `--dry-run``--no-notify` 等启动参数语义。
- [改进] Web 历史报告详情不再内嵌展示 AI 建议卡片,结构化决策信号集中在 AI 建议页查询,并保留按来源报告 ID 筛选或 URL 参数精确定位入口。
- [改进] 新增 GenerationBackend Phase 1 抽象与 LiteLLM backend 配置,默认保持普通分析、`generate_text()` 和 Agent Chat 的 LiteLLM 行为不变。
- [新功能] #1743 Phase 2 新增显式 opt-in 的 `codex_cli` 本地 CLI generation backend固定安全 preset、结构化错误、fallback、stream 降级和 usage unavailable contract。
- [改进] `GENERATION_BACKEND=codex_cli` 下普通分析与大盘复盘不再因缺少 LiteLLM API Key 被误判不可用local CLI 失败会暴露结构化错误或按配置回退 LiteLLM。
- [改进] `codex_cli` preset 改用 `--output-last-message` 文件读取最终响应stdout/stderr 仅作为诊断预览,避免 Codex CLI session 元数据混入主分析 JSON。
- [修复] `codex_cli` preset 不再把 Codex CLI 同时打印到 stdout 的最终响应重复计入输出上限,也不在 `stdout_preview` 暴露重复的最终响应内容。
- [修复] 恢复主分析 JSON 解析的宽松 schema fallback 语义,完整报告 schema 校验失败时继续按 raw JSON 解析,避免有效字段被错误降级为文本 fallback。
- [改进] 本地 CLI backend 对诊断 stdout/stderr 与最终响应实行执行期总量上限,并为新增 generation backend 数字配置补齐最大值校验。
- [文档] 补充本地 CLI backend 隐私边界、非离线模型说明、Docker/CI 登录态限制,以及 `codex_cli` experimental/limited 状态。
- [修复] unsupported `GENERATION_BACKEND``generate_text()` 与大盘复盘路径中显式报配置错误,避免被当成空响应或模板报告 fallback。
- [改进] Web 设置页明确 `AGENT_GENERATION_BACKEND=auto` 当前使用 LiteLLM 工具调用路径,避免暗示已有动态 backend 选择。
- [改进] Web 首页“任务已存在”重复分析提示新增手动关闭按钮与 5 秒后自动消失。

View File

@@ -25,20 +25,38 @@
---
## Generation BackendPhase 1
## Generation BackendPhase 2
当前 generation backend 抽象只用于把普通分析、大盘复盘`generate_text()` 和 Agent Chat 的后端选择契约先收口起来。Phase 1 唯一可执行 backend 是 `litellm`,因此默认行为与历史 LiteLLM 路径保持一致
Generation backend 普通分析、大盘复盘`generate_text()` 的外层运行时选择。默认仍是 `litellm`,零配置路径与历史行为保持一致;`codex_cli` 是显式 opt-in 的本地 CLI backend当前标记为 **experimental/limited**
```env
GENERATION_BACKEND=litellm
GENERATION_FALLBACK_BACKEND=litellm
GENERATION_BACKEND_TIMEOUT_SECONDS=300
GENERATION_BACKEND_MAX_OUTPUT_BYTES=1048576
GENERATION_BACKEND_MAX_CONCURRENCY=1
LOCAL_CLI_BACKEND_MAX_CONCURRENCY=1
AGENT_GENERATION_BACKEND=auto
```
- `GENERATION_BACKEND` 只支持 `litellm`。配置 `codex``claude_code``opencode``hermes` 等值会得到明确配置错误,不会静默回退到 LiteLLM
- `GENERATION_FALLBACK_BACKEND=litellm` 在 primary 也是 `litellm` 时是 backend 级 no-op模型级 fallback 仍由 `LITELLM_FALLBACK_MODELS`、Router 或 Channels 负责
- `AGENT_GENERATION_BACKEND=auto` 的完整语义是:当前 generation backend 支持 tool calling 时自动复用,否则继续使用 LiteLLM tool backendPhase 1 只有 LiteLLM 可执行,所以运行结果等价于现有 Agent LiteLLM 行为
- 本地 CLI / Hermes HTTP / Agent text-only backend 是后续 phase 的增量,不在当前版本中启用
- `GENERATION_BACKEND=litellm|codex_cli``codex_cli` 是 generation backend不是 LiteLLM provider不要写 `LITELLM_MODEL=codex_cli/...`
- `GENERATION_FALLBACK_BACKEND` 未配置时默认 `litellm`;本地 `.env` 显式空值 `GENERATION_FALLBACK_BACKEND=` 表示禁用 backend-level fallbackprimary 与 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 仍会把同一最终响应打印到 stdoutDSA 会从 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`
- 本地 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` presetstdout 中重复打印的最终响应不会重复计入,也不会作为 `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 诊断。
### Codex CLI 本地 backend 隐私与边界
- 本地 CLI Backend 不等于离线模型Codex CLI 背后的服务可能处理股票代码、新闻、持仓上下文、分析 prompt、报告草稿等内容。
- Docker、云服务器、CI 不天然拥有你本机的 CLI 登录态。
- GitHub Actions 只负责透传配置值,不安装或登录 Codex CLI如果在 Actions 中 opt-in `GENERATION_BACKEND=codex_cli`runner 上缺少可执行文件或登录态时应看到结构化失败。
- DSA 不读取 Codex credential 文件,但子进程可能读取 CLI 自身登录态。
- Web 设置页只暴露安全 preset不允许提交任意 command / argv / shell string。
- `codex_cli` 仍标记为 experimental/limited如果你的 CLI 版本不支持稳定的 `--output-last-message` 非交互输出,请保持 `GENERATION_BACKEND=litellm`
## 方式一:极简单模型配置(适合新手)
@@ -379,7 +397,7 @@ model_list:
渠道模式无需上传 YAML 文件。仓库自带 `00-daily-analysis.yml` 已显式透传以下常用字段:
- 运行时选择:`LLM_CHANNELS`、`LITELLM_MODEL`、`LITELLM_FALLBACK_MODELS`、`AGENT_LITELLM_MODEL`、`VISION_MODEL`、`VISION_PROVIDER_PRIORITY`、`LLM_TEMPERATURE`、`LLM_USAGE_HMAC_SECRET`、`LLM_USAGE_HMAC_KEY_VERSION`、`LLM_PROMPT_CACHE_TELEMETRY_ENABLED`、`LLM_PROMPT_CACHE_HINTS_ENABLED`、`LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL`
- 运行时选择:`GENERATION_BACKEND`、`GENERATION_FALLBACK_BACKEND`、`GENERATION_BACKEND_TIMEOUT_SECONDS`、`GENERATION_BACKEND_MAX_OUTPUT_BYTES`、`GENERATION_BACKEND_MAX_CONCURRENCY`、`LOCAL_CLI_BACKEND_MAX_CONCURRENCY`、`AGENT_GENERATION_BACKEND`、`LLM_CHANNELS`、`LITELLM_MODEL`、`LITELLM_FALLBACK_MODELS`、`AGENT_LITELLM_MODEL`、`VISION_MODEL`、`VISION_PROVIDER_PRIORITY`、`LLM_TEMPERATURE`、`LLM_USAGE_HMAC_SECRET`、`LLM_USAGE_HMAC_KEY_VERSION`、`LLM_PROMPT_CACHE_TELEMETRY_ENABLED`、`LLM_PROMPT_CACHE_HINTS_ENABLED`、`LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL`
- 多 Key`GEMINI_API_KEYS`、`ANTHROPIC_API_KEYS`、`OPENAI_API_KEYS`、`DEEPSEEK_API_KEYS`(当前 workflow 仅从 repository secrets 导入,不会读取同名 Variables
- 常用渠道名:`primary`、`secondary`、`aihubmix`、`deepseek`、`dashscope`、`zhipu`、`moonshot`、`minimax`、`volcengine`、`siliconflow`、`openrouter`、`gemini`、`anthropic`、`openai`、`ollama`

View File

@@ -18,20 +18,38 @@ If you are choosing a concrete provider, setting up GitHub Actions Secrets / Var
---
## Generation Backend (Phase 1)
## Generation Backend (Phase 2)
The generation backend abstraction currently only centralizes the backend-selection contract for regular analysis, market review, `generate_text()`, and Agent Chat. In Phase 1, the only executable backend is `litellm`, so default behavior remains the historical LiteLLM path.
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**.
```env
GENERATION_BACKEND=litellm
GENERATION_FALLBACK_BACKEND=litellm
GENERATION_BACKEND_TIMEOUT_SECONDS=300
GENERATION_BACKEND_MAX_OUTPUT_BYTES=1048576
GENERATION_BACKEND_MAX_CONCURRENCY=1
LOCAL_CLI_BACKEND_MAX_CONCURRENCY=1
AGENT_GENERATION_BACKEND=auto
```
- `GENERATION_BACKEND` only supports `litellm`. Values such as `codex`, `claude_code`, `opencode`, or `hermes` produce an explicit configuration error and are not silently downgraded to LiteLLM.
- `GENERATION_FALLBACK_BACKEND=litellm` is a backend-level no-op when the primary backend is also `litellm`; model-level fallback still belongs to `LITELLM_FALLBACK_MODELS`, Router, or Channels.
- `AGENT_GENERATION_BACKEND=auto` means: reuse the current generation backend only if it supports tool calling; otherwise continue to use the LiteLLM tool backend. Because Phase 1 only executes LiteLLM, runtime behavior is equivalent to the existing Agent LiteLLM path.
- Local CLI, Hermes HTTP, and Agent text-only backends are later-phase additions and are not enabled in this version.
- `GENERATION_BACKEND=litellm|codex_cli`. `codex_cli` is a generation backend, not a LiteLLM provider; do not set `LITELLM_MODEL=codex_cli/...`.
- 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`.
- 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.
### Codex 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.
- 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.
- 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`.
## Method 1: Simple Model Config (For Beginners)
@@ -333,7 +351,7 @@ P0.5a does not introduce PromptBlock IR, `block_id`, `stability_class`, `static_
The bundled `00-daily-analysis.yml` explicitly passes the common LLM runtime fields to the job environment:
- Runtime selection: `LLM_CHANNELS`, `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, `VISION_PROVIDER_PRIORITY`, `LLM_TEMPERATURE`, `LLM_USAGE_HMAC_SECRET`, `LLM_USAGE_HMAC_KEY_VERSION`, `LLM_PROMPT_CACHE_TELEMETRY_ENABLED`, `LLM_PROMPT_CACHE_HINTS_ENABLED`, `LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL`
- Runtime selection: `GENERATION_BACKEND`, `GENERATION_FALLBACK_BACKEND`, `GENERATION_BACKEND_TIMEOUT_SECONDS`, `GENERATION_BACKEND_MAX_OUTPUT_BYTES`, `GENERATION_BACKEND_MAX_CONCURRENCY`, `LOCAL_CLI_BACKEND_MAX_CONCURRENCY`, `AGENT_GENERATION_BACKEND`, `LLM_CHANNELS`, `LITELLM_MODEL`, `LITELLM_FALLBACK_MODELS`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, `VISION_PROVIDER_PRIORITY`, `LLM_TEMPERATURE`, `LLM_USAGE_HMAC_SECRET`, `LLM_USAGE_HMAC_KEY_VERSION`, `LLM_PROMPT_CACHE_TELEMETRY_ENABLED`, `LLM_PROMPT_CACHE_HINTS_ENABLED`, `LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL`
- Multiple keys: `GEMINI_API_KEYS`, `ANTHROPIC_API_KEYS`, `OPENAI_API_KEYS`, `DEEPSEEK_API_KEYS` (the current workflow imports these from repository Secrets only, not from same-named Variables)
- Common channel names: `primary`, `secondary`, `aihubmix`, `deepseek`, `dashscope`, `zhipu`, `moonshot`, `minimax`, `volcengine`, `siliconflow`, `openrouter`, `gemini`, `anthropic`, `openai`, `ollama`

View File

@@ -226,9 +226,13 @@ daily_stock_analysis/
| 变量名 | 说明 | 默认值 | 必填 |
|--------|------|--------|:----:|
| `GENERATION_BACKEND` | 普通分析生成后端;Phase 1 仅支持 `litellm`,未知值会作为配置错误处理,不静默回退 | `litellm` | 否 |
| `GENERATION_FALLBACK_BACKEND` | backend 级 fallback当前 `litellm -> litellm` 解析为 no-op模型 fallback 仍由 LiteLLM 配置负责 | `litellm` | 否 |
| `AGENT_GENERATION_BACKEND` | Agent Chat 生成后端;`auto` 在 Phase 1 中等价于现有 LiteLLM tool-calling 后端 | `auto` | 否 |
| `GENERATION_BACKEND` | 普通分析生成后端;支持 `litellm` 或显式 opt-in 的 `codex_cli`experimental/limited | `litellm` | 否 |
| `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` | 否 |
| `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` | 否 |
@@ -257,6 +261,8 @@ daily_stock_analysis/
| `ANTHROPIC_TEMPERATURE` | Claude 温度参数0.0-1.0 | `0.7` | 可选 |
| `ANTHROPIC_MAX_TOKENS` | Claude 响应最大 token 数 | `8192` | 可选 |
> GitHub Actions 说明:仓库自带 `00-daily-analysis.yml` 在 `GENERATION_FALLBACK_BACKEND` 未配置时显式使用 `litellm`,避免未设置的 Secret/Variable 被导出为空值并意外禁用 backend fallback。若要在 Actions 中禁用 backend fallback请将 fallback 设为 primary backend让 resolver 走 self no-op。
> *注:`ANSPIRE_API_KEYS`、`AIHUBMIX_KEY`、`GEMINI_API_KEY`、`ANTHROPIC_API_KEY`、`OPENAI_API_KEY` 或 `OLLAMA_API_BASE` 至少配置一个。`ANSPIRE_API_KEYS` 与 `AIHUBMIX_KEY` 无需配置 `OPENAI_BASE_URL`,系统自动适配。
> 问股 single-agent 路径会在后台为 DeepSeek V4 thinking + tool-call 保存最近 3 条 provider trace并按原时序回放 `reasoning_content` / tool 结果;该能力不新增配置项,不进入 Web 历史 APIClaude extended thinking 仅覆盖离线 plumbingmulti-agent trace 注入留作后续增强。

View File

@@ -195,9 +195,13 @@ Default schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.
| Variable | Description | Default | Required |
|--------|------|--------|:----:|
| `GENERATION_BACKEND` | Generation backend for regular analysis. Phase 1 only supports `litellm`; unknown values are treated as configuration errors and are not silently downgraded | `litellm` | No |
| `GENERATION_FALLBACK_BACKEND` | Backend-level fallback. The current `litellm -> litellm` setting resolves to no-op; model fallback remains owned by LiteLLM config | `litellm` | No |
| `AGENT_GENERATION_BACKEND` | Agent Chat generation backend. In Phase 1, `auto` is equivalent to the existing LiteLLM tool-calling backend | `auto` | No |
| `GENERATION_BACKEND` | Generation backend for regular analysis. Supports `litellm` or explicit opt-in `codex_cli` (experimental/limited) | `litellm` | 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 |
| `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 |
@@ -216,6 +220,8 @@ Default schedule: Every weekday at **18:00 (Beijing Time)** automatic execution.
| `OLLAMA_API_BASE` | Ollama local service address (e.g. `http://localhost:11434`), see [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) | - | Optional |
| `OPENAI_MODEL` | OpenAI model name (legacy) | `gpt-5.5` | Optional |
> GitHub Actions note: the bundled `00-daily-analysis.yml` explicitly uses `litellm` when `GENERATION_FALLBACK_BACKEND` is not configured, so an unset Secret/Variable is not exported as an empty value that disables backend fallback. To disable backend fallback in Actions, set the fallback to the primary backend and let the resolver treat it as self no-op.
> *Note: Configure at least one of `ANSPIRE_API_KEYS`, `AIHUBMIX_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_API_BASE`, or `LLM_CHANNELS` / `LITELLM_CONFIG`. `ANSPIRE_API_KEYS` and `AIHUBMIX_KEY` are auto-adapted without an `OPENAI_BASE_URL`.
### Notification Channel Configuration

View File

@@ -20,7 +20,9 @@
优先级保持不变:`LITELLM_CONFIG` / `LITELLM_CONFIG_YAML` > `LLM_CHANNELS` > legacy provider keys。P4 只补文档,不迁移、不清空、不静默改写旧配置。
Generation backend 配置是更外层的运行时选择契约。Phase 1 只支持 `GENERATION_BACKEND=litellm``GENERATION_FALLBACK_BACKEND=litellm``AGENT_GENERATION_BACKEND=auto|litellm`;这些字段不会改变本页所述的 provider/model/Base URL 三层路由优先级。配置本地 CLI、Hermes 或其他非 `litellm` backend 会得到明确配置错误,不会自动降级到 LiteLLM
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 仍会把同一最终响应打印到 stdoutDSA 会从 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 工具调用仍使用 LiteLLMWeb 设置页只暴露 `AGENT_GENERATION_BACKEND=auto|litellm`,手写 `codex_cli` 不会启用 text-only Agent mode只会返回明确 unsupported tool-calling 诊断
本地 CLI Backend 不等于离线模型。Docker、云服务器和 CI 不天然拥有本机 CLI 登录态DSA 不读取 Codex credential 文件,但子进程可能使用 CLI 自身登录态,股票代码、新闻、持仓上下文、分析 prompt 和报告草稿可能被对应 CLI 背后的服务处理。
## Web 设置页路径

View File

@@ -32,7 +32,12 @@ from src.agent.provider_trace import (
trace_model_matches,
)
from src.llm.errors import call_litellm_with_param_recovery
from src.llm.backend_registry import LITELLM_BACKEND_ID, resolve_agent_generation_backend_id
from src.llm.backend_registry import (
AUTO_AGENT_BACKEND_ID,
CODEX_CLI_BACKEND_ID,
LITELLM_BACKEND_ID,
resolve_agent_generation_backend_id,
)
from src.llm.generation_backend import GenerationError, GenerationErrorCode
from src.llm.generation_params import apply_litellm_generation_params, resolve_litellm_wire_model
from src.llm.usage import attach_message_hmacs, extract_usage_payload, normalize_litellm_usage
@@ -383,6 +388,7 @@ class LLMToolAdapter:
retryable=False,
fallbackable=False,
backend=self._generation_backend_id,
provider=self._generation_backend_id,
details={
"field": "AGENT_GENERATION_BACKEND",
"requested_backend": self._generation_backend_id,
@@ -390,13 +396,41 @@ class LLMToolAdapter:
},
)
logger.error(
"Agent LLM backend %s does not support Phase 1 tool calling",
"Agent LLM backend %s does not support tool calling",
self._generation_backend_id,
)
return
litellm_model = get_effective_agent_primary_model(config)
if not litellm_model:
generation_backend = str(
getattr(config, "generation_backend", LITELLM_BACKEND_ID) or LITELLM_BACKEND_ID
).strip().lower()
agent_backend = str(
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:
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,
details={
"field": "AGENT_GENERATION_BACKEND",
"requested_backend": AUTO_AGENT_BACKEND_ID,
"generation_backend": CODEX_CLI_BACKEND_ID,
"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,
)
return
logger.warning("Agent LLM: no effective primary model configured")
return

View File

@@ -42,18 +42,25 @@ from src.config 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,
LITELLM_BACKEND_ID,
resolve_generation_backend_id,
resolve_generation_fallback_backend_id,
)
from src.llm.generation_backend import GenerationError, GenerationErrorCode
from src.llm.litellm_backend import LiteLLMGenerationBackend
from src.llm.backend_factory import create_generation_backend
from src.llm.generation_backend import (
GenerationBackend,
GenerationError,
GenerationErrorCode,
)
from src.llm.usage import (
attach_legacy_message_stability_audit,
attach_message_hmacs,
extract_usage_payload,
normalize_litellm_usage,
should_persist_usage_telemetry,
)
from src.llm.local_cli_backend import redact_diagnostic_text
from src.llm.provider_cache import (
apply_prompt_cache_hints,
build_provider_cache_route_context,
@@ -2133,7 +2140,17 @@ class GeminiAnalyzer:
self._litellm_available = False
self._init_litellm()
if not self._litellm_available:
logger.warning("No LLM configured (LITELLM_MODEL / API keys), AI analysis will be unavailable")
try:
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
except GenerationError:
backend_id = ""
if backend_id == CODEX_CLI_BACKEND_ID:
logger.info(
"Analyzer generation backend: codex_cli configured; "
"LiteLLM API keys are not required for stock analysis generation"
)
else:
logger.warning("No LLM configured (LITELLM_MODEL / API keys), AI analysis will be unavailable")
def _get_runtime_config(self) -> Config:
"""Return the runtime config, honoring injected overrides for tests/pipeline."""
@@ -2274,7 +2291,18 @@ class GeminiAnalyzer:
config = self._get_runtime_config()
litellm_model = config.litellm_model
if not litellm_model:
logger.warning("Analyzer LLM: LITELLM_MODEL not configured")
backend_id = ""
try:
backend_id = resolve_generation_backend_id(config)
except GenerationError:
pass
if backend_id == CODEX_CLI_BACKEND_ID:
logger.info(
"Analyzer LiteLLM: LITELLM_MODEL not configured; "
"using codex_cli generation backend"
)
else:
logger.warning("Analyzer LLM: LITELLM_MODEL not configured")
return
self._litellm_available = True
@@ -2353,29 +2381,45 @@ class GeminiAnalyzer:
def is_available(self) -> bool:
"""Check whether the configured generation backend is available."""
if self.get_generation_backend_config_error() is not None:
return False
backend_error = self.get_generation_backend_config_error()
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:
return True
return self._litellm_runtime_available()
def _litellm_runtime_available(self) -> bool:
return self._router is not None or self._litellm_available
def _can_use_generation_fallback(self, backend_error: GenerationError) -> bool:
if not backend_error.fallbackable:
return False
try:
_backend_id, fallback_backend_id = self._resolve_generation_backend_config()
except GenerationError:
return False
return (
fallback_backend_id == LITELLM_BACKEND_ID
and self._litellm_runtime_available()
)
def _resolve_generation_backend_config(self) -> Tuple[str, Optional[str]]:
"""Resolve and validate Phase 1 generation backend settings."""
"""Resolve and validate generation backend ids."""
config = self._get_runtime_config()
backend_id = resolve_generation_backend_id(config)
fallback_backend_id = resolve_generation_fallback_backend_id(config)
if backend_id != LITELLM_BACKEND_ID:
raise GenerationError(
error_code=GenerationErrorCode.CAPABILITY_UNSUPPORTED,
stage="generation",
retryable=False,
fallbackable=False,
backend=backend_id,
)
return backend_id, fallback_backend_id
def get_generation_backend_config_error(self) -> Optional[GenerationError]:
"""Return a structured backend config error, if Phase 1 cannot run it."""
"""Return a structured backend config error, if the backend cannot run."""
try:
self._resolve_generation_backend_config()
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
if backend_id == CODEX_CLI_BACKEND_ID:
backend = self._get_generation_backend(backend_id)
get_config_error = getattr(backend, "get_config_error", None)
if callable(get_config_error):
return get_config_error()
except GenerationError as exc:
return exc
return None
@@ -2572,10 +2616,15 @@ class GeminiAnalyzer:
return response_text, usage
def _get_generation_backend(self) -> LiteLLMGenerationBackend:
"""Return the configured Phase 1 generation backend."""
self._resolve_generation_backend_config()
return LiteLLMGenerationBackend(self._call_litellm_impl)
def _get_generation_backend(self, backend_id: Optional[str] = None) -> GenerationBackend:
"""Return the configured generation backend."""
config = self._get_runtime_config()
resolved_backend_id = backend_id or self._resolve_generation_backend_config()[0]
return create_generation_backend(
resolved_backend_id,
config=config,
litellm_completion_callable=self._call_litellm_impl,
)
def _call_litellm(
self,
@@ -2589,15 +2638,99 @@ class GeminiAnalyzer:
audit_context: Optional[Dict[str, Any]] = None,
) -> Tuple[str, str, Dict[str, Any]]:
"""Compatibility wrapper around the configured generation backend."""
result = self._get_generation_backend().generate(
prompt,
generation_config,
system_prompt=system_prompt,
stream=stream,
stream_progress_callback=stream_progress_callback,
response_validator=response_validator,
audit_context=audit_context,
)
backend_id, fallback_backend_id = self._resolve_generation_backend_config()
try:
result = self._get_generation_backend(backend_id).generate(
prompt,
generation_config,
system_prompt=system_prompt,
stream=stream,
stream_progress_callback=stream_progress_callback,
response_validator=response_validator,
audit_context=audit_context,
)
except GenerationError as exc:
if not exc.fallbackable or not fallback_backend_id:
raise
try:
fallback_backend = self._get_generation_backend(fallback_backend_id)
except GenerationError as fallback_exc:
raise GenerationError(
error_code=fallback_exc.error_code,
stage="fallback",
retryable=False,
fallbackable=False,
backend=fallback_backend_id,
provider=fallback_exc.provider,
details={
"primary_error": {
"error_code": exc.error_code.value,
"backend": exc.backend,
"provider": exc.provider,
"stage": exc.stage,
"details": exc.details,
},
"fallback_error": fallback_exc.details,
},
) from fallback_exc
try:
result = fallback_backend.generate(
prompt,
generation_config,
system_prompt=system_prompt,
stream=stream,
stream_progress_callback=stream_progress_callback,
response_validator=response_validator,
audit_context=audit_context,
)
except _AllModelsFailedError:
raise
except GenerationError as fallback_exc:
raise GenerationError(
error_code=fallback_exc.error_code,
stage="fallback",
retryable=False,
fallbackable=False,
backend=fallback_backend_id,
provider=fallback_exc.provider,
details={
"reason": "fallback_backend_failed",
"primary_error": {
"error_code": exc.error_code.value,
"backend": exc.backend,
"provider": exc.provider,
"stage": exc.stage,
"details": exc.details,
},
"fallback_error": {
"error_code": fallback_exc.error_code.value,
"backend": fallback_exc.backend,
"provider": fallback_exc.provider,
"stage": fallback_exc.stage,
"details": fallback_exc.details,
},
},
) from fallback_exc
except Exception as fallback_exc:
raise GenerationError(
error_code=GenerationErrorCode.UNKNOWN_BACKEND_ERROR,
stage="fallback",
retryable=False,
fallbackable=False,
backend=fallback_backend_id,
provider=fallback_backend_id,
details={
"reason": "fallback_backend_failed",
"primary_error": {
"error_code": exc.error_code.value,
"backend": exc.backend,
"provider": exc.provider,
"stage": exc.stage,
"details": exc.details,
},
"fallback_error": str(fallback_exc),
},
) from fallback_exc
return result.text, result.model, result.usage
def _call_litellm_impl(
@@ -2854,7 +2987,8 @@ class GeminiAnalyzer:
)
if isinstance(result, tuple):
text, model_used, usage = result
persist_llm_usage(usage, model_used, call_type="market_review")
if should_persist_usage_telemetry(usage):
persist_llm_usage(usage, model_used, call_type="market_review")
return text
return result
except GenerationError:
@@ -2883,7 +3017,7 @@ class GeminiAnalyzer:
Args:
context: 从 storage.get_analysis_context() 获取的上下文数据
news_context: 预先搜索的新闻内容(可选)
Returns:
AnalysisResult 对象
"""
@@ -2919,26 +3053,28 @@ class GeminiAnalyzer:
name = STOCK_NAME_MAP.get(code, f'股票{code}')
backend_error = self.get_generation_backend_config_error()
if backend_error is not None:
if backend_error is not None and not self._can_use_generation_fallback(backend_error):
details = backend_error.details or {}
field = str(details.get("field") or "GENERATION_BACKEND")
requested_backend = str(details.get("requested_backend") or backend_error.backend)
reason = str(details.get("reason") or backend_error.error_code.value)
if report_language == "en":
summary = (
"AI analysis is unavailable because the generation backend "
f"configuration is invalid: {field}={requested_backend}."
f"cannot start: {backend_error.error_code.value}."
)
risk_warning = (
f"Phase 1 only supports litellm for {field}; set it back to "
"litellm and retry."
f"Check {field}={requested_backend} ({reason}) or set a valid "
"backend/fallback before retrying."
)
else:
summary = (
"AI 分析功能不可用:生成后端配置错误"
f"{field}={requested_backend}"
"AI 分析功能不可用:生成后端无法启动"
f"{backend_error.error_code.value}"
)
risk_warning = (
f"Phase 1 中 {field} 仅支持 litellm请设回 litellm 后重试。"
f"请检查 {field}={requested_backend}{reason}"
"或配置有效后端/回退后重试。"
)
return AnalysisResult(
code=code,
@@ -3005,16 +3141,24 @@ 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
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 ''}")
# 记录完整 prompt 到日志INFO级别记录摘要DEBUG记录完整
prompt_preview = prompt[:500] + "..." if len(prompt) > 500 else prompt
# 本地 CLI backend 是进程执行能力,不记录完整 prompt。
if backend_id == CODEX_CLI_BACKEND_ID:
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}")
logger.debug(f"=== 完整 Prompt ({len(prompt)}字符) ===\n{prompt}\n=== End Prompt ===")
if backend_id != CODEX_CLI_BACKEND_ID:
logger.debug(f"=== 完整 Prompt ({len(prompt)}字符) ===\n{prompt}\n=== End Prompt ===")
# 设置生成配置
generation_config = {
@@ -3060,11 +3204,15 @@ class GeminiAnalyzer:
logger.info(
f"[LLM返回] {model_name} 响应成功, 耗时 {elapsed:.2f}s, 响应长度 {len(response_text)} 字符"
)
response_preview = response_text[:300] + "..." if len(response_text) > 300 else response_text
if backend_id == CODEX_CLI_BACKEND_ID:
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}")
logger.debug(
f"=== {model_name} 完整响应 ({len(response_text)}字符) ===\n{response_text}\n=== End Response ==="
)
if backend_id != CODEX_CLI_BACKEND_ID:
logger.debug(
f"=== {model_name} 完整响应 ({len(response_text)}字符) ===\n{response_text}\n=== End Response ==="
)
# Keep parser/retry progress monotonic so task progress/message never "goes backward".
parse_progress = min(99, 93 + retry_count * 2)
_emit_progress(parse_progress, f"{name}LLM 返回完成,正在解析 JSON")
@@ -3114,7 +3262,8 @@ class GeminiAnalyzer:
)
break
persist_llm_usage(llm_usage, model_used, call_type="analysis", stock_code=code)
if should_persist_usage_telemetry(llm_usage):
persist_llm_usage(llm_usage, model_used, call_type="analysis", stock_code=code)
logger.info(f"[LLM解析] {name}({code}) 分析完成: {result.trend_prediction}, 评分 {result.sentiment_score}")
@@ -3775,6 +3924,137 @@ class GeminiAnalyzer:
"""Delegate to module-level apply_placeholder_fill."""
apply_placeholder_fill(result, missing_fields)
def _extract_analysis_json_object(self, response_text: str) -> Tuple[str, Dict[str, Any]]:
"""Extract the single allowed JSON object from an LLM response."""
text = response_text or ""
stripped = text.strip()
if not stripped:
raise ValueError("empty_response")
fence_pattern = re.compile(
r"```[ \t]*(?P<lang>[A-Za-z0-9_-]*)[ \t]*\n?(?P<body>.*?)```",
flags=re.DOTALL,
)
fenced_matches = list(fence_pattern.finditer(text))
if len(fenced_matches) > 1:
raise ValueError("ambiguous_json")
if len(fenced_matches) == 1:
match = fenced_matches[0]
outside = (text[:match.start()] + text[match.end():]).strip()
if outside:
raise ValueError("ambiguous_json")
fence_lang = (match.group("lang") or "").strip().lower()
if fence_lang not in {"", "json"}:
raise ValueError("ambiguous_json")
json_str = match.group("body").strip()
data = self._load_analysis_json_candidate(json_str)
return json_str, data
if "```" in text:
raise ValueError("ambiguous_json")
try:
data = self._load_analysis_json_candidate(stripped)
except json.JSONDecodeError as exc:
if self._contains_embedded_json_object(text):
raise ValueError("ambiguous_json") from exc
raise
return stripped, data
def _load_analysis_json_candidate(self, json_str: str) -> Dict[str, Any]:
"""Parse one already-selected JSON candidate, repairing common LLM JSON drift."""
try:
data = json.loads(json_str)
except json.JSONDecodeError:
stripped = (json_str or "").strip()
try:
_obj, end = json.JSONDecoder().raw_decode(stripped)
except json.JSONDecodeError:
pass
else:
if stripped[end:].strip():
raise
if not (stripped.startswith("{") and stripped.endswith("}")):
raise
repaired = self._fix_json_string(stripped)
data = json.loads(repaired)
if not isinstance(data, dict):
raise TypeError("json_root_not_object")
return data
@staticmethod
def _contains_embedded_json_object(text: str) -> bool:
decoder = json.JSONDecoder()
count = 0
for index, char in enumerate(text):
if char != "{":
continue
try:
_obj, end = decoder.raw_decode(text[index:])
except json.JSONDecodeError:
continue
count += 1
before = text[:index].strip()
after = text[index + end:].strip()
if count > 1 or before or after:
return True
return False
def _validate_analysis_minimal_contract(self, data: Dict[str, Any]) -> None:
try:
AnalysisReportSchema.model_validate(data)
except Exception as exc:
logger.warning(
"AnalysisReportSchema validation failed; continuing with raw parser contract: %s",
str(exc)[:200],
)
minimal_keys = {
"sentiment_score",
"trend_prediction",
"operation_advice",
"analysis_summary",
"dashboard",
}
if not any(key in data for key in minimal_keys):
raise self._generation_validation_error(
GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
reason="minimal_contract_failed",
message="analysis JSON does not contain any minimal parser field",
)
if "sentiment_score" in data:
try:
int(data.get("sentiment_score", 50))
except (TypeError, ValueError) as exc:
raise self._generation_validation_error(
GenerationErrorCode.SCHEMA_VALIDATION_FAILED,
reason="parser_contract_failed",
message="sentiment_score must be integer-compatible",
) from exc
def _generation_validation_error(
self,
error_code: GenerationErrorCode,
*,
reason: str,
message: str,
) -> GenerationError:
try:
backend_id, _fallback_backend_id = self._resolve_generation_backend_config()
except GenerationError:
backend_id = "generation_backend"
return GenerationError(
error_code=error_code,
stage="validation",
retryable=True,
fallbackable=True,
backend=backend_id,
provider=backend_id,
details={
"reason": reason,
"message": message,
},
)
def _parse_response(
self,
response_text: str,
@@ -3791,100 +4071,75 @@ class GeminiAnalyzer:
report_language = normalize_report_language(
getattr(self._get_runtime_config(), "report_language", "zh")
)
# 清理响应文本:移除 markdown 代码块标记
cleaned_text = response_text
if '```json' in cleaned_text:
cleaned_text = cleaned_text.replace('```json', '').replace('```', '')
elif '```' in cleaned_text:
cleaned_text = cleaned_text.replace('```', '')
# 尝试找到 JSON 内容
json_start = cleaned_text.find('{')
json_end = cleaned_text.rfind('}') + 1
if json_start >= 0 and json_end > json_start:
json_str = cleaned_text[json_start:json_end]
# 尝试修复常见的 JSON 问题
json_str = self._fix_json_string(json_str)
data = json.loads(json_str)
# Schema validation (lenient: on failure, continue with raw dict)
try:
AnalysisReportSchema.model_validate(data)
except Exception as e:
logger.warning(
"LLM report schema validation failed, continuing with raw dict: %s",
str(e)[:100],
)
# 提取 dashboard 数据
dashboard = data.get('dashboard', None)
# 优先使用 AI 返回的股票名称(如果原名称无效或包含代码)
ai_stock_name = data.get('stock_name')
if ai_stock_name and (name.startswith('股票') or name == code or 'Unknown' in name):
name = ai_stock_name
# 解析所有字段,使用默认值防止缺失
# 解析 decision_type如果没有则根据 operation_advice 推断
decision_type = data.get('decision_type', '')
if not decision_type:
op = data.get('operation_advice', 'Hold' if report_language == "en" else '持有')
decision_type = infer_decision_type_from_advice(op, default='hold')
explicit_action = data.get("action")
if explicit_action is None and isinstance(dashboard, dict):
explicit_action = dashboard.get("action")
result = AnalysisResult(
code=code,
name=name,
# 核心指标
sentiment_score=int(data.get('sentiment_score', 50)),
trend_prediction=data.get('trend_prediction', 'Sideways' if report_language == "en" else '震荡'),
operation_advice=data.get('operation_advice', 'Hold' if report_language == "en" else '持有'),
decision_type=decision_type,
confidence_level=localize_confidence_level(
data.get('confidence_level', 'Medium' if report_language == "en" else ''),
report_language,
),
report_language=report_language,
# 决策仪表盘
dashboard=dashboard,
# 走势分析
trend_analysis=data.get('trend_analysis', ''),
short_term_outlook=data.get('short_term_outlook', ''),
medium_term_outlook=data.get('medium_term_outlook', ''),
# 技术面
technical_analysis=data.get('technical_analysis', ''),
ma_analysis=data.get('ma_analysis', ''),
volume_analysis=data.get('volume_analysis', ''),
pattern_analysis=data.get('pattern_analysis', ''),
# 基本面
fundamental_analysis=data.get('fundamental_analysis', ''),
sector_position=data.get('sector_position', ''),
company_highlights=data.get('company_highlights', ''),
# 情绪面/消息面
news_summary=data.get('news_summary', ''),
market_sentiment=data.get('market_sentiment', ''),
hot_topics=data.get('hot_topics', ''),
# 综合
analysis_summary=data.get('analysis_summary', 'Analysis completed' if report_language == "en" else '分析完成'),
key_points=data.get('key_points', ''),
risk_warning=data.get('risk_warning', ''),
buy_reason=data.get('buy_reason', ''),
# 元数据
search_performed=data.get('search_performed', False),
data_sources=data.get('data_sources', 'Technical data' if report_language == "en" else '技术面数据'),
success=True,
)
return populate_decision_action_fields(result, explicit_action=explicit_action)
else:
# 没有找到 JSON标记为失败
logger.warning(f"无法从响应中提取 JSON标记为解析失败")
try:
_json_str, data = self._extract_analysis_json_object(response_text)
self._validate_analysis_minimal_contract(data)
except Exception as exc:
logger.warning("无法从响应中提取唯一有效 JSON标记为解析失败: %s", exc)
return self._parse_text_response(response_text, code, name)
# 提取 dashboard 数据
dashboard = data.get('dashboard', None)
# 优先使用 AI 返回的股票名称(如果原名称无效或包含代码)
ai_stock_name = data.get('stock_name')
if ai_stock_name and (name.startswith('股票') or name == code or 'Unknown' in name):
name = ai_stock_name
# 解析所有字段,使用默认值防止缺失
# 解析 decision_type如果没有则根据 operation_advice 推断
decision_type = data.get('decision_type', '')
if not decision_type:
op = data.get('operation_advice', 'Hold' if report_language == "en" else '持有')
decision_type = infer_decision_type_from_advice(op, default='hold')
explicit_action = data.get("action")
if explicit_action is None and isinstance(dashboard, dict):
explicit_action = dashboard.get("action")
result = AnalysisResult(
code=code,
name=name,
# 核心指标
sentiment_score=int(data.get('sentiment_score', 50)),
trend_prediction=data.get('trend_prediction', 'Sideways' if report_language == "en" else '震荡'),
operation_advice=data.get('operation_advice', 'Hold' if report_language == "en" else '持有'),
decision_type=decision_type,
confidence_level=localize_confidence_level(
data.get('confidence_level', 'Medium' if report_language == "en" else ''),
report_language,
),
report_language=report_language,
# 决策仪表盘
dashboard=dashboard,
# 走势分析
trend_analysis=data.get('trend_analysis', ''),
short_term_outlook=data.get('short_term_outlook', ''),
medium_term_outlook=data.get('medium_term_outlook', ''),
# 技术面
technical_analysis=data.get('technical_analysis', ''),
ma_analysis=data.get('ma_analysis', ''),
volume_analysis=data.get('volume_analysis', ''),
pattern_analysis=data.get('pattern_analysis', ''),
# 基本面
fundamental_analysis=data.get('fundamental_analysis', ''),
sector_position=data.get('sector_position', ''),
company_highlights=data.get('company_highlights', ''),
# 情绪面/消息面
news_summary=data.get('news_summary', ''),
market_sentiment=data.get('market_sentiment', ''),
hot_topics=data.get('hot_topics', ''),
# 综合
analysis_summary=data.get('analysis_summary', 'Analysis completed' if report_language == "en" else '分析完成'),
key_points=data.get('key_points', ''),
risk_warning=data.get('risk_warning', ''),
buy_reason=data.get('buy_reason', ''),
# 元数据
search_performed=data.get('search_performed', False),
data_sources=data.get('data_sources', 'Technical data' if report_language == "en" else '技术面数据'),
success=True,
)
return populate_decision_action_fields(result, explicit_action=explicit_action)
except json.JSONDecodeError as e:
logger.warning(f"JSON 解析失败: {e},标记为解析失败")
@@ -3911,32 +4166,44 @@ class GeminiAnalyzer:
return json_str
def _validate_json_response(self, text: str) -> None:
"""Validate that *text* contains a parseable JSON object.
"""Validate that *text* contains one parser-compatible JSON object.
Used as the ``response_validator`` argument to :meth:`_call_litellm` so
that a JSON-less or unparseable reply from the primary model is treated
as a model failure and triggers fallback to the next configured model.
Raises:
ValueError: if no JSON object is found in *text*.
json.JSONDecodeError: if the extracted JSON cannot be parsed (after
:meth:`_fix_json_string` attempts repair).
GenerationError: if the response has no unique parser-compatible
JSON object, the selected JSON candidate cannot be parsed, or
the parsed object cannot satisfy the minimal parser contract.
"""
cleaned = text
if "```json" in cleaned:
cleaned = cleaned.replace("```json", "").replace("```", "")
elif "```" in cleaned:
cleaned = cleaned.replace("```", "")
try:
_json_str, data = self._extract_analysis_json_object(text)
except ValueError as exc:
reason = str(exc) or "invalid_json"
if reason == "ambiguous_json":
message = "JSON source is ambiguous"
else:
message = "No unique JSON object found in LLM response"
raise self._generation_validation_error(
GenerationErrorCode.INVALID_JSON,
reason=reason,
message=message,
) from exc
except json.JSONDecodeError as exc:
raise self._generation_validation_error(
GenerationErrorCode.INVALID_JSON,
reason="invalid_json",
message=str(exc)[:200],
) from exc
except Exception as exc:
raise self._generation_validation_error(
GenerationErrorCode.INVALID_JSON,
reason="invalid_json",
message=str(exc)[:200],
) from exc
json_start = cleaned.find("{")
json_end = cleaned.rfind("}") + 1
if json_start < 0 or json_end <= json_start:
raise ValueError("No JSON object found in LLM response")
json_str = cleaned[json_start:json_end]
json_str = self._fix_json_string(json_str)
json.loads(json_str)
self._validate_analysis_minimal_contract(data)
def _parse_text_response(
self,

View File

@@ -38,10 +38,21 @@ from src.notification_contracts import (
)
from src.llm.backend_registry import (
AUTO_AGENT_BACKEND_ID,
CODEX_CLI_BACKEND_ID,
LITELLM_BACKEND_ID,
SUPPORTED_AGENT_GENERATION_BACKENDS,
SUPPORTED_GENERATION_BACKENDS,
)
from src.llm.local_cli_backend import (
DEFAULT_GENERATION_BACKEND_MAX_CONCURRENCY,
DEFAULT_LOCAL_CLI_BACKEND_MAX_CONCURRENCY,
DEFAULT_LOCAL_CLI_MAX_OUTPUT_BYTES,
DEFAULT_LOCAL_CLI_TIMEOUT_SECONDS,
MAX_GENERATION_BACKEND_MAX_CONCURRENCY,
MAX_LOCAL_CLI_BACKEND_MAX_CONCURRENCY,
MAX_LOCAL_CLI_OUTPUT_BYTES,
MAX_LOCAL_CLI_TIMEOUT_SECONDS,
)
from src.llm import generation_params as llm_generation_params
from src.scheduler import normalize_schedule_times
@@ -683,6 +694,10 @@ class Config:
# === AI 分析配置 ===
generation_backend: str = LITELLM_BACKEND_ID
generation_fallback_backend: str = LITELLM_BACKEND_ID
generation_backend_timeout_seconds: int = DEFAULT_LOCAL_CLI_TIMEOUT_SECONDS
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
# 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
@@ -1364,14 +1379,43 @@ class Config:
os.getenv('GENERATION_BACKEND', LITELLM_BACKEND_ID).strip().lower()
or LITELLM_BACKEND_ID
)
generation_fallback_backend = (
os.getenv('GENERATION_FALLBACK_BACKEND', LITELLM_BACKEND_ID).strip().lower()
or LITELLM_BACKEND_ID
)
_generation_fallback_raw = os.getenv('GENERATION_FALLBACK_BACKEND')
if _generation_fallback_raw is None:
generation_fallback_backend = LITELLM_BACKEND_ID
else:
generation_fallback_backend = _generation_fallback_raw.strip().lower()
agent_generation_backend = (
os.getenv('AGENT_GENERATION_BACKEND', AUTO_AGENT_BACKEND_ID).strip().lower()
or AUTO_AGENT_BACKEND_ID
)
generation_backend_timeout_seconds = parse_env_int(
os.getenv('GENERATION_BACKEND_TIMEOUT_SECONDS'),
DEFAULT_LOCAL_CLI_TIMEOUT_SECONDS,
field_name='GENERATION_BACKEND_TIMEOUT_SECONDS',
minimum=1,
maximum=MAX_LOCAL_CLI_TIMEOUT_SECONDS,
)
generation_backend_max_output_bytes = parse_env_int(
os.getenv('GENERATION_BACKEND_MAX_OUTPUT_BYTES'),
DEFAULT_LOCAL_CLI_MAX_OUTPUT_BYTES,
field_name='GENERATION_BACKEND_MAX_OUTPUT_BYTES',
minimum=1,
maximum=MAX_LOCAL_CLI_OUTPUT_BYTES,
)
generation_backend_max_concurrency = parse_env_int(
os.getenv('GENERATION_BACKEND_MAX_CONCURRENCY'),
DEFAULT_GENERATION_BACKEND_MAX_CONCURRENCY,
field_name='GENERATION_BACKEND_MAX_CONCURRENCY',
minimum=1,
maximum=MAX_GENERATION_BACKEND_MAX_CONCURRENCY,
)
local_cli_backend_max_concurrency = parse_env_int(
os.getenv('LOCAL_CLI_BACKEND_MAX_CONCURRENCY'),
DEFAULT_LOCAL_CLI_BACKEND_MAX_CONCURRENCY,
field_name='LOCAL_CLI_BACKEND_MAX_CONCURRENCY',
minimum=1,
maximum=MAX_LOCAL_CLI_BACKEND_MAX_CONCURRENCY,
)
agent_litellm_model = normalize_agent_litellm_model(
os.getenv('AGENT_LITELLM_MODEL', ''),
@@ -1518,6 +1562,10 @@ class Config:
),
generation_backend=generation_backend,
generation_fallback_backend=generation_fallback_backend,
generation_backend_timeout_seconds=generation_backend_timeout_seconds,
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,
litellm_model=litellm_model,
litellm_fallback_models=litellm_fallback_models,
llm_temperature=resolve_unified_llm_temperature(litellm_model),
@@ -2588,9 +2636,7 @@ class Config:
# --- Generation backend selection ---
generation_backend = (self.generation_backend or LITELLM_BACKEND_ID).strip().lower()
generation_fallback_backend = (
self.generation_fallback_backend or LITELLM_BACKEND_ID
).strip().lower()
generation_fallback_backend = str(self.generation_fallback_backend or "").strip().lower()
agent_generation_backend = (
self.agent_generation_backend or AUTO_AGENT_BACKEND_ID
).strip().lower()
@@ -2598,16 +2644,18 @@ class Config:
issues.append(ConfigIssue(
severity="error",
message=(
"GENERATION_BACKEND 当前支持 litellm。"
"GENERATION_BACKEND 当前支持 litellm 或 codex_cli"
f"已配置的值为:{generation_backend}"
),
field="GENERATION_BACKEND",
))
if generation_fallback_backend not in SUPPORTED_GENERATION_BACKENDS:
if generation_fallback_backend and generation_fallback_backend == generation_backend:
generation_fallback_backend = ""
if generation_fallback_backend and generation_fallback_backend != LITELLM_BACKEND_ID:
issues.append(ConfigIssue(
severity="error",
message=(
"GENERATION_FALLBACK_BACKEND 当前支持 litellm。"
"GENERATION_FALLBACK_BACKEND 当前支持 litellm、与 primary 相同的 no-op 值,或空字符串"
f"已配置的值为:{generation_fallback_backend}"
),
field="GENERATION_FALLBACK_BACKEND",
@@ -2616,18 +2664,29 @@ class Config:
issues.append(ConfigIssue(
severity="error",
message=(
"AGENT_GENERATION_BACKEND 当前支持 autolitellm"
"AGENT_GENERATION_BACKEND 当前支持 autolitellm"
"codex_cli 仅作为显式 unsupported diagnostic 保留,不支持 Agent 工具调用。"
f"已配置的值为:{agent_generation_backend}"
),
field="AGENT_GENERATION_BACKEND",
))
if (self.litellm_model or "").strip().lower().startswith(f"{CODEX_CLI_BACKEND_ID}/"):
issues.append(ConfigIssue(
severity="error",
message=(
"codex_cli 是 GENERATION_BACKEND不是 LiteLLM provider。"
"请不要使用 LITELLM_MODEL=codex_cli/...。"
),
field="LITELLM_MODEL",
))
# --- LLM availability ---
# llm_model_list is populated for YAML / channels / managed legacy keys.
# 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)
if not self.llm_model_list and not has_direct_env_model:
local_generation_backend = generation_backend == CODEX_CLI_BACKEND_ID
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(
severity="error",
@@ -2658,7 +2717,7 @@ class Config:
),
field="LITELLM_CONFIG",
))
elif not self.litellm_model:
elif not local_generation_backend and not self.litellm_model:
issues.append(ConfigIssue(
severity="info",
message=(

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-22"
SCHEMA_VERSION = "2026-06-23-local-cli-backend"
_CATEGORY_DEFINITIONS: List[Dict[str, Any]] = [
{
@@ -126,11 +126,14 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"is_required": False,
"is_editable": True,
"default_value": "litellm",
"options": [{"label": "Default model settings", "value": "litellm"}],
"validation": {"enum": ["litellm"]},
"options": [
{"label": "Default model settings", "value": "litellm"},
{"label": "Codex CLI (experimental)", "value": "codex_cli"},
],
"validation": {"enum": ["litellm", "codex_cli"]},
"display_order": 0,
"help_key": "settings.ai_model.GENERATION_BACKEND",
"examples": ["GENERATION_BACKEND=litellm"],
"examples": ["GENERATION_BACKEND=litellm", "GENERATION_BACKEND=codex_cli"],
"docs": [
{
"label": "LLM 配置指南",
@@ -140,8 +143,8 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"warning_codes": [],
},
"GENERATION_FALLBACK_BACKEND": {
"title": "Fallback Generation Method (reserved)",
"description": "Reserved fallback method for future multi-method generation; keep the default model settings for the current default behavior.",
"title": "Fallback Generation Method",
"description": "Backend-level fallback method. Empty disables backend fallback; litellm can be used as fallback for Codex CLI.",
"category": "ai_model",
"data_type": "string",
"ui_control": "select",
@@ -149,11 +152,109 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"is_required": False,
"is_editable": True,
"default_value": "litellm",
"options": [{"label": "Default model settings", "value": "litellm"}],
"validation": {"enum": ["litellm"]},
"options": [
{"label": "Disabled", "value": ""},
{"label": "Default model settings", "value": "litellm"},
],
"validation": {"enum": ["", "litellm"]},
"display_order": 0,
"help_key": "settings.ai_model.GENERATION_FALLBACK_BACKEND",
"examples": ["GENERATION_FALLBACK_BACKEND=litellm"],
"examples": ["GENERATION_FALLBACK_BACKEND=litellm", "GENERATION_FALLBACK_BACKEND="],
"docs": [
{
"label": "LLM 配置指南",
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/LLM_CONFIG_GUIDE.md",
},
],
"warning_codes": [],
},
"GENERATION_BACKEND_TIMEOUT_SECONDS": {
"title": "Generation Backend Timeout",
"description": "Maximum seconds allowed for one generation backend call. Applies to local CLI backends; LiteLLM behavior is unchanged.",
"category": "ai_model",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "300",
"options": [],
"validation": {"min": 1, "max": 3600},
"display_order": 1,
"help_key": "settings.ai_model.GENERATION_BACKEND_TIMEOUT_SECONDS",
"examples": ["GENERATION_BACKEND_TIMEOUT_SECONDS=300"],
"docs": [
{
"label": "LLM 配置指南",
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/LLM_CONFIG_GUIDE.md",
},
],
"warning_codes": [],
},
"GENERATION_BACKEND_MAX_OUTPUT_BYTES": {
"title": "Generation Backend Max Output Bytes",
"description": (
"Maximum captured diagnostic stdout/stderr and final-response bytes "
"for one local CLI backend call."
),
"category": "ai_model",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "1048576",
"options": [],
"validation": {"min": 1, "max": 33554432},
"display_order": 1,
"help_key": "settings.ai_model.GENERATION_BACKEND_MAX_OUTPUT_BYTES",
"examples": ["GENERATION_BACKEND_MAX_OUTPUT_BYTES=1048576"],
"docs": [
{
"label": "LLM 配置指南",
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/LLM_CONFIG_GUIDE.md",
},
],
"warning_codes": [],
},
"GENERATION_BACKEND_MAX_CONCURRENCY": {
"title": "Generation Backend Max Concurrency",
"description": "Global generation backend concurrency cap. Local CLI effective concurrency also respects LOCAL_CLI_BACKEND_MAX_CONCURRENCY.",
"category": "ai_model",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "1",
"options": [],
"validation": {"min": 1, "max": 16},
"display_order": 1,
"help_key": "settings.ai_model.GENERATION_BACKEND_MAX_CONCURRENCY",
"examples": ["GENERATION_BACKEND_MAX_CONCURRENCY=1"],
"docs": [
{
"label": "LLM 配置指南",
"href": "https://github.com/ZhuLinsen/daily_stock_analysis/blob/main/docs/LLM_CONFIG_GUIDE.md",
},
],
"warning_codes": [],
},
"LOCAL_CLI_BACKEND_MAX_CONCURRENCY": {
"title": "Local CLI Backend Max Concurrency",
"description": "Local CLI backend concurrency cap. Effective local CLI concurrency is the minimum of this value and GENERATION_BACKEND_MAX_CONCURRENCY.",
"category": "ai_model",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "1",
"options": [],
"validation": {"min": 1, "max": 4},
"display_order": 1,
"help_key": "settings.ai_model.LOCAL_CLI_BACKEND_MAX_CONCURRENCY",
"examples": ["LOCAL_CLI_BACKEND_MAX_CONCURRENCY=1"],
"docs": [
{
"label": "LLM 配置指南",
@@ -3555,7 +3656,7 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"default_value": "auto",
"options": [
{"label": "Auto", "value": "auto"},
{"label": "Default model tool calling", "value": "litellm"},
{"label": "Default model settings", "value": "litellm"},
],
"validation": {"enum": ["auto", "litellm"]},
"display_order": 2,

View File

@@ -12,6 +12,7 @@ from typing import Any, Optional, Tuple
from src.config import Config
from src.llm.backend_registry import (
CODEX_CLI_BACKEND_ID,
resolve_generation_backend_id,
resolve_generation_fallback_backend_id,
)
@@ -22,6 +23,12 @@ 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:
return True
except GenerationError:
pass
if (getattr(config, "litellm_model", "") or "").strip():
return True
if getattr(config, "llm_model_list", None):

View File

@@ -4,6 +4,7 @@ from src.llm.backend_registry import (
AUTO_AGENT_BACKEND_ID,
LITELLM_BACKEND_ID,
SUPPORTED_AGENT_GENERATION_BACKENDS,
SUPPORTED_GENERATION_FALLBACK_BACKENDS,
SUPPORTED_GENERATION_BACKENDS,
resolve_agent_generation_backend_id,
resolve_generation_backend_id,
@@ -28,6 +29,7 @@ __all__ = [
"LITELLM_BACKEND_ID",
"LiteLLMGenerationBackend",
"SUPPORTED_AGENT_GENERATION_BACKENDS",
"SUPPORTED_GENERATION_FALLBACK_BACKENDS",
"SUPPORTED_GENERATION_BACKENDS",
"resolve_agent_generation_backend_id",
"resolve_generation_backend_id",

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""Generation backend factory."""
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.generation_backend import GenerationBackend, GenerationError, GenerationErrorCode
from src.llm.litellm_backend import LiteLLMCallable, LiteLLMGenerationBackend
from src.llm.local_cli_backend import LocalCliGenerationBackend
def create_generation_backend(
backend_id: str,
*,
config: Any,
litellm_completion_callable: Optional[LiteLLMCallable] = None,
) -> GenerationBackend:
"""Create the configured generation backend."""
normalized = (backend_id or "").strip().lower()
if normalized == LITELLM_BACKEND_ID:
if litellm_completion_callable is None:
raise GenerationError(
error_code=GenerationErrorCode.BACKEND_NOT_CONFIGURED,
stage="configuration",
retryable=False,
fallbackable=False,
backend=LITELLM_BACKEND_ID,
provider=LITELLM_BACKEND_ID,
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)
raise GenerationError(
error_code=GenerationErrorCode.BACKEND_NOT_CONFIGURED,
stage="configuration",
retryable=False,
fallbackable=False,
backend=normalized or "unknown",
provider=normalized or "unknown",
details={"reason": "unknown_backend", "requested_backend": normalized},
)

View File

@@ -9,10 +9,16 @@ from typing import Any, Optional
from src.llm.generation_backend import GenerationError, GenerationErrorCode
LITELLM_BACKEND_ID = "litellm"
CODEX_CLI_BACKEND_ID = "codex_cli"
AUTO_AGENT_BACKEND_ID = "auto"
SUPPORTED_GENERATION_BACKENDS = frozenset({LITELLM_BACKEND_ID})
SUPPORTED_AGENT_GENERATION_BACKENDS = frozenset({AUTO_AGENT_BACKEND_ID, LITELLM_BACKEND_ID})
SUPPORTED_GENERATION_BACKENDS = frozenset({LITELLM_BACKEND_ID, CODEX_CLI_BACKEND_ID})
SUPPORTED_GENERATION_FALLBACK_BACKENDS = frozenset({LITELLM_BACKEND_ID})
SUPPORTED_AGENT_GENERATION_BACKENDS = frozenset({
AUTO_AGENT_BACKEND_ID,
LITELLM_BACKEND_ID,
CODEX_CLI_BACKEND_ID,
})
def _read_backend_config_value(config: Any, field_name: str, default: str) -> Any:
@@ -39,16 +45,23 @@ def normalize_backend_id(value: Any, *, default: str) -> str:
def _unsupported_backend_error(backend_id: str, *, field: str) -> GenerationError:
if field == "AGENT_GENERATION_BACKEND":
supported = SUPPORTED_AGENT_GENERATION_BACKENDS
elif field == "GENERATION_FALLBACK_BACKEND":
supported = SUPPORTED_GENERATION_FALLBACK_BACKENDS
else:
supported = SUPPORTED_GENERATION_BACKENDS
return GenerationError(
error_code=GenerationErrorCode.BACKEND_NOT_CONFIGURED,
stage="generation",
retryable=False,
fallbackable=False,
backend=backend_id,
provider=backend_id,
details={
"field": field,
"requested_backend": backend_id,
"supported_backends": sorted(SUPPORTED_GENERATION_BACKENDS),
"supported_backends": sorted(supported),
},
)
@@ -67,27 +80,30 @@ def resolve_generation_backend_id(config: Any) -> str:
def resolve_generation_fallback_backend_id(config: Any) -> Optional[str]:
"""Return the backend-level fallback target, or None for self/no-op."""
primary = resolve_generation_backend_id(config)
fallback = normalize_backend_id(
_read_backend_config_value(
config,
"generation_fallback_backend",
LITELLM_BACKEND_ID,
),
default=LITELLM_BACKEND_ID,
raw_fallback = _read_backend_config_value(
config,
"generation_fallback_backend",
None,
)
if fallback not in SUPPORTED_GENERATION_BACKENDS:
raise _unsupported_backend_error(fallback, field="GENERATION_FALLBACK_BACKEND")
if raw_fallback is None:
fallback = LITELLM_BACKEND_ID
else:
fallback = str(raw_fallback).strip().lower()
if not fallback:
return None
if fallback == primary:
return None
if fallback != LITELLM_BACKEND_ID:
raise _unsupported_backend_error(fallback, field="GENERATION_FALLBACK_BACKEND")
return fallback
def resolve_agent_generation_backend_id(config: Any) -> str:
"""Return the Agent backend id for Phase 1.
"""Return the Agent tool-calling backend id.
Full contract: auto may reuse the primary generation backend only when it
supports tools; otherwise Agent must use the LiteLLM tool backend or fail
explicitly. Phase 1 has only LiteLLM, so auto resolves to litellm.
Phase 2 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.
"""
backend_id = normalize_backend_id(
_read_backend_config_value(
@@ -98,18 +114,7 @@ def resolve_agent_generation_backend_id(config: Any) -> str:
default=AUTO_AGENT_BACKEND_ID,
)
if backend_id not in SUPPORTED_AGENT_GENERATION_BACKENDS:
raise GenerationError(
error_code=GenerationErrorCode.BACKEND_NOT_CONFIGURED,
stage="generation",
retryable=False,
fallbackable=False,
backend=backend_id,
details={
"field": "AGENT_GENERATION_BACKEND",
"requested_backend": backend_id,
"supported_backends": sorted(SUPPORTED_AGENT_GENERATION_BACKENDS),
},
)
raise _unsupported_backend_error(backend_id, field="AGENT_GENERATION_BACKEND")
if backend_id == AUTO_AGENT_BACKEND_ID:
return LITELLM_BACKEND_ID
return backend_id

View File

@@ -11,20 +11,25 @@ from typing import Any, Callable, Dict, Optional, Protocol
class GenerationErrorCode(str, Enum):
"""Structured generation backend error codes.
Phase 1 uses the LiteLLM-related subset directly and reserves the local
CLI / HTTP-oriented codes so future backends share the same contract.
Shared across LiteLLM and local CLI generation backends.
"""
BACKEND_NOT_CONFIGURED = "backend_not_configured"
COMMAND_NOT_FOUND = "command_not_found"
COMMAND_NOT_EXECUTABLE = "command_not_executable"
TIMEOUT = "timeout"
NON_ZERO_EXIT = "non_zero_exit"
EMPTY_OUTPUT = "empty_output"
OUTPUT_TOO_LARGE = "output_too_large"
INVALID_JSON = "invalid_json"
SCHEMA_VALIDATION_FAILED = "schema_validation_failed"
UNSUPPORTED_TOOL_CALLING = "unsupported_tool_calling"
INTERACTIVE_PROMPT_REQUIRED = "interactive_prompt_required"
APPROVAL_REQUIRED = "approval_required"
LOGIN_REQUIRED = "login_required"
CAPABILITY_UNSUPPORTED = "capability_unsupported"
UNSAFE_CONFIG = "unsafe_config"
UNKNOWN_BACKEND_ERROR = "unknown_backend_error"
@dataclass(frozen=True)
@@ -56,10 +61,9 @@ class GenerationResult:
class GenerationError(Exception):
"""Structured generation backend failure.
``stage`` is intentionally descriptive rather than a closed enum. Phase 1
uses ``generation``, ``validation``, and ``fallback``; later backends may
add stages such as ``configuration``, ``execution``, ``parsing``, or
``health_check``.
``stage`` is intentionally descriptive rather than a closed enum. Current
generation paths use values such as ``generation``, ``configuration``,
``execution``, ``validation``, and ``fallback``.
"""
error_code: GenerationErrorCode
@@ -71,6 +75,8 @@ class GenerationError(Exception):
details: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.provider:
self.provider = self.backend
Exception.__init__(self, self.message)
@property

View File

@@ -0,0 +1,990 @@
# -*- coding: utf-8 -*-
"""Local CLI generation backend.
Phase 2 exposes a restricted Codex CLI preset as an opt-in generation backend.
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.
"""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
from contextlib import contextmanager
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import tempfile
import threading
import time
from typing import Any, Callable, Dict, Mapping, Optional, Sequence
from urllib.parse import parse_qsl, urlsplit
from src.llm.backend_registry import CODEX_CLI_BACKEND_ID
from src.llm.generation_backend import (
GenerationBackend,
GenerationCapabilities,
GenerationError,
GenerationErrorCode,
GenerationResult,
)
DEFAULT_LOCAL_CLI_TIMEOUT_SECONDS = 300
DEFAULT_LOCAL_CLI_MAX_OUTPUT_BYTES = 1024 * 1024
DEFAULT_GENERATION_BACKEND_MAX_CONCURRENCY = 1
DEFAULT_LOCAL_CLI_BACKEND_MAX_CONCURRENCY = 1
MAX_LOCAL_CLI_TIMEOUT_SECONDS = 3600
MAX_LOCAL_CLI_OUTPUT_BYTES = 32 * 1024 * 1024
MAX_GENERATION_BACKEND_MAX_CONCURRENCY = 16
MAX_LOCAL_CLI_BACKEND_MAX_CONCURRENCY = 4
_PREVIEW_LIMIT = 800
_FINAL_MESSAGE_OMITTED_PREVIEW = "<final-message omitted from stdout preview>"
_STDOUT_PREVIEW_OMITTED = "<stdout preview omitted because output-last-message was too large>"
_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",
"unknown argument",
"unrecognized argument",
"unexpected argument",
"unexpected option",
"no such option",
"unknown flag",
"unrecognized flag",
)
_SENSITIVE_URL_KEY_PARTS = {
"access_token",
"api_key",
"apikey",
"auth_token",
"authorization",
"cookie",
"password",
"secret",
"sendkey",
"token",
"webhook",
}
_SAFE_ENV_EXACT = {
"PATH",
"HOME",
"HOMEDRIVE",
"HOMEPATH",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"TMPDIR",
"TEMP",
"TMP",
"LANG",
"LC_ALL",
"LC_CTYPE",
"NO_COLOR",
"TERM",
"CODEX_HOME",
"SYSTEMROOT",
"WINDIR",
"PATHEXT",
"COMSPEC",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
}
_SAFE_ENV_PREFIXES = ("CODEX_CLI_",)
_SENSITIVE_ENV_PATTERNS = (
"API_KEY",
"API_KEYS",
"AUTHORIZATION",
"COOKIE",
"DATABASE_URL",
"DB_URL",
"FEISHU",
"GEMINI",
"GITHUB_TOKEN",
"OPENAI",
"ANTHROPIC",
"DEEPSEEK",
"SECRET",
"SESSION",
"TOKEN",
"TUSHARE",
"WEBHOOK",
)
_CONCURRENCY_CONDITION = threading.Condition()
_CONCURRENCY_ACTIVE = 0
@dataclass(frozen=True)
class LocalCliPreset:
"""Safe executable preset exposed to Web/API users."""
preset_id: str
executable: str
argv: Sequence[str]
display_name: str
experimental: bool = True
output_last_message_arg: Optional[str] = None
CODEX_CLI_PRESET = LocalCliPreset(
preset_id=CODEX_CLI_BACKEND_ID,
executable="codex",
argv=(
"exec",
"--skip-git-repo-check",
"--sandbox",
"read-only",
"--color",
"never",
"--ephemeral",
"-",
),
display_name="Codex CLI",
experimental=True,
output_last_message_arg="--output-last-message",
)
SAFE_LOCAL_CLI_PRESETS = {
CODEX_CLI_BACKEND_ID: CODEX_CLI_PRESET,
}
def effective_local_cli_concurrency(config: Any) -> int:
"""Return the effective local CLI concurrency limit."""
backend_limit = _positive_int(
getattr(config, "generation_backend_max_concurrency", None),
DEFAULT_GENERATION_BACKEND_MAX_CONCURRENCY,
)
local_limit = _positive_int(
getattr(config, "local_cli_backend_max_concurrency", None),
DEFAULT_LOCAL_CLI_BACKEND_MAX_CONCURRENCY,
)
backend_limit = min(backend_limit, MAX_GENERATION_BACKEND_MAX_CONCURRENCY)
local_limit = min(local_limit, MAX_LOCAL_CLI_BACKEND_MAX_CONCURRENCY)
return max(1, min(local_limit, backend_limit))
def build_local_cli_env(source: Optional[Mapping[str, str]] = None) -> Dict[str, str]:
"""Build an allowlisted child environment with sensitive names removed."""
source_env = source if source is not None else os.environ
child_env: Dict[str, str] = {}
for key, value in source_env.items():
upper = key.upper()
allowed = upper in _SAFE_ENV_EXACT or any(
upper.startswith(prefix) for prefix in _SAFE_ENV_PREFIXES
)
if not allowed or _is_sensitive_env_name(upper):
continue
child_env[key] = value
return child_env
def _popen_session_kwargs() -> Dict[str, Any]:
"""Return platform-specific subprocess isolation kwargs."""
if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
return {"creationflags": creationflags} if creationflags else {}
return {"start_new_session": True}
def redact_diagnostic_text(text: str, *, home: Optional[str] = None, limit: int = _PREVIEW_LIMIT) -> str:
"""Redact sensitive diagnostics and return a bounded preview."""
redacted = text or ""
home_path = home or os.path.expanduser("~")
if home_path:
redacted = redacted.replace(home_path, "~")
redacted = re.sub(r"([a-zA-Z][a-zA-Z0-9+.-]*://)[^/\s:@]+:[^@\s/]+@", r"\1<redacted>@", redacted)
redacted = _URL_PATTERN.sub(_redact_sensitive_diagnostic_url, redacted)
redacted = re.sub(r"(?i)(authorization\s*[:=]\s*)(bearer\s+)?[^\s]+", r"\1<redacted>", redacted)
redacted = re.sub(r"(?i)(cookie\s*[:=]\s*)[^\n\r]+", r"\1<redacted>", redacted)
redacted = re.sub(r"(?i)(session[_-]?secret\s*[:=]\s*)[^\s]+", r"\1<redacted>", redacted)
redacted = re.sub(r"\b(sk-[A-Za-z0-9_-]{12,})\b", "<redacted-api-key>", redacted)
redacted = re.sub(r"\b(AIza[A-Za-z0-9_-]{16,})\b", "<redacted-api-key>", redacted)
redacted = re.sub(r"\b(gh[pousr]_[A-Za-z0-9_]{16,})\b", "<redacted-token>", redacted)
# Conservative by design: local CLI diagnostics may contain opaque long-lived credentials.
redacted = re.sub(r"\b([A-Za-z0-9_-]{32,})\b", "<redacted-token>", redacted)
if len(redacted) > limit:
return redacted[:limit] + "...<truncated>"
return redacted
def _redact_sensitive_diagnostic_url(match: re.Match[str]) -> str:
url = match.group(0)
return "<redacted-url>" if _is_sensitive_diagnostic_url(url) else url
def _is_sensitive_diagnostic_url(url: str) -> bool:
try:
parsed = urlsplit(url)
except ValueError:
return True
if parsed.username or parsed.password:
return True
if _is_webhook_diagnostic_url(parsed.hostname or "", parsed.path):
return True
return (
_has_sensitive_url_params(parsed.query)
or _has_sensitive_url_params(parsed.fragment)
)
def _is_webhook_diagnostic_url(hostname: str, path: str) -> bool:
hostname = str(hostname or "").lower().strip(".")
normalized_path = f"/{path.lstrip('/').lower()}"
path_segments = {segment for segment in normalized_path.split("/") if segment}
if hostname == "hooks.slack.com" and normalized_path.startswith("/services/"):
return True
if hostname == "oapi.dingtalk.com" and normalized_path.startswith("/robot/send"):
return True
if hostname in {"discord.com", "discordapp.com"} and "/api/webhooks/" in normalized_path:
return True
if hostname == "open.feishu.cn" and "/open-apis/bot/" in normalized_path and "/hook/" in normalized_path:
return True
if hostname == "qyapi.weixin.qq.com" and normalized_path.startswith("/cgi-bin/webhook/send"):
return True
if hostname.startswith("hooks."):
return True
return bool({"hook", "webhook", "webhooks"} & path_segments)
def _has_sensitive_url_params(params_text: str) -> bool:
if not params_text:
return False
try:
params = parse_qsl(params_text, keep_blank_values=True)
except ValueError:
return True
for key, value in params:
key_text = str(key or "").strip().lower().replace("-", "_")
if key_text in _SENSITIVE_URL_KEY_PARTS or any(part in key_text for part in _SENSITIVE_URL_KEY_PARTS):
return True
if re.search(r"\b(sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{16,}|[A-Za-z0-9_-]{32,})\b", str(value or "")):
return True
return False
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)
)
def resolve_local_cli_preset(preset_id: str) -> LocalCliPreset:
"""Return a safe preset or raise a structured unsafe_config error."""
preset = SAFE_LOCAL_CLI_PRESETS.get((preset_id or "").strip().lower())
if preset is None:
raise GenerationError(
error_code=GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
backend=preset_id or "local_cli",
provider=preset_id or "local_cli",
details={
"reason": "unknown_local_cli_preset",
"preset_id": preset_id,
"allowed_presets": sorted(SAFE_LOCAL_CLI_PRESETS),
},
)
return preset
class LocalCliGenerationBackend(GenerationBackend):
"""Restricted subprocess-backed generation backend."""
backend_id = CODEX_CLI_BACKEND_ID
capabilities = GenerationCapabilities(
supports_json=True,
supports_tools=False,
supports_stream=False,
supports_vision=False,
supports_health_check=False,
supports_smoke_test=False,
)
def __init__(
self,
config: Any,
*,
preset_id: str = CODEX_CLI_BACKEND_ID,
preset: Optional[LocalCliPreset] = None,
) -> None:
self._config = config
self._preset = preset or resolve_local_cli_preset(preset_id)
@property
def preset_id(self) -> str:
return self._preset.preset_id
def get_config_error(self) -> Optional[GenerationError]:
"""Return executable/config validation errors without running a prompt."""
try:
self._resolve_command()
except GenerationError as exc:
return exc
return None
def generate(
self,
prompt: str,
generation_config: Dict[str, Any],
*,
system_prompt: Optional[str] = None,
stream: bool = False,
stream_progress_callback: Optional[Callable[[int], None]] = None,
response_validator: Optional[Callable[[str], None]] = None,
audit_context: Optional[Dict[str, Any]] = None,
) -> GenerationResult:
executable, argv, executable_summary = self._resolve_command()
timeout_seconds = min(
_positive_int(
getattr(self._config, "generation_backend_timeout_seconds", None),
DEFAULT_LOCAL_CLI_TIMEOUT_SECONDS,
),
MAX_LOCAL_CLI_TIMEOUT_SECONDS,
)
max_output_bytes = min(
_positive_int(
getattr(self._config, "generation_backend_max_output_bytes", None),
DEFAULT_LOCAL_CLI_MAX_OUTPUT_BYTES,
),
MAX_LOCAL_CLI_OUTPUT_BYTES,
)
concurrency_limit = effective_local_cli_concurrency(self._config)
prompt_text = prompt
if system_prompt:
prompt_text = f"{system_prompt.strip()}\n\n{prompt}"
diagnostics: Dict[str, Any] = {
"preset_id": self._preset.preset_id,
"executable": executable_summary,
"stream_degraded": bool(stream),
"timeout_seconds": timeout_seconds,
"max_output_bytes": max_output_bytes,
"concurrency_limit": concurrency_limit,
}
stdout = ""
stderr = ""
text = ""
stdio_output_bytes = 0
final_output_bytes = 0
last_message_path: Optional[Path] = None
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:
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"
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,
):
process = subprocess.Popen(
[executable, *command_argv],
stdin=prompt_handle,
stdout=stdout_handle,
stderr=stderr_handle,
cwd=cwd,
env=child_env,
text=True,
shell=False,
**_popen_session_kwargs(),
)
self._emit_progress(stream_progress_callback, 1)
deadline = time.monotonic() + timeout_seconds
while True:
stdout_handle.flush()
stderr_handle.flush()
try:
stdio_output_bytes = _combined_path_size_required(stdout_path, stderr_path)
except OSError as exc:
self._terminate_process_group(process)
diagnostics.update(_preview_diagnostics_from_files(stdout_path, stderr_path))
raise self._output_file_error(
diagnostics,
reason="output_stat_failed",
exc=exc,
) from exc
if stdio_output_bytes > max_output_bytes:
self._terminate_process_group(process)
diagnostics.update(_preview_diagnostics_from_files(stdout_path, stderr_path))
raise self._error(
GenerationErrorCode.OUTPUT_TOO_LARGE,
stage="execution",
retryable=False,
fallbackable=True,
details={
**diagnostics,
"reason": "output_too_large",
"output_bytes": stdio_output_bytes,
},
)
if process.poll() is not None:
break
if time.monotonic() >= deadline:
self._terminate_process_group(process)
diagnostics.update(_preview_diagnostics_from_files(stdout_path, stderr_path))
raise self._error(
GenerationErrorCode.TIMEOUT,
stage="execution",
retryable=True,
fallbackable=True,
details={
**diagnostics,
"reason": "timeout",
"timeout_seconds": timeout_seconds,
},
)
time.sleep(_PROCESS_POLL_INTERVAL_SECONDS)
try:
stdio_output_bytes = _combined_path_size_required(stdout_path, stderr_path)
except OSError as exc:
diagnostics.update(_preview_diagnostics_from_files(stdout_path, stderr_path))
raise self._output_file_error(
diagnostics,
reason="output_stat_failed",
exc=exc,
) from exc
if stdio_output_bytes > max_output_bytes:
diagnostics.update(_preview_diagnostics_from_files(stdout_path, stderr_path))
raise self._error(
GenerationErrorCode.OUTPUT_TOO_LARGE,
stage="execution",
retryable=False,
fallbackable=True,
details={
**diagnostics,
"reason": "output_too_large",
"output_bytes": stdio_output_bytes,
},
)
try:
stdout = _read_text_file_required(stdout_path)
stderr = _read_text_file_required(stderr_path)
except OSError as exc:
diagnostics.update(_preview_diagnostics_from_files(stdout_path, stderr_path))
raise self._output_file_error(
diagnostics,
reason="output_read_failed",
exc=exc,
) from exc
if last_message_path is not None:
diagnostics["output_source"] = "output_last_message"
if process.returncode != 0:
preview_stdout, omitted = _stdout_preview_without_repeated_final_message(
stdout,
last_message_path,
max_output_bytes,
)
diagnostics.update(_preview_diagnostics(preview_stdout, stderr))
if omitted:
diagnostics["stdout_final_message_omitted"] = True
raise self._non_zero_exit_error(
process.returncode,
stdout,
stderr,
diagnostics,
)
try:
final_output_bytes = _path_size_required(last_message_path)
except FileNotFoundError as exc:
diagnostics.update(_preview_diagnostics(stdout, stderr))
raise self._error(
GenerationErrorCode.EMPTY_OUTPUT,
stage="execution",
retryable=True,
fallbackable=True,
details={
**diagnostics,
"reason": "missing_last_message_output",
"error": redact_diagnostic_text(str(exc), limit=200),
},
) from exc
except OSError as exc:
diagnostics.update(_preview_diagnostics(stdout, stderr))
raise self._output_file_error(
diagnostics,
reason="output_stat_failed",
exc=exc,
) from exc
if final_output_bytes > max_output_bytes:
diagnostics.update(
_preview_diagnostics(_STDOUT_PREVIEW_OMITTED, stderr)
)
raise self._error(
GenerationErrorCode.OUTPUT_TOO_LARGE,
stage="execution",
retryable=False,
fallbackable=True,
details={
**diagnostics,
"reason": "output_too_large",
"output_bytes": final_output_bytes,
},
)
try:
text = _read_text_file_required(last_message_path).strip()
except OSError as exc:
diagnostics.update(_preview_diagnostics(stdout, stderr))
raise self._output_file_error(
diagnostics,
reason="output_read_failed",
exc=exc,
) from exc
diagnostic_stdout, omitted = _strip_repeated_final_message_from_stdout(
stdout,
text,
replacement="",
)
preview_stdout, _ = _strip_repeated_final_message_from_stdout(
stdout,
text,
replacement=_FINAL_MESSAGE_OMITTED_PREVIEW,
)
stdio_output_bytes = _text_size_bytes(diagnostic_stdout) + _text_size_bytes(
stderr
)
diagnostics.update(_preview_diagnostics(preview_stdout, stderr))
if omitted:
diagnostics["stdout_final_message_omitted"] = True
else:
diagnostics.update(_preview_diagnostics(stdout, stderr))
if process.returncode != 0:
raise self._non_zero_exit_error(
process.returncode,
stdout,
stderr,
diagnostics,
)
diagnostics["output_source"] = "stdout"
text = (stdout or "").strip()
except OSError as exc:
if _is_command_not_executable_error(exc):
raise self._error(
GenerationErrorCode.COMMAND_NOT_EXECUTABLE,
stage="execution",
retryable=False,
fallbackable=True,
details={
**diagnostics,
"reason": "process_start_failed",
"error": redact_diagnostic_text(str(exc), limit=200),
},
) from exc
raise self._error(
GenerationErrorCode.UNKNOWN_BACKEND_ERROR,
stage="execution",
retryable=False,
fallbackable=True,
details={
**diagnostics,
"reason": "process_start_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(
GenerationErrorCode.OUTPUT_TOO_LARGE,
stage="execution",
retryable=False,
fallbackable=True,
details={
**diagnostics,
"reason": "output_too_large",
"output_bytes": total_output_bytes,
},
)
if not text:
reason = "empty_last_message_output" if last_message_path is not None else "empty_stdout"
raise self._error(
GenerationErrorCode.EMPTY_OUTPUT,
stage="execution",
retryable=True,
fallbackable=True,
details={**diagnostics, "reason": reason},
)
self._emit_progress(stream_progress_callback, 2)
if response_validator is not None:
try:
response_validator(text)
except GenerationError:
raise
except Exception as exc:
raise self._error(
GenerationErrorCode.INVALID_JSON,
stage="validation",
retryable=True,
fallbackable=True,
details={
**diagnostics,
"reason": str(exc) or "invalid_json",
},
) from exc
return GenerationResult(
text=text,
model=self._preset.preset_id,
provider=self._preset.preset_id,
backend=self.backend_id,
usage={
"usage_available": False,
"usage_source": "unavailable",
"backend": self.backend_id,
},
raw=None,
diagnostics=diagnostics,
)
def _resolve_command(self) -> tuple[str, list[str], Dict[str, str]]:
tokens = [self._preset.executable, *self._preset.argv]
if self._preset.output_last_message_arg:
tokens.append(self._preset.output_last_message_arg)
unsafe = _first_unsafe_token(tokens)
if unsafe:
raise self._error(
GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
details={"reason": "shell_metachar", "token_preview": unsafe},
)
resolved = shutil.which(self._preset.executable)
if not resolved:
raise self._error(
GenerationErrorCode.COMMAND_NOT_FOUND,
stage="configuration",
retryable=False,
fallbackable=True,
details={
"reason": "executable_not_found",
"preset_id": self._preset.preset_id,
"executable_basename": Path(self._preset.executable).name,
},
)
if not os.access(resolved, os.X_OK):
raise self._error(
GenerationErrorCode.COMMAND_NOT_EXECUTABLE,
stage="configuration",
retryable=False,
fallbackable=True,
details={
"reason": "executable_not_executable",
"preset_id": self._preset.preset_id,
"executable": _executable_summary(resolved),
},
)
return resolved, list(self._preset.argv), _executable_summary(resolved)
def _build_runtime_argv(
self,
argv: Sequence[str],
cwd: str,
) -> tuple[list[str], Optional[Path]]:
output_arg = self._preset.output_last_message_arg
if not output_arg:
return list(argv), None
last_message_path = Path(cwd) / "last-message.txt"
runtime_argv = list(argv)
injected = [output_arg, str(last_message_path)]
if runtime_argv and runtime_argv[-1] == "-":
runtime_argv = [*runtime_argv[:-1], *injected, runtime_argv[-1]]
else:
runtime_argv = [*runtime_argv, *injected]
unsafe = _first_unsafe_token(runtime_argv)
if unsafe:
raise self._error(
GenerationErrorCode.UNSAFE_CONFIG,
stage="configuration",
retryable=False,
fallbackable=False,
details={"reason": "shell_metachar", "token_preview": unsafe},
)
return runtime_argv, last_message_path
def _non_zero_exit_error(
self,
returncode: int,
stdout: str,
stderr: str,
diagnostics: Dict[str, Any],
) -> GenerationError:
combined = f"{stdout}\n{stderr}".lower()
code = GenerationErrorCode.NON_ZERO_EXIT
reason = "non_zero_exit"
if _is_cli_contract_unsupported(combined):
reason = "cli_contract_unsupported"
elif "login" in combined or "authentication" in combined or "not authenticated" in combined:
code = GenerationErrorCode.LOGIN_REQUIRED
reason = "login_required"
elif "approval" in combined or "approve" in combined or "permission" in combined:
code = GenerationErrorCode.APPROVAL_REQUIRED
reason = "approval_required"
elif "tty" in combined or "interactive" in combined or "prompt" in combined:
code = GenerationErrorCode.INTERACTIVE_PROMPT_REQUIRED
reason = "interactive_prompt_required"
return self._error(
code,
stage="execution",
retryable=False,
fallbackable=True,
details={**diagnostics, "reason": reason, "returncode": returncode},
)
def _output_file_error(
self,
diagnostics: Dict[str, Any],
*,
reason: str,
exc: OSError,
) -> GenerationError:
return self._error(
GenerationErrorCode.UNKNOWN_BACKEND_ERROR,
stage="execution",
retryable=True,
fallbackable=True,
details={
**diagnostics,
"reason": reason,
"error": redact_diagnostic_text(str(exc), limit=200),
},
)
def _error(
self,
error_code: GenerationErrorCode,
*,
stage: str,
retryable: bool,
fallbackable: bool,
details: Dict[str, Any],
) -> GenerationError:
return GenerationError(
error_code=error_code,
stage=stage,
retryable=retryable,
fallbackable=fallbackable,
backend=self.backend_id,
provider=self._preset.preset_id,
details=details,
)
@staticmethod
def _emit_progress(callback: Optional[Callable[[int], None]], value: int) -> None:
if callback is None:
return
try:
callback(value)
except Exception:
return
@staticmethod
def _terminate_process_group(process: subprocess.Popen[str]) -> None:
if process.poll() is not None:
return
if os.name == "nt":
ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None)
if ctrl_break is not None:
try:
process.send_signal(ctrl_break)
process.wait(timeout=2)
return
except Exception:
pass
try:
process.terminate()
except Exception:
return
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
try:
process.kill()
except Exception:
return
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
return
return
try:
os.killpg(process.pid, signal.SIGTERM)
except Exception:
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except Exception:
process.kill()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
return
@contextmanager
def _local_cli_concurrency_slot(limit: int):
global _CONCURRENCY_ACTIVE
normalized_limit = max(1, int(limit or 1))
with _CONCURRENCY_CONDITION:
_CONCURRENCY_CONDITION.wait_for(lambda: _CONCURRENCY_ACTIVE < normalized_limit)
_CONCURRENCY_ACTIVE += 1
try:
yield
finally:
with _CONCURRENCY_CONDITION:
_CONCURRENCY_ACTIVE -= 1
_CONCURRENCY_CONDITION.notify_all()
def _positive_int(value: Any, default: int) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return parsed if parsed > 0 else default
def _is_command_not_executable_error(exc: OSError) -> bool:
if not isinstance(exc, OSError):
return False
if os.name == "nt" and getattr(exc, "winerror", None) == 193:
return True
return False
def _is_sensitive_env_name(upper_name: str) -> bool:
return any(pattern in upper_name for pattern in _SENSITIVE_ENV_PATTERNS)
def _first_unsafe_token(tokens: Sequence[str]) -> str:
for token in tokens:
value = str(token)
if any(marker in value for marker in _SHELL_META_CHARS):
return redact_diagnostic_text(value, limit=120)
if any(marker in value for marker in _SHELL_META_STRINGS):
return redact_diagnostic_text(value, limit=120)
return ""
def _executable_summary(path: str) -> Dict[str, str]:
digest = hashlib.sha256(path.encode("utf-8")).hexdigest()[:12]
return {
"basename": Path(path).name,
"path_hash": digest,
}
def _preview_diagnostics(stdout: str, stderr: str) -> Dict[str, str]:
return {
"stdout_preview": redact_diagnostic_text(stdout or ""),
"stderr_preview": redact_diagnostic_text(stderr or ""),
}
def _preview_diagnostics_from_files(stdout_path: Path, stderr_path: Path) -> Dict[str, str]:
return _preview_diagnostics(
_read_text_file(stdout_path, limit_bytes=_PREVIEW_LIMIT * 4),
_read_text_file(stderr_path, limit_bytes=_PREVIEW_LIMIT * 4),
)
def _stdout_preview_without_repeated_final_message(
stdout: str,
final_message_path: Path,
max_output_bytes: int,
) -> tuple[str, bool]:
try:
if _path_size_required(final_message_path) > max_output_bytes:
return _STDOUT_PREVIEW_OMITTED, True
final_message = _read_text_file_required(final_message_path).strip()
except OSError:
return stdout, False
return _strip_repeated_final_message_from_stdout(
stdout,
final_message,
replacement=_FINAL_MESSAGE_OMITTED_PREVIEW,
)
def _strip_repeated_final_message_from_stdout(
stdout: str,
final_message: str,
*,
replacement: str,
) -> tuple[str, bool]:
final = (final_message or "").strip()
if not final or final not in stdout:
return stdout, False
return stdout.replace(final, replacement), True
def _text_size_bytes(text: str) -> int:
return len((text or "").encode("utf-8", errors="replace"))
def _combined_path_size_required(*paths: Path) -> int:
return sum(_path_size_required(path) for path in paths)
def _path_size_required(path: Path) -> int:
return path.stat().st_size
def _read_text_file(path: Path, *, limit_bytes: Optional[int] = None) -> str:
try:
with path.open("rb") as handle:
raw = handle.read() if limit_bytes is None else handle.read(limit_bytes)
except OSError:
return ""
return raw.decode("utf-8", errors="replace")
def _read_text_file_required(path: Path) -> str:
with path.open("rb") as handle:
raw = handle.read()
return raw.decode("utf-8", errors="replace")

View File

@@ -6,6 +6,7 @@ from __future__ import annotations
from typing import Any, Dict, List
from src.config import get_effective_agent_models_to_try, get_effective_agent_primary_model
from src.llm.backend_registry import CODEX_CLI_BACKEND_ID
_PLACEHOLDER_TO_PROVIDER = {
@@ -120,6 +121,9 @@ 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:
return []
deployments = _build_non_legacy_deployments(config)
if not deployments:
deployments = _build_legacy_deployments(config)

View File

@@ -8,6 +8,7 @@ import logging
import json
import os
import re
import shutil
import time
from dataclasses import dataclass, field
from pathlib import Path
@@ -43,6 +44,12 @@ from src.core.config_registry import (
get_registered_field_keys,
)
from src.llm.errors import call_litellm_with_param_recovery
from src.llm.backend_registry import (
AUTO_AGENT_BACKEND_ID,
CODEX_CLI_BACKEND_ID,
LITELLM_BACKEND_ID,
normalize_backend_id,
)
from src.llm.generation_params import apply_litellm_generation_params
from src.notification_contracts import (
FEISHU_APP_BOT_ENV_GROUP,
@@ -2654,6 +2661,30 @@ class SystemConfigService:
return "", "尚未检测到主模型配置"
def _build_setup_primary_llm_check(self, effective_map: Dict[str, str]) -> Dict[str, Any]:
generation_backend = normalize_backend_id(
effective_map.get("GENERATION_BACKEND"),
default=LITELLM_BACKEND_ID,
)
if generation_backend == CODEX_CLI_BACKEND_ID:
if shutil.which("codex"):
return self._setup_check(
"llm_primary",
"LLM 主渠道",
"ai_model",
True,
"configured",
"已启用 Codex CLI 本地生成 Backendexperimental/limited",
)
return self._setup_check(
"llm_primary",
"LLM 主渠道",
"ai_model",
True,
"needs_action",
"已选择 codex_cli但未找到 codex 可执行文件。",
"请先安装并登录 Codex CLI或将 GENERATION_BACKEND 设回 litellm。",
)
model, source = self._resolve_setup_primary_model(effective_map)
if model:
source_label = {
@@ -2685,8 +2716,57 @@ class SystemConfigService:
effective_map: Dict[str, str],
primary_check: Dict[str, Any],
) -> Dict[str, Any]:
generation_backend = normalize_backend_id(
effective_map.get("GENERATION_BACKEND"),
default=LITELLM_BACKEND_ID,
)
agent_backend = normalize_backend_id(
effective_map.get("AGENT_GENERATION_BACKEND"),
default=AUTO_AGENT_BACKEND_ID,
)
if agent_backend == CODEX_CLI_BACKEND_ID:
return self._setup_check(
"llm_agent",
"Agent 渠道",
"agent",
True,
"needs_action",
"Agent 工具调用暂不支持 codex_cli text-only backend。",
"请将 AGENT_GENERATION_BACKEND 设为 auto 或 litellm并配置 LiteLLM 工具调用渠道。",
)
agent_model_raw = (effective_map.get("AGENT_LITELLM_MODEL") or "").strip()
if not agent_model_raw:
if generation_backend == CODEX_CLI_BACKEND_ID:
litellm_model, _source = self._resolve_setup_primary_model(effective_map)
if litellm_model:
return self._setup_check(
"llm_agent",
"Agent 渠道",
"agent",
True,
"configured",
"Agent 工具调用将继续使用 LiteLLM 渠道。",
)
if agent_backend == LITELLM_BACKEND_ID:
return self._setup_check(
"llm_agent",
"Agent 渠道",
"agent",
True,
"needs_action",
"AGENT_GENERATION_BACKEND 已选择 litellm但未检测到可用 LiteLLM 模型配置。",
"如需使用 Ask-Stock Agent请配置 AGENT_LITELLM_MODEL、LITELLM_MODEL、LLM_CHANNELS 或 LITELLM_CONFIG。",
)
return self._setup_check(
"llm_agent",
"Agent 渠道",
"agent",
True,
"needs_action",
"Agent 工具调用需要 LiteLLM 模型配置codex_cli 主生成方式不会被自动继承。",
"如需使用 Ask-Stock Agent请配置 LiteLLM 模型,或将 AGENT_GENERATION_BACKEND 固定为 litellm 后补齐模型配置。",
)
if primary_check["status"] == "configured":
return self._setup_check(
"llm_agent",

View File

@@ -51,6 +51,20 @@ 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"},
},
],
)
self.assertEqual(list_agent_model_deployments(config), [])
def test_models_endpoint_returns_channel_deployments_with_api_base(self) -> None:
config = _build_config(
llm_channels=[{"name": "openai"}],

View File

@@ -65,24 +65,55 @@ class ConfigEnvCompatibilityTestCase(unittest.TestCase):
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_generation_backend_env_accepts_phase1_values(
def test_generation_backend_env_accepts_phase2_values(
self, _mock_parse_litellm_yaml, _mock_setup_env
):
with patch.dict(
os.environ,
{
"STOCK_LIST": "600519",
"GENERATION_BACKEND": " LiteLLM ",
"GENERATION_FALLBACK_BACKEND": "LITELLM",
"AGENT_GENERATION_BACKEND": " litellm ",
"GENERATION_BACKEND": " codex_CLI ",
"GENERATION_FALLBACK_BACKEND": "",
"GENERATION_BACKEND_TIMEOUT_SECONDS": "300",
"GENERATION_BACKEND_MAX_OUTPUT_BYTES": "1048576",
"GENERATION_BACKEND_MAX_CONCURRENCY": "2",
"LOCAL_CLI_BACKEND_MAX_CONCURRENCY": "1",
"AGENT_GENERATION_BACKEND": " codex_cli ",
},
clear=True,
):
config = Config._load_from_env()
self.assertEqual(config.generation_backend, "litellm")
self.assertEqual(config.generation_fallback_backend, "litellm")
self.assertEqual(config.agent_generation_backend, "litellm")
self.assertEqual(config.generation_backend, "codex_cli")
self.assertEqual(config.generation_fallback_backend, "")
self.assertEqual(config.generation_backend_timeout_seconds, 300)
self.assertEqual(config.generation_backend_max_output_bytes, 1048576)
self.assertEqual(config.generation_backend_max_concurrency, 2)
self.assertEqual(config.local_cli_backend_max_concurrency, 1)
self.assertEqual(config.agent_generation_backend, "codex_cli")
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
def test_generation_backend_env_clamps_phase2_numeric_maxima(
self, _mock_parse_litellm_yaml, _mock_setup_env
):
with patch.dict(
os.environ,
{
"STOCK_LIST": "600519",
"GENERATION_BACKEND_TIMEOUT_SECONDS": "999999",
"GENERATION_BACKEND_MAX_OUTPUT_BYTES": "999999999",
"GENERATION_BACKEND_MAX_CONCURRENCY": "999",
"LOCAL_CLI_BACKEND_MAX_CONCURRENCY": "999",
},
clear=True,
):
config = Config._load_from_env()
self.assertEqual(config.generation_backend_timeout_seconds, 3600)
self.assertEqual(config.generation_backend_max_output_bytes, 33554432)
self.assertEqual(config.generation_backend_max_concurrency, 16)
self.assertEqual(config.local_cli_backend_max_concurrency, 4)
@patch("src.config.setup_env")
@patch.object(Config, "_parse_litellm_yaml", return_value=[])

View File

@@ -192,11 +192,14 @@ class TestGenerationBackendFieldsRegistered(unittest.TestCase):
self.assertEqual(field["category"], "ai_model")
self.assertEqual(field["ui_control"], "select")
self.assertEqual(field["default_value"], "litellm")
self.assertEqual(field["validation"], {"enum": ["litellm"]})
self.assertEqual(
field["options"],
[{"label": "Default model settings", "value": "litellm"}],
)
if key == "GENERATION_BACKEND":
self.assertEqual(field["validation"], {"enum": ["litellm", "codex_cli"]})
self.assertIn({"label": "Default model settings", "value": "litellm"}, field["options"])
self.assertIn({"label": "Codex CLI (experimental)", "value": "codex_cli"}, field["options"])
else:
self.assertEqual(field["validation"], {"enum": ["", "litellm"]})
self.assertIn({"label": "Disabled", "value": ""}, field["options"])
self.assertIn({"label": "Default model settings", "value": "litellm"}, field["options"])
self.assertEqual(field["help_key"], help_key)
self.assertNotEqual(field["display_order"], 9000)
@@ -211,16 +214,27 @@ class TestGenerationBackendFieldsRegistered(unittest.TestCase):
field["options"],
[
{"label": "Auto", "value": "auto"},
{"label": "Default model tool calling", "value": "litellm"},
{"label": "Default model settings", "value": "litellm"},
],
)
self.assertEqual(field["help_key"], "settings.agent.AGENT_GENERATION_BACKEND")
self.assertNotEqual(field["display_order"], 9000)
def test_generation_backend_numeric_fields_have_upper_bounds(self):
expected = {
"GENERATION_BACKEND_TIMEOUT_SECONDS": {"min": 1, "max": 3600},
"GENERATION_BACKEND_MAX_OUTPUT_BYTES": {"min": 1, "max": 33554432},
"GENERATION_BACKEND_MAX_CONCURRENCY": {"min": 1, "max": 16},
"LOCAL_CLI_BACKEND_MAX_CONCURRENCY": {"min": 1, "max": 4},
}
for key, validation in expected.items():
self.assertEqual(get_field_definition(key)["validation"], validation)
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-22")
self.assertEqual(SCHEMA_VERSION, "2026-06-23-local-cli-backend")
categories = {
category["category"]: {field["key"] for field in category["fields"]}
@@ -229,6 +243,10 @@ class TestGenerationBackendFieldsRegistered(unittest.TestCase):
self.assertIn("GENERATION_BACKEND", categories["ai_model"])
self.assertIn("GENERATION_FALLBACK_BACKEND", categories["ai_model"])
self.assertIn("GENERATION_BACKEND_TIMEOUT_SECONDS", categories["ai_model"])
self.assertIn("GENERATION_BACKEND_MAX_OUTPUT_BYTES", categories["ai_model"])
self.assertIn("GENERATION_BACKEND_MAX_CONCURRENCY", categories["ai_model"])
self.assertIn("LOCAL_CLI_BACKEND_MAX_CONCURRENCY", categories["ai_model"])
self.assertIn("AGENT_GENERATION_BACKEND", categories["agent"])

View File

@@ -192,7 +192,7 @@ class TestValidateStructuredLLM:
error = next(i for i in issues if i.field == "GENERATION_BACKEND")
assert error.severity == "error"
assert "仅支持 litellm" in error.message
assert "litellm 或 codex_cli" in error.message
assert "codex" in error.message
def test_unknown_generation_fallback_backend_is_structured_config_error(self):
@@ -202,7 +202,7 @@ class TestValidateStructuredLLM:
error = next(i for i in issues if i.field == "GENERATION_FALLBACK_BACKEND")
assert error.severity == "error"
assert "仅支持 litellm" in error.message
assert "GENERATION_FALLBACK_BACKEND" in error.message
assert "claude_code" in error.message
def test_unknown_agent_generation_backend_is_structured_config_error(self):
@@ -212,9 +212,34 @@ class TestValidateStructuredLLM:
error = next(i for i in issues if i.field == "AGENT_GENERATION_BACKEND")
assert error.severity == "error"
assert "仅支持 autolitellm" in error.message
assert "autolitellm" in error.message
assert "不支持 Agent 工具调用" in error.message
assert "hermes" in error.message
def test_codex_cli_without_litellm_keys_is_not_llm_config_error(self):
cfg = _make_config(
generation_backend="codex_cli",
litellm_model="",
llm_model_list=[],
gemini_api_keys=[],
anthropic_api_keys=[],
openai_api_keys=[],
deepseek_api_keys=[],
)
issues = cfg.validate_structured()
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")
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
def test_no_llm_is_error(self):
"""Empty llm_model_list must produce an error regardless of legacy keys."""
cfg = _make_config(llm_model_list=[])
@@ -333,6 +358,19 @@ class TestValidateStructuredLLM:
assert all("LITELLM_MODEL" not in i.message for i in llm_issues)
assert any("主模型" in i.message for i in llm_issues)
def test_codex_cli_without_litellm_model_does_not_emit_primary_model_hint(self):
cfg = _make_config(
generation_backend="codex_cli",
generation_fallback_backend="",
litellm_model="",
llm_model_list=[],
)
issues = cfg.validate_structured()
assert not any(i.field == "LITELLM_MODEL" and "主模型" in i.message for i in issues)
assert not any(i.severity == "error" and "AI 模型" in i.message for i in issues)
def test_direct_env_provider_model_without_model_list_no_error(self):
"""Direct LiteLLM env providers should count as configured for runtime."""
cfg = _make_config(

View File

@@ -116,6 +116,33 @@ def test_daily_analysis_maps_prompt_cache_config() -> None:
assert f"secrets.{key}" in env[key]
def test_daily_analysis_maps_generation_backend_runtime_config() -> None:
env = _load_daily_analysis_env()
for key in (
"GENERATION_BACKEND",
"GENERATION_FALLBACK_BACKEND",
"GENERATION_BACKEND_TIMEOUT_SECONDS",
"GENERATION_BACKEND_MAX_OUTPUT_BYTES",
"GENERATION_BACKEND_MAX_CONCURRENCY",
"LOCAL_CLI_BACKEND_MAX_CONCURRENCY",
"AGENT_GENERATION_BACKEND",
):
assert key in env
assert f"vars.{key}" in env[key]
assert f"secrets.{key}" in env[key]
def test_daily_analysis_generation_fallback_defaults_to_litellm() -> None:
env = _load_daily_analysis_env()
expression = env["GENERATION_FALLBACK_BACKEND"]
assert expression == (
"${{ vars.GENERATION_FALLBACK_BACKEND || "
"secrets.GENERATION_FALLBACK_BACKEND || 'litellm' }}"
)
def test_env_example_includes_provider_template_channel_examples() -> None:
templates = _extract_provider_templates()
env_example = ENV_EXAMPLE_PATH.read_text(encoding="utf-8")

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Tests for generation backend contracts and Phase 1 LiteLLM resolver."""
"""Tests for generation backend contracts and backend resolver semantics."""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -16,6 +16,7 @@ from src.llm.backend_registry import ( # noqa: E402
resolve_generation_backend_id,
resolve_generation_fallback_backend_id,
)
from src.llm.backend_factory import create_generation_backend # noqa: E402
from src.llm.generation_backend import ( # noqa: E402
GenerationCapabilities,
GenerationError,
@@ -23,6 +24,7 @@ from src.llm.generation_backend import ( # noqa: E402
GenerationResult,
)
from src.llm.litellm_backend import LiteLLMGenerationBackend # noqa: E402
from src.llm.local_cli_backend import LocalCliGenerationBackend # noqa: E402
def _config(**overrides):
@@ -69,18 +71,24 @@ def test_generation_result_and_capabilities_fields_are_public_contract() -> None
assert capabilities.supports_smoke_test is False
def test_generation_error_codes_include_phase1_and_reserved_values() -> None:
def test_generation_error_codes_include_phase2_values() -> None:
assert {code.value for code in GenerationErrorCode} == {
"backend_not_configured",
"command_not_found",
"command_not_executable",
"timeout",
"non_zero_exit",
"empty_output",
"output_too_large",
"invalid_json",
"schema_validation_failed",
"unsupported_tool_calling",
"interactive_prompt_required",
"approval_required",
"login_required",
"capability_unsupported",
"unsafe_config",
"unknown_backend_error",
}
@@ -92,14 +100,16 @@ def test_generation_error_stage_uses_descriptive_string_contract() -> None:
fallbackable=True,
backend="litellm",
provider="gemini",
details={"phase1_allowed_stages": ["generation", "validation", "fallback"]},
details={"allowed_stages": ["generation", "configuration", "execution", "validation", "fallback"]},
)
assert str(error) == "invalid_json at generation for backend litellm"
assert error.stage in {"generation", "validation", "fallback"}
assert error.stage in {"generation", "configuration", "execution", "validation", "fallback"}
assert error.provider == "gemini"
assert error.details["phase1_allowed_stages"] == [
assert error.details["allowed_stages"] == [
"generation",
"configuration",
"execution",
"validation",
"fallback",
]
@@ -167,6 +177,18 @@ 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:
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)
def test_resolvers_default_to_litellm_and_self_fallback_is_noop() -> None:
config = _config(
generation_backend="",
@@ -205,6 +227,13 @@ 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")
assert resolve_generation_backend_id(config) == "codex_cli"
assert resolve_agent_generation_backend_id(config) == "litellm"
def test_unknown_generation_backend_raises_structured_config_error() -> None:
with pytest.raises(GenerationError) as exc_info:
resolve_generation_backend_id(_config(generation_backend="codex"))
@@ -217,17 +246,32 @@ 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"] == ["litellm"]
assert error.details["supported_backends"] == ["codex_cli", "litellm"]
def test_generation_backend_codex_does_not_fallback_to_litellm() -> None:
config = _config(generation_backend="codex", generation_fallback_backend="litellm")
def test_codex_cli_generation_backend_can_fallback_to_litellm() -> None:
config = _config(generation_backend="codex_cli", generation_fallback_backend="litellm")
assert resolve_generation_backend_id(config) == "codex_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="")
assert resolve_generation_fallback_backend_id(config) is None
def test_codex_cli_is_not_listed_as_supported_generation_fallback() -> None:
with pytest.raises(GenerationError) as exc_info:
resolve_generation_fallback_backend_id(config)
resolve_generation_fallback_backend_id(
_config(generation_backend="litellm", generation_fallback_backend="codex_cli")
)
assert exc_info.value.error_code is GenerationErrorCode.BACKEND_NOT_CONFIGURED
assert exc_info.value.details["requested_backend"] == "codex"
error = exc_info.value
assert error.details["field"] == "GENERATION_FALLBACK_BACKEND"
assert error.details["requested_backend"] == "codex_cli"
assert error.details["supported_backends"] == ["litellm"]
def test_unknown_agent_backend_raises_structured_config_error() -> None:
@@ -238,17 +282,39 @@ 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", "litellm"]
assert error.details["supported_backends"] == ["auto", "codex_cli", "litellm"]
def test_llm_tool_adapter_unknown_agent_backend_is_not_silent_litellm_fallback() -> 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"))
adapter = LLMToolAdapter(_config(agent_generation_backend="codex_cli"))
assert adapter.is_available is False
response = adapter.call_completion([])
assert response.provider == "error"
assert "backend_not_configured" in (response.content or "")
assert "codex" in (response.content or "")
assert "unsupported_tool_calling" in (response.content or "")
assert "codex_cli" in (response.content or "")
def test_agent_auto_with_codex_cli_returns_unsupported_when_litellm_agent_backend_missing() -> None:
from src.agent.llm_adapter import LLMToolAdapter
config = _config(
generation_backend="codex_cli",
agent_generation_backend="auto",
litellm_model="",
agent_litellm_model="",
litellm_fallback_models=[],
llm_model_list=[],
)
with patch("src.agent.llm_adapter.litellm.register_model", create=True):
adapter = LLMToolAdapter(config)
assert adapter.is_available is False
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 "")

View File

@@ -0,0 +1,961 @@
# -*- coding: utf-8 -*-
"""Tests for the restricted local CLI generation backend."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import json
import os
from pathlib import Path
import subprocess
import sys
import time
from types import SimpleNamespace
import pytest
from tests.litellm_stub import ensure_litellm_stub
ensure_litellm_stub()
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
LocalCliGenerationBackend,
LocalCliPreset,
build_local_cli_env,
effective_local_cli_concurrency,
redact_diagnostic_text,
)
def _config(**overrides):
defaults = {
"generation_backend_timeout_seconds": 5,
"generation_backend_max_output_bytes": 1024 * 1024,
"generation_backend_max_concurrency": 1,
"local_cli_backend_max_concurrency": 1,
"generation_backend": "codex_cli",
"generation_fallback_backend": "",
"report_language": "zh",
}
defaults.update(overrides)
return SimpleNamespace(**defaults)
def _script(tmp_path: Path, source: str) -> str:
path = tmp_path / "mock_cli.py"
path.write_text(source, encoding="utf-8")
return str(path)
def _backend(tmp_path: Path, source: str, **config_overrides) -> LocalCliGenerationBackend:
preset = LocalCliPreset(
preset_id="codex_cli",
executable=sys.executable,
argv=(_script(tmp_path, source),),
display_name="Mock CLI",
)
return LocalCliGenerationBackend(_config(**config_overrides), preset=preset)
def test_success_uses_stdin_temp_cwd_and_usage_unavailable(tmp_path: Path) -> None:
backend = _backend(
tmp_path,
"""
import json, os, sys
prompt = sys.stdin.read()
print(json.dumps({"prompt": prompt, "cwd": os.getcwd(), "sentiment_score": 70}, ensure_ascii=False))
""",
)
result = backend.generate("hello", {}, response_validator=lambda text: json.loads(text))
payload = json.loads(result.text)
assert payload["prompt"] == "hello"
assert payload["cwd"] != os.getcwd()
assert not Path(payload["cwd"]).exists()
assert result.usage == {
"usage_available": False,
"usage_source": "unavailable",
"backend": "codex_cli",
}
assert result.diagnostics["executable"]["basename"] == Path(sys.executable).name
assert "path" not in result.diagnostics["executable"]
def test_codex_preset_reads_output_last_message_instead_of_stdout(tmp_path: Path) -> None:
final_payload = json.dumps({"prompt": "hello", "sentiment_score": 88, "source": "last_message"})
script = _script(
tmp_path,
f"""
import json, sys
args = sys.argv[1:]
output_path = args[args.index("--output-last-message") + 1]
prompt = sys.stdin.read()
with open(output_path, "w", encoding="utf-8") as handle:
handle.write(json.dumps({{"prompt": prompt, "sentiment_score": 88, "source": "last_message"}}))
print("OpenAI Codex v0.142.0")
print("23,011")
print({final_payload!r})
""",
)
preset = LocalCliPreset(
preset_id="codex_cli",
executable=sys.executable,
argv=(script, "-"),
display_name="Mock Codex CLI",
output_last_message_arg="--output-last-message",
)
backend = LocalCliGenerationBackend(_config(), preset=preset)
result = backend.generate("hello", {}, response_validator=lambda text: json.loads(text))
payload = json.loads(result.text)
assert payload == {
"prompt": "hello",
"sentiment_score": 88,
"source": "last_message",
}
assert result.diagnostics["output_source"] == "output_last_message"
assert "OpenAI Codex" in result.diagnostics["stdout_preview"]
assert "final-message omitted" in result.diagnostics["stdout_preview"]
assert "last_message" not in result.diagnostics["stdout_preview"]
def test_output_last_message_stdout_duplicate_is_not_double_counted(tmp_path: Path) -> None:
final_payload = json.dumps(
{
"sentiment_score": 70,
"source": "last_message",
"details": "x" * 40,
}
)
script = _script(
tmp_path,
f"""
import sys
args = sys.argv[1:]
output_path = args[args.index("--output-last-message") + 1]
with open(output_path, "w", encoding="utf-8") as handle:
handle.write({final_payload!r})
print({final_payload!r})
""",
)
preset = LocalCliPreset(
"codex_cli",
sys.executable,
(script,),
"Mock CLI",
output_last_message_arg="--output-last-message",
)
backend = LocalCliGenerationBackend(
_config(generation_backend_max_output_bytes=len(final_payload.encode("utf-8")) + 2),
preset=preset,
)
result = backend.generate("prompt", {}, response_validator=lambda text: json.loads(text))
assert json.loads(result.text)["sentiment_score"] == 70
assert result.diagnostics["stdout_final_message_omitted"] is True
assert "last_message" not in result.diagnostics["stdout_preview"]
def test_output_last_message_nonzero_exit_omits_duplicate_final_stdout_preview(
tmp_path: Path,
) -> None:
final_payload = json.dumps(
{
"sentiment_score": 70,
"source": "secret_final_payload",
}
)
script = _script(
tmp_path,
f"""
import sys
args = sys.argv[1:]
output_path = args[args.index("--output-last-message") + 1]
with open(output_path, "w", encoding="utf-8") as handle:
handle.write({final_payload!r})
print("diagnostic: before final")
print({final_payload!r})
sys.exit(2)
""",
)
preset = LocalCliPreset(
"codex_cli",
sys.executable,
(script,),
"Mock CLI",
output_last_message_arg="--output-last-message",
)
backend = LocalCliGenerationBackend(_config(), preset=preset)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.NON_ZERO_EXIT
assert "diagnostic: before final" in exc_info.value.details["stdout_preview"]
assert "final-message omitted" in exc_info.value.details["stdout_preview"]
assert "secret_final_payload" not in exc_info.value.details["stdout_preview"]
def test_stream_request_degrades_to_non_stream(tmp_path: Path) -> None:
progress = []
backend = _backend(tmp_path, "print('{\"sentiment_score\": 60}')")
result = backend.generate(
"prompt",
{},
stream=True,
stream_progress_callback=progress.append,
)
assert json.loads(result.text)["sentiment_score"] == 60
assert result.diagnostics["stream_degraded"] is True
assert progress
def test_stderr_does_not_affect_successful_stdout_or_json_parsing(tmp_path: Path) -> None:
analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer)
analyzer._config_override = _config()
backend = _backend(
tmp_path,
"""
import sys
print('{"sentiment_score": 70, "trend_prediction": "看多"}')
print('{"bad": "stderr"}', file=sys.stderr)
""",
)
result = backend.generate(
"prompt",
{},
response_validator=analyzer._validate_json_response,
)
assert json.loads(result.text)["sentiment_score"] == 70
assert "stderr" in result.diagnostics["stderr_preview"]
def test_multiple_json_objects_fail_as_invalid_json_ambiguous(tmp_path: Path) -> None:
analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer)
analyzer._config_override = _config()
backend = _backend(tmp_path, "print('{\"sentiment_score\": 70} {\"sentiment_score\": 80}')")
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {}, response_validator=analyzer._validate_json_response)
assert exc_info.value.error_code is GenerationErrorCode.INVALID_JSON
assert exc_info.value.details["reason"] == "ambiguous_json"
def test_command_not_executable(monkeypatch, tmp_path: Path) -> None:
not_exec = tmp_path / "not-executable"
not_exec.write_text("#!/bin/sh\n", encoding="utf-8")
monkeypatch.setattr("src.llm.local_cli_backend.shutil.which", lambda _cmd: str(not_exec))
preset = LocalCliPreset("codex_cli", "mock", (), "Mock CLI")
backend = LocalCliGenerationBackend(_config(), preset=preset)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.COMMAND_NOT_EXECUTABLE
def test_command_not_found(monkeypatch) -> None:
monkeypatch.setattr("src.llm.local_cli_backend.shutil.which", lambda _cmd: None)
backend = LocalCliGenerationBackend(_config())
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.COMMAND_NOT_FOUND
def test_shell_metachar_returns_unsafe_config() -> None:
preset = LocalCliPreset("codex_cli", "mock", ("echo", "ok;rm"), "Mock CLI")
backend = LocalCliGenerationBackend(_config(), 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"] == "shell_metachar"
def test_output_last_message_arg_shell_metachar_returns_unsafe_config(tmp_path: Path) -> None:
preset = LocalCliPreset(
"codex_cli",
sys.executable,
(_script(tmp_path, "print('ok')"),),
"Mock CLI",
output_last_message_arg="--output-last-message;rm",
)
backend = LocalCliGenerationBackend(_config(), 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"] == "shell_metachar"
def test_output_too_large(tmp_path: Path) -> None:
backend = _backend(
tmp_path,
"print('x' * 100)",
generation_backend_max_output_bytes=20,
)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.OUTPUT_TOO_LARGE
def test_output_stat_error_is_structured_and_kills_process_group(
tmp_path: Path,
monkeypatch,
) -> None:
pid_file = tmp_path / "child-stat-error.pid"
backend = _backend(
tmp_path,
f"""
import subprocess, sys, time
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
open({str(pid_file)!r}, "w", encoding="utf-8").write(str(child.pid))
sys.stdout.write("started")
sys.stdout.flush()
time.sleep(30)
""",
)
def _raise_stat_error(*_paths):
deadline = time.time() + 3
while not pid_file.exists() and time.time() < deadline:
time.sleep(0.01)
raise OSError("mock stat failure sk-secretsecretsecret")
monkeypatch.setattr(
"src.llm.local_cli_backend._combined_path_size_required",
_raise_stat_error,
)
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"] == "output_stat_failed"
assert "sk-secret" not in exc_info.value.details["error"]
child_pid = int(pid_file.read_text(encoding="utf-8"))
deadline = time.time() + 3
while time.time() < deadline:
try:
os.kill(child_pid, 0)
except OSError:
break
time.sleep(0.05)
else:
pytest.fail("child process was not terminated after output stat failure")
def test_output_read_error_is_structured_unknown_not_empty(
tmp_path: Path,
monkeypatch,
) -> None:
backend = _backend(tmp_path, "print('{\"sentiment_score\": 70}')")
def _raise_read_error(_path):
raise OSError("mock read failure")
monkeypatch.setattr(
"src.llm.local_cli_backend._read_text_file_required",
_raise_read_error,
)
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"] == "output_read_failed"
def test_stdout_output_limit_is_not_double_counted(tmp_path: Path) -> None:
backend = _backend(
tmp_path,
"print('{\"sentiment_score\": 70}')",
generation_backend_max_output_bytes=30,
)
result = backend.generate("prompt", {}, response_validator=lambda text: json.loads(text))
assert json.loads(result.text)["sentiment_score"] == 70
assert result.diagnostics["output_source"] == "stdout"
def test_output_too_large_kills_process_group(tmp_path: Path) -> None:
pid_file = tmp_path / "child-output-limit.pid"
backend = _backend(
tmp_path,
f"""
import subprocess, sys, time
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
open({str(pid_file)!r}, "w", encoding="utf-8").write(str(child.pid))
sys.stdout.write("x" * 100000)
sys.stdout.flush()
time.sleep(30)
""",
generation_backend_max_output_bytes=20,
)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.OUTPUT_TOO_LARGE
child_pid = int(pid_file.read_text(encoding="utf-8"))
deadline = time.time() + 3
while time.time() < deadline:
try:
os.kill(child_pid, 0)
except OSError:
break
time.sleep(0.05)
else:
pytest.fail("child process was not terminated with the process group")
def test_output_last_message_too_large(tmp_path: Path) -> None:
script = _script(
tmp_path,
"""
import sys
args = sys.argv[1:]
output_path = args[args.index("--output-last-message") + 1]
with open(output_path, "w", encoding="utf-8") as handle:
handle.write("x" * 100)
""",
)
preset = LocalCliPreset(
"codex_cli",
sys.executable,
(script,),
"Mock CLI",
output_last_message_arg="--output-last-message",
)
backend = LocalCliGenerationBackend(_config(generation_backend_max_output_bytes=20), preset=preset)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.OUTPUT_TOO_LARGE
def test_output_last_message_total_limit_includes_stdio(tmp_path: Path) -> None:
script = _script(
tmp_path,
"""
import sys
args = sys.argv[1:]
output_path = args[args.index("--output-last-message") + 1]
print("stdout bytes")
with open(output_path, "w", encoding="utf-8") as handle:
handle.write("final bytes")
""",
)
preset = LocalCliPreset(
"codex_cli",
sys.executable,
(script,),
"Mock CLI",
output_last_message_arg="--output-last-message",
)
backend = LocalCliGenerationBackend(_config(generation_backend_max_output_bytes=20), preset=preset)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.OUTPUT_TOO_LARGE
def test_empty_stdout_returns_empty_output(tmp_path: Path) -> None:
backend = _backend(tmp_path, "")
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.EMPTY_OUTPUT
assert exc_info.value.details["reason"] == "empty_stdout"
def test_missing_output_last_message_returns_empty_output(tmp_path: Path) -> None:
preset = LocalCliPreset(
"codex_cli",
sys.executable,
(_script(tmp_path, "print('metadata only')"),),
"Mock CLI",
output_last_message_arg="--output-last-message",
)
backend = LocalCliGenerationBackend(_config(), preset=preset)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.EMPTY_OUTPUT
assert exc_info.value.details["reason"] == "missing_last_message_output"
assert exc_info.value.details["output_source"] == "output_last_message"
def test_non_zero_exit_maps_login_required(tmp_path: Path) -> None:
backend = _backend(
tmp_path,
"""
import sys
print('not authenticated, please login', file=sys.stderr)
raise SystemExit(2)
""",
)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.LOGIN_REQUIRED
assert exc_info.value.details["returncode"] == 2
def test_non_zero_exit_maps_cli_contract_unsupported(tmp_path: Path) -> None:
preset = LocalCliPreset(
"codex_cli",
sys.executable,
(
_script(
tmp_path,
"""
import sys
print("error: unexpected argument '--output-last-message' found", file=sys.stderr)
raise SystemExit(2)
""",
),
),
"Mock CLI",
output_last_message_arg="--output-last-message",
)
backend = LocalCliGenerationBackend(_config(), preset=preset)
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.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_non_zero_exit_mentions_preset_arg_without_unknown_marker_stays_generic(tmp_path: Path) -> None:
preset = LocalCliPreset(
"codex_cli",
sys.executable,
(
_script(
tmp_path,
"""
import sys
print("failed while writing --output-last-message file", file=sys.stderr)
raise SystemExit(2)
""",
),
),
"Mock CLI",
output_last_message_arg="--output-last-message",
)
backend = LocalCliGenerationBackend(_config(), preset=preset)
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.details["reason"] == "non_zero_exit"
def test_non_zero_exit_with_missing_last_message_still_maps_login_required(tmp_path: Path) -> None:
preset = LocalCliPreset(
"codex_cli",
sys.executable,
(
_script(
tmp_path,
"""
import sys
print("not authenticated, please login", file=sys.stderr)
raise SystemExit(2)
""",
),
),
"Mock CLI",
output_last_message_arg="--output-last-message",
)
backend = LocalCliGenerationBackend(_config(), preset=preset)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.LOGIN_REQUIRED
assert exc_info.value.details["reason"] == "login_required"
def test_process_start_error_diagnostics_are_redacted(monkeypatch) -> None:
home_path = Path.home()
executable_path = str(home_path / "secret" / "bin" / "codex")
monkeypatch.setattr("src.llm.local_cli_backend.shutil.which", lambda _cmd: executable_path)
monkeypatch.setattr("src.llm.local_cli_backend.os.access", lambda _path, _mode: True)
def _raise_os_error(*_args, **_kwargs):
raise OSError(f"Exec format error: {executable_path} sk-secretsecretsecret")
monkeypatch.setattr("src.llm.local_cli_backend.subprocess.Popen", _raise_os_error)
backend = LocalCliGenerationBackend(_config())
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.UNKNOWN_BACKEND_ERROR
error = exc_info.value.details["error"]
assert str(home_path) not in error
assert "sk-secret" not in error
def test_prompt_is_passed_as_stdin_file_not_pipe(tmp_path: Path, monkeypatch) -> None:
captured = {}
def _raise_os_error(*_args, **kwargs):
stdin = kwargs.get("stdin")
captured["stdin"] = stdin
captured["stdin_closed_at_popen"] = getattr(stdin, "closed", True)
raise OSError("mock start failure")
monkeypatch.setattr("src.llm.local_cli_backend.subprocess.Popen", _raise_os_error)
backend = _backend(tmp_path, "print('unused')")
with pytest.raises(GenerationError):
backend.generate("x" * 200000, {})
stdin = captured["stdin"]
assert stdin is not subprocess.PIPE
assert hasattr(stdin, "fileno")
assert not captured["stdin_closed_at_popen"]
def test_timeout_kills_process_group(tmp_path: Path) -> None:
pid_file = tmp_path / "child.pid"
backend = _backend(
tmp_path,
f"""
import subprocess, sys, time
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
open({str(pid_file)!r}, "w", encoding="utf-8").write(str(child.pid))
time.sleep(30)
""",
generation_backend_timeout_seconds=1,
)
with pytest.raises(GenerationError) as exc_info:
backend.generate("prompt", {})
assert exc_info.value.error_code is GenerationErrorCode.TIMEOUT
child_pid = int(pid_file.read_text(encoding="utf-8"))
deadline = time.time() + 3
while time.time() < deadline:
try:
os.kill(child_pid, 0)
except OSError:
break
time.sleep(0.05)
else:
pytest.fail("child process was not terminated with the process group")
def test_env_allowlist_and_denylist(monkeypatch) -> None:
monkeypatch.setenv("PATH", "/bin")
monkeypatch.setenv("HOME", "/tmp/home")
monkeypatch.setenv("UNRELATED_VALUE", "leak")
monkeypatch.setenv("OPENAI_API_KEY", "sk-secret")
monkeypatch.setenv("WEBHOOK_TOKEN", "token")
monkeypatch.setenv("AUTHORIZATION", "Bearer token")
child_env = build_local_cli_env()
assert child_env["PATH"] == "/bin"
assert child_env["HOME"] == "/tmp/home"
assert "UNRELATED_VALUE" 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
def test_env_allowlist_preserves_windows_runtime_context() -> None:
source = {
"Path": r"C:\Users\tester\AppData\Local\Microsoft\WindowsApps",
"SystemRoot": r"C:\Windows",
"WINDIR": r"C:\Windows",
"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",
"OPENAI_API_KEY": "sk-secret",
"UNRELATED_VALUE": "leak",
}
child_env = build_local_cli_env(source)
for key in (
"Path",
"SystemRoot",
"WINDIR",
"PATHEXT",
"ComSpec",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"HOMEDRIVE",
"HOMEPATH",
):
assert child_env[key] == source[key]
assert "OPENAI_API_KEY" not in child_env
assert "UNRELATED_VALUE" not in child_env
def test_generate_passes_allowlisted_windows_context_to_child_env(monkeypatch, tmp_path: Path) -> None:
windows_context = {
"SystemRoot": r"C:\Windows",
"WINDIR": r"C:\Windows",
"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("OPENAI_API_KEY", "sk-secret")
monkeypatch.setenv("UNRELATED_VALUE", "leak")
backend = _backend(
tmp_path,
"""
import json, os
keys = [
"SystemRoot",
"WINDIR",
"PATHEXT",
"ComSpec",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"HOMEDRIVE",
"HOMEPATH",
"OPENAI_API_KEY",
"UNRELATED_VALUE",
]
print(json.dumps({key: os.environ.get(key) for key in keys}, ensure_ascii=False))
""",
)
result = backend.generate("prompt", {})
payload = json.loads(result.text)
for key, value in windows_context.items():
assert payload[key] == value
assert payload["OPENAI_API_KEY"] is None
assert payload["UNRELATED_VALUE"] is None
def test_popen_session_kwargs_are_platform_specific(monkeypatch) -> None:
monkeypatch.setattr(local_cli_backend_module.os, "name", "nt")
monkeypatch.setattr(
local_cli_backend_module.subprocess,
"CREATE_NEW_PROCESS_GROUP",
0x00000200,
raising=False,
)
assert local_cli_backend_module._popen_session_kwargs() == {
"creationflags": 0x00000200,
}
monkeypatch.setattr(local_cli_backend_module.os, "name", "posix")
assert local_cli_backend_module._popen_session_kwargs() == {
"start_new_session": True,
}
def test_windows_terminate_process_group_prefers_ctrl_break(monkeypatch) -> None:
class FakeProcess:
pid = 1234
def __init__(self) -> None:
self.signals = []
self.terminated = False
self.killed = False
def poll(self):
return None
def send_signal(self, sig):
self.signals.append(sig)
def wait(self, timeout=None):
return 0
def terminate(self):
self.terminated = True
def kill(self):
self.killed = True
monkeypatch.setattr(local_cli_backend_module.os, "name", "nt")
monkeypatch.setattr(
local_cli_backend_module.signal,
"CTRL_BREAK_EVENT",
1,
raising=False,
)
process = FakeProcess()
LocalCliGenerationBackend._terminate_process_group(process)
assert process.signals == [1]
assert process.terminated is False
assert process.killed is False
def test_windows_terminate_process_group_falls_back_to_kill(monkeypatch) -> None:
class FakeProcess:
pid = 1234
def __init__(self) -> None:
self.signals = []
self.terminated = False
self.killed = False
self._wait_calls = 0
def poll(self):
return None
def send_signal(self, sig):
self.signals.append(sig)
raise OSError("no console")
def wait(self, timeout=None):
self._wait_calls += 1
if self._wait_calls == 1:
raise subprocess.TimeoutExpired(cmd="mock", timeout=timeout)
return 0
def terminate(self):
self.terminated = True
def kill(self):
self.killed = True
monkeypatch.setattr(local_cli_backend_module.os, "name", "nt")
monkeypatch.setattr(
local_cli_backend_module.signal,
"CTRL_BREAK_EVENT",
1,
raising=False,
)
process = FakeProcess()
LocalCliGenerationBackend._terminate_process_group(process)
assert process.signals == [1]
assert process.terminated is True
assert process.killed is True
def test_diagnostics_redaction_and_truncation() -> None:
text = (
"Authorization: Bearer sk-abc123456789012345678901234567890 "
"https://user:pass@example.com/path "
+ "safe text " * 20
)
redacted = redact_diagnostic_text(text, home="/Users/example", limit=60)
assert "sk-abc" not in redacted
assert "user:pass" not in redacted
assert "<truncated>" in redacted
def test_diagnostics_redacts_webhook_urls_and_preserves_adjacent_normal_urls() -> None:
text = (
"slack=https://hooks.slack.com/services/T000/B000/super-secret "
"dingtalk=https://oapi.dingtalk.com/robot/send?access_token=abc123&foo=bar "
"docs=https://example.com/public/docs?foo=bar"
)
redacted = redact_diagnostic_text(text, limit=1000)
assert "hooks.slack.com" not in redacted
assert "oapi.dingtalk.com" not in redacted
assert "super-secret" not in redacted
assert "access_token" not in redacted
assert redacted.count("<redacted-url>") == 2
assert "https://example.com/public/docs?foo=bar" in redacted
def test_effective_local_cli_concurrency_uses_minimum() -> None:
assert effective_local_cli_concurrency(_config()) == 1
assert effective_local_cli_concurrency(
_config(generation_backend_max_concurrency=4, local_cli_backend_max_concurrency=2)
) == 2
assert effective_local_cli_concurrency(
_config(generation_backend_max_concurrency=1, local_cli_backend_max_concurrency=5)
) == 1
assert effective_local_cli_concurrency(
_config(generation_backend_max_concurrency=999, local_cli_backend_max_concurrency=999)
) == 4
def test_local_cli_concurrency_limit_serializes_subprocesses(tmp_path: Path) -> None:
events_dir = tmp_path / "events"
events_dir.mkdir()
backend = _backend(
tmp_path,
f"""
import json, os, pathlib, time
events_dir = pathlib.Path({str(events_dir)!r})
pid = os.getpid()
start = time.time()
time.sleep(0.25)
end = time.time()
(events_dir / f"{{pid}}.json").write_text(
json.dumps({{"start": start, "end": end}}),
encoding="utf-8",
)
print(json.dumps({{"sentiment_score": 60}}))
""",
generation_backend_max_concurrency=4,
local_cli_backend_max_concurrency=1,
)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(lambda _: backend.generate("prompt", {}), range(2)))
assert [json.loads(result.text)["sentiment_score"] for result in results] == [60, 60]
intervals = [
json.loads(path.read_text(encoding="utf-8"))
for path in events_dir.glob("*.json")
]
assert len(intervals) == 2
intervals.sort(key=lambda item: item["start"])
assert intervals[1]["start"] >= intervals[0]["end"]

View File

@@ -172,6 +172,220 @@ class TestAnalyzerGenerateText:
generation_config={"max_tokens": 1024, "temperature": 0.5},
)
def test_generate_text_does_not_persist_unavailable_usage(self):
analyzer = self._make_analyzer()
usage = {
"usage_available": False,
"usage_source": "unavailable",
"backend": "codex_cli",
}
with patch.object(analyzer, "_call_litellm", return_value=("复盘", "codex_cli", usage)), \
patch("src.analyzer.persist_llm_usage") as mock_persist:
result = analyzer.generate_text("写一份复盘")
assert result == "复盘"
mock_persist.assert_not_called()
def test_codex_cli_is_available_without_litellm_api_keys(self):
analyzer = self._make_analyzer()
analyzer._litellm_available = False
analyzer._router = None
analyzer._config_override = SimpleNamespace(
generation_backend="codex_cli",
generation_fallback_backend="",
generation_backend_timeout_seconds=300,
generation_backend_max_output_bytes=1048576,
generation_backend_max_concurrency=1,
local_cli_backend_max_concurrency=1,
)
with patch("src.llm.local_cli_backend.shutil.which", return_value="/usr/bin/codex"), \
patch("src.llm.local_cli_backend.os.access", return_value=True):
assert analyzer.get_generation_backend_config_error() is None
assert analyzer.is_available() is True
def test_analyze_uses_litellm_fallback_when_codex_cli_config_error_is_fallbackable(self):
from src.llm.generation_backend import GenerationBackend, GenerationError, GenerationErrorCode
from src.llm.local_cli_backend import LocalCliGenerationBackend
analyzer = self._make_analyzer()
analyzer._litellm_available = True
analyzer._config_override = SimpleNamespace(
generation_backend="codex_cli",
generation_fallback_backend="litellm",
litellm_model="gemini/gemini-2.0-flash",
litellm_fallback_models=[],
llm_model_list=[],
report_language="zh",
gemini_request_delay=0,
llm_temperature=0.7,
report_integrity_enabled=False,
report_integrity_retry=0,
)
codex_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"},
)
primary_backend = MagicMock(spec=LocalCliGenerationBackend)
primary_backend.get_config_error.return_value = codex_error
primary_backend.generate.side_effect = codex_error
fallback_backend = MagicMock(spec=GenerationBackend)
fallback_backend.generate.return_value = SimpleNamespace(
text=json.dumps({
"sentiment_score": 70,
"trend_prediction": "看多",
"operation_advice": "持有",
"analysis_summary": "fallback ok",
}),
model="gemini/gemini-2.0-flash",
usage={
"usage_available": False,
"usage_source": "unavailable",
"backend": "litellm",
},
)
def _backend_for(backend_id=None):
return primary_backend if backend_id == "codex_cli" else fallback_backend
with patch.object(analyzer, "_get_generation_backend", side_effect=_backend_for), \
patch.object(analyzer, "_get_analysis_system_prompt", return_value="system"), \
patch.object(analyzer, "_get_skill_prompt_sections", return_value=(None, None, True)), \
patch.object(analyzer, "_format_prompt", return_value="prompt"), \
patch.object(analyzer, "_build_market_snapshot", return_value={}):
assert analyzer.is_available() is True
result = analyzer.analyze({"code": "600519", "stock_name": "贵州茅台"})
assert result.success is True
assert result.analysis_summary == "fallback ok"
primary_backend.generate.assert_called()
fallback_backend.generate.assert_called()
def test_analyze_preserves_litellm_text_fallback_after_codex_cli_primary_failure(self):
from src.analyzer import AnalysisResult, _AllModelsFailedError
from src.llm.generation_backend import GenerationBackend, GenerationError, GenerationErrorCode
analyzer = self._make_analyzer()
analyzer._litellm_available = True
analyzer._config_override = SimpleNamespace(
generation_backend="codex_cli",
generation_fallback_backend="litellm",
litellm_model="provider/primary-model",
litellm_fallback_models=["provider/fallback-model"],
llm_model_list=[],
report_language="zh",
gemini_request_delay=0,
llm_temperature=0.7,
report_integrity_enabled=False,
report_integrity_retry=0,
)
primary_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"},
)
all_models_error = _AllModelsFailedError(
"all fallback models returned invalid JSON",
last_response_text="这不是 JSON而是 fallback 模型返回的纯文本分析",
last_model="provider/fallback-model",
last_usage={"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
)
text_fallback_result = AnalysisResult(
code="600519",
name="贵州茅台",
sentiment_score=50,
trend_prediction="震荡",
operation_advice="持有",
analysis_summary="纯文本兜底摘要",
success=False,
error_message="LLM response is not valid JSON; analysis result will not be persisted",
)
primary_backend = MagicMock(spec=GenerationBackend)
primary_backend.generate.side_effect = primary_error
fallback_backend = MagicMock(spec=GenerationBackend)
fallback_backend.generate.side_effect = all_models_error
def _backend_for(backend_id):
return primary_backend if backend_id == "codex_cli" else fallback_backend
with patch.object(analyzer, "get_generation_backend_config_error", return_value=None), \
patch.object(analyzer, "is_available", return_value=True), \
patch.object(analyzer, "_get_generation_backend", side_effect=_backend_for), \
patch.object(analyzer, "_get_analysis_system_prompt", return_value="system"), \
patch.object(analyzer, "_get_skill_prompt_sections", return_value=(None, None, True)), \
patch.object(analyzer, "_format_prompt", return_value="prompt"), \
patch.object(analyzer, "_parse_response", return_value=text_fallback_result) as mock_parse, \
patch.object(analyzer, "_build_market_snapshot", return_value={}), \
patch("src.analyzer.persist_llm_usage") as mock_persist:
result = analyzer.analyze({"code": "600519", "stock_name": "贵州茅台"})
assert result.analysis_summary == "纯文本兜底摘要"
assert result.raw_response == "这不是 JSON而是 fallback 模型返回的纯文本分析"
assert result.model_used == "provider/fallback-model"
mock_parse.assert_called_once_with(
"这不是 JSON而是 fallback 模型返回的纯文本分析",
"600519",
"贵州茅台",
)
mock_persist.assert_called_once_with(
{"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
"provider/fallback-model",
call_type="analysis",
stock_code="600519",
)
primary_backend.generate.assert_called_once()
fallback_backend.generate.assert_called_once()
def test_analyze_does_not_persist_unavailable_usage(self):
analyzer = self._make_analyzer()
analyzer._config_override = SimpleNamespace(
generation_backend="codex_cli",
generation_fallback_backend="",
generation_backend_timeout_seconds=300,
generation_backend_max_output_bytes=1048576,
generation_backend_max_concurrency=1,
local_cli_backend_max_concurrency=1,
litellm_model="",
gemini_request_delay=0,
report_language="zh",
llm_temperature=0.7,
report_integrity_enabled=False,
report_integrity_retry=0,
)
response_text = json.dumps({
"sentiment_score": 70,
"trend_prediction": "看多",
"operation_advice": "持有",
"analysis_summary": "测试",
})
usage = {
"usage_available": False,
"usage_source": "unavailable",
"backend": "codex_cli",
}
with patch.object(analyzer, "get_generation_backend_config_error", return_value=None), \
patch.object(analyzer, "is_available", return_value=True), \
patch.object(analyzer, "_get_analysis_system_prompt", return_value="system"), \
patch.object(analyzer, "_get_skill_prompt_sections", return_value=(None, None, True)), \
patch.object(analyzer, "_format_prompt", return_value="prompt"), \
patch.object(analyzer, "_call_litellm", return_value=(response_text, "codex_cli", usage)), \
patch.object(analyzer, "_build_market_snapshot", return_value={}), \
patch("src.analyzer.persist_llm_usage") as mock_persist:
result = analyzer.analyze({"code": "600519", "stock_name": "贵州茅台"})
assert result.success is True
mock_persist.assert_not_called()
def test_generate_text_returns_none_on_failure(self):
analyzer = self._make_analyzer()
with patch.object(analyzer, "_call_litellm", side_effect=Exception("LLM error")):
@@ -203,8 +417,10 @@ class TestAnalyzerGenerateText:
assert gen_cfg["temperature"] == 0.7
def test_call_litellm_wrapper_uses_generation_backend_tuple_contract(self):
from src.llm.generation_backend import GenerationBackend
analyzer = self._make_analyzer()
backend = MagicMock()
backend = MagicMock(spec=GenerationBackend)
backend.generate.return_value = SimpleNamespace(
text="backend response",
model="gemini/gemini-3.1-pro-preview",
@@ -236,6 +452,51 @@ class TestAnalyzerGenerateText:
assert callable(backend.generate.call_args.kwargs["response_validator"])
assert backend.generate.call_args.kwargs["audit_context"] == {"call_type": "analysis"}
def test_call_litellm_wraps_fallback_generation_error_with_primary_context(self):
from src.llm.generation_backend import GenerationBackend, GenerationError, GenerationErrorCode
analyzer = self._make_analyzer()
analyzer._config_override.generation_backend = "codex_cli"
analyzer._config_override.generation_fallback_backend = "litellm"
primary_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"},
)
fallback_error = GenerationError(
error_code=GenerationErrorCode.INVALID_JSON,
stage="validation",
retryable=True,
fallbackable=True,
backend="litellm",
provider="gemini",
details={"reason": "invalid_json"},
)
primary_backend = MagicMock(spec=GenerationBackend)
primary_backend.generate.side_effect = primary_error
fallback_backend = MagicMock(spec=GenerationBackend)
fallback_backend.generate.side_effect = fallback_error
def _backend_for(backend_id):
return primary_backend if backend_id == "codex_cli" else fallback_backend
with patch.object(analyzer, "_get_generation_backend", side_effect=_backend_for):
with pytest.raises(GenerationError) as exc_info:
analyzer._call_litellm("prompt", {"max_tokens": 128})
error = exc_info.value
assert error.stage == "fallback"
assert error.error_code is GenerationErrorCode.INVALID_JSON
assert error.details["reason"] == "fallback_backend_failed"
assert error.details["primary_error"]["error_code"] == "command_not_found"
assert error.details["primary_error"]["details"]["reason"] == "executable_not_found"
assert error.details["fallback_error"]["error_code"] == "invalid_json"
assert error.details["fallback_error"]["details"]["reason"] == "invalid_json"
def test_call_litellm_rejects_unknown_generation_backend_without_litellm_fallback(self):
from src.llm.generation_backend import GenerationError
@@ -1797,6 +2058,40 @@ class TestMarketAnalyzerBypassFix:
assert diagnostic["error_type"] == "GenerationError"
assert "backend_not_configured" in str(diagnostic["error_message"])
def test_local_backend_execution_error_does_not_template_fallback(self):
from src.llm.generation_backend import GenerationError, GenerationErrorCode
from src.market_analyzer import MarketOverview, MarketIndex
ma = self._make_market_analyzer_with_mock_generate_text(return_value=None)
ma.analyzer.generate_text.side_effect = GenerationError(
error_code=GenerationErrorCode.COMMAND_NOT_FOUND,
stage="configuration",
retryable=False,
fallbackable=True,
backend="codex_cli",
provider="codex_cli",
details={"reason": "executable_not_found"},
)
overview = MarketOverview(
date="2026-03-05",
indices=[
MarketIndex(
code="000001",
name="上证指数",
current=3300.0,
change=5.0,
change_pct=0.15,
)
],
)
with patch.object(ma, "_generate_template_review", wraps=ma._generate_template_review) as template_review:
with pytest.raises(GenerationError) as exc_info:
ma.generate_market_review(overview, [])
assert exc_info.value.error_code is GenerationErrorCode.COMMAND_NOT_FOUND
template_review.assert_not_called()
def test_generation_backend_config_error_without_analyzer_does_not_template_fallback(self):
from src.llm.generation_backend import GenerationError
from src.market_analyzer import MarketOverview, MarketIndex

View File

@@ -54,6 +54,8 @@ class TestMarketReviewRuntimeCompatibility(unittest.TestCase):
news_max_age_days=3,
news_strategy_profile="short",
has_search_capability_enabled=lambda: False,
generation_backend="litellm",
generation_fallback_backend="litellm",
)
def test_build_market_review_runtime_includes_legacy_provider_configs(self) -> None:
@@ -153,6 +155,34 @@ 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)
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()
def test_build_market_review_runtime_drops_unavailable_analyzer_without_backend_error(self) -> None:
config = self._base_config()
config.openai_api_key = "openai-key"
@@ -175,6 +205,13 @@ 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 = ""
self.assertTrue(has_configured_llm_runtime(config))
def test_has_configured_llm_runtime_supports_legacy_fields(self) -> None:
base = self._base_config()
test_configs = [

View File

@@ -208,6 +208,173 @@ class TestAnalyzerSchemaFallback(unittest.TestCase):
self.assertEqual(result.dashboard["decision_stability"]["applied"], True)
self.assertEqual(result.dashboard["decision_stability"]["reason"], "回测验证")
def test_parse_response_repairs_single_json_candidate(self) -> None:
analyzer = GeminiAnalyzer()
response = """```json
{
"stock_name": "贵州茅台",
"sentiment_score": 68,
"trend_prediction": "看多",
"operation_advice": "持有",
}
```"""
result = analyzer._parse_response(response, "600519", "股票600519")
self.assertTrue(result.success)
self.assertEqual(result.name, "贵州茅台")
self.assertEqual(result.sentiment_score, 68)
def test_parse_response_accepts_single_generic_json_fence(self) -> None:
analyzer = GeminiAnalyzer()
response = """```
{
"stock_name": "贵州茅台",
"sentiment_score": 67,
"trend_prediction": "看多",
"operation_advice": "持有",
"analysis_summary": "技术面向好"
}
```"""
result = analyzer._parse_response(response, "600519", "股票600519")
self.assertTrue(result.success)
self.assertEqual(result.name, "贵州茅台")
self.assertEqual(result.sentiment_score, 67)
def test_parse_response_repairs_nested_single_json_candidate(self) -> None:
analyzer = GeminiAnalyzer()
response = """```json
{
"stock_name": "贵州茅台",
"sentiment_score": 69,
"trend_prediction": "看多",
"operation_advice": "持有",
"dashboard": {"core_conclusion": {"one_sentence": "继续观察",},},
}
```"""
result = analyzer._parse_response(response, "600519", "股票600519")
self.assertTrue(result.success)
self.assertEqual(result.sentiment_score, 69)
self.assertEqual(result.dashboard["core_conclusion"]["one_sentence"], "继续观察")
def test_validate_json_response_accepts_single_generic_json_fence(self) -> None:
analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer)
analyzer._config_override = SimpleNamespace(generation_backend="litellm")
analyzer._validate_json_response("""```
{
"stock_name": "贵州茅台",
"sentiment_score": 66,
"trend_prediction": "看多",
"operation_advice": "持有",
"analysis_summary": "技术面向好"
}
```""")
def test_validate_json_response_accepts_single_json_fence(self) -> None:
analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer)
analyzer._config_override = SimpleNamespace(generation_backend="litellm")
analyzer._validate_json_response("""```json
{
"stock_name": "贵州茅台",
"sentiment_score": 65,
"trend_prediction": "看多",
"operation_advice": "持有",
"analysis_summary": "技术面向好"
}
```""")
def test_validate_json_response_rejects_ambiguous_json_before_repair(self) -> None:
analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer)
analyzer._config_override = SimpleNamespace(generation_backend="litellm")
with self.assertRaises(Exception) as context:
analyzer._validate_json_response('{"sentiment_score": 70} {"sentiment_score": 80}')
self.assertEqual(getattr(context.exception, "details", {}).get("reason"), "ambiguous_json")
def test_validate_json_response_rejects_generic_fence_with_outside_text(self) -> None:
analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer)
analyzer._config_override = SimpleNamespace(generation_backend="litellm")
with self.assertRaises(Exception) as context:
analyzer._validate_json_response("""Here is the JSON:
```
{"sentiment_score": 70, "trend_prediction": "看多"}
```""")
self.assertEqual(getattr(context.exception, "details", {}).get("reason"), "ambiguous_json")
def test_validate_json_response_rejects_multiple_json_fences(self) -> None:
analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer)
analyzer._config_override = SimpleNamespace(generation_backend="litellm")
with self.assertRaises(Exception) as context:
analyzer._validate_json_response("""```json
{"sentiment_score": 70}
```
```json
{"sentiment_score": 80}
```""")
self.assertEqual(getattr(context.exception, "details", {}).get("reason"), "ambiguous_json")
def test_validate_json_response_rejects_non_json_language_fence(self) -> None:
analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer)
analyzer._config_override = SimpleNamespace(generation_backend="litellm")
with self.assertRaises(Exception) as context:
analyzer._validate_json_response("""```text
{"sentiment_score": 70, "trend_prediction": "看多"}
```""")
self.assertEqual(getattr(context.exception, "details", {}).get("reason"), "ambiguous_json")
def test_validate_json_response_rejects_missing_minimal_contract(self) -> None:
analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer)
analyzer._config_override = SimpleNamespace(generation_backend="litellm")
with self.assertRaises(Exception) as context:
analyzer._validate_json_response('{"stock_name": "贵州茅台"}')
self.assertEqual(getattr(context.exception, "details", {}).get("reason"), "minimal_contract_failed")
def test_validate_json_response_rejects_parser_unconstructable_sentiment(self) -> None:
analyzer = GeminiAnalyzer.__new__(GeminiAnalyzer)
analyzer._config_override = SimpleNamespace(generation_backend="litellm")
with self.assertRaises(Exception) as context:
analyzer._validate_json_response(json.dumps({
"stock_name": "贵州茅台",
"sentiment_score": "not-a-number",
"trend_prediction": "看多",
"operation_advice": "持有",
"analysis_summary": "测试摘要",
}))
self.assertEqual(getattr(context.exception, "details", {}).get("reason"), "parser_contract_failed")
def test_parse_response_falls_back_when_parser_contract_fails(self) -> None:
analyzer = GeminiAnalyzer()
response = json.dumps({
"stock_name": "贵州茅台",
"sentiment_score": "not-a-number",
"trend_prediction": "看多",
"operation_advice": "持有",
"analysis_summary": "测试摘要",
})
result = analyzer._parse_response(response, "600519", "股票600519")
self.assertFalse(result.success)
self.assertEqual(result.sentiment_score, 50)
self.assertIn("JSON", result.error_message)
def test_parse_text_response_honors_injected_runtime_report_language(self) -> None:
"""Fallback text parsing should use the analyzer's injected config, not the global singleton."""
with patch.object(GeminiAnalyzer, "_init_litellm", return_value=None):

View File

@@ -138,6 +138,30 @@ class SystemConfigApiTestCase(unittest.TestCase):
self.assertTrue(stock_schema["examples"])
self.assertTrue(stock_schema["docs"])
def test_get_config_schema_exposes_generation_backend_bounds_and_agent_options(self) -> None:
payload = system_config.get_system_config(include_schema=True, service=self.service).model_dump(by_alias=True)
item_map = {item["key"]: item for item in payload["items"]}
self.assertEqual(
item_map["GENERATION_BACKEND_TIMEOUT_SECONDS"]["schema"]["validation"],
{"min": 1, "max": 3600},
)
self.assertEqual(
item_map["GENERATION_BACKEND_MAX_OUTPUT_BYTES"]["schema"]["validation"],
{"min": 1, "max": 33554432},
)
self.assertEqual(
item_map["GENERATION_BACKEND_MAX_CONCURRENCY"]["schema"]["validation"],
{"min": 1, "max": 16},
)
self.assertEqual(
item_map["LOCAL_CLI_BACKEND_MAX_CONCURRENCY"]["schema"]["validation"],
{"min": 1, "max": 4},
)
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"]})
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)
item_map = {item["key"]: item for item in payload["items"]}
@@ -374,6 +398,34 @@ class SystemConfigApiTestCase(unittest.TestCase):
self.assertIn("CUSTOM_NOTE=config backup\n", env_content)
self.assertIn("GEMINI_API_KEY=secret-key-value\n", env_content)
def test_import_export_system_config_preserves_generation_backend_keys(self) -> None:
current = system_config.get_system_config(include_schema=False, service=self.service).model_dump()
payload = system_config.import_system_config(
request_obj=self._build_request(),
request=ImportSystemConfigRequest(
config_version=current["config_version"],
content=(
"GENERATION_BACKEND=codex_cli\n"
"GENERATION_FALLBACK_BACKEND=\n"
"GENERATION_BACKEND_MAX_OUTPUT_BYTES=1048576\n"
"AGENT_GENERATION_BACKEND=auto\n"
),
reload_now=False,
),
service=self.service,
).model_dump()
export_payload = system_config.export_system_config(
request=self._build_request(),
service=self.service,
).model_dump()
self.assertTrue(payload["success"])
self.assertIn("GENERATION_BACKEND=codex_cli\n", export_payload["content"])
self.assertIn("GENERATION_FALLBACK_BACKEND=\n", export_payload["content"])
self.assertIn("GENERATION_BACKEND_MAX_OUTPUT_BYTES=1048576\n", export_payload["content"])
self.assertIn("AGENT_GENERATION_BACKEND=auto\n", export_payload["content"])
def test_import_system_config_returns_conflict_when_version_is_stale(self) -> None:
with self.assertRaises(HTTPException) as context:
system_config.import_system_config(

View File

@@ -117,6 +117,22 @@ class SystemConfigServiceTestCase(unittest.TestCase):
self.assertEqual(items["REPORT_SHOW_LLM_MODEL"]["value"], "false")
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",
)
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"]},
)
def test_get_config_preserves_explicit_empty_switch_value(self) -> None:
self._rewrite_env(
"STOCK_LIST=600519,000001",
@@ -415,6 +431,53 @@ class SystemConfigServiceTestCase(unittest.TestCase):
self.assertEqual(checks["stock_list"]["status"], "configured")
self.assertEqual(checks["notification"]["status"], "optional")
def test_get_setup_status_treats_codex_cli_as_primary_runtime_without_api_keys(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"GENERATION_FALLBACK_BACKEND=",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value="/usr/bin/codex"):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertEqual(checks["llm_primary"]["status"], "configured")
self.assertIn("Codex CLI", checks["llm_primary"]["message"])
self.assertNotIn("llm_primary", status["required_missing_keys"])
def test_get_setup_status_rejects_agent_codex_cli_tool_backend(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"AGENT_GENERATION_BACKEND=codex_cli",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value="/usr/bin/codex"):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertEqual(checks["llm_agent"]["status"], "needs_action")
self.assertIn("暂不支持 codex_cli", checks["llm_agent"]["message"])
def test_get_setup_status_agent_litellm_without_model_reports_missing_model(self) -> None:
self._rewrite_env(
"GENERATION_BACKEND=codex_cli",
"AGENT_GENERATION_BACKEND=litellm",
"STOCK_LIST=600519",
)
with patch.dict(os.environ, {}, clear=True), \
patch("src.services.system_config_service.shutil.which", return_value="/usr/bin/codex"):
status = self.service.get_setup_status()
checks = {check["key"]: check for check in status["checks"]}
self.assertEqual(checks["llm_agent"]["status"], "needs_action")
self.assertIn("未检测到可用 LiteLLM 模型配置", checks["llm_agent"]["message"])
self.assertNotIn("需要 LiteLLM backend", checks["llm_agent"]["message"])
def test_get_setup_status_accepts_anspire_one_key_llm(self) -> None:
self._rewrite_env(
"ANSPIRE_API_KEYS=sk-anspire-test-value",
@@ -1184,6 +1247,23 @@ class SystemConfigServiceTestCase(unittest.TestCase):
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_enum" for issue in validation["issues"]))
def test_validate_reports_generation_backend_numeric_maximum(self) -> None:
validation = self.service.validate(
items=[
{"key": "GENERATION_BACKEND_TIMEOUT_SECONDS", "value": "3601"},
{"key": "GENERATION_BACKEND_MAX_OUTPUT_BYTES", "value": "33554433"},
{"key": "GENERATION_BACKEND_MAX_CONCURRENCY", "value": "17"},
{"key": "LOCAL_CLI_BACKEND_MAX_CONCURRENCY", "value": "5"},
]
)
self.assertFalse(validation["valid"])
issues = {issue["key"]: issue for issue in validation["issues"]}
self.assertEqual(issues["GENERATION_BACKEND_TIMEOUT_SECONDS"]["expected"], "<=3600")
self.assertEqual(issues["GENERATION_BACKEND_MAX_OUTPUT_BYTES"]["expected"], "<=33554432")
self.assertEqual(issues["GENERATION_BACKEND_MAX_CONCURRENCY"]["expected"], "<=16")
self.assertEqual(issues["LOCAL_CLI_BACKEND_MAX_CONCURRENCY"]["expected"], "<=4")
def test_validate_accepts_report_language_english(self) -> None:
validation = self.service.validate(items=[{"key": "REPORT_LANGUAGE", "value": "en"}])