Skip to content
Merged
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
34 changes: 30 additions & 4 deletions apps/dsa-web/src/components/settings/LLMChannelEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,11 @@ const ChannelRow: React.FC<ChannelRowProps> = ({
>
{testState.text}
</span>
{selectedModels[0] ? (
<p className="text-[11px] text-secondary-text">
基础连接测试默认使用模型列表首项:{selectedModels[0]}
</p>
) : null}
{testState.hint ? (
<p className="text-[11px] text-secondary-text">
{testState.hint}
Expand Down Expand Up @@ -703,7 +708,7 @@ const LLM_ERROR_LABELS: Record<string, string> = {
auth: '鉴权失败',
timeout: '请求超时',
quota: '额度或限流',
model_not_found: '模型不存在',
model_not_found: '模型不可用',
empty_response: '空响应',
format_error: '格式异常',
network_error: '网络异常',
Expand Down Expand Up @@ -733,7 +738,7 @@ const LLM_REASON_HINTS: Record<string, string> = {
dns_error: '域名解析失败;请检查 Base URL 域名、网络代理和 DNS 配置。',
tls_error: 'TLS/证书握手失败;请检查 HTTPS 证书、中转网关或公司代理策略。',
connection_refused: '目标服务拒绝连接;请确认 Base URL 端口、服务进程和防火墙配置。',
model_access_denied: '当前账号没有访问该模型的权限;请在服务商控制台确认模型开通状态。',
model_access_denied: '当前账号无法使用该模型;请确认模型是否已开通、账号是否可见,或模型是否已被禁用。',
provider_prefix_mismatch: '模型 provider 前缀与当前渠道不匹配;请确认模型名是否应使用该渠道的 OpenAI-compatible 路由。',
capability_unsupported: '当前模型或兼容层不支持该能力;这不影响基础文本连接,可换模型或关闭该能力依赖。',
};
Expand Down Expand Up @@ -767,6 +772,27 @@ function getLlmTroubleshootingHint(
return LLM_TROUBLESHOOTING_HINTS[code || ''];
}

function buildLlmTestHint(result: {
errorCode?: string | null;
stage?: string | null;
details?: Record<string, unknown>;
resolvedModel?: string | null;
}): string | undefined {
const reason = typeof result.details?.reason === 'string' ? result.details.reason : '';
const detailsModel = typeof result.details?.model === 'string' ? result.details.model : '';
const testedModel = result.resolvedModel || detailsModel;
const modelHint = testedModel ? `本次测试模型:${testedModel}。` : '';
const scopeInfo = '基础连接测试默认只测试模型列表中的第一个模型。';
const shouldSuggestModelListChange = reason === 'model_access_denied'
|| reason === 'model_not_found'
|| (result.errorCode === 'model_not_found' && !reason);
const modelActionHint = shouldSuggestModelListChange
? '若该模型不可用,请调整模型顺序或移除不可用模型后重试。'
: '';
const troubleshootingHint = getLlmTroubleshootingHint(result.errorCode, result.stage, 'test', result.details);
return [modelHint, scopeInfo, modelActionHint, troubleshootingHint].filter(Boolean).join(' ') || undefined;
}

function buildLlmFailureText(result: {
message: string;
error?: string | null;
Expand Down Expand Up @@ -1339,7 +1365,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
const text = result.success
? `连接成功${result.resolvedModel ? ` · ${result.resolvedModel}` : ''}${result.latencyMs ? ` · ${result.latencyMs} ms` : ''}`
: buildLlmFailureText(result);
const hint = result.success ? undefined : getLlmTroubleshootingHint(result.errorCode, result.stage, 'test', result.details);
const hint = result.success ? undefined : buildLlmTestHint(result);

setTestStates((previous) => ({
...previous,
Expand Down Expand Up @@ -1479,7 +1505,7 @@ export const LLMChannelEditor: React.FC<LLMChannelEditorProps> = ({
? '未返回能力检测结果'
: buildLlmFailureText(result),
hint: getFirstCapabilityHint(capabilityResults)
|| (!result.success ? getLlmTroubleshootingHint(result.errorCode, result.stage, 'test', result.details) : undefined),
|| (!result.success ? buildLlmTestHint(result) : undefined),
results: capabilityResults,
},
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,52 @@ describe('LLMChannelEditor', () => {

expect(await screen.findByText(/聊天调用 · 鉴权失败:LLM authentication failed/i)).toBeInTheDocument();
expect(screen.getByText(/请检查 API Key 是否正确/i)).toBeInTheDocument();
expect(screen.queryByText(/调整模型顺序或移除不可用模型/i)).not.toBeInTheDocument();
});

it('shows tested model and model-availability hints when a model is disabled', async () => {
testLLMChannel.mockResolvedValue({
success: false,
message: 'LLM channel test failed',
error: 'litellm.APIError: APIError: OpenAIException - Model disabled.',
errorCode: 'model_not_found',
stage: 'chat_completion',
retryable: false,
details: { reason: 'model_access_denied', model: 'openai/deepseek-ai/DeepSeek-V3' },
resolvedProtocol: 'openai',
resolvedModel: 'openai/deepseek-ai/DeepSeek-V3',
latencyMs: null,
});

render(
<LLMChannelEditor
items={[
{ key: 'LLM_CHANNELS', value: 'siliconflow' },
{ key: 'LLM_SILICONFLOW_PROTOCOL', value: 'openai' },
{ key: 'LLM_SILICONFLOW_BASE_URL', value: 'https://api.siliconflow.cn/v1' },
{ key: 'LLM_SILICONFLOW_ENABLED', value: 'true' },
{ key: 'LLM_SILICONFLOW_API_KEY', value: 'secret-key' },
{ key: 'LLM_SILICONFLOW_MODELS', value: 'deepseek-ai/DeepSeek-V3,Qwen/Qwen3-Coder' },
]}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
/>
);

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

expect(await screen.findByText(/聊天调用 · 模型不可用:LLM channel test failed/i)).toBeInTheDocument();
expect(screen.getByText(/本次测试模型:openai\/deepseek-ai\/DeepSeek-V3/i)).toBeInTheDocument();
expect(screen.getByText(/基础连接测试默认使用模型列表首项:deepseek-ai\/DeepSeek-V3/i)).toBeInTheDocument();
expect(screen.getByText(/基础连接测试默认只测试模型列表中的第一个模型/i)).toBeInTheDocument();
expect(screen.getByText(/调整模型顺序或移除不可用模型/i)).toBeInTheDocument();
expect(screen.getByText(/模型是否已开通、账号是否可见/i)).toBeInTheDocument();
expect(screen.queryByText(/Base URL、代理、TLS/i)).not.toBeInTheDocument();
expect(testLLMChannel).toHaveBeenCalledWith(expect.objectContaining({
models: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen3-Coder'],
}));
});

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

expect(await screen.findByText(/服务商返回配额已耗尽/i)).toBeInTheDocument();
expect(screen.queryByText(/调整模型顺序或移除不可用模型/i)).not.toBeInTheDocument();
});

it('does not show model-list action hints for network failures', async () => {
testLLMChannel.mockResolvedValue({
success: false,
message: 'LLM request failed before a valid response was returned',
error: 'DNS lookup failed',
errorCode: 'network_error',
stage: 'chat_completion',
retryable: true,
details: { reason: 'dns_error' },
resolvedProtocol: 'openai',
resolvedModel: 'openai/gpt-4o-mini',
latencyMs: null,
});

render(
<LLMChannelEditor
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' }]}
configVersion="v1"
maskToken="******"
onSaved={() => {}}
/>
);

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

expect(await screen.findByText(/域名解析失败/i)).toBeInTheDocument();
expect(screen.queryByText(/调整模型顺序或移除不可用模型/i)).not.toBeInTheDocument();
});

it('does not request runtime capabilities during the basic connection test', async () => {
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
- [改进] 放宽 LiteLLM 依赖约束,保留 `>=1.80.10` 最低版本并显式排除 PyPI 事故版本 `1.82.7` / `1.82.8`,允许安装后续 1.x 修复版本。
- [改进] 补齐通知渠道 P0 基线、Actions 映射与 `--check-notify` 只读诊断,完善 AstrBot 配置入口和通知回归快照。
- [修复] 修正 LLM 渠道测试中 `Model disabled` 被误报为网络异常的问题,并在失败提示中展示本次实际测试模型。
- [chore] 清理仓库根目录:移除误入库的 `.codex`、`review.md` 跟踪记录,将 smoke 测试入口迁移到 `scripts/`、环境检查脚本迁移为 `scripts/check_env.py`,并将 LiteLLM YAML 示例迁移到 `docs/examples/`。

## [3.15.0] - 2026-05-05
Expand Down
2 changes: 1 addition & 1 deletion docs/LLM_CONFIG_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ LITELLM_MODEL=ollama/qwen3:8b
### Web 渠道编辑器的兼容性 / 迁移 / 回退规则

- 预设里的 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`;“测试连接”默认只发一次最小聊天请求。可选的“运行时能力检测”必须由用户显式选择后触发,会额外发起 JSON / tools / stream / vision smoke 请求,结果仅代表当前账号、模型和 endpoint 的一次 best-effort 检测。上述检测返回的 `stage / error_code / details / latency_ms / capability_results` 仅用于结构化诊断提示,**不会写回** `.env`,也不会阻止保存。
- 设置页的“获取模型”只对 `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`,也不会阻止保存。
- 运行时能力检测会产生真实 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
2 changes: 1 addition & 1 deletion docs/LLM_CONFIG_GUIDE_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ The backend exposes a read-only status endpoint at `GET /api/v1/system/config/se
### Web channel editor: compatibility, migration, and rollback rules

- 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 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.
- "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.
- 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
Loading
Loading