Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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` 等上游拦截错误被误报为网络异常的问题;新增阻断分类仅为诊断信号,不触发用户配置迁移或清理。
- [测试] 补充 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
35 changes: 35 additions & 0 deletions src/services/system_config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ class SystemConfigService:

_LLM_CAPABILITY_ORDER: Tuple[str, ...] = ("json", "tools", "stream", "vision")
_LLM_STREAM_CHUNK_LIMIT = 8
_LLM_PROVIDER_BLOCKED_TOKENS: Tuple[str, ...] = (
"your request was blocked",
"request was blocked",
"request has been blocked",
"request is blocked",
"request blocked",
"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 +2583,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 +2674,16 @@ 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 known 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()
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 +2724,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
10 changes: 10 additions & 0 deletions tests/test_system_config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1411,6 +1411,10 @@ class RateLimitError(Exception):
(Exception("model gpt-4o is not authorized for this account"), "model_not_found", "model_access_denied"),
(Exception("litellm.APIError: APIError: OpenAIException - Model disabled."), "model_not_found", "model_access_denied"),
(Exception("Model is disabled for this account"), "model_not_found", "model_access_denied"),
(Exception("litellm.APIError: APIError: OpenAIException - Your request was blocked."), "request_blocked", "provider_blocked"),
(Exception("request was blocked by policy"), "request_blocked", "provider_blocked"),
(Exception("This request has been blocked by provider safety policy."), "request_blocked", "provider_blocked"),
(Exception("moderation_blocked"), "request_blocked", "provider_blocked"),
(Exception("LLM Provider NOT provided for model foo"), "model_not_found", "provider_prefix_mismatch"),
]

Expand Down Expand Up @@ -1496,6 +1500,10 @@ def test_discover_llm_channel_models_classifies_error_scenarios(self, mock_get)
quota_exceeded_response.json.return_value = {"error": {"message": "insufficient_quota"}}
rate_limit_response = Mock(ok=False, status_code=429, text="too many requests")
rate_limit_response.json.return_value = {"error": {"message": "too many requests"}}
blocked_response = Mock(ok=False, status_code=403, text="Your request was blocked.")
blocked_response.json.return_value = {"error": {"message": "Your request was blocked."}}
blocked_by_policy_response = Mock(ok=False, status_code=403, text="Request has been blocked by policy filter.")
blocked_by_policy_response.json.return_value = {"error": {"message": "Request has been blocked by policy filter."}}
invalid_json_response = Mock(ok=True, status_code=200, text="<html>bad gateway</html>")
invalid_json_response.json.side_effect = ValueError("invalid json")

Expand All @@ -1506,6 +1514,8 @@ def test_discover_llm_channel_models_classifies_error_scenarios(self, mock_get)
(billing_rate_limit_response, "quota", "model_discovery", True, "insufficient_balance"),
(quota_exceeded_response, "quota", "model_discovery", True, "quota_exceeded"),
(rate_limit_response, "quota", "model_discovery", True, "rate_limit"),
(blocked_response, "request_blocked", "model_discovery", False, "provider_blocked"),
(blocked_by_policy_response, "request_blocked", "model_discovery", False, "provider_blocked"),
(invalid_json_response, "format_error", "response_parse", False, "non_json"),
]:
with self.subTest(error_code=error_code):
Expand Down
Loading