fix: 最小化修复问股链路在 macOS 本地环境下的失败反馈:优先保留后端真实错误原因并补足… (#1138) (#1139)

* fix(issue-1138): [bug]-macos-26操作系统环境下,搭建完成后,问股页面反馈“问股执行失败
This commit is contained in:
mumu
2026-04-28 22:02:55 +08:00
committed by GitHub
parent b4065c4fd7
commit 260fe06f08
11 changed files with 246 additions and 30 deletions

View File

@@ -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',
]);

View File

@@ -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: '分析出错',
});
});
});

View File

@@ -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] }));

View File

@@ -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

View File

@@ -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 配置兼容说明”。
---
## 📱 推送相关

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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]

View File

@@ -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()

View File

@@ -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