Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/dsa-web/src/components/settings/LLMChannelEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ const LLM_ERROR_LABELS: Record<string, string> = {
model_not_found: '模型不可用',
empty_response: '空响应',
format_error: '格式异常',
request_blocked: '请求被拦截',
network_error: '网络异常',
invalid_config: '配置无效',
unsupported_protocol: '协议暂不支持',
Expand All @@ -724,6 +725,7 @@ const LLM_TROUBLESHOOTING_HINTS: Record<string, string> = {
quota: '请检查余额、套餐额度、RPM/TPM 限流或并发设置,必要时稍后重试。',
model_not_found: '请确认模型名与渠道协议匹配,并先用“获取模型”核对该渠道实际可用模型列表。',
empty_response: '渠道已连通但未返回正文;可尝试切换兼容模型、关闭额外响应模式后再测试。',
request_blocked: '服务商或中转网关拒绝了本次请求;请检查账号风控、地域限制、模型权限、网关策略、内容安全策略或请求来源限制。',
network_error: '请检查 Base URL、代理、TLS/证书、中转网关或本地网络策略,并可稍后重试。',
invalid_config: '先补齐协议、Base URL、API Key 和模型配置,再执行一键测试。',
unsupported_protocol: '当前仅对 OpenAI Compatible / DeepSeek 渠道提供自动模型发现,请改为手动维护模型列表。',
Expand All @@ -739,6 +741,7 @@ const LLM_REASON_HINTS: Record<string, string> = {
tls_error: 'TLS/证书握手失败;请检查 HTTPS 证书、中转网关或公司代理策略。',
connection_refused: '目标服务拒绝连接;请确认 Base URL 端口、服务进程和防火墙配置。',
model_access_denied: '当前账号无法使用该模型;请确认模型是否已开通、账号是否可见,或模型是否已被禁用。',
provider_blocked: '服务商或中转网关拦截了请求;请检查账号风控、地域限制、模型权限、网关策略、内容安全策略或请求来源限制。',
provider_prefix_mismatch: '模型 provider 前缀与当前渠道不匹配;请确认模型名是否应使用该渠道的 OpenAI-compatible 路由。',
capability_unsupported: '当前模型或兼容层不支持该能力;这不影响基础文本连接,可换模型或关闭该能力依赖。',
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,47 @@ describe('LLMChannelEditor', () => {
}));
});

it('shows provider blocked troubleshooting hints without network wording', async () => {
testLLMChannel.mockResolvedValue({
success: false,
message: 'LLM request was blocked by provider or gateway policy',
error: 'litellm.APIError: APIError: OpenAIException - Your request was blocked.',
errorCode: 'request_blocked',
stage: 'chat_completion',
retryable: false,
details: { reason: 'provider_blocked', model: 'openai/gpt-5.5' },
resolvedProtocol: 'openai',
resolvedModel: 'openai/gpt-5.5',
latencyMs: null,
});

render(
<LLMChannelEditor
items={[
{ key: 'LLM_CHANNELS', value: 'openai' },
{ key: 'LLM_OPENAI_PROTOCOL', value: 'openai' },
{ key: 'LLM_OPENAI_BASE_URL', value: 'https://proxy.example.com/v1' },
{ key: 'LLM_OPENAI_ENABLED', value: 'true' },
{ key: 'LLM_OPENAI_API_KEY', value: 'secret-key' },
{ key: 'LLM_OPENAI_MODELS', value: 'gpt-5.5,gpt-4o-mini' },
]}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
/>
);

fireEvent.click(screen.getByRole('button', { name: /OpenAI 官方/i }));
fireEvent.click(screen.getByRole('button', { name: '测试连接' }));

expect(await screen.findByText(/聊天调用 · 请求被拦截:LLM request was blocked/i)).toBeInTheDocument();
expect(screen.getByText(/本次测试模型:openai\/gpt-5\.5/i)).toBeInTheDocument();
expect(screen.getByText(/基础连接测试默认使用模型列表首项:gpt-5\.5/i)).toBeInTheDocument();
expect(screen.getByText(/账号风控、地域限制、模型权限、网关策略、内容安全策略/i)).toBeInTheDocument();
expect(screen.queryByText(/Base URL、代理、TLS/i)).not.toBeInTheDocument();
expect(screen.queryByText(/调整模型顺序或移除不可用模型/i)).not.toBeInTheDocument();
});

it('shows focused quota exceeded troubleshooting hints', async () => {
testLLMChannel.mockResolvedValue({
success: false,
Expand Down
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 修正 LLM 渠道测试中 `Model disabled` 被误报为网络异常的问题,并在失败提示中展示本次实际测试模型。
- [chore] 清理仓库根目录:移除误入库的 `.codex`、`review.md` 跟踪记录,将 smoke 测试入口迁移到 `scripts/`、环境检查脚本迁移为 `scripts/check_env.py`,并将 LiteLLM YAML 示例迁移到 `docs/examples/`。
- [新功能] Web 设置页新增通知渠道一键测试,支持临时配置、耗时与脱敏 attempts 展示。
- [修复] 修正 LLM 渠道测试中 `Your request was blocked` 等上游拦截错误被误报为网络异常的问题;新增阻断分类为诊断性字符串匹配(`Your request was blocked`、`blocked by policy`、`moderation_blocked`),仅覆盖 [Issue #1223](https://github.com/ZhuLinsen/daily_stock_analysis/issues/1223) 复现与回归路径(`litellm.completion`、`/models`),不触发用户配置迁移或清理。
- [测试] 补充 LLM 渠道连接测试与模型发现失败分流的回归用例(含 `request_blocked/provider_blocked`、`model_access_denied`、`quota`、`network_error` 的边界),覆盖 `Your request was blocked` 与 `request has been blocked by policy` 等上游拦截信号,兼容性验证覆盖 `litellm.completion` 与 `/models` 两条执行路径。

## [3.15.0] - 2026-05-05

Expand Down
1 change: 1 addition & 0 deletions docs/LLM_CONFIG_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ LITELLM_MODEL=ollama/qwen3:8b

- 预设里的 provider / Base URL / 示例模型只用于**初始化表单**;真正落盘时仍是你当前输入的 `LLM_{CHANNEL}_PROTOCOL`、`LLM_{CHANNEL}_BASE_URL`、`LLM_{CHANNEL}_MODELS`、`LLM_{CHANNEL}_API_KEY(S)`,不会在后台偷偷改成别的 provider 名或 URL。
- 设置页的“获取模型”只对 `OpenAI Compatible` / `DeepSeek` 渠道调用 `{base_url}/models`;“测试连接”默认只对模型列表首项发起一次最小聊天请求,并在结果中展示后端规范化后的 `resolved_model`。若返回 `details.reason=model_access_denied`(例如 Issue #1208 中已观测到的 SiliconFlow / OpenAI Compatible 经 LiteLLM 返回 `Model disabled`),请把它视为基于 provider 文案的 best-effort 模型可用性诊断,优先确认该模型是否已在当前账号/key 下开通,必要时调整模型顺序或移除不可用模型后重试;未覆盖或语义不同的 provider 文案会继续走兜底诊断。可选的“运行时能力检测”必须由用户显式选择后触发,会额外发起 JSON / tools / stream / vision smoke 请求,结果仅代表当前账号、模型和 endpoint 的一次 best-effort 检测。上述检测返回的 `stage / error_code / details / latency_ms / capability_results` 仅用于结构化诊断提示,**不会写回** `.env`,也不会阻止保存。
- 对于上游拦截类文案,本次修复会将 `Your request was blocked.`、`Request was blocked.`、`request was blocked by policy`、`moderation_blocked` 等(来源可见 [Issue #1223](https://github.com/ZhuLinsen/daily_stock_analysis/issues/1223))归入 `request_blocked + provider_blocked`;包含 `local firewall` 字样的本地网络拦截会保留为网络诊断分支,行为不触发配置清理或迁移。
- 运行时能力检测会产生真实 LLM 请求,可能带来 token / 图像输入费用、RPM/TPM 限流、余额不足或超时。检测失败可能来自账号权限、模型未开通、endpoint 区域、余额、服务商兼容层或 LiteLLM 转换路径,不等于该 provider 全局不支持对应能力。P3 未对所有真实 provider 做在线 smoke;兼容依据来自当前依赖约束 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` 下的 LiteLLM `completion()` / OpenAI I/O format / streaming / exception mapping,以及 OpenAI Chat Completions 的 JSON mode、tool calling、streaming 和 vision input 形状。
- 相关外部来源:LiteLLM Python SDK / OpenAI I/O format / streaming / exception mapping:<https://docs.litellm.ai/>;LiteLLM OpenAI-compatible 路由:<https://docs.litellm.ai/docs/providers/openai_compatible>;OpenAI Chat Completions:<https://platform.openai.com/docs/api-reference/chat/create>;JSON mode:<https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat>;tool calling:<https://platform.openai.com/docs/guides/function-calling?api-mode=chat>;streaming:<https://platform.openai.com/docs/guides/streaming-responses?api-mode=chat>;vision input:<https://platform.openai.com/docs/guides/images-vision?api-mode=chat>。
- 保存渠道时,只会更新这次提交的 key;不会因为切换渠道模式而静默迁移整个旧配置。唯一会被**同步清理**的是运行时模型引用:如果 `LITELLM_MODEL`、`AGENT_LITELLM_MODEL`、`VISION_MODEL` 或 `LITELLM_FALLBACK_MODELS` 指向了当前已启用渠道里已经不存在的模型,设置页会在保存前把这些失效引用清空/移除,避免运行时继续指向无效模型;即使当前启用渠道没有任何可选模型,也会清理缺少 legacy Key 支撑的托管 provider 旧值。`cohere/*`、`google/*`、`xai/*` 这类直连模型仅用于说明历史 `direct-env` 兼容保留语义,不等于可用性承诺,是否可用请按各厂商官方模型/API 文档再做实际验证。
Expand Down
1 change: 1 addition & 0 deletions docs/LLM_CONFIG_GUIDE_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ The backend exposes a read-only status endpoint at `GET /api/v1/system/config/se

- The preset provider / Base URL / sample models are **form defaults only**. What gets persisted is still exactly what you submit in `LLM_{CHANNEL}_PROTOCOL`, `LLM_{CHANNEL}_BASE_URL`, `LLM_{CHANNEL}_MODELS`, and `LLM_{CHANNEL}_API_KEY(S)`; the editor does not silently rewrite them to a different provider name or URL.
- "Discover models" only calls `{base_url}/models` for `OpenAI Compatible` / `DeepSeek` channels, and the default "Test connection" action sends one minimal chat completion request against the first model in the list and shows the backend-normalized `resolved_model` in the result. If the response includes `details.reason=model_access_denied` (for example, the observed Issue #1208 SiliconFlow / OpenAI Compatible sample returned `Model disabled` through LiteLLM), treat it as a best-effort model availability diagnostic based on provider wording: first confirm that the tested model is enabled for the current account/key, then adjust the model order or remove unavailable models before retrying. Provider messages not covered by this conservative rule, or provider messages with different semantics, continue to use the fallback diagnostic path. Optional runtime capability checks must be explicitly selected by the user and send additional JSON / tools / stream / vision smoke requests; the result only represents a best-effort check for the current account, model, and endpoint at that moment. The returned `stage / error_code / details / latency_ms / capability_results` fields are for structured diagnostics only, are **never persisted** back into `.env`, and do not block saving.
- For upstream block responses, this release maps `Your request was blocked.`, `Request was blocked.`, `request was blocked by policy`, and `moderation_blocked` to `request_blocked + provider_blocked` based on Issue #1223. Firewall-block messages that include `local firewall` stay on the generic network branch, and this change still does not trigger config migration or cleanup.
- Runtime capability checks send real LLM requests and may incur token / image-input cost, RPM/TPM rate limiting, insufficient balance errors, or timeouts. A failed check may come from account permissions, model entitlement, endpoint region, balance, provider compatibility layers, or LiteLLM translation behavior; it does not prove that the provider globally lacks that capability. P3 does not include online smoke coverage for every real provider. Its compatibility basis is the repository dependency constraint `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`, LiteLLM `completion()` / OpenAI I/O format / streaming / exception mapping, and the OpenAI Chat Completions shapes for JSON mode, tool calling, streaming, and vision input.
- External references: LiteLLM Python SDK / OpenAI I/O format / streaming / exception mapping: <https://docs.litellm.ai/>; LiteLLM OpenAI-compatible routing: <https://docs.litellm.ai/docs/providers/openai_compatible>; OpenAI Chat Completions: <https://platform.openai.com/docs/api-reference/chat/create>; JSON mode: <https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat>; tool calling: <https://platform.openai.com/docs/guides/function-calling?api-mode=chat>; streaming: <https://platform.openai.com/docs/guides/streaming-responses?api-mode=chat>; vision input: <https://platform.openai.com/docs/guides/images-vision?api-mode=chat>.
- Saving channels only updates the keys submitted in that save operation; there is no whole-config silent migration when you switch channel settings. The one deliberate cleanup is runtime model references: if `LITELLM_MODEL`, `AGENT_LITELLM_MODEL`, `VISION_MODEL`, or `LITELLM_FALLBACK_MODELS` point to models that no longer exist in the currently enabled channels, the editor clears/removes those stale references before saving so runtime calls do not keep targeting invalid models. Even when enabled channels expose no selectable models, stale managed-provider values without a matching legacy key are cleaned. `cohere/*`, `google/*`, and `xai/*` are kept as explicit direct-env compatibility examples for legacy retention behavior only, and are not a runtime availability guarantee.
Expand Down
39 changes: 39 additions & 0 deletions src/services/system_config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ class SystemConfigService:

_LLM_CAPABILITY_ORDER: Tuple[str, ...] = ("json", "tools", "stream", "vision")
_LLM_STREAM_CHUNK_LIMIT = 8
# 仅对现有复现与回归样本中的上游拦截文案做 best-effort 识别(来源:Issue #1223 复现日志 + 回归覆盖);
# 该分类只用于诊断展示,不作为配置迁移或清理触发条件。
_LLM_PROVIDER_BLOCKED_TOKENS: Tuple[str, ...] = (
"your request was blocked",
"request was blocked",
"request was blocked by safety",
"request was blocked by policy",
"request has been blocked by provider safety",
Comment on lines +95 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match plain request-was-blocked messages

For upstream responses that say Request was blocked. or Request was blocked by regional restrictions without the word “your” and without the specific safety/policy suffixes, none of these tokens match, so _classify_llm_exception/_classify_llm_http_error still fall through to the generic auth/network paths instead of returning request_blocked. The stated acceptance criteria include plain “request was blocked”, so add that broader token (or an equivalent bounded regex) to cover those provider/gateway messages.

Useful? React with 👍 / 👎.

"blocked by safety",
"blocked by policy",
"blocked by content",
"moderation_blocked",
)
_LLM_CAPABILITY_PROBE_IMAGE = (
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
Expand Down Expand Up @@ -2572,6 +2585,13 @@ def _classify_llm_http_error(status_code: int, error_text: str) -> _LLMDiagnosti
"Configured model could not be found on this channel",
"model_not_found",
)
if SystemConfigService._has_provider_blocked_signal(error_text or ""):
return _LLMDiagnostic(
"request_blocked",
False,
"LLM request was blocked by provider or gateway policy",
"provider_blocked",
)
if status_code in {401, 403} or any(token in lowered for token in ("unauthorized", "forbidden", "invalid api key", "authentication")):
return _LLMDiagnostic("auth", False, "LLM authentication failed", "api_key_rejected")
if status_code == 402 or any(token in lowered for token in ("billing", "balance", "insufficient balance")):
Expand Down Expand Up @@ -2656,6 +2676,18 @@ def _has_model_access_denied_signal(text: str) -> bool:
)
return any(token in lowered for token in access_denied_tokens)

@staticmethod
def _has_provider_blocked_signal(text: str) -> bool:
"""Match explicit upstream provider/gateway blocked signals for diagnostic classification.

These markers are only used to surface a dedicated `request_blocked` reason
in runtime checks and do not mutate or migrate persisted user config.
"""
lowered = text.lower()
if "request was blocked" in lowered and "local firewall" in lowered:
return False
return any(token in lowered for token in SystemConfigService._LLM_PROVIDER_BLOCKED_TOKENS)

@staticmethod
def _has_provider_prefix_mismatch_signal(text: str) -> bool:
lowered = text.lower()
Expand Down Expand Up @@ -2696,6 +2728,13 @@ def _classify_llm_exception(exc: Exception) -> _LLMDiagnostic:
"LLM request was rejected by quota or rate limiting",
"rate_limit",
)
if SystemConfigService._has_provider_blocked_signal(str(exc)):
return _LLMDiagnostic(
"request_blocked",
False,
"LLM request was blocked by provider or gateway policy",
"provider_blocked",
)
if SystemConfigService._has_provider_prefix_mismatch_signal(text):
return _LLMDiagnostic(
"model_not_found",
Expand Down
Loading
Loading