mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
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:
14
.env.example
14
.env.example
@@ -113,12 +113,18 @@ STOCK_INDEX_REMOTE_UPDATE_ENABLED=true
|
||||
# 【进阶】需要多模型 / 多平台 fallback → 配置下方「多渠道」或在 Web 设置页可视化管理。
|
||||
# ===================================
|
||||
|
||||
# 生成后端(Phase 1 仅支持 litellm)
|
||||
# 非 litellm 值会作为配置错误处理,不会静默回退到 LiteLLM。
|
||||
# 生成后端:默认 litellm;codex_cli 为显式 opt-in 的本地 CLI backend(experimental/limited)。
|
||||
# 本地 CLI Backend 不等于离线模型,CLI 背后的服务可能处理分析 prompt 和报告草稿。
|
||||
GENERATION_BACKEND=litellm
|
||||
# 后端级 fallback;litellm -> litellm 会被解析为 no-op,模型 fallback 仍由 LiteLLM 配置负责。
|
||||
# 后端级 fallback;本地 .env 空值禁用 backend-level fallback,litellm -> litellm 会被解析为 no-op。
|
||||
# 默认 GitHub Actions workflow 未配置该变量时会显式使用 litellm;Actions 中要禁用 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(填一个即可)---
|
||||
|
||||
7
.github/workflows/00-daily-analysis.yml
vendored
7
.github/workflows/00-daily-analysis.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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('优先选择当前可用');
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 秒后自动消失。
|
||||
|
||||
@@ -25,20 +25,38 @@
|
||||
|
||||
---
|
||||
|
||||
## Generation Backend(Phase 1)
|
||||
## Generation Backend(Phase 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 backend;Phase 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 fallback;primary 与 fallback 相同时解析为 no-op。仓库自带 GitHub Actions workflow 未配置该变量时会显式导出 `litellm`,如果要在 Actions 中禁用 backend fallback,请把 fallback 设为 primary backend,例如 `GENERATION_BACKEND=codex_cli` + `GENERATION_FALLBACK_BACKEND=codex_cli`。
|
||||
- `GENERATION_BACKEND=codex_cli` 且没有 Gemini/OpenAI/Anthropic/DeepSeek API Key 时,普通分析和大盘复盘仍会尝试本地 CLI backend;如果 `codex` executable 不存在,会返回结构化 `command_not_found`,不会报“API Key 未配置”。
|
||||
- 当前 `codex_cli` preset 使用 `codex exec --output-last-message <temp-file> -` 读取最终响应;Codex CLI 仍会把同一最终响应打印到 stdout,DSA 会从 stdout 诊断预览和输出大小统计中剔除这份重复内容,不参与主分析 JSON 解析。官方依据见 [Codex non-interactive mode](https://developers.openai.com/codex/noninteractive) 与 [Codex CLI command line options](https://developers.openai.com/codex/cli/reference)。本仓库当前只验证 `codex-cli 0.142.0`,不声明更宽最低版本;如果 CLI 版本不支持 preset 参数,DSA 会返回结构化 `non_zero_exit` / `cli_contract_unsupported` 诊断,并在配置 backend fallback 时回退到 `litellm`。
|
||||
- `codex_cli` 不支持 streaming。请求 stream 时会自动降级为 non-stream,不会因此返回 `capability_unsupported`。
|
||||
- 本地 CLI usage 通常不可用,系统不会写入 fake 0 token、fake cost 或 fake cache telemetry。
|
||||
- 本地 CLI 执行上限有硬边界:`GENERATION_BACKEND_TIMEOUT_SECONDS` 最大 `3600`,`GENERATION_BACKEND_MAX_OUTPUT_BYTES` 最大 `33554432`,`GENERATION_BACKEND_MAX_CONCURRENCY` 最大 `16`,`LOCAL_CLI_BACKEND_MAX_CONCURRENCY` 最大 `4`。诊断 stdout/stderr 与最终响应合计超过输出上限时会返回结构化 `output_too_large`;对 `--output-last-message` preset,stdout 中重复打印的最终响应不会重复计入,也不会作为 `stdout_preview` 暴露。
|
||||
- 本地 CLI 默认并发为 1;有效并发为 `min(LOCAL_CLI_BACKEND_MAX_CONCURRENCY, GENERATION_BACKEND_MAX_CONCURRENCY)`,不继承 `MAX_WORKERS`。
|
||||
- `AGENT_GENERATION_BACKEND=auto` 不会无条件继承 `GENERATION_BACKEND=codex_cli`;Agent 工具调用继续使用 LiteLLM。Web 设置页仅暴露 `auto|litellm`;手写 `AGENT_GENERATION_BACKEND=codex_cli` 在 Phase 2 不实现 text-only Agent mode,会返回明确 unsupported tool-calling 诊断。
|
||||
|
||||
### 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`
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 历史 API,Claude extended thinking 仅覆盖离线 plumbing,multi-agent trace 注入留作后续增强。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 仍会把同一最终响应打印到 stdout,DSA 会从 stdout 诊断预览和输出大小统计中剔除这份重复内容。诊断 stdout/stderr 与最终响应一起受 `GENERATION_BACKEND_MAX_OUTPUT_BYTES` 总上限约束,超限时返回结构化 `output_too_large`。官方依据见 [Codex non-interactive mode](https://developers.openai.com/codex/noninteractive) 与 [Codex CLI command line options](https://developers.openai.com/codex/cli/reference);本仓库当前只验证 `codex-cli 0.142.0`,不声明更宽最低版本。`GENERATION_FALLBACK_BACKEND=` 空值会在本地 `.env` 禁用 backend-level fallback,未配置时默认回退到 `litellm`;默认 GitHub Actions workflow 未配置该变量时会显式使用 `litellm`,如需禁用 fallback 可设为 primary backend 走 self no-op。Agent 工具调用仍使用 LiteLLM;Web 设置页只暴露 `AGENT_GENERATION_BACKEND=auto|litellm`,手写 `codex_cli` 不会启用 text-only Agent mode,只会返回明确 unsupported tool-calling 诊断。
|
||||
|
||||
本地 CLI Backend 不等于离线模型。Docker、云服务器和 CI 不天然拥有本机 CLI 登录态;DSA 不读取 Codex credential 文件,但子进程可能使用 CLI 自身登录态,股票代码、新闻、持仓上下文、分析 prompt 和报告草稿可能被对应 CLI 背后的服务处理。
|
||||
|
||||
## Web 设置页路径
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
583
src/analyzer.py
583
src/analyzer.py
@@ -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,
|
||||
|
||||
@@ -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 当前仅支持 auto 或 litellm。"
|
||||
"AGENT_GENERATION_BACKEND 当前支持 auto、litellm;"
|
||||
"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=(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
46
src/llm/backend_factory.py
Normal file
46
src/llm/backend_factory.py
Normal 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},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
990
src/llm/local_cli_backend.py
Normal file
990
src/llm/local_cli_backend.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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 本地生成 Backend(experimental/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",
|
||||
|
||||
@@ -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"}],
|
||||
|
||||
@@ -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=[])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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 "仅支持 auto 或 litellm" in error.message
|
||||
assert "auto、litellm" 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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
961
tests/test_local_cli_backend.py
Normal file
961
tests/test_local_cli_backend.py
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"}])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user