mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
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:
128
.env.example
128
.env.example
@@ -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=
|
||||
# 多 Key:GEMINI_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 API(从 https://console.anthropic.com 获取)
|
||||
# 【Anthropic Claude】https://console.anthropic.com
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxx
|
||||
# 多 Key:ANTHROPIC_API_KEYS=key1,key2
|
||||
# 示例:LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022
|
||||
# ANTHROPIC_TEMPERATURE=0.7
|
||||
|
||||
# 【方案三】OpenAI / DeepSeek / OpenRouter 等兼容 API
|
||||
# 单 Key:OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx
|
||||
# 多 Key:OPENAI_API_KEYS=key1,key2
|
||||
# OPENAI_BASE_URL=第三方 API 地址(官方 OpenAI 可不填;DeepSeek: https://api.deepseek.com/v1;OpenRouter: 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。
|
||||
# 获取 Key:https://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 兼容 API(DeepSeek、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. 创建 Bot:https://discord.com/developers/applications -> 新建应用 -> Bot -> 创建 Bot
|
||||
# 2. 获取 Bot Token:Bot 页面 -> 重置 Token
|
||||
# 3. 获取频道 ID:Discord 开启开发者模式 -> 右键频道 -> 复制 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=
|
||||
#
|
||||
# 【高级配置】消息长度限制(字节)
|
||||
# 超过限制会自动分批发送,一般无需修改
|
||||
|
||||
399
apps/dsa-web/src/components/settings/LLMChannelEditor.tsx
Normal file
399
apps/dsa-web/src/components/settings/LLMChannelEditor.tsx
Normal 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/v1(Gemini 原生可留空)"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './LLMChannelEditor';
|
||||
export * from './SettingsAlert';
|
||||
export * from './ChangePasswordCard';
|
||||
export * from './ImageStockExtractor';
|
||||
|
||||
@@ -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
|
||||
}, []);
|
||||
|
||||
// 点击历史项加载报告
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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。',
|
||||
|
||||
74
litellm_config.example.yaml
Normal file
74
litellm_config.example.yaml
Normal 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 冷却时间(秒)
|
||||
@@ -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 次免费)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
# 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(self._extra_litellm_params(model, config))
|
||||
call_kwargs.update(extra_litellm_params(model, config))
|
||||
response = litellm.completion(**call_kwargs)
|
||||
|
||||
if response and response.choices and response.choices[0].message.content:
|
||||
|
||||
309
src/config.py
309
src/config.py
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user