feat: multi-channel LLM support with visual channel editor (#494)

* feat: multi-channel LLM support with visual channel editor

- Add three-tier LLM config: LITELLM_CONFIG (YAML) > LLM_CHANNELS (env) > legacy keys
- Each channel gets independent base_url / api_key / models (no OPENAI_BASE_URL conflict)
- Support DEEPSEEK_API_KEY as standalone provider (auto-infers deepseek-chat model)
- Add LLMChannelEditor component with 9 presets (AIHubmix/DeepSeek/Dashscope/GLM/Moonshot/SiliconFlow/OpenRouter/Gemini/Custom)
- Expand config_registry with ~50 new fields for web settings coverage
- Rewrite .env.example AI section with clear quick-start guide (Scenario A vs B)
- Add litellm_config.example.yaml template
- Add PyYAML dependency for YAML config support
- Full backward compatibility: existing single-key configs work unchanged

* chore: replace placeholder key values with empty defaults in .env.example

* fix: resolve ESLint errors in LLMChannelEditor and HomePage

* refactor: use native litellm deepseek/ provider and extract shared LLM helpers

- Replace openai/deepseek-* + manual api_base with deepseek/ prefix (litellm
  natively resolves DEEPSEEK_API_KEY and base_url)
- Extract duplicated _get_api_keys_for_model and _extra_litellm_params from
  analyzer.py and llm_adapter.py into shared functions in config.py
- Update litellm_config.example.yaml to use deepseek/ prefix
- Thinking mode (deepseek-chat opt-in, deepseek-reasoner auto) unaffected:
  get_thinking_extra_body uses model short name stripped of provider prefix
This commit is contained in:
mumu
2026-03-06 21:14:34 +08:00
committed by GitHub
parent d5b8b60c69
commit e642f776c8
12 changed files with 1661 additions and 146 deletions

View File

@@ -10,7 +10,7 @@ STOCK_LIST=600519,300750,002594
# 数据源配置
# Tushare Pro Token可选从 https://tushare.pro/weborder/#/login?reg=834638 获取)
TUSHARE_TOKEN=your_tushare_token_here
TUSHARE_TOKEN=
# ===================================
# 定时任务配置(本地/Docker运行
@@ -23,68 +23,92 @@ TUSHARE_TOKEN=your_tushare_token_here
# TRADING_DAY_CHECK_ENABLED=true
# ===================================
# AI 模型配置(统一通过 LiteLLM至少配置一个 API Key
# AI 模型配置
# ===================================
#
# 核心配置(二选一或组合):
# LITELLM_MODEL - 主模型,仅填一个,格式 provider/model-name使用第三方模型提供商或 OpenAI 兼容 API 时须加 openai 前缀
# LITELLM_FALLBACK_MODELS - 备选模型,逗号分隔,主模型全部失败时按序尝试
# 若未配置 LITELLM_MODEL系统将根据已有 API Key 自动推断(推断结果打印在日志中)
# 【快速上手 — 根据使用场景选一种即可】
#
# 模型格式示例gemini/gemini-2.5-flash、anthropic/claude-3-5-sonnet-20241022、openai/gpt-4o
# 场景 A只用一个模型最简单
# → 填对应 API Key 即可,系统自动识别模型。
# 例:只用 Gemini → 填 GEMINI_API_KEY
# 例:只用 DeepSeek → 填 DEEPSEEK_API_KEY
# 例:想用 AIHubmix 聚合 → 填 AIHUBMIX_KEY
#
# 场景 B同时使用多个模型/平台(推荐渠道模式)
# → 配置 LLM_CHANNELS每个渠道独立填 base_url / api_key / models。
# 也可在 Web 设置页 → AI 模型 → 渠道编辑器中可视化配置。
# 详见下方「多渠道配置」区域。
#
# ⚠️ 两种方式不要混用:配了渠道后,传统 API Key 区域的配置会被忽略。
#
# 高级选项(通常无需手动设置,可自动推断):
# LITELLM_MODEL 主模型,格式 provider/model-name
# LITELLM_FALLBACK_MODELS 备选模型,逗号分隔
# ===================================
# LITELLM_MODEL=gemini/gemini-3-flash-preview
# LITELLM_FALLBACK_MODELS=anthropic/claude-3-5-sonnet-20241022,openai/gpt-4o-mini
# 温度参数
# -----------------------------------
# API Key 配置(场景 A只用一个模型
# -----------------------------------
# 【Gemini】免费额度https://aistudio.google.com
GEMINI_API_KEY=
# 多 Key 负载均衡GEMINI_API_KEYS=key1,key2,key3
GEMINI_TEMPERATURE=0.7
# ===================================
# API Key 配置(支持多个 Key逗号分隔自动负载均衡
# ===================================
# 【DeepSeek】https://platform.deepseek.com
# DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
# 【方案一】Gemini API有免费额度从 https://aistudio.google.com 获取
# Key
GEMINI_API_KEY=
# 多 KeyGEMINI_API_KEYS=key1,key2,key3
# 示例LITELLM_MODEL=gemini/gemini-3-flash-preview
# 【推荐】AIHubmix 聚合https://aihubmix.com/?aff=CfMq
# 一个 Key 用 GPT/Claude/Gemini/GLM/Qwen 等模型,无需科学上网
# AIHUBMIX_KEY=
# 【方案二】Anthropic Claude APIhttps://console.anthropic.com 获取)
# 【Anthropic Claudehttps://console.anthropic.com
# ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxx
# 多 KeyANTHROPIC_API_KEYS=key1,key2
# 示例LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022
# ANTHROPIC_TEMPERATURE=0.7
# 【方案三】OpenAI / DeepSeek / OpenRouter 等兼容 API
# 单 KeyOPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx
# 多 KeyOPENAI_API_KEYS=key1,key2
# OPENAI_BASE_URL=第三方 API 地址(官方 OpenAI 可不填DeepSeek: https://api.deepseek.com/v1OpenRouter: https://openrouter.ai/api/v1
# OPENAI_TEMPERATURE=0.7
# OPENAI_VISION_MODEL=gpt-4o # 图片识别专用模型(可选)
#
# --- 【推荐】AIHubmix 一站式 ---
# AIHubmix 支持一站式使用全球主流 AI 模型,一个 Key 可在本项目中切换使用任何模型,
# 无需科学上网含免费模型glm-5、gpt-4o-free 等顶级模型),
# 付费模型拥有极高的稳定性和无限并发能力,适合大规模生产级应用使用。
# 使用 AIHUBMIX_KEY 时无需配置 OPENAI_BASE_URL系统自动使用 aihubmix.com/v1。
# 获取 Keyhttps://aihubmix.com/?aff=CfMq
# AIHUBMIX_KEY=your_aihubmix_key_here
# 示例LITELLM_MODEL=openai/gemini-3.1-pro-preview
#
# --- DeepSeek ---
# 【OpenAI 兼容】适用于 OpenAI / 任意兼容 API
# OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx
# OPENAI_BASE_URL=https://api.deepseek.com/v1
# 示例LITELLM_MODEL=openai/deepseek-chat 或 openai/deepseek-reasoner
# 思考模式deepseek-reasoner/deepseek-r1/qwq 自动识别deepseek-chat 需 extra_body 启用
# OPENAI_BASE_URL=(官方可不填;第三方填对应地址)
# OPENAI_TEMPERATURE=0.7
# OPENAI_VISION_MODEL=gpt-4o
# 【其他】https://docs.litellm.ai/docs/providers
# 设好环境变量 + provider/model 格式即可(如 COHERE_API_KEY + cohere/command-r-plus
# -----------------------------------
# 多渠道配置(场景 B同时使用多个模型/平台)
# -----------------------------------
# 每个渠道独立配置 base_url / api_key / models互不冲突。
# 任何 OpenAI 兼容 APIDeepSeek、Qwen、GLM、Moonshot 等)都可以直接作为渠道添加。
#
# 示例AIHubmix + DeepSeek + Gemini 三渠道共存
# LLM_CHANNELS=aihubmix,deepseek,gemini
#
# LLM_AIHUBMIX_BASE_URL=https://aihubmix.com/v1
# LLM_AIHUBMIX_API_KEY=
# LLM_AIHUBMIX_MODELS=gpt-4o-mini,claude-3-5-sonnet,qwen-plus
#
# LLM_DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# LLM_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
# LLM_DEEPSEEK_MODELS=deepseek-chat,deepseek-reasoner
#
# LLM_GEMINI_API_KEYS=key1,key2,key3
# LLM_GEMINI_MODELS=gemini/gemini-2.5-flash
#
# 高级YAML 配置(可选,标准 LiteLLM 格式,参考 litellm_config.example.yaml
# LITELLM_CONFIG=./litellm_config.yaml
# 搜索引擎配置(用于获取股票新闻)
# Tavily API Keys支持多个逗号分隔
TAVILY_API_KEYS=your_tavily_key_here
TAVILY_API_KEYS=
# SerpAPI Keys支持多个逗号分隔
SERPAPI_API_KEYS=your_serpapi_key_here
SERPAPI_API_KEYS=
# Brave Search API Keys支持多个逗号分隔
# 获取: https://brave.com/search/api/
BRAVE_API_KEYS=your_brave_key_here
BRAVE_API_KEYS=
# ===================================
# 新闻时效与分析筛选配置
@@ -154,8 +178,8 @@ AGENT_SKILLS=bull_trend,ma_golden_cross,volume_breakout,shrink_pullback
# 1. 获取授权码以QQ邮箱为例设置 -> 账户 -> POP3/SMTP服务 -> 开启 -> 获取授权码
# 2. 填写下面两项即可:
#
# EMAIL_SENDER=your_email@qq.com
# EMAIL_PASSWORD=your_email_auth_code
# EMAIL_SENDER=
# EMAIL_PASSWORD=
# EMAIL_RECEIVERS=receiver@example.com # 可选,留空则发给自己
#
# 【方式四扩展】股票分组发往不同邮箱Issue #268可选
@@ -170,19 +194,19 @@ AGENT_SKILLS=bull_trend,ma_golden_cross,volume_breakout,shrink_pullback
# 系统会自动识别常见服务并使用对应格式
#
# CUSTOM_WEBHOOK_URLS=https://oapi.dingtalk.com/robot/send?access_token=xxx,https://hooks.slack.com/services/xxx
# CUSTOM_WEBHOOK_BEARER_TOKEN=your_bearer_token # 可选,用于需要认证的 Webhook (Header Authorization: Bearer <token>)
# CUSTOM_WEBHOOK_BEARER_TOKEN= # 可选,用于需要认证的 Webhook (Header Authorization: Bearer <token>)
# WEBHOOK_VERIFY_SSL=true # 默认校验。设为 false 可支持自签名证书。警告:禁用后存在 MITM 劫持风险,仅限可信内网
#
# 【方式六】Pushover 配置
# 注册Pushover账号并创建应用Token https://pushover.net/apps/build
# PUSHOVER_USER_KEY=your_user_key
# PUSHOVER_API_TOKEN=your_api_token
# PUSHOVER_USER_KEY=
# PUSHOVER_API_TOKEN=
#
# 【方式七】PushPlus 配置(国内推送服务,推荐)
# 注册PushPlus账号并获取Token https://www.pushplus.plus
# PUSHPLUS_TOKEN=your_pushplus_token
# PUSHPLUS_TOKEN=
# 群组推送:填写群组编码后,消息推送给群组所有订阅用户(一对多)
# PUSHPLUS_TOPIC=your_group_topic_code
# PUSHPLUS_TOPIC=
#
# 【方式八】Discord 配置
# 支持两种方式Webhook推荐配置简单和 Bot API权限高
@@ -195,12 +219,12 @@ AGENT_SKILLS=bull_trend,ma_golden_cross,volume_breakout,shrink_pullback
# 1. 创建 Bothttps://discord.com/developers/applications -> 新建应用 -> Bot -> 创建 Bot
# 2. 获取 Bot TokenBot 页面 -> 重置 Token
# 3. 获取频道 IDDiscord 开启开发者模式 -> 右键频道 -> 复制 ID
# DISCORD_BOT_TOKEN=your_bot_token_here
# DISCORD_MAIN_CHANNEL_ID=your_channel_id_here
# DISCORD_BOT_TOKEN=
# DISCORD_MAIN_CHANNEL_ID=
#
# 【方式九】Server酱3 配置(国内推送服务,支持微信推送)
# 注册Server酱3账号并获取SendKey https://sc3.ft07.com/
# SERVERCHAN3_SENDKEY=your_serverchan3_sendkey
# SERVERCHAN3_SENDKEY=
#
# 【高级配置】消息长度限制(字节)
# 超过限制会自动分批发送,一般无需修改

View File

@@ -0,0 +1,399 @@
import { useState, useMemo, useCallback } from 'react';
import type React from 'react';
import { EyeToggleIcon } from '../common';
import { systemConfigApi } from '../../api/systemConfig';
/** Well-known channel presets for quick-add dropdown. */
const CHANNEL_PRESETS: Record<string, { label: string; baseUrl: string; placeholder: string }> = {
aihubmix: {
label: 'AIHubmix聚合平台',
baseUrl: 'https://aihubmix.com/v1',
placeholder: 'gpt-4o-mini,claude-3-5-sonnet,qwen-plus',
},
deepseek: {
label: 'DeepSeek 官方',
baseUrl: 'https://api.deepseek.com/v1',
placeholder: 'deepseek-chat,deepseek-reasoner',
},
dashscope: {
label: '通义千问Dashscope',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
placeholder: 'qwen-plus,qwen-turbo',
},
zhipu: {
label: '智谱 GLM',
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
placeholder: 'glm-4-flash,glm-4-plus',
},
moonshot: {
label: 'Moonshot月之暗面',
baseUrl: 'https://api.moonshot.cn/v1',
placeholder: 'moonshot-v1-8k',
},
siliconflow: {
label: '硅基流动SiliconFlow',
baseUrl: 'https://api.siliconflow.cn/v1',
placeholder: 'deepseek-ai/DeepSeek-V3',
},
openrouter: {
label: 'OpenRouter',
baseUrl: 'https://openrouter.ai/api/v1',
placeholder: 'gpt-4o,claude-3.5-sonnet',
},
gemini: {
label: 'Gemini原生无需 base_url',
baseUrl: '',
placeholder: 'gemini/gemini-2.5-flash',
},
custom: {
label: '自定义渠道',
baseUrl: '',
placeholder: 'model-name-1,model-name-2',
},
};
interface ChannelConfig {
/** Channel identifier (used in env var prefix). */
name: string;
baseUrl: string;
apiKey: string;
models: string;
}
interface LLMChannelEditorProps {
/** All config items from the server (to read existing channel vars). */
items: Array<{ key: string; value: string }>;
/** Current config version for API calls. */
configVersion: string;
/** Mask token for secrets. */
maskToken: string;
/** Called after successful save to reload config. */
onSaved: () => void;
/** Disable interactions while parent is busy. */
disabled?: boolean;
}
/** Extract `LLM_{NAME}_*` env vars from items and group them by channel. */
function parseChannelsFromItems(items: Array<{ key: string; value: string }>): ChannelConfig[] {
const itemMap = new Map(items.map((i) => [i.key, i.value]));
const channelNames = (itemMap.get('LLM_CHANNELS') || '')
.split(',')
.map((s) => s.trim().toUpperCase())
.filter(Boolean);
if (channelNames.length === 0) {
return [];
}
return channelNames.map((name) => ({
name: name.toLowerCase(),
baseUrl: itemMap.get(`LLM_${name}_BASE_URL`) || '',
apiKey: itemMap.get(`LLM_${name}_API_KEY`) || itemMap.get(`LLM_${name}_API_KEYS`) || '',
models: itemMap.get(`LLM_${name}_MODELS`) || '',
}));
}
/** Build env var update items from channel list. */
function channelsToUpdateItems(
channels: ChannelConfig[],
previousChannelNames: string[],
): Array<{ key: string; value: string }> {
const updates: Array<{ key: string; value: string }> = [];
const activeNames = channels.map((c) => c.name.toUpperCase());
// LLM_CHANNELS
updates.push({ key: 'LLM_CHANNELS', value: channels.map((c) => c.name).join(',') });
// Per-channel vars
for (const ch of channels) {
const prefix = `LLM_${ch.name.toUpperCase()}`;
updates.push({ key: `${prefix}_BASE_URL`, value: ch.baseUrl });
// Use API_KEY for single key, API_KEYS for comma-separated multi-key
const isMultiKey = ch.apiKey.includes(',');
updates.push({ key: `${prefix}_API_KEY${isMultiKey ? 'S' : ''}`, value: ch.apiKey });
// Clear the other key variant
updates.push({ key: `${prefix}_API_KEY${isMultiKey ? '' : 'S'}`, value: '' });
updates.push({ key: `${prefix}_MODELS`, value: ch.models });
}
// Clear removed channel vars
for (const oldName of previousChannelNames) {
const upper = oldName.toUpperCase();
if (!activeNames.includes(upper)) {
const prefix = `LLM_${upper}`;
updates.push({ key: `${prefix}_BASE_URL`, value: '' });
updates.push({ key: `${prefix}_API_KEY`, value: '' });
updates.push({ key: `${prefix}_API_KEYS`, value: '' });
updates.push({ key: `${prefix}_MODELS`, value: '' });
}
}
return updates;
}
export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
items,
configVersion,
maskToken,
onSaved,
disabled = false,
}) => {
const initialChannels = useMemo(() => parseChannelsFromItems(items), [items]);
const initialNames = useMemo(
() => initialChannels.map((c) => c.name),
[initialChannels],
);
const [channels, setChannels] = useState<ChannelConfig[]>(initialChannels);
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [visibleKeys, setVisibleKeys] = useState<Record<number, boolean>>({});
const [isCollapsed, setIsCollapsed] = useState(initialChannels.length === 0);
const [addPreset, setAddPreset] = useState('aihubmix');
// Detect if user has unsaved channel changes
const hasChanges = useMemo(() => {
if (channels.length !== initialChannels.length) return true;
return channels.some((ch, idx) => {
const init = initialChannels[idx];
if (!init) return true;
return (
ch.name !== init.name ||
ch.baseUrl !== init.baseUrl ||
ch.apiKey !== init.apiKey ||
ch.models !== init.models
);
});
}, [channels, initialChannels]);
const updateChannel = useCallback((index: number, field: keyof ChannelConfig, value: string) => {
setChannels((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
}, []);
const removeChannel = useCallback((index: number) => {
setChannels((prev) => prev.filter((_, i) => i !== index));
setVisibleKeys((prev) => {
const next = { ...prev };
delete next[index];
return next;
});
}, []);
const addChannel = useCallback(() => {
const preset = CHANNEL_PRESETS[addPreset] || CHANNEL_PRESETS.custom;
// Determine a unique name
const baseName = addPreset === 'custom' ? 'custom' : addPreset;
const existingNames = new Set(channels.map((c) => c.name));
let name = baseName;
let counter = 2;
while (existingNames.has(name)) {
name = `${baseName}${counter}`;
counter++;
}
setChannels((prev) => [
...prev,
{ name, baseUrl: preset.baseUrl, apiKey: '', models: '' },
]);
setIsCollapsed(false);
}, [addPreset, channels]);
const handleSave = useCallback(async () => {
setIsSaving(true);
setSaveMessage(null);
try {
const updateItems = channelsToUpdateItems(channels, initialNames);
await systemConfigApi.update({
configVersion,
maskToken,
reloadNow: true,
items: updateItems,
});
setSaveMessage({ type: 'success', text: '渠道配置已保存' });
onSaved();
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : '保存失败';
setSaveMessage({ type: 'error', text: msg });
} finally {
setIsSaving(false);
}
}, [channels, configVersion, initialNames, maskToken, onSaved]);
const toggleKeyVisibility = useCallback((index: number) => {
setVisibleKeys((prev) => ({ ...prev, [index]: !prev[index] }));
}, []);
const busy = disabled || isSaving;
return (
<div className="rounded-xl border border-cyan/20 bg-elevated/50 p-4">
<button
type="button"
className="flex w-full items-center justify-between text-left"
onClick={() => setIsCollapsed((prev) => !prev)}
>
<div>
<h3 className="text-sm font-semibold text-white">LLM </h3>
<p className="mt-0.5 text-xs text-muted">
{channels.length > 0
? `已配置 ${channels.length} 个渠道:${channels.map((c) => c.name).join('、')}`
: '同时使用多个模型平台时启用;只用单个模型可跳过此项'}
</p>
</div>
<span className="text-xs text-muted">{isCollapsed ? '▶ 展开' : '▼ 收起'}</span>
</button>
{!isCollapsed && (
<div className="mt-4 space-y-3">
{channels.map((channel, index) => (
<div
key={`${channel.name}-${index}`}
className="rounded-lg border border-white/8 bg-card/40 p-3 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-accent">
{CHANNEL_PRESETS[channel.name]?.label || channel.name}
</span>
</div>
<button
type="button"
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-40"
disabled={busy}
onClick={() => removeChannel(index)}
>
</button>
</div>
{/* Channel name */}
<div>
<label className="mb-1 block text-xs text-secondary"></label>
<input
type="text"
className="input-terminal w-full"
value={channel.name}
disabled={busy}
onChange={(e) => updateChannel(index, 'name', e.target.value.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase())}
placeholder="如 aihubmix、deepseek"
/>
</div>
{/* Base URL */}
<div>
<label className="mb-1 block text-xs text-secondary">API Base URL</label>
<input
type="text"
className="input-terminal w-full"
value={channel.baseUrl}
disabled={busy}
onChange={(e) => updateChannel(index, 'baseUrl', e.target.value)}
placeholder="https://api.example.com/v1Gemini 原生可留空)"
/>
</div>
{/* API Key */}
<div>
<label className="mb-1 block text-xs text-secondary">API Key</label>
<div className="flex items-center gap-2">
<input
type={visibleKeys[index] ? 'text' : 'password'}
className="input-terminal flex-1"
value={channel.apiKey}
disabled={busy}
onChange={(e) => updateChannel(index, 'apiKey', e.target.value)}
placeholder="sk-xxxxxxxxxxxxxxxx"
/>
<button
type="button"
className="btn-secondary !p-2"
onClick={() => toggleKeyVisibility(index)}
title={visibleKeys[index] ? '隐藏' : '显示'}
>
<EyeToggleIcon visible={!!visibleKeys[index]} />
</button>
</div>
</div>
{/* Models */}
<div>
<label className="mb-1 block text-xs text-secondary"></label>
<input
type="text"
className="input-terminal w-full"
value={channel.models}
disabled={busy}
onChange={(e) => updateChannel(index, 'models', e.target.value)}
placeholder={CHANNEL_PRESETS[channel.name]?.placeholder || 'model-1,model-2'}
/>
<p className="mt-1 text-[11px] text-muted">
Base URL openai/
</p>
</div>
</div>
))}
{/* Add channel */}
<div className="flex flex-wrap items-center gap-2">
<select
className="input-terminal text-xs"
value={addPreset}
disabled={busy}
onChange={(e) => setAddPreset(e.target.value)}
>
{Object.entries(CHANNEL_PRESETS).map(([key, preset]) => (
<option key={key} value={key}>
{preset.label}
</option>
))}
</select>
<button
type="button"
className="btn-secondary !px-3 !py-1.5 text-xs"
disabled={busy}
onClick={addChannel}
>
+
</button>
</div>
{/* Save */}
{hasChanges && (
<div className="flex items-center gap-3 border-t border-white/8 pt-3">
<button
type="button"
className="btn-primary !px-4 !py-1.5 text-xs"
disabled={busy}
onClick={() => void handleSave()}
>
{isSaving ? '保存中...' : '保存渠道'}
</button>
<button
type="button"
className="btn-secondary !px-3 !py-1.5 text-xs"
disabled={busy}
onClick={() => setChannels(initialChannels)}
>
</button>
<span className="text-[11px] text-muted"></span>
</div>
)}
{saveMessage && (
<p
className={`text-xs ${saveMessage.type === 'success' ? 'text-green-400' : 'text-red-400'}`}
>
{saveMessage.text}
</p>
)}
</div>
)}
</div>
);
};

View File

@@ -1,3 +1,4 @@
export * from './LLMChannelEditor';
export * from './SettingsAlert';
export * from './ChangePasswordCard';
export * from './ImageStockExtractor';

View File

@@ -173,22 +173,21 @@ const HomePage: React.FC = () => {
}, [fetchHistory, isLoadingMore, hasMore]);
// 初始加载 - 自动选择第一条(仅挂载时执行一次)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
fetchHistory(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Background polling: re-fetch history every 30s for CLI-initiated analyses
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const interval = setInterval(() => {
fetchHistory(false, true, true);
}, 30_000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Refresh when tab regains visibility (e.g. user ran main.py in another terminal)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
@@ -197,6 +196,7 @@ const HomePage: React.FC = () => {
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 点击历史项加载报告

View File

@@ -4,6 +4,7 @@ import { useAuth, useSystemConfig } from '../hooks';
import {
ChangePasswordCard,
ImageStockExtractor,
LLMChannelEditor,
SettingsAlert,
SettingsField,
SettingsLoading,
@@ -53,7 +54,15 @@ const SettingsPage: React.FC = () => {
};
}, [clearToast, toast]);
const activeItems = itemsByCategory[activeCategory] || [];
const rawActiveItems = itemsByCategory[activeCategory] || [];
// Hide per-channel LLM_*_ env vars from the normal field list;
// they are managed by the LLMChannelEditor component instead.
const LLM_CHANNEL_KEY_RE = /^LLM_[A-Z0-9]+_(BASE_URL|API_KEY|API_KEYS|MODELS|EXTRA_HEADERS)$/;
const activeItems =
activeCategory === 'ai_model'
? rawActiveItems.filter((item) => !LLM_CHANNEL_KEY_RE.test(item.key))
: rawActiveItems;
return (
<div className="min-h-screen px-4 pb-6 pt-4 md:px-6">
@@ -151,6 +160,15 @@ const SettingsPage: React.FC = () => {
/>
</div>
) : null}
{activeCategory === 'ai_model' ? (
<LLMChannelEditor
items={rawActiveItems}
configVersion={configVersion}
maskToken={maskToken}
onSaved={() => void load()}
disabled={isSaving || isLoading}
/>
) : null}
{activeCategory === 'system' && passwordChangeable ? (
<div className="space-y-3">
<ChangePasswordCard />

View File

@@ -30,6 +30,12 @@ const fieldTitleMap: Record<string, string> = {
BRAVE_API_KEYS: 'Brave API Keys',
REALTIME_SOURCE_PRIORITY: '实时数据源优先级',
ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中实时技术面',
LITELLM_MODEL: '主模型',
LITELLM_FALLBACK_MODELS: '备选模型',
LITELLM_CONFIG: 'LiteLLM 配置文件',
LLM_CHANNELS: 'LLM 渠道列表',
AIHUBMIX_KEY: 'AIHubmix Key',
DEEPSEEK_API_KEY: 'DeepSeek API Key',
GEMINI_API_KEY: 'Gemini API Key',
GEMINI_MODEL: 'Gemini 模型',
GEMINI_TEMPERATURE: 'Gemini 温度参数',
@@ -64,6 +70,12 @@ const fieldDescriptionMap: Record<string, string> = {
BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',
REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',
ENABLE_REALTIME_TECHNICAL_INDICATORS: '盘中分析时用实时价计算 MA5/MA10/MA20 与多头排列Issue #234关闭则用昨日收盘。',
LITELLM_MODEL: '主模型,格式 provider/model如 gemini/gemini-2.5-flash。配置渠道后自动推断。',
LITELLM_FALLBACK_MODELS: '备选模型,逗号分隔,主模型失败时按序尝试。',
LITELLM_CONFIG: 'LiteLLM YAML 配置文件路径(高级用法),优先级最高。',
LLM_CHANNELS: '渠道名称列表(逗号分隔)。推荐使用上方渠道编辑器管理。',
AIHUBMIX_KEY: 'AIHubmix 一站式密钥,自动指向 aihubmix.com/v1。',
DEEPSEEK_API_KEY: 'DeepSeek 官方 API 密钥。填写后自动使用 deepseek-chat 模型。',
GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',
GEMINI_MODEL: '设置 Gemini 分析模型名称。',
GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',

View File

@@ -0,0 +1,74 @@
# ===================================
# LiteLLM Router 配置模板
# ===================================
#
# 用法:
# 1. 复制此文件为 litellm_config.yaml
# 2. 在 .env 中设置 LITELLM_CONFIG=./litellm_config.yaml
# 3. 按需配置下面的 model_list
#
# 密钥引用格式:
# api_key: "os.environ/ENV_VAR_NAME" → 从环境变量读取,避免明文写入文件
# api_key: "sk-xxxxxxxx" → 直接写入(不推荐)
#
# 更多文档: https://docs.litellm.ai/docs/proxy/configs
# ===================================
model_list:
# --- AIHubmix (OpenAI 兼容,一个 Key 使用多种模型) ---
- model_name: openai/gpt-4o-mini
litellm_params:
model: openai/gpt-4o-mini
api_key: "os.environ/AIHUBMIX_KEY"
api_base: https://aihubmix.com/v1
- model_name: openai/claude-3-5-sonnet-20241022
litellm_params:
model: openai/claude-3-5-sonnet-20241022
api_key: "os.environ/AIHUBMIX_KEY"
api_base: https://aihubmix.com/v1
# --- DeepSeek 官方 API (原生 provider自动解析 base_url) ---
- model_name: deepseek/deepseek-chat
litellm_params:
model: deepseek/deepseek-chat
api_key: "os.environ/DEEPSEEK_API_KEY"
- model_name: deepseek/deepseek-reasoner
litellm_params:
model: deepseek/deepseek-reasoner
api_key: "os.environ/DEEPSEEK_API_KEY"
# --- Google Gemini (原生,多 Key 负载均衡) ---
- model_name: gemini/gemini-2.5-flash
litellm_params:
model: gemini/gemini-2.5-flash
api_key: "os.environ/GEMINI_API_KEY_1"
- model_name: gemini/gemini-2.5-flash
litellm_params:
model: gemini/gemini-2.5-flash
api_key: "os.environ/GEMINI_API_KEY_2"
# --- Anthropic Claude (原生) ---
# - model_name: anthropic/claude-3-5-sonnet-20241022
# litellm_params:
# model: anthropic/claude-3-5-sonnet-20241022
# api_key: "os.environ/ANTHROPIC_API_KEY"
# --- OpenRouter (聚合平台) ---
# - model_name: openai/meta-llama/llama-3-70b-instruct
# litellm_params:
# model: openai/meta-llama/llama-3-70b-instruct
# api_key: "os.environ/OPENROUTER_API_KEY"
# api_base: https://openrouter.ai/api/v1
# ===================================
# Router 设置(可选)
# ===================================
router_settings:
routing_strategy: simple-shuffle # simple-shuffle / least-busy / latency-based
num_retries: 2 # 单个 deployment 失败后重试次数
# timeout: 30 # 请求超时(秒)
# allowed_fails: 3 # deployment 被冷却前允许的失败次数
# cooldown_time: 60 # deployment 冷却时间(秒)

View File

@@ -28,6 +28,7 @@ json-repair>=0.55.1 # JSON 修复
# AI 分析
litellm>=1.80.10 # Unified LLM client (Gemini/Anthropic/OpenAI/DeepSeek etc.)
openai>=1.0.0 # OpenAI SDK (transitive dependency of litellm, kept explicit)
PyYAML>=6.0 # YAML parser for LITELLM_CONFIG support
# 搜索引擎(用于获取股票新闻)
tavily-python>=0.3.0 # Tavily 搜索 API每月 1000 次免费)

View File

@@ -15,7 +15,7 @@ from typing import Any, Dict, List, Optional
import litellm
from litellm import Router
from src.config import get_config
from src.config import get_config, get_api_keys_for_model, extra_litellm_params
logger = logging.getLogger(__name__)
@@ -110,62 +110,70 @@ class LLMToolAdapter:
self._litellm_available = False
self._init_litellm()
def _get_api_keys_for_model(self, model: str) -> List[str]:
"""Return API keys for the given litellm model based on provider prefix."""
config = self._config
if model.startswith("gemini/") or model.startswith("vertex_ai/"):
return [k for k in config.gemini_api_keys if k and len(k) >= 8]
if model.startswith("anthropic/"):
return [k for k in config.anthropic_api_keys if k and len(k) >= 8]
# openai/, deepseek/, or any other provider uses openai_api_keys
return [k for k in config.openai_api_keys if k and len(k) >= 8]
def _extra_litellm_params(self, model: str) -> dict:
"""Build extra litellm params (api_base, custom headers) for a model."""
config = self._config
params: Dict[str, Any] = {}
if not model.startswith("gemini/") and not model.startswith("anthropic/") and not model.startswith("vertex_ai/"):
if config.openai_base_url:
params["api_base"] = config.openai_base_url
if config.openai_base_url and "aihubmix.com" in config.openai_base_url:
params["extra_headers"] = {"APP-Code": "GPIJ3886"}
return params
def _has_channel_config(self) -> bool:
"""Check if multi-channel config (channels / YAML) is active."""
return bool(self._config.llm_model_list) and not all(
e.get('model_name', '').startswith('__legacy_') for e in self._config.llm_model_list
)
def _init_litellm(self) -> None:
"""Initialize litellm Router for multi-key, or flag single-key availability."""
"""Initialize litellm Router from channels / YAML / legacy keys."""
config = self._config
litellm_model = config.litellm_model
if not litellm_model:
logger.warning("Agent LLM: LITELLM_MODEL not configured")
return
keys = self._get_api_keys_for_model(litellm_model)
if not keys:
logger.warning(f"Agent LLM: No API keys found for model {litellm_model}")
return
self._litellm_available = True
if len(keys) > 1:
extra_params = self._extra_litellm_params(litellm_model)
model_list = [
{
"model_name": litellm_model,
"litellm_params": {
"model": litellm_model,
"api_key": k,
**extra_params,
},
}
for k in keys
]
# --- Channel / YAML path ---
if self._has_channel_config():
model_list = config.llm_model_list
self._router = Router(
model_list=model_list,
routing_strategy="simple-shuffle",
num_retries=2,
)
models_in_router = list(dict.fromkeys(m["litellm_params"]["model"] for m in model_list))
logger.info(f"Agent LLM: Router initialized with {len(keys)} keys for {litellm_model} (models: {models_in_router})")
unique_models = list(dict.fromkeys(
e['litellm_params']['model'] for e in model_list
))
logger.info(
f"Agent LLM: Router initialized from channels/YAML — "
f"{len(model_list)} deployment(s), models: {unique_models}"
)
return
# --- Legacy path ---
keys = get_api_keys_for_model(litellm_model, config)
if not keys:
logger.info(
f"Agent LLM: litellm initialized (model={litellm_model}, "
f"API key from environment)"
)
return
if len(keys) > 1:
ep = extra_litellm_params(litellm_model, config)
legacy_model_list = [
{
"model_name": litellm_model,
"litellm_params": {
"model": litellm_model,
"api_key": k,
**ep,
},
}
for k in keys
]
self._router = Router(
model_list=legacy_model_list,
routing_strategy="simple-shuffle",
num_retries=2,
)
logger.info(
f"Agent LLM: Legacy Router initialized with {len(keys)} keys "
f"for {litellm_model}"
)
else:
logger.info(f"Agent LLM: litellm initialized (model={litellm_model})")
@@ -246,13 +254,19 @@ class LLMToolAdapter:
call_kwargs["tools"] = tools
# Use Router for primary model (multi-key), direct litellm for others
if self._router and model == self._config.litellm_model:
use_channel_router = self._has_channel_config()
if use_channel_router and self._router:
# Channel / YAML path: Router manages all models
response = self._router.completion(**call_kwargs)
elif self._router and model == self._config.litellm_model:
# Legacy path: Router for primary model multi-key
response = self._router.completion(**call_kwargs)
else:
keys = self._get_api_keys_for_model(model)
# Legacy path: direct call for fallback/other models
keys = get_api_keys_for_model(model, self._config)
if keys:
call_kwargs["api_key"] = keys[0]
call_kwargs.update(self._extra_litellm_params(model))
call_kwargs.update(extra_litellm_params(model, self._config))
response = litellm.completion(**call_kwargs)
return self._parse_litellm_response(response, model)

View File

@@ -21,7 +21,7 @@ from json_repair import repair_json
from litellm import Router
from src.agent.llm_adapter import get_thinking_extra_body
from src.config import Config, get_config
from src.config import Config, get_config, get_api_keys_for_model, extra_litellm_params
logger = logging.getLogger(__name__)
@@ -533,44 +533,46 @@ class GeminiAnalyzer:
if not self._litellm_available:
logger.warning("No LLM configured (LITELLM_MODEL / API keys), AI analysis will be unavailable")
@staticmethod
def _get_api_keys_for_model(model: str, config: Config) -> List[str]:
"""Return API keys for a litellm model based on provider prefix."""
if model.startswith("gemini/") or model.startswith("vertex_ai/"):
return [k for k in config.gemini_api_keys if k and len(k) >= 8]
if model.startswith("anthropic/"):
return [k for k in config.anthropic_api_keys if k and len(k) >= 8]
return [k for k in config.openai_api_keys if k and len(k) >= 8]
@staticmethod
def _extra_litellm_params(model: str, config: Config) -> dict:
"""Build extra litellm params (api_base, headers) for OpenAI-compatible models."""
params: Dict[str, Any] = {}
if not model.startswith("gemini/") and not model.startswith("anthropic/") and not model.startswith("vertex_ai/"):
if config.openai_base_url:
params["api_base"] = config.openai_base_url
if config.openai_base_url and "aihubmix.com" in config.openai_base_url:
params["extra_headers"] = {"APP-Code": "GPIJ3886"}
return params
def _has_channel_config(self, config: Config) -> bool:
"""Check if multi-channel config (channels / YAML / legacy model_list) is active."""
return bool(config.llm_model_list) and not all(
e.get('model_name', '').startswith('__legacy_') for e in config.llm_model_list
)
def _init_litellm(self) -> None:
"""Initialize litellm Router (multi-key) or flag single-key availability."""
"""Initialize litellm Router from channels / YAML / legacy keys."""
config = get_config()
litellm_model = config.litellm_model
if not litellm_model:
logger.warning("Analyzer LLM: LITELLM_MODEL not configured")
return
keys = self._get_api_keys_for_model(litellm_model, config)
if not keys:
logger.warning(f"Analyzer LLM: No API keys found for model {litellm_model}")
return
self._litellm_available = True
# --- Channel / YAML path: build Router from pre-built model_list ---
if self._has_channel_config(config):
model_list = config.llm_model_list
self._router = Router(
model_list=model_list,
routing_strategy="simple-shuffle",
num_retries=2,
)
unique_models = list(dict.fromkeys(
e['litellm_params']['model'] for e in model_list
))
logger.info(
f"Analyzer LLM: Router initialized from channels/YAML — "
f"{len(model_list)} deployment(s), models: {unique_models}"
)
return
# --- Legacy path: build Router for multi-key, or use single key ---
keys = get_api_keys_for_model(litellm_model, config)
if len(keys) > 1:
extra_params = self._extra_litellm_params(litellm_model, config)
model_list = [
# Build legacy Router for primary model multi-key load-balancing
extra_params = extra_litellm_params(litellm_model, config)
legacy_model_list = [
{
"model_name": litellm_model,
"litellm_params": {
@@ -582,14 +584,21 @@ class GeminiAnalyzer:
for k in keys
]
self._router = Router(
model_list=model_list,
model_list=legacy_model_list,
routing_strategy="simple-shuffle",
num_retries=2,
)
models_in_router = list(dict.fromkeys(m["litellm_params"]["model"] for m in model_list))
logger.info(f"Analyzer LLM: Router initialized with {len(keys)} keys for {litellm_model} (models: {models_in_router})")
else:
logger.info(
f"Analyzer LLM: Legacy Router initialized with {len(keys)} keys "
f"for {litellm_model}"
)
elif keys:
logger.info(f"Analyzer LLM: litellm initialized (model={litellm_model})")
else:
logger.info(
f"Analyzer LLM: litellm initialized (model={litellm_model}, "
f"API key from environment)"
)
def is_available(self) -> bool:
"""Check if LiteLLM is properly configured with at least one API key."""
@@ -598,6 +607,11 @@ class GeminiAnalyzer:
def _call_litellm(self, prompt: str, generation_config: dict) -> str:
"""Call LLM via litellm with fallback across configured models.
When channels/YAML are configured, every model goes through the Router
(which handles per-model key selection, load balancing, and retries).
In legacy mode, the primary model may use the Router while fallback
models fall back to direct litellm.completion().
Args:
prompt: User prompt text.
generation_config: Dict with optional keys: temperature, max_output_tokens, max_tokens.
@@ -616,12 +630,10 @@ class GeminiAnalyzer:
models_to_try = [config.litellm_model] + (config.litellm_fallback_models or [])
models_to_try = [m for m in models_to_try if m]
use_channel_router = self._has_channel_config(config)
last_error = None
for model in models_to_try:
keys = self._get_api_keys_for_model(model, config)
if not keys:
logger.debug(f"[LiteLLM] Skipping {model}: no API keys")
continue
try:
model_short = model.split("/")[-1] if "/" in model else model
call_kwargs: Dict[str, Any] = {
@@ -637,11 +649,18 @@ class GeminiAnalyzer:
if extra:
call_kwargs["extra_body"] = extra
if self._router and model == config.litellm_model:
if use_channel_router and self._router:
# Channel / YAML path: Router manages key + base_url per model
response = self._router.completion(**call_kwargs)
elif self._router and model == config.litellm_model:
# Legacy path: Router only for primary model multi-key
response = self._router.completion(**call_kwargs)
else:
call_kwargs["api_key"] = keys[0]
call_kwargs.update(self._extra_litellm_params(model, config))
# Legacy path: direct call for fallback models
keys = get_api_keys_for_model(model, config)
if keys:
call_kwargs["api_key"] = keys[0]
call_kwargs.update(extra_litellm_params(model, config))
response = litellm.completion(**call_kwargs)
if response and response.choices and response.choices[0].message.content:

View File

@@ -10,10 +10,11 @@ A股自选股智能分析系统 - 配置管理模块
3. 提供类型安全的配置访问接口
"""
import json
import os
import re
from pathlib import Path
from typing import List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
from dotenv import load_dotenv, dotenv_values
from dataclasses import dataclass, field
@@ -64,10 +65,19 @@ class Config:
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
# --- Multi-channel LLM config (new) ---
# LITELLM_CONFIG: path to a standard litellm_config.yaml file (most powerful)
litellm_config_path: Optional[str] = None
# LLM_CHANNELS: list of channel dicts, each with name/base_url/api_keys/models
llm_channels: List[Dict[str, Any]] = field(default_factory=list)
# Pre-built LiteLLM Router model_list (populated from channels, YAML, or legacy keys)
llm_model_list: List[Dict[str, Any]] = field(default_factory=list)
# Multi-key support: each list is parsed from *_API_KEYS (comma-separated) with single-key fallback
gemini_api_keys: List[str] = field(default_factory=list)
anthropic_api_keys: List[str] = field(default_factory=list)
openai_api_keys: List[str] = field(default_factory=list)
deepseek_api_keys: List[str] = field(default_factory=list)
# Legacy single-key fields (kept for backward compatibility; gemini_api_keys[0] when set)
gemini_api_key: Optional[str] = None
@@ -394,6 +404,14 @@ class Config:
if _fallback_key:
openai_api_keys = [_fallback_key]
# DEEPSEEK_API_KEYS > DEEPSEEK_API_KEY (independent from OpenAI-compatible layer)
_deepseek_keys_raw = os.getenv('DEEPSEEK_API_KEYS', '')
deepseek_api_keys = [k.strip() for k in _deepseek_keys_raw.split(',') if k.strip()]
if not deepseek_api_keys:
_single_deepseek = os.getenv('DEEPSEEK_API_KEY', '').strip()
if _single_deepseek:
deepseek_api_keys = [_single_deepseek]
# LITELLM_MODEL: explicit config takes precedence; else infer from available keys
litellm_model = os.getenv('LITELLM_MODEL', '').strip()
if not litellm_model:
@@ -404,6 +422,8 @@ class Config:
litellm_model = f'gemini/{_gemini_model_name}'
elif anthropic_api_keys:
litellm_model = f'anthropic/{_anthropic_model_name}'
elif deepseek_api_keys:
litellm_model = 'deepseek/deepseek-chat'
elif openai_api_keys:
# For openai-compatible models, add prefix only if not already prefixed
if '/' not in _openai_model_name:
@@ -424,6 +444,50 @@ class Config:
else:
litellm_fallback_models = []
# === LLM Channels + YAML config ===
litellm_config_path = os.getenv('LITELLM_CONFIG', '').strip() or None
llm_channels: List[Dict[str, Any]] = []
llm_model_list: List[Dict[str, Any]] = []
# Priority 1: LITELLM_CONFIG (standard LiteLLM YAML config file)
if litellm_config_path:
llm_model_list = cls._parse_litellm_yaml(litellm_config_path)
# Priority 2: LLM_CHANNELS (env var based channel config)
if not llm_model_list:
_channels_str = os.getenv('LLM_CHANNELS', '').strip()
if _channels_str:
llm_channels = cls._parse_llm_channels(_channels_str)
llm_model_list = cls._channels_to_model_list(llm_channels)
# Priority 3: Legacy env vars → auto-build model_list (backward compatible)
if not llm_model_list:
llm_model_list = cls._legacy_keys_to_model_list(
gemini_api_keys, anthropic_api_keys, openai_api_keys,
os.getenv('OPENAI_BASE_URL') or (
'https://aihubmix.com/v1' if os.getenv('AIHUBMIX_KEY') else None
),
deepseek_api_keys,
)
# Auto-infer LITELLM_MODEL from channels when not explicitly set
if not litellm_model and llm_channels:
for _ch in llm_channels:
if _ch.get('models'):
litellm_model = _ch['models'][0]
break
# Auto-infer LITELLM_FALLBACK_MODELS from channels when not explicitly set
if not litellm_fallback_models and llm_channels and litellm_model:
_all_ch_models: List[str] = []
for _ch in llm_channels:
_all_ch_models.extend(_ch.get('models', []))
_seen = {litellm_model}
litellm_fallback_models = [
m for m in _all_ch_models
if m not in _seen and not _seen.add(m) # type: ignore[func-returns-value]
]
# 解析搜索引擎 API Keys支持多个 key逗号分隔
bocha_keys_str = os.getenv('BOCHA_API_KEYS', '')
bocha_api_keys = [k.strip() for k in bocha_keys_str.split(',') if k.strip()]
@@ -455,9 +519,13 @@ class Config:
tushare_token=os.getenv('TUSHARE_TOKEN'),
litellm_model=litellm_model,
litellm_fallback_models=litellm_fallback_models,
litellm_config_path=litellm_config_path,
llm_channels=llm_channels,
llm_model_list=llm_model_list,
gemini_api_keys=gemini_api_keys,
anthropic_api_keys=anthropic_api_keys,
openai_api_keys=openai_api_keys,
deepseek_api_keys=deepseek_api_keys,
gemini_api_key=os.getenv('GEMINI_API_KEY'),
gemini_model=os.getenv('GEMINI_MODEL', 'gemini-3-flash-preview'),
gemini_model_fallback=os.getenv('GEMINI_MODEL_FALLBACK', 'gemini-2.5-flash'),
@@ -596,6 +664,204 @@ class Config:
circuit_breaker_cooldown=int(os.getenv('CIRCUIT_BREAKER_COOLDOWN', '300'))
)
@classmethod
def _parse_litellm_yaml(cls, config_path: str) -> List[Dict[str, Any]]:
"""Parse a standard LiteLLM config YAML file into Router model_list.
Supports the ``os.environ/VAR_NAME`` syntax for secret references.
Returns an empty list on any error (logged, never raises).
"""
import logging
_logger = logging.getLogger(__name__)
try:
import yaml
except ImportError:
_logger.warning("PyYAML not installed; LITELLM_CONFIG ignored. Install with: pip install pyyaml")
return []
path = Path(config_path)
if not path.is_absolute():
path = Path(__file__).parent.parent / path
if not path.exists():
_logger.warning(f"LITELLM_CONFIG file not found: {path}")
return []
try:
with open(path, encoding='utf-8') as f:
yaml_config = yaml.safe_load(f) or {}
except Exception as e:
_logger.warning(f"Failed to parse LITELLM_CONFIG: {e}")
return []
model_list = yaml_config.get('model_list', [])
if not isinstance(model_list, list):
_logger.warning("LITELLM_CONFIG: model_list must be a list")
return []
# Resolve os.environ/ references in string params
for entry in model_list:
params = entry.get('litellm_params', {})
for key in list(params.keys()):
val = params.get(key)
if isinstance(val, str) and val.startswith('os.environ/'):
env_name = val.split('/', 1)[1]
params[key] = os.getenv(env_name, '')
_logger.info(f"LITELLM_CONFIG: loaded {len(model_list)} model deployment(s) from {path}")
return model_list
@classmethod
def _parse_llm_channels(cls, channels_str: str) -> List[Dict[str, Any]]:
"""Parse LLM_CHANNELS env var and per-channel env vars.
Format:
LLM_CHANNELS=aihubmix,deepseek,gemini
LLM_AIHUBMIX_BASE_URL=https://aihubmix.com/v1
LLM_AIHUBMIX_API_KEY=sk-xxx (or LLM_AIHUBMIX_API_KEYS=k1,k2)
LLM_AIHUBMIX_MODELS=openai/gpt-4o-mini,openai/claude-3-5-sonnet
"""
import logging
_logger = logging.getLogger(__name__)
channels: List[Dict[str, Any]] = []
for raw_name in channels_str.split(','):
ch_name = raw_name.strip()
if not ch_name:
continue
ch_upper = ch_name.upper()
base_url = os.getenv(f'LLM_{ch_upper}_BASE_URL', '').strip() or None
# API keys: LLM_{NAME}_API_KEYS (multi) > LLM_{NAME}_API_KEY (single)
api_keys_raw = os.getenv(f'LLM_{ch_upper}_API_KEYS', '')
api_keys = [k.strip() for k in api_keys_raw.split(',') if k.strip()]
if not api_keys:
single_key = os.getenv(f'LLM_{ch_upper}_API_KEY', '').strip()
if single_key:
api_keys = [single_key]
# Models
models_raw = os.getenv(f'LLM_{ch_upper}_MODELS', '')
models = [m.strip() for m in models_raw.split(',') if m.strip()]
# Auto-prefix: models without provider prefix in channels with base_url → openai/
models = [
(f'openai/{m}' if '/' not in m and base_url else m)
for m in models
]
# Extra headers (JSON string, optional)
extra_headers_raw = os.getenv(f'LLM_{ch_upper}_EXTRA_HEADERS', '').strip()
extra_headers = None
if extra_headers_raw:
try:
extra_headers = json.loads(extra_headers_raw)
except json.JSONDecodeError:
_logger.warning(f"LLM_{ch_upper}_EXTRA_HEADERS: invalid JSON, ignored")
if not api_keys:
_logger.warning(f"LLM channel '{ch_name}': no API key configured, skipped")
continue
if not models:
_logger.warning(f"LLM channel '{ch_name}': no models configured, skipped")
continue
channels.append({
'name': ch_name.lower(),
'base_url': base_url,
'api_keys': api_keys,
'models': models,
'extra_headers': extra_headers,
})
_logger.info(f"LLM channel '{ch_name}': {len(models)} model(s), {len(api_keys)} key(s)")
return channels
@classmethod
def _channels_to_model_list(cls, channels: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Convert parsed LLM channels to LiteLLM Router model_list format."""
model_list: List[Dict[str, Any]] = []
for ch in channels:
for model_name in ch['models']:
for api_key in ch['api_keys']:
litellm_params: Dict[str, Any] = {
'model': model_name,
'api_key': api_key,
}
if ch['base_url']:
litellm_params['api_base'] = ch['base_url']
# Auto-inject aihubmix sponsored header
headers = dict(ch.get('extra_headers') or {})
if ch['base_url'] and 'aihubmix.com' in ch['base_url']:
headers.setdefault('APP-Code', 'GPIJ3886')
if headers:
litellm_params['extra_headers'] = headers
model_list.append({
'model_name': model_name,
'litellm_params': litellm_params,
})
return model_list
@classmethod
def _legacy_keys_to_model_list(
cls,
gemini_keys: List[str],
anthropic_keys: List[str],
openai_keys: List[str],
openai_base_url: Optional[str],
deepseek_keys: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Build Router model_list from legacy per-provider keys (backward compat).
Returns a model_list where each provider's keys are expanded into
deployments, keyed by placeholder model_name tokens. The analyzer
resolves actual model_names at call time from LITELLM_MODEL /
LITELLM_FALLBACK_MODELS.
"""
model_list: List[Dict[str, Any]] = []
# Gemini keys
for k in gemini_keys:
if k and len(k) >= 8:
model_list.append({
'model_name': '__legacy_gemini__',
'litellm_params': {'model': '__legacy_gemini__', 'api_key': k},
})
# Anthropic keys
for k in anthropic_keys:
if k and len(k) >= 8:
model_list.append({
'model_name': '__legacy_anthropic__',
'litellm_params': {'model': '__legacy_anthropic__', 'api_key': k},
})
# OpenAI-compatible keys
for k in openai_keys:
if k and len(k) >= 8:
params: Dict[str, Any] = {'model': '__legacy_openai__', 'api_key': k}
if openai_base_url:
params['api_base'] = openai_base_url
if openai_base_url and 'aihubmix.com' in openai_base_url:
params['extra_headers'] = {'APP-Code': 'GPIJ3886'}
model_list.append({
'model_name': '__legacy_openai__',
'litellm_params': params,
})
# DeepSeek keys (native litellm provider — auto-resolves api_base)
for k in (deepseek_keys or []):
if k and len(k) >= 8:
model_list.append({
'model_name': '__legacy_deepseek__',
'litellm_params': {
'model': '__legacy_deepseek__',
'api_key': k,
},
})
return model_list
@classmethod
def _parse_stock_email_groups(cls) -> List[Tuple[List[str], List[str]]]:
"""
@@ -765,6 +1031,47 @@ def get_config() -> Config:
return Config.get_instance()
# ============================================================
# Shared LLM helpers (used by both analyzer and agent/llm_adapter)
# ============================================================
def get_api_keys_for_model(model: str, config: Config) -> List[str]:
"""Return explicitly managed API keys for a litellm model (legacy path only).
When llm_model_list is populated (channels / YAML), the Router handles key
selection, so this function is not needed. Kept for backward compat when
no Router is built and a direct litellm.completion() call is needed.
"""
if model.startswith("gemini/") or model.startswith("vertex_ai/"):
return [k for k in config.gemini_api_keys if k and len(k) >= 8]
if model.startswith("anthropic/"):
return [k for k in config.anthropic_api_keys if k and len(k) >= 8]
if model.startswith("deepseek/"):
return [k for k in config.deepseek_api_keys if k and len(k) >= 8]
if model.startswith("openai/") or "/" not in model:
return [k for k in config.openai_api_keys if k and len(k) >= 8]
# Other LiteLLM-native providers API key resolved from env vars
return []
def extra_litellm_params(model: str, config: Config) -> Dict[str, Any]:
"""Build extra litellm params for a model (legacy path only).
When llm_model_list is populated, the Router already carries api_base
and headers per-deployment, so this is not called.
"""
params: Dict[str, Any] = {}
# deepseek/ provider: litellm auto-resolves api_base, no manual override needed
if model.startswith("deepseek/"):
return params
if model.startswith("openai/") or "/" not in model:
if config.openai_base_url:
params["api_base"] = config.openai_base_url
if config.openai_base_url and "aihubmix.com" in config.openai_base_url:
params["extra_headers"] = {"APP-Code": "GPIJ3886"}
return params
if __name__ == "__main__":
# 测试配置加载
config = get_config()

View File

@@ -78,6 +78,113 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"validation": {"min_items": 1},
"display_order": 10,
},
# ------------------------------------------------------------------
# AI Model LiteLLM unified config
# ------------------------------------------------------------------
"LITELLM_MODEL": {
"title": "Primary Model (LiteLLM)",
"description": "Unified primary model in provider/model format (e.g. gemini/gemini-3-flash-preview, openai/deepseek-chat, anthropic/claude-3-5-sonnet-20241022). If empty, auto-inferred from available API keys.",
"category": "ai_model",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 1,
},
"LITELLM_FALLBACK_MODELS": {
"title": "Fallback Models (LiteLLM)",
"description": "Comma-separated fallback models tried when the primary model fails (e.g. anthropic/claude-3-5-sonnet-20241022,openai/gpt-4o-mini). Enables cross-provider redundancy.",
"category": "ai_model",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 2,
},
# ------------------------------------------------------------------
# AI Model Multi-channel LLM configuration
# ------------------------------------------------------------------
"LITELLM_CONFIG": {
"title": "LiteLLM Config File",
"description": "Path to litellm_config.yaml (advanced). Takes priority over channels and legacy keys.",
"category": "ai_model",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 3,
},
"LLM_CHANNELS": {
"title": "LLM Channels",
"description": "Channel names (comma-separated). Managed by the channel editor above.",
"category": "ai_model",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 4,
},
"AIHUBMIX_KEY": {
"title": "AIHubmix Key",
"description": "AIHubmix one-stop API key access all mainstream models with a single key, no VPN required. Auto-sets base URL to aihubmix.com/v1. Get key: https://aihubmix.com/?aff=CfMq",
"category": "ai_model",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 5,
},
# ------------------------------------------------------------------
# AI Model DeepSeek official (independent from OpenAI-compatible)
# ------------------------------------------------------------------
"DEEPSEEK_API_KEY": {
"title": "DeepSeek API Key",
"description": "Official DeepSeek API key (from https://platform.deepseek.com). Auto-infers openai/deepseek-chat when set alone. Also works in multi-channel mode.",
"category": "ai_model",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 6,
},
"DEEPSEEK_API_KEYS": {
"title": "DeepSeek API Keys (Multi)",
"description": "Comma-separated DeepSeek API keys for load balancing. Takes priority over DEEPSEEK_API_KEY.",
"category": "ai_model",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {"multi_value": True, "delimiter": ","},
"display_order": 7,
},
"TUSHARE_TOKEN": {
"title": "Tushare Token",
"description": "Token for Tushare Pro API.",
@@ -162,6 +269,76 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"validation": {"multi_value": True, "delimiter": ","},
"display_order": 50,
},
"BOCHA_API_KEYS": {
"title": "Bocha API Keys",
"description": "Comma-separated Bocha Search API keys.",
"category": "data_source",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {"multi_value": True, "delimiter": ","},
"display_order": 51,
},
"ENABLE_REALTIME_QUOTE": {
"title": "Enable Realtime Quote",
"description": "Enable realtime market quotes. Disable to only use historical close prices.",
"category": "data_source",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "true",
"options": [],
"validation": {},
"display_order": 22,
},
"ENABLE_CHIP_DISTRIBUTION": {
"title": "Enable Chip Distribution",
"description": "Enable chip distribution analysis. May be unstable; recommended to disable on cloud deployments.",
"category": "data_source",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "true",
"options": [],
"validation": {},
"display_order": 23,
},
"NEWS_MAX_AGE_DAYS": {
"title": "News Max Age (Days)",
"description": "Maximum age of news in days. Older articles are excluded from analysis context.",
"category": "data_source",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "3",
"options": [],
"validation": {"min": 1, "max": 30},
"display_order": 60,
},
"BIAS_THRESHOLD": {
"title": "Bias Threshold (%)",
"description": "Deviation threshold from MA5 (%). Exceeding this triggers 'do not chase' warning. Strong trend stocks auto-widen to 1.5x.",
"category": "data_source",
"data_type": "number",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "5.0",
"options": [],
"validation": {"min": 0.0, "max": 50.0},
"display_order": 61,
},
"PYTDX_HOST": {
"title": "Pytdx Host",
"description": "Tongdaxin data server IP. Used with PYTDX_PORT. Overrides built-in defaults.",
@@ -206,7 +383,7 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
},
"GEMINI_API_KEY": {
"title": "Gemini API Key",
"description": "API key for Gemini service.",
"description": "Single API key for Gemini service (from https://aistudio.google.com).",
"category": "ai_model",
"data_type": "string",
"ui_control": "password",
@@ -218,6 +395,20 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"validation": {},
"display_order": 10,
},
"GEMINI_API_KEYS": {
"title": "Gemini API Keys (Multi)",
"description": "Comma-separated Gemini API keys for load balancing. Takes priority over GEMINI_API_KEY.",
"category": "ai_model",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {"multi_value": True, "delimiter": ","},
"display_order": 11,
},
"GEMINI_MODEL": {
"title": "Gemini Model",
"description": "Gemini model name.",
@@ -232,6 +423,20 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"validation": {},
"display_order": 20,
},
"GEMINI_MODEL_FALLBACK": {
"title": "Gemini Fallback Model",
"description": "Fallback Gemini model name (used when LITELLM_FALLBACK_MODELS is not set and primary is Gemini).",
"category": "ai_model",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "gemini-2.5-flash",
"options": [],
"validation": {},
"display_order": 21,
},
"GEMINI_TEMPERATURE": {
"title": "Gemini Temperature",
"description": "Temperature in range [0.0, 2.0].",
@@ -260,6 +465,20 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"validation": {},
"display_order": 40,
},
"OPENAI_API_KEYS": {
"title": "OpenAI API Keys (Multi)",
"description": "Comma-separated OpenAI-compatible API keys for load balancing. Takes priority over AIHUBMIX_KEY and OPENAI_API_KEY.",
"category": "ai_model",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {"multi_value": True, "delimiter": ","},
"display_order": 41,
},
"OPENAI_BASE_URL": {
"title": "OpenAI Base URL",
"description": "Base URL for OpenAI-compatible endpoint.",
@@ -302,9 +521,23 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"validation": {},
"display_order": 61,
},
"OPENAI_TEMPERATURE": {
"title": "OpenAI Temperature",
"description": "Temperature for OpenAI-compatible models in range [0.0, 2.0].",
"category": "ai_model",
"data_type": "number",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "0.7",
"options": [],
"validation": {"min": 0.0, "max": 2.0},
"display_order": 62,
},
"ANTHROPIC_API_KEY": {
"title": "Anthropic API Key",
"description": "Anthropic Claude 服务的 API Key",
"description": "Anthropic Claude API key (from https://console.anthropic.com).",
"category": "ai_model",
"data_type": "string",
"ui_control": "password",
@@ -316,6 +549,20 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"validation": {},
"display_order": 35,
},
"ANTHROPIC_API_KEYS": {
"title": "Anthropic API Keys (Multi)",
"description": "Comma-separated Anthropic API keys for load balancing. Takes priority over ANTHROPIC_API_KEY.",
"category": "ai_model",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {"multi_value": True, "delimiter": ","},
"display_order": 35,
},
"ANTHROPIC_MODEL": {
"title": "Anthropic Model",
"description": "Claude 模型名称(如 claude-3-5-sonnet-20241022",
@@ -470,6 +717,290 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"validation": {},
"display_order": 53,
},
# ------------------------------------------------------------------
# Notification Feishu
# ------------------------------------------------------------------
"FEISHU_WEBHOOK_URL": {
"title": "Feishu Webhook URL",
"description": "Webhook URL for Feishu (Lark) bot notifications.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 12,
},
"FEISHU_APP_ID": {
"title": "Feishu App ID",
"description": "Feishu app bot App ID (for event-driven bot mode).",
"category": "notification",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 13,
},
"FEISHU_APP_SECRET": {
"title": "Feishu App Secret",
"description": "Feishu app bot App Secret.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 14,
},
# ------------------------------------------------------------------
# Notification Telegram
# ------------------------------------------------------------------
"TELEGRAM_BOT_TOKEN": {
"title": "Telegram Bot Token",
"description": "Telegram bot token (from @BotFather).",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 15,
},
"TELEGRAM_CHAT_ID": {
"title": "Telegram Chat ID",
"description": "Telegram chat/group ID to send messages to.",
"category": "notification",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 16,
},
"TELEGRAM_MESSAGE_THREAD_ID": {
"title": "Telegram Thread ID",
"description": "Telegram topic/thread ID for group messages (optional).",
"category": "notification",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 17,
},
# ------------------------------------------------------------------
# Notification Email
# ------------------------------------------------------------------
"EMAIL_SENDER": {
"title": "Email Sender",
"description": "Sender email address (SMTP host auto-detected).",
"category": "notification",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 25,
},
"EMAIL_PASSWORD": {
"title": "Email Password",
"description": "Email password or app-specific authorization code.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 26,
},
"EMAIL_RECEIVERS": {
"title": "Email Receivers",
"description": "Comma-separated recipient email addresses. Leave empty to send to yourself.",
"category": "notification",
"data_type": "array",
"ui_control": "textarea",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {"multi_value": True, "delimiter": ","},
"display_order": 27,
},
# ------------------------------------------------------------------
# Notification Discord
# ------------------------------------------------------------------
"DISCORD_WEBHOOK_URL": {
"title": "Discord Webhook URL",
"description": "Discord webhook URL for channel notifications.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 33,
},
"DISCORD_BOT_TOKEN": {
"title": "Discord Bot Token",
"description": "Discord bot token for interactive bot mode.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 34,
},
"DISCORD_MAIN_CHANNEL_ID": {
"title": "Discord Channel ID",
"description": "Discord main channel ID for sending messages.",
"category": "notification",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 35,
},
# ------------------------------------------------------------------
# Notification Pushover
# ------------------------------------------------------------------
"PUSHOVER_USER_KEY": {
"title": "Pushover User Key",
"description": "Pushover user key (from https://pushover.net).",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 42,
},
"PUSHOVER_API_TOKEN": {
"title": "Pushover API Token",
"description": "Pushover application API token.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 43,
},
"PUSHPLUS_TOPIC": {
"title": "PushPlus Topic",
"description": "PushPlus group topic code for one-to-many push.",
"category": "notification",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 41,
},
# ------------------------------------------------------------------
# Notification Server酱 / misc
# ------------------------------------------------------------------
"SERVERCHAN3_SENDKEY": {
"title": "ServerChan3 SendKey",
"description": "Server酱3 SendKey for push notifications.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 45,
},
"SINGLE_STOCK_NOTIFY": {
"title": "Single Stock Notify",
"description": "Push immediately after each single stock analysis instead of batching all results together.",
"category": "notification",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "false",
"options": [],
"validation": {},
"display_order": 54,
},
"REPORT_TYPE": {
"title": "Report Type",
"description": "Report format: 'simple' (concise) or 'full' (detailed).",
"category": "notification",
"data_type": "string",
"ui_control": "select",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "simple",
"options": ["simple", "full"],
"validation": {"enum": ["simple", "full"]},
"display_order": 55,
},
"MERGE_EMAIL_NOTIFICATION": {
"title": "Merge Email Notification",
"description": "Merge stock analysis and market review into a single email notification.",
"category": "notification",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "false",
"options": [],
"validation": {},
"display_order": 56,
},
"SCHEDULE_TIME": {
"title": "Schedule Time",
"description": "Daily schedule time in HH:MM format.",
@@ -540,6 +1071,118 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"validation": {},
"display_order": 45,
},
"SCHEDULE_ENABLED": {
"title": "Schedule Enabled",
"description": "Enable daily scheduled analysis run.",
"category": "system",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "false",
"options": [],
"validation": {},
"display_order": 8,
},
"SCHEDULE_RUN_IMMEDIATELY": {
"title": "Schedule Run Immediately",
"description": "Whether to run one analysis immediately on startup in schedule mode.",
"category": "system",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "true",
"options": [],
"validation": {},
"display_order": 11,
},
"TRADING_DAY_CHECK_ENABLED": {
"title": "Trading Day Check",
"description": "Skip analysis on non-trading days. Set to false or use --force-run to override.",
"category": "system",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "true",
"options": [],
"validation": {},
"display_order": 12,
},
"MARKET_REVIEW_ENABLED": {
"title": "Market Review Enabled",
"description": "Enable market overview/review in analysis reports.",
"category": "system",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "true",
"options": [],
"validation": {},
"display_order": 46,
},
"MARKET_REVIEW_REGION": {
"title": "Market Review Region",
"description": "Market region for review: cn (A-shares), us (US stocks), or both.",
"category": "system",
"data_type": "string",
"ui_control": "select",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "cn",
"options": ["cn", "us", "both"],
"validation": {"enum": ["cn", "us", "both"]},
"display_order": 47,
},
"MAX_WORKERS": {
"title": "Max Workers",
"description": "Maximum concurrent analysis threads. Keep low to avoid API rate limits.",
"category": "system",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "3",
"options": [],
"validation": {"min": 1, "max": 20},
"display_order": 50,
},
"ANALYSIS_DELAY": {
"title": "Analysis Delay",
"description": "Delay in seconds between individual stock analyses (for API rate limiting).",
"category": "system",
"data_type": "number",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "0",
"options": [],
"validation": {"min": 0, "max": 60},
"display_order": 51,
},
"DEBUG": {
"title": "Debug Mode",
"description": "Enable debug mode with verbose logging.",
"category": "system",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "false",
"options": [],
"validation": {},
"display_order": 55,
},
"BACKTEST_ENABLED": {
"title": "Backtest Enabled",
"description": "Whether backtest is enabled.",
@@ -740,7 +1383,7 @@ def _infer_category(key: str) -> str:
return "base"
if key.startswith("BACKTEST_"):
return "backtest"
if key.startswith(("GEMINI_", "OPENAI_", "ANTHROPIC_")):
if key.startswith(("GEMINI_", "OPENAI_", "ANTHROPIC_", "LITELLM_", "AIHUBMIX_", "DEEPSEEK_", "LLM_")):
return "ai_model"
if key.endswith("_PRIORITY") or key.startswith(
(
@@ -753,8 +1396,11 @@ def _infer_category(key: str) -> str:
"TAVILY",
"SERPAPI",
"BRAVE",
"BOCHA",
"NEWS_",
"BIAS_",
)
):
) or key in ("ENABLE_REALTIME_QUOTE", "ENABLE_CHIP_DISTRIBUTION"):
return "data_source"
if key.startswith((
"WECHAT",
@@ -771,7 +1417,7 @@ def _infer_category(key: str) -> str:
"ASTRBOT",
)) or "WEBHOOK" in key:
return "notification"
if key.startswith(("LOG_", "SCHEDULE_", "WEBUI_", "HTTP_", "HTTPS_", "MAX_", "DEBUG")):
if key.startswith(("LOG_", "SCHEDULE_", "WEBUI_", "HTTP_", "HTTPS_", "MAX_", "DEBUG", "MARKET_REVIEW_", "TRADING_DAY_", "ANALYSIS_DELAY")):
return "system"
return "uncategorized"