mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* fix(issue-1138): [bug]-macos-26操作系统环境下,搭建完成后,问股页面反馈“问股执行失败
This commit is contained in:
@@ -347,6 +347,7 @@ export function parseApiError(error: unknown): ParsedApiError {
|
||||
includesAny(matchText, ['all llm models failed']) && includesAny(matchText, ['last error: none'])
|
||||
) || includesAny(matchText, [
|
||||
'no llm configured',
|
||||
'no effective primary model configured',
|
||||
'litellm_model not configured',
|
||||
'ai analysis will be unavailable',
|
||||
]);
|
||||
|
||||
@@ -77,4 +77,70 @@ describe('agentChatStore.startStream', () => {
|
||||
expect(state.messages[1].thinkingSteps).toHaveLength(2);
|
||||
expect(state.progressSteps).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves parsed error details when done.success is false', async () => {
|
||||
vi.mocked(agentApi.chatStream).mockResolvedValue(
|
||||
createStreamResponse([
|
||||
'data: {"type":"done","success":false,"error":"Agent LLM: no effective primary model configured"}',
|
||||
]),
|
||||
);
|
||||
|
||||
await useAgentChatStore
|
||||
.getState()
|
||||
.startStream({ message: '分析茅台', session_id: 'session-test' }, { skillName: '趋势技能' });
|
||||
|
||||
const state = useAgentChatStore.getState();
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.messages).toHaveLength(1);
|
||||
expect(state.chatError).toMatchObject({
|
||||
title: '系统没有配置可用的 LLM 模型',
|
||||
message: '请先在系统设置中配置主模型、可用渠道或相关 API Key 后再重试。',
|
||||
category: 'llm_not_configured',
|
||||
rawMessage: 'Agent LLM: no effective primary model configured',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the same parser for SSE error events', async () => {
|
||||
vi.mocked(agentApi.chatStream).mockResolvedValue(
|
||||
createStreamResponse([
|
||||
'data: {"type":"error","message":"connect timeout while calling upstream provider"}',
|
||||
]),
|
||||
);
|
||||
|
||||
await useAgentChatStore
|
||||
.getState()
|
||||
.startStream({ message: '分析茅台', session_id: 'session-test' }, { skillName: '趋势技能' });
|
||||
|
||||
const state = useAgentChatStore.getState();
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.messages).toHaveLength(1);
|
||||
expect(state.chatError).toMatchObject({
|
||||
title: '连接上游服务超时',
|
||||
message: '服务端访问外部依赖时超时,请稍后重试,或检查当前网络与代理设置。',
|
||||
category: 'upstream_timeout',
|
||||
rawMessage: 'connect timeout while calling upstream provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when SSE error fields are empty strings', async () => {
|
||||
vi.mocked(agentApi.chatStream).mockResolvedValue(
|
||||
createStreamResponse([
|
||||
'data: {"type":"error","error":"","message":" ","content":""}',
|
||||
]),
|
||||
);
|
||||
|
||||
await useAgentChatStore
|
||||
.getState()
|
||||
.startStream({ message: '分析茅台', session_id: 'session-test' }, { skillName: '趋势技能' });
|
||||
|
||||
const state = useAgentChatStore.getState();
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.messages).toHaveLength(1);
|
||||
expect(state.chatError).toMatchObject({
|
||||
title: '请求失败',
|
||||
message: '分析出错',
|
||||
category: 'unknown',
|
||||
rawMessage: '分析出错',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { create } from 'zustand';
|
||||
import { agentApi } from '../api/agent';
|
||||
import type { ChatSessionItem, ChatStreamRequest } from '../api/agent';
|
||||
import {
|
||||
createParsedApiError,
|
||||
getParsedApiError,
|
||||
isApiRequestError,
|
||||
isParsedApiError,
|
||||
@@ -36,6 +35,45 @@ export interface StreamMeta {
|
||||
skillName?: string;
|
||||
}
|
||||
|
||||
type StreamFailureEvent = {
|
||||
type: string;
|
||||
success?: boolean;
|
||||
content?: string;
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
function getFirstMeaningfulStreamError(...candidates: Array<unknown>): unknown {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string') {
|
||||
if (candidate.trim() !== '') {
|
||||
return candidate;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate != null) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getStreamFailureError(
|
||||
event: StreamFailureEvent,
|
||||
fallbackMessage: string,
|
||||
): ParsedApiError {
|
||||
return getParsedApiError(
|
||||
getFirstMeaningfulStreamError(
|
||||
event.error,
|
||||
event.message,
|
||||
event.content,
|
||||
fallbackMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentChatState {
|
||||
messages: Message[];
|
||||
loading: boolean;
|
||||
@@ -216,38 +254,22 @@ export const useAgentChatStore = create<AgentChatState & AgentChatActions>((set,
|
||||
let buf = '';
|
||||
let finalContent: string | null = null;
|
||||
const currentProgressSteps: ProgressStep[] = [];
|
||||
const processLine = (line: string) => {
|
||||
if (!line.startsWith('data: ')) return;
|
||||
const processLine = (line: string) => {
|
||||
if (!line.startsWith('data: ')) return;
|
||||
|
||||
const event = JSON.parse(line.slice(6)) as ProgressStep;
|
||||
if (event.type === 'done') {
|
||||
const doneEvent = event as unknown as {
|
||||
type: string;
|
||||
success: boolean;
|
||||
content?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (doneEvent.success === false) {
|
||||
const parsedStreamError = getParsedApiError(
|
||||
doneEvent.error ||
|
||||
doneEvent.content ||
|
||||
'大模型调用出错,请检查 API Key 配置',
|
||||
);
|
||||
throw createParsedApiError({
|
||||
title: '问股执行失败',
|
||||
message: parsedStreamError.message,
|
||||
rawMessage: parsedStreamError.rawMessage,
|
||||
status: parsedStreamError.status,
|
||||
category: parsedStreamError.category,
|
||||
});
|
||||
const event = JSON.parse(line.slice(6)) as ProgressStep;
|
||||
if (event.type === 'done') {
|
||||
const doneEvent = event as unknown as StreamFailureEvent;
|
||||
if (doneEvent.success === false) {
|
||||
throw getStreamFailureError(doneEvent, '大模型调用出错,请检查 API Key 配置');
|
||||
}
|
||||
finalContent = doneEvent.content ?? '';
|
||||
return;
|
||||
}
|
||||
finalContent = doneEvent.content ?? '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
throw getParsedApiError(event.message || '分析出错');
|
||||
}
|
||||
if (event.type === 'error') {
|
||||
throw getStreamFailureError(event as unknown as StreamFailureEvent, '分析出错');
|
||||
}
|
||||
|
||||
currentProgressSteps.push(event);
|
||||
set((s) => ({ progressSteps: [...s.progressSteps, event] }));
|
||||
|
||||
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
- [修复] 问股 Agent 在未配置可用 LLM 时保留后端真实错误原因并维持 `done.success=false` 失败语义,避免前端把配置缺失误当成成功回答。
|
||||
- [文档] 补充 LLM 配置指南与 FAQ,明确问股 Agent 对 `LITELLM_CONFIG` / `LLM_CHANNELS` / legacy `GEMINI_*` `OPENAI_*` `ANTHROPIC_*` 的兼容优先级、回退路径与“不静默迁移旧配置”的结论。
|
||||
|
||||
## [3.14.1] - 2026-04-26
|
||||
|
||||
|
||||
@@ -140,6 +140,10 @@ PROXY_PORT=10809
|
||||
|
||||
使用渠道模式:设置 `LLM_CHANNELS=aihubmix,deepseek,gemini`,并配置各渠道的 `LLM_{NAME}_BASE_URL`、`LLM_{NAME}_API_KEY`、`LLM_{NAME}_MODELS`。也可在 Web 设置页 → AI 模型 → AI 模型接入 中可视化配置。
|
||||
|
||||
**Q: 问股/Agent 提示未配置可用 LLM,但我只有旧的 `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` 配置,怎么办?**
|
||||
|
||||
先确认当前是否启用了 `LITELLM_CONFIG` 或 `LLM_CHANNELS`;如果启用了,上层配置会覆盖 legacy keys。若你没有启用这两层,且 `AGENT_LITELLM_MODEL` 为空,问股 Agent 仍会自动继承 legacy provider 模型:`GEMINI_MODEL`、`OPENAI_MODEL`、`ANTHROPIC_MODEL` 分别映射到对应 provider 前缀的 LiteLLM 模型名。此次修复不会静默迁移或清空旧配置,只是把“真实缺失原因”直接返回到前端,便于你判断到底是缺 key、缺模型名,还是被上层配置覆盖。完整兼容语义见 [LLM 配置指南](LLM_CONFIG_GUIDE.md) 中“问股 Agent / LiteLLM 配置兼容说明”。
|
||||
|
||||
---
|
||||
|
||||
## 📱 推送相关
|
||||
|
||||
@@ -138,6 +138,10 @@ Start with one provider and its API key. If you want to pin a primary model, add
|
||||
|
||||
Use channel mode: set `LLM_CHANNELS=aihubmix,deepseek,gemini` and configure each channel's `LLM_{NAME}_BASE_URL`, `LLM_{NAME}_API_KEY`, `LLM_{NAME}_MODELS`. You can also configure this visually in Web Settings → AI Model → AI Model Access.
|
||||
|
||||
**Q: The ask-stock / Agent page says no usable LLM is configured, but I only use legacy `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` settings. What should I check?**
|
||||
|
||||
First confirm whether `LITELLM_CONFIG` or `LLM_CHANNELS` is active, because either of those tiers overrides legacy keys. If neither tier is active and `AGENT_LITELLM_MODEL` is empty, the ask-stock Agent still inherits legacy provider models automatically: `GEMINI_MODEL`, `OPENAI_MODEL`, and `ANTHROPIC_MODEL` are mapped to LiteLLM provider-prefixed model names for the corresponding runtime. This fix does not silently migrate or clear old settings; it only returns the real backend reason to the frontend so you can see whether the issue is a missing key, a missing model name, or an upper-tier config taking precedence. Full compatibility details are documented in the [LLM Config Guide](LLM_CONFIG_GUIDE_EN.md) under “Ask-Stock Agent / LiteLLM compatibility notes”.
|
||||
|
||||
---
|
||||
|
||||
## Push Notification Related
|
||||
|
||||
@@ -118,6 +118,15 @@ LITELLM_MODEL=ollama/qwen3:8b
|
||||
- 如果你通过 OpenAI Compatible 渠道接 MiniMax,请在渠道模型里直接填写 `minimax/<模型名>`,例如 `minimax/MiniMax-M1`。
|
||||
- Web 设置页里的主模型、Agent 主模型、Fallback、Vision 下拉会保留这个值原样展示,不会再错误改写成 `openai/minimax/<模型名>`。
|
||||
|
||||
### 问股 Agent / LiteLLM 配置兼容说明
|
||||
|
||||
- 问股 Agent 运行时沿用与普通分析相同的三层优先级:`LITELLM_CONFIG`(LiteLLM YAML)> `LLM_CHANNELS` > legacy provider keys。只要上层配置有效生效,下层配置就不会再参与本次请求。
|
||||
- YAML 模式下,Agent 直接复用 LiteLLM `model_list` / `model_name` 路由语义;渠道模式下,优先读取 `AGENT_LITELLM_MODEL`,留空时继承 `LITELLM_MODEL`,再按 `LITELLM_FALLBACK_MODELS` 继续 fallback。
|
||||
- 如果你没有启用 YAML / Channels,且 `AGENT_LITELLM_MODEL` 也留空,但本地仍保留 legacy 环境变量,问股 Agent 依然会继承旧配置:`GEMINI_API_KEY + GEMINI_MODEL` -> `gemini/<model>`,`OPENAI_API_KEY + OPENAI_MODEL` -> `openai/<model>`,`ANTHROPIC_API_KEY + ANTHROPIC_MODEL` -> `anthropic/<model>`。
|
||||
- 本次修复只增强“失败时保留后端真实错误原因”和“未配置 LLM 时给出更具体诊断”,**不会**静默删除、清空、迁移或改写你现有的 `GEMINI_*` / `OPENAI_*` / `ANTHROPIC_*` / `LITELLM_*` 配置。
|
||||
- 如果当前环境没有任何有效 Agent 模型链路,问股页面会继续按失败语义返回,并直接展示后端真实配置诊断;补齐任一有效模型来源后即可恢复,无需额外执行配置迁移脚本。
|
||||
- 推荐的新配置方式仍然是显式设置 `LITELLM_MODEL` / `AGENT_LITELLM_MODEL` 或使用 `LLM_CHANNELS`;legacy provider keys 目前保留为兼容回退路径,方便旧 `.env`、本地 macOS 开发环境和历史部署平滑继续运行。
|
||||
|
||||
### Kimi K2.6 固定 temperature 兼容说明
|
||||
|
||||
- Moonshot 官方说明 Kimi API 兼容 OpenAI 接口,Base URL 使用 `https://api.moonshot.ai/v1`:<https://platform.kimi.ai/docs/guide/kimi-k2-6-quickstart>
|
||||
|
||||
@@ -118,6 +118,15 @@ LITELLM_MODEL=ollama/qwen3:8b
|
||||
- If you access MiniMax through an OpenAI-compatible channel, enter the model as `minimax/<model-name>` in the channel model list, for example `minimax/MiniMax-M1`.
|
||||
- The Web settings page now keeps that value unchanged in Primary, Agent Primary, Fallback, and Vision selectors instead of rewriting it to `openai/minimax/<model-name>`.
|
||||
|
||||
### Ask-Stock Agent / LiteLLM compatibility notes
|
||||
|
||||
- The ask-stock Agent follows the same three-tier runtime priority as the regular analyzer: `LITELLM_CONFIG` (LiteLLM YAML) > `LLM_CHANNELS` > legacy provider keys. Once an upper tier is valid and active, lower tiers are ignored for that request.
|
||||
- In YAML mode, the Agent reuses LiteLLM `model_list` / `model_name` routing semantics directly. In channel mode, it first reads `AGENT_LITELLM_MODEL`; when that is empty it inherits `LITELLM_MODEL`, then continues through `LITELLM_FALLBACK_MODELS`.
|
||||
- If you do not use YAML or Channels, leave `AGENT_LITELLM_MODEL` empty, and still rely on legacy provider env vars, the ask-stock Agent continues to inherit them: `GEMINI_API_KEY + GEMINI_MODEL` -> `gemini/<model>`, `OPENAI_API_KEY + OPENAI_MODEL` -> `openai/<model>`, and `ANTHROPIC_API_KEY + ANTHROPIC_MODEL` -> `anthropic/<model>`.
|
||||
- This fix only improves two things: preserving the backend's real failure reason and returning a more specific diagnostic when no usable Agent LLM is configured. It does **not** silently delete, clear, migrate, or rewrite your existing `GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, or `LITELLM_*` settings.
|
||||
- If the current environment has no valid Agent model path at all, the ask-stock page still returns a failure and now surfaces the backend's real configuration diagnosis. As soon as you restore any valid model source, the flow recovers without running any migration step.
|
||||
- The recommended forward path is still to configure `LITELLM_MODEL` / `AGENT_LITELLM_MODEL` explicitly or move to `LLM_CHANNELS`; legacy provider keys remain a compatibility fallback for older `.env` files, local macOS development, and existing deployments.
|
||||
|
||||
### Kimi K2.6 Fixed-Temperature Compatibility Notes
|
||||
|
||||
- Moonshot officially documents Kimi as an OpenAI-compatible API, with `https://api.moonshot.ai/v1` as the base URL: <https://platform.kimi.ai/docs/guide/kimi-k2-6-quickstart>
|
||||
|
||||
@@ -313,6 +313,13 @@ class LLMToolAdapter:
|
||||
"""Shared completion path for both tool and text-only calls."""
|
||||
config = self._config
|
||||
models_to_try = get_effective_agent_models_to_try(config)
|
||||
if not models_to_try:
|
||||
error_msg = (
|
||||
"No LLM configured. Please set LITELLM_MODEL, LLM_CHANNELS, "
|
||||
"or provider API keys before using Agent."
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return LLMResponse(content=error_msg, provider="error")
|
||||
started_at = time.time()
|
||||
providers = [self._get_model_provider(model) for model in models_to_try]
|
||||
|
||||
|
||||
@@ -428,6 +428,31 @@ class TestAgentExecutor(unittest.TestCase):
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.model, "")
|
||||
|
||||
def test_error_provider_preserves_failure_reason_in_agent_result(self):
|
||||
"""LLM adapter error responses must surface as failed Agent results, not final answers."""
|
||||
registry = _make_registry_with_echo()
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.return_value = LLMResponse(
|
||||
content="No LLM configured. Please set LITELLM_MODEL, LLM_CHANNELS, or provider API keys before using Agent.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 1},
|
||||
provider="error",
|
||||
model="",
|
||||
)
|
||||
|
||||
executor = AgentExecutor(registry, adapter, max_steps=2)
|
||||
result = executor.run("Analyze 600519")
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.content, "")
|
||||
self.assertEqual(
|
||||
result.error,
|
||||
"No LLM configured. Please set LITELLM_MODEL, LLM_CHANNELS, or provider API keys before using Agent.",
|
||||
)
|
||||
self.assertEqual(result.total_steps, 1)
|
||||
self.assertEqual(result.total_tokens, 1)
|
||||
self.assertEqual(result.model, "")
|
||||
|
||||
def test_timeout_budget_aborts_single_agent_loop(self):
|
||||
"""Single-agent executor should stop once the configured timeout budget is exhausted."""
|
||||
registry = _make_registry_with_echo()
|
||||
|
||||
@@ -99,6 +99,46 @@ class TestAgentConfig(unittest.TestCase):
|
||||
self.assertEqual(config.agent_litellm_model, 'openai/gpt-4o-mini')
|
||||
self.assertTrue(config.is_agent_available())
|
||||
|
||||
def test_agent_models_to_try_inherit_legacy_provider_models(self):
|
||||
"""Legacy provider key/model envs should still produce a non-empty Agent model try list."""
|
||||
from src.config import Config, get_effective_agent_models_to_try
|
||||
|
||||
test_cases = [
|
||||
(
|
||||
{
|
||||
"GEMINI_API_KEY": "gemini-test-key",
|
||||
"GEMINI_MODEL": "gemini-2.5-flash",
|
||||
"AGENT_LITELLM_MODEL": "",
|
||||
},
|
||||
"gemini/gemini-2.5-flash",
|
||||
),
|
||||
(
|
||||
{
|
||||
"OPENAI_API_KEY": "sk-test-value",
|
||||
"OPENAI_MODEL": "gpt-4o-mini",
|
||||
"AGENT_LITELLM_MODEL": "",
|
||||
},
|
||||
"openai/gpt-4o-mini",
|
||||
),
|
||||
(
|
||||
{
|
||||
"ANTHROPIC_API_KEY": "anthropic-test-key",
|
||||
"ANTHROPIC_MODEL": "claude-3-5-sonnet-20241022",
|
||||
"AGENT_LITELLM_MODEL": "",
|
||||
},
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
),
|
||||
]
|
||||
|
||||
with patch("src.config.setup_env"), patch.object(Config, "_parse_litellm_yaml", return_value=[]):
|
||||
for env, expected_model in test_cases:
|
||||
with self.subTest(expected_model=expected_model), patch.dict(os.environ, env, clear=True):
|
||||
Config._instance = None
|
||||
config = Config._load_from_env()
|
||||
self.assertEqual(get_effective_agent_models_to_try(config), [expected_model])
|
||||
|
||||
Config._instance = None
|
||||
|
||||
|
||||
class TestAgentFactorySkillBaseline(unittest.TestCase):
|
||||
"""Ensure explicit skill selection does not silently re-apply the default bull-trend baseline."""
|
||||
@@ -1200,6 +1240,33 @@ class TestAgentConstructionChain(unittest.TestCase):
|
||||
self.assertIn("window exceeded", result.content)
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("src.agent.llm_adapter.Router")
|
||||
def test_llm_adapter_reports_missing_configuration_without_generic_none_error(self, _mock_router):
|
||||
"""Missing Agent model config should return a stable, actionable error message."""
|
||||
mock_cfg = SimpleNamespace(
|
||||
agent_litellm_model="",
|
||||
litellm_model="",
|
||||
litellm_fallback_models=[],
|
||||
llm_model_list=[],
|
||||
llm_temperature=0.7,
|
||||
gemini_api_keys=[],
|
||||
anthropic_api_keys=[],
|
||||
openai_api_keys=[],
|
||||
deepseek_api_keys=[],
|
||||
openai_base_url=None,
|
||||
)
|
||||
|
||||
from src.agent.llm_adapter import LLMToolAdapter
|
||||
adapter = LLMToolAdapter(config=mock_cfg)
|
||||
|
||||
result = adapter.call_completion(messages=[{"role": "user", "content": "hi"}], tools=[])
|
||||
|
||||
self.assertEqual(result.provider, "error")
|
||||
self.assertEqual(
|
||||
result.content,
|
||||
"No LLM configured. Please set LITELLM_MODEL, LLM_CHANNELS, or provider API keys before using Agent.",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _safe_int tests
|
||||
|
||||
Reference in New Issue
Block a user