Skip to content

Commit bb7cb6a

Browse files
committed
fix: classify blocked LLM channel tests (#1223)
1 parent fb6c71a commit bb7cb6a

8 files changed

Lines changed: 97 additions & 4 deletions

File tree

apps/dsa-web/src/components/settings/LLMChannelEditor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,7 @@ const LLM_ERROR_LABELS: Record<string, string> = {
709709
timeout: '请求超时',
710710
quota: '额度或限流',
711711
model_not_found: '模型不可用',
712+
request_blocked: '请求被拦截',
712713
empty_response: '空响应',
713714
format_error: '格式异常',
714715
network_error: '网络异常',
@@ -735,6 +736,7 @@ const LLM_REASON_HINTS: Record<string, string> = {
735736
rate_limit: '服务商触发 RPM/TPM 或并发限流;请降低请求频率或稍后重试。',
736737
insufficient_balance: '服务商返回余额、账单或额度不足;请检查账户余额和套餐状态。',
737738
quota_exceeded: '服务商返回配额已耗尽;请确认账号套餐、余量和项目额度。',
739+
provider_blocked: '请求被服务商或中转网关拦截;请检查账号风控、地域限制、模型权限、代理商网关策略、内容安全策略或请求来源限制。',
738740
dns_error: '域名解析失败;请检查 Base URL 域名、网络代理和 DNS 配置。',
739741
tls_error: 'TLS/证书握手失败;请检查 HTTPS 证书、中转网关或公司代理策略。',
740742
connection_refused: '目标服务拒绝连接;请确认 Base URL 端口、服务进程和防火墙配置。',

apps/dsa-web/src/components/settings/__tests__/LLMChannelEditor.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,46 @@ describe('LLMChannelEditor', () => {
988988
}));
989989
});
990990

991+
it('shows provider blocked troubleshooting without network or model-list hints', async () => {
992+
testLLMChannel.mockResolvedValue({
993+
success: false,
994+
message: 'LLM request was blocked by provider or gateway policy',
995+
error: 'litellm.APIError: APIError: OpenAIException - Your request was blocked.',
996+
errorCode: 'request_blocked',
997+
stage: 'chat_completion',
998+
retryable: false,
999+
details: { reason: 'provider_blocked', model: 'openai/gpt-5.5' },
1000+
resolvedProtocol: 'openai',
1001+
resolvedModel: 'openai/gpt-5.5',
1002+
latencyMs: null,
1003+
});
1004+
1005+
render(
1006+
<LLMChannelEditor
1007+
items={[
1008+
{ key: 'LLM_CHANNELS', value: 'proxy' },
1009+
{ key: 'LLM_PROXY_PROTOCOL', value: 'openai' },
1010+
{ key: 'LLM_PROXY_BASE_URL', value: 'https://gateway.example.com/v1' },
1011+
{ key: 'LLM_PROXY_ENABLED', value: 'true' },
1012+
{ key: 'LLM_PROXY_API_KEY', value: 'secret-key' },
1013+
{ key: 'LLM_PROXY_MODELS', value: 'gpt-5.5,gpt-4o-mini' },
1014+
]}
1015+
configVersion="v1"
1016+
maskToken="******"
1017+
onSaved={() => {}}
1018+
/>
1019+
);
1020+
1021+
fireEvent.click(screen.getByRole('button', { name: /proxy/i }));
1022+
fireEvent.click(screen.getByRole('button', { name: '测试连接' }));
1023+
1024+
expect(await screen.findByText(/ · /i)).toBeInTheDocument();
1025+
expect(screen.getByText(/openai\/gpt-5\.5/i)).toBeInTheDocument();
1026+
expect(screen.getByText(//i)).toBeInTheDocument();
1027+
expect(screen.queryByText(/Base URLTLS/i)).not.toBeInTheDocument();
1028+
expect(screen.queryByText(//i)).not.toBeInTheDocument();
1029+
});
1030+
9911031
it('shows focused quota exceeded troubleshooting hints', async () => {
9921032
testLLMChannel.mockResolvedValue({
9931033
success: false,

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1515
- [改进] 放宽 LiteLLM 依赖约束,保留 `>=1.80.10` 最低版本并显式排除 PyPI 事故版本 `1.82.7` / `1.82.8`,允许安装后续 1.x 修复版本。
1616
- [改进] 补齐通知渠道 P0 基线、Actions 映射与 `--check-notify` 只读诊断,完善 AstrBot 配置入口和通知回归快照。
1717
- [修复] 修正 LLM 渠道测试中 `Model disabled` 被误报为网络异常的问题,并在失败提示中展示本次实际测试模型。
18+
- [修复] 修正 LLM 渠道测试中 `Your request was blocked` 等服务商或网关拦截错误被误报为网络异常的问题。
1819
- [chore] 清理仓库根目录:移除误入库的 `.codex``review.md` 跟踪记录,将 smoke 测试入口迁移到 `scripts/`、环境检查脚本迁移为 `scripts/check_env.py`,并将 LiteLLM YAML 示例迁移到 `docs/examples/`
1920
- [新功能] Web 设置页新增通知渠道一键测试,支持临时配置、耗时与脱敏 attempts 展示。
2021

docs/LLM_CONFIG_GUIDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ LITELLM_MODEL=ollama/qwen3:8b
9898

9999
- 预设里的 provider / Base URL / 示例模型只用于**初始化表单**;真正落盘时仍是你当前输入的 `LLM_{CHANNEL}_PROTOCOL``LLM_{CHANNEL}_BASE_URL``LLM_{CHANNEL}_MODELS``LLM_{CHANNEL}_API_KEY(S)`,不会在后台偷偷改成别的 provider 名或 URL。
100100
- 设置页的“获取模型”只对 `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`,也不会阻止保存。
101+
- 若返回 `details.reason=provider_blocked`,表示服务商或中转网关明确拦截了本次请求;它区别于本地网络 / TLS 异常和 `model_access_denied`,应优先检查账号风控、地域或请求来源限制、模型权限、代理商网关策略和内容安全策略。
101102
- 运行时能力检测会产生真实 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 形状。
102103
- 相关外部来源: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>
103104
- 保存渠道时,只会更新这次提交的 key;不会因为切换渠道模式而静默迁移整个旧配置。唯一会被**同步清理**的是运行时模型引用:如果 `LITELLM_MODEL``AGENT_LITELLM_MODEL``VISION_MODEL``LITELLM_FALLBACK_MODELS` 指向了当前已启用渠道里已经不存在的模型,设置页会在保存前把这些失效引用清空/移除,避免运行时继续指向无效模型;即使当前启用渠道没有任何可选模型,也会清理缺少 legacy Key 支撑的托管 provider 旧值。`cohere/*``google/*``xai/*` 这类直连模型仅用于说明历史 `direct-env` 兼容保留语义,不等于可用性承诺,是否可用请按各厂商官方模型/API 文档再做实际验证。

docs/LLM_CONFIG_GUIDE_EN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ The backend exposes a read-only status endpoint at `GET /api/v1/system/config/se
9898

9999
- 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.
100100
- "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.
101+
- If the response includes `details.reason=provider_blocked`, the provider or relay gateway explicitly blocked this request. This is distinct from local network / TLS failures and `model_access_denied`; first check account risk controls, region or request-source restrictions, model entitlement, relay gateway policy, and content-safety policy.
101102
- 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.
102103
- 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>.
103104
- 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.

docs/llm-providers.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Ollama 默认 Base URL `http://127.0.0.1:11434` 主要面向本地、Docker 或
132132
| `connection_refused` | 目标端口无服务,或本地服务未启动。 | 检查 Base URL、端口、防火墙;Ollama 确认本机或 runner 能访问服务。 |
133133
| `endpoint_not_found` | `/models` 或 chat endpoint 路径不存在。 | 确认 Base URL 是否填到兼容入口,不要多拼或少拼厂商要求的路径。 |
134134
| `model_access_denied` | 基于已观测 provider 文案的 best-effort 模型可用性归类:模型可能被禁用、未开通、账号不可见或当前 key 无权限访问。 | 先查看测试结果里的“本次测试模型”,在服务商控制台确认该模型已开通;必要时调整模型顺序、移除不可用模型,或点击「获取模型」核对账号可见模型。 |
135+
| `provider_blocked` | 服务商或中转网关明确拦截了本次请求,可能来自账号风控、地域、请求来源、模型权限、代理商策略或内容安全策略。 | 先查看测试结果里的“本次测试模型”和服务商控制台日志;检查账号/项目状态、地域或来源限制、网关策略和内容安全规则,而不是优先排查 Base URL、TLS 或本地网络。 |
135136
| `provider_prefix_mismatch` | LiteLLM provider prefix 与渠道协议不匹配。 | OpenAI-compatible 渠道通常使用 `openai/<model>`;不要把 `Qwen/...``deepseek-ai/...` 误当 provider prefix。 |
136137
| `non_json` | 服务商返回非 JSON 或代理返回 HTML / 文本错误页。 | 检查 Base URL、网关路径、代理错误页和 Chat Completions 兼容入口。 |
137138
| `null_response` | LiteLLM 没有返回可解析响应对象。 | 检查 provider 是否兼容 Chat Completions,必要时换模型或 endpoint 重试。 |
@@ -148,7 +149,7 @@ Ollama 默认 Base URL `http://127.0.0.1:11434` 主要面向本地、Docker 或
148149
- Issue #1208 中真实脱敏样例来自 SiliconFlow / OpenAI Compatible 渠道测试,经 LiteLLM 返回 `litellm.APIError: APIError: OpenAIException - Model disabled.`
149150
- 线上复核记录(2026-05-06T16:21:21Z):在 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0` 约束下,本地验证环境为 Python `3.13.12`、LiteLLM `1.82.3`、Base URL `https://api.siliconflow.cn/v1`、模型 `Qwen/Qwen3-235B-A22B-Thinking-2507`。直连 SiliconFlow Chat Completions 返回 HTTP `403`,响应体为 `{"code":30003,"message":"Model disabled.","data":null}`;同一模型通过 LiteLLM `completion(model="openai/Qwen/Qwen3-235B-A22B-Thinking-2507")` 返回 `APIError: OpenAIException - Model disabled.`
150151

151-
因此当前运行时把该已观测 provider `message` 作为 best-effort 模型可用性诊断,而不是把它声明为官方跨 provider 错误码。实现仅在错误文本同时包含 `model` 和明确权限、禁用或不可用信号时进入该诊断;未覆盖或语义不同的 provider 文案会继续走既有兜底诊断。
152+
因此当前运行时把该已观测 provider `message` 作为 best-effort 模型可用性诊断,而不是把它声明为官方跨 provider 错误码。实现仅在错误文本同时包含 `model` 和明确权限、禁用或不可用信号时进入该诊断;未覆盖或语义不同的 provider 文案会继续走既有兜底诊断。`provider_blocked` 同样是基于明确拦截文案的 best-effort 诊断,用于区分服务商/网关策略拦截与本地网络、TLS 或模型不可用问题。
152153

153154
## 运行时能力检测边界
154155

src/services/system_config_service.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2572,8 +2572,6 @@ def _classify_llm_http_error(status_code: int, error_text: str) -> _LLMDiagnosti
25722572
"Configured model could not be found on this channel",
25732573
"model_not_found",
25742574
)
2575-
if status_code in {401, 403} or any(token in lowered for token in ("unauthorized", "forbidden", "invalid api key", "authentication")):
2576-
return _LLMDiagnostic("auth", False, "LLM authentication failed", "api_key_rejected")
25772575
if status_code == 402 or any(token in lowered for token in ("billing", "balance", "insufficient balance")):
25782576
return _LLMDiagnostic(
25792577
"quota",
@@ -2595,6 +2593,15 @@ def _classify_llm_http_error(status_code: int, error_text: str) -> _LLMDiagnosti
25952593
"LLM request was rejected by quota or rate limiting",
25962594
"rate_limit",
25972595
)
2596+
if SystemConfigService._has_request_blocked_signal(error_text or ""):
2597+
return _LLMDiagnostic(
2598+
"request_blocked",
2599+
False,
2600+
"LLM request was blocked by provider or gateway policy",
2601+
"provider_blocked",
2602+
)
2603+
if status_code in {401, 403} or any(token in lowered for token in ("unauthorized", "forbidden", "invalid api key", "authentication")):
2604+
return _LLMDiagnostic("auth", False, "LLM authentication failed", "api_key_rejected")
25982605
if status_code == 404:
25992606
return _LLMDiagnostic(
26002607
"network_error",
@@ -2656,6 +2663,21 @@ def _has_model_access_denied_signal(text: str) -> bool:
26562663
)
26572664
return any(token in lowered for token in access_denied_tokens)
26582665

2666+
@staticmethod
2667+
def _has_request_blocked_signal(text: str) -> bool:
2668+
lowered = text.lower()
2669+
blocked_tokens = (
2670+
"your request was blocked",
2671+
"the request was blocked",
2672+
"request blocked by policy",
2673+
"blocked by policy",
2674+
"blocked due to policy",
2675+
"moderation_blocked",
2676+
"policy_blocked",
2677+
"请求被拦截",
2678+
)
2679+
return any(token in lowered for token in blocked_tokens)
2680+
26592681
@staticmethod
26602682
def _has_provider_prefix_mismatch_signal(text: str) -> bool:
26612683
lowered = text.lower()
@@ -2710,6 +2732,13 @@ def _classify_llm_exception(exc: Exception) -> _LLMDiagnostic:
27102732
"Configured model is not available for this channel",
27112733
"model_access_denied",
27122734
)
2735+
if SystemConfigService._has_request_blocked_signal(str(exc)):
2736+
return _LLMDiagnostic(
2737+
"request_blocked",
2738+
False,
2739+
"LLM request was blocked by provider or gateway policy",
2740+
"provider_blocked",
2741+
)
27132742
if any(token in exc_name for token in ("auth", "permission")) or any(token in text for token in ("unauthorized", "forbidden", "invalid api key", "authentication")):
27142743
return _LLMDiagnostic("auth", False, "LLM authentication failed", "api_key_rejected")
27152744
if ("notfound" in exc_name or "model" in text) and (

0 commit comments

Comments
 (0)