Skip to content

Commit 53d4921

Browse files
massif-01ZhuLinsen
authored andcommitted
fix: 修正 LLM Model disabled 诊断 (ZhuLinsen#1208) (ZhuLinsen#1211)
* fix: improve LLM model-disabled diagnostics (ZhuLinsen#1208) * docs: clarify model disabled diagnostic scope (ZhuLinsen#1208) * docs: add SiliconFlow model disabled evidence (ZhuLinsen#1208)
1 parent bea7a2d commit 53d4921

8 files changed

Lines changed: 151 additions & 9 deletions

File tree

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

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,11 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
439439
>
440440
{testState.text}
441441
</span>
442+
{selectedModels[0] ? (
443+
<p className="text-[11px] text-secondary-text">
444+
基础连接测试默认使用模型列表首项:{selectedModels[0]}
445+
</p>
446+
) : null}
442447
{testState.hint ? (
443448
<p className="text-[11px] text-secondary-text">
444449
{testState.hint}
@@ -703,7 +708,7 @@ const LLM_ERROR_LABELS: Record<string, string> = {
703708
auth: '鉴权失败',
704709
timeout: '请求超时',
705710
quota: '额度或限流',
706-
model_not_found: '模型不存在',
711+
model_not_found: '模型不可用',
707712
empty_response: '空响应',
708713
format_error: '格式异常',
709714
network_error: '网络异常',
@@ -733,7 +738,7 @@ const LLM_REASON_HINTS: Record<string, string> = {
733738
dns_error: '域名解析失败;请检查 Base URL 域名、网络代理和 DNS 配置。',
734739
tls_error: 'TLS/证书握手失败;请检查 HTTPS 证书、中转网关或公司代理策略。',
735740
connection_refused: '目标服务拒绝连接;请确认 Base URL 端口、服务进程和防火墙配置。',
736-
model_access_denied: '当前账号没有访问该模型的权限;请在服务商控制台确认模型开通状态。',
741+
model_access_denied: '当前账号无法使用该模型;请确认模型是否已开通、账号是否可见,或模型是否已被禁用。',
737742
provider_prefix_mismatch: '模型 provider 前缀与当前渠道不匹配;请确认模型名是否应使用该渠道的 OpenAI-compatible 路由。',
738743
capability_unsupported: '当前模型或兼容层不支持该能力;这不影响基础文本连接,可换模型或关闭该能力依赖。',
739744
};
@@ -767,6 +772,27 @@ function getLlmTroubleshootingHint(
767772
return LLM_TROUBLESHOOTING_HINTS[code || ''];
768773
}
769774

775+
function buildLlmTestHint(result: {
776+
errorCode?: string | null;
777+
stage?: string | null;
778+
details?: Record<string, unknown>;
779+
resolvedModel?: string | null;
780+
}): string | undefined {
781+
const reason = typeof result.details?.reason === 'string' ? result.details.reason : '';
782+
const detailsModel = typeof result.details?.model === 'string' ? result.details.model : '';
783+
const testedModel = result.resolvedModel || detailsModel;
784+
const modelHint = testedModel ? `本次测试模型:${testedModel}。` : '';
785+
const scopeInfo = '基础连接测试默认只测试模型列表中的第一个模型。';
786+
const shouldSuggestModelListChange = reason === 'model_access_denied'
787+
|| reason === 'model_not_found'
788+
|| (result.errorCode === 'model_not_found' && !reason);
789+
const modelActionHint = shouldSuggestModelListChange
790+
? '若该模型不可用,请调整模型顺序或移除不可用模型后重试。'
791+
: '';
792+
const troubleshootingHint = getLlmTroubleshootingHint(result.errorCode, result.stage, 'test', result.details);
793+
return [modelHint, scopeInfo, modelActionHint, troubleshootingHint].filter(Boolean).join(' ') || undefined;
794+
}
795+
770796
function buildLlmFailureText(result: {
771797
message: string;
772798
error?: string | null;
@@ -1339,7 +1365,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
13391365
const text = result.success
13401366
? `连接成功${result.resolvedModel ? ` · ${result.resolvedModel}` : ''}${result.latencyMs ? ` · ${result.latencyMs} ms` : ''}`
13411367
: buildLlmFailureText(result);
1342-
const hint = result.success ? undefined : getLlmTroubleshootingHint(result.errorCode, result.stage, 'test', result.details);
1368+
const hint = result.success ? undefined : buildLlmTestHint(result);
13431369

13441370
setTestStates((previous) => ({
13451371
...previous,
@@ -1479,7 +1505,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
14791505
? '未返回能力检测结果'
14801506
: buildLlmFailureText(result),
14811507
hint: getFirstCapabilityHint(capabilityResults)
1482-
|| (!result.success ? getLlmTroubleshootingHint(result.errorCode, result.stage, 'test', result.details) : undefined),
1508+
|| (!result.success ? buildLlmTestHint(result) : undefined),
14831509
results: capabilityResults,
14841510
},
14851511
}));

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,52 @@ describe('LLMChannelEditor', () => {
940940

941941
expect(await screen.findByText(/ · LLM authentication failed/i)).toBeInTheDocument();
942942
expect(screen.getByText(/ API Key /i)).toBeInTheDocument();
943+
expect(screen.queryByText(//i)).not.toBeInTheDocument();
944+
});
945+
946+
it('shows tested model and model-availability hints when a model is disabled', async () => {
947+
testLLMChannel.mockResolvedValue({
948+
success: false,
949+
message: 'LLM channel test failed',
950+
error: 'litellm.APIError: APIError: OpenAIException - Model disabled.',
951+
errorCode: 'model_not_found',
952+
stage: 'chat_completion',
953+
retryable: false,
954+
details: { reason: 'model_access_denied', model: 'openai/deepseek-ai/DeepSeek-V3' },
955+
resolvedProtocol: 'openai',
956+
resolvedModel: 'openai/deepseek-ai/DeepSeek-V3',
957+
latencyMs: null,
958+
});
959+
960+
render(
961+
<LLMChannelEditor
962+
items={[
963+
{ key: 'LLM_CHANNELS', value: 'siliconflow' },
964+
{ key: 'LLM_SILICONFLOW_PROTOCOL', value: 'openai' },
965+
{ key: 'LLM_SILICONFLOW_BASE_URL', value: 'https://api.siliconflow.cn/v1' },
966+
{ key: 'LLM_SILICONFLOW_ENABLED', value: 'true' },
967+
{ key: 'LLM_SILICONFLOW_API_KEY', value: 'secret-key' },
968+
{ key: 'LLM_SILICONFLOW_MODELS', value: 'deepseek-ai/DeepSeek-V3,Qwen/Qwen3-Coder' },
969+
]}
970+
configVersion="v1"
971+
maskToken="******"
972+
onSaved={() => {}}
973+
/>
974+
);
975+
976+
fireEvent.click(screen.getByRole('button', { name: /SiliconFlow/i }));
977+
fireEvent.click(screen.getByRole('button', { name: '测试连接' }));
978+
979+
expect(await screen.findByText(/ · LLM channel test failed/i)).toBeInTheDocument();
980+
expect(screen.getByText(/openai\/deepseek-ai\/DeepSeek-V3/i)).toBeInTheDocument();
981+
expect(screen.getByText(/使deepseek-ai\/DeepSeek-V3/i)).toBeInTheDocument();
982+
expect(screen.getByText(//i)).toBeInTheDocument();
983+
expect(screen.getByText(//i)).toBeInTheDocument();
984+
expect(screen.getByText(//i)).toBeInTheDocument();
985+
expect(screen.queryByText(/Base URLTLS/i)).not.toBeInTheDocument();
986+
expect(testLLMChannel).toHaveBeenCalledWith(expect.objectContaining({
987+
models: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen3-Coder'],
988+
}));
943989
});
944990

945991
it('shows focused quota exceeded troubleshooting hints', async () => {
@@ -969,6 +1015,37 @@ describe('LLMChannelEditor', () => {
9691015
fireEvent.click(screen.getByRole('button', { name: '测试连接' }));
9701016

9711017
expect(await screen.findByText(//i)).toBeInTheDocument();
1018+
expect(screen.queryByText(//i)).not.toBeInTheDocument();
1019+
});
1020+
1021+
it('does not show model-list action hints for network failures', async () => {
1022+
testLLMChannel.mockResolvedValue({
1023+
success: false,
1024+
message: 'LLM request failed before a valid response was returned',
1025+
error: 'DNS lookup failed',
1026+
errorCode: 'network_error',
1027+
stage: 'chat_completion',
1028+
retryable: true,
1029+
details: { reason: 'dns_error' },
1030+
resolvedProtocol: 'openai',
1031+
resolvedModel: 'openai/gpt-4o-mini',
1032+
latencyMs: null,
1033+
});
1034+
1035+
render(
1036+
<LLMChannelEditor
1037+
items={[{ key: 'LLM_CHANNELS', value: 'openai' }, { key: 'LLM_OPENAI_PROTOCOL', value: 'openai' }, { key: 'LLM_OPENAI_BASE_URL', value: 'https://api.openai.com/v1' }, { key: 'LLM_OPENAI_ENABLED', value: 'true' }, { key: 'LLM_OPENAI_API_KEY', value: 'secret-key' }, { key: 'LLM_OPENAI_MODELS', value: 'gpt-4o-mini' }]}
1038+
configVersion="v1"
1039+
maskToken="******"
1040+
onSaved={() => {}}
1041+
/>
1042+
);
1043+
1044+
fireEvent.click(screen.getByRole('button', { name: /OpenAI /i }));
1045+
fireEvent.click(screen.getByRole('button', { name: '测试连接' }));
1046+
1047+
expect(await screen.findByText(//i)).toBeInTheDocument();
1048+
expect(screen.queryByText(//i)).not.toBeInTheDocument();
9721049
});
9731050

9741051
it('does not request runtime capabilities during the basic connection test', async () => {

docs/CHANGELOG.md

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

docs/LLM_CONFIG_GUIDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ LITELLM_MODEL=ollama/qwen3:8b
9797
### Web 渠道编辑器的兼容性 / 迁移 / 回退规则
9898

9999
- 预设里的 provider / Base URL / 示例模型只用于**初始化表单**;真正落盘时仍是你当前输入的 `LLM_{CHANNEL}_PROTOCOL``LLM_{CHANNEL}_BASE_URL``LLM_{CHANNEL}_MODELS``LLM_{CHANNEL}_API_KEY(S)`,不会在后台偷偷改成别的 provider 名或 URL。
100-
- 设置页的“获取模型”只对 `OpenAI Compatible` / `DeepSeek` 渠道调用 `{base_url}/models`;“测试连接”默认只发一次最小聊天请求。可选的“运行时能力检测”必须由用户显式选择后触发,会额外发起 JSON / tools / stream / vision smoke 请求,结果仅代表当前账号、模型和 endpoint 的一次 best-effort 检测。上述检测返回的 `stage / error_code / details / latency_ms / capability_results` 仅用于结构化诊断提示,**不会写回** `.env`,也不会阻止保存。
100+
- 设置页的“获取模型”只对 `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`,也不会阻止保存。
101101
- 运行时能力检测会产生真实 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 形状。
102102
- 相关外部来源: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>
103103
- 保存渠道时,只会更新这次提交的 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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ The backend exposes a read-only status endpoint at `GET /api/v1/system/config/se
9797
### Web channel editor: compatibility, migration, and rollback rules
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.
100-
- "Discover models" only calls `{base_url}/models` for `OpenAI Compatible` / `DeepSeek` channels, and the default "Test connection" action only sends one minimal chat completion request. 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.
100+
- "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.
101101
- 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.
102102
- 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>.
103103
- 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.

0 commit comments

Comments
 (0)