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