From bac198d59087dd6cceef14967848a5d5065f2c1b Mon Sep 17 00:00:00 2001 From: jimmylin Date: Mon, 1 Jun 2026 10:32:46 +0800 Subject: [PATCH] fix provider test model mapping --- src/__tests__/unit/error-classifier.test.ts | 17 +++++++++++++++++ .../unit/provider-key-lifecycle.test.ts | 15 +++++++++++++++ src/components/settings/PresetConnectDialog.tsx | 10 +++++++++- src/lib/error-classifier.ts | 12 ++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/unit/error-classifier.test.ts diff --git a/src/__tests__/unit/error-classifier.test.ts b/src/__tests__/unit/error-classifier.test.ts new file mode 100644 index 00000000..4b5b2206 --- /dev/null +++ b/src/__tests__/unit/error-classifier.test.ts @@ -0,0 +1,17 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { classifyError } from '../../lib/error-classifier'; + +describe('error-classifier', () => { + it('classifies 503 provider responses as temporarily unavailable', () => { + const result = classifyError({ + error: new Error('HTTP 503: {"error":"Service temporarily unavailable"}'), + providerName: 'Third-party Provider', + }); + + assert.equal(result.category, 'PROVIDER_UNAVAILABLE'); + assert.equal(result.retryable, true); + assert.ok(result.userMessage.includes('Third-party Provider')); + assert.ok(result.actionHint.includes('model name mapping')); + }); +}); diff --git a/src/__tests__/unit/provider-key-lifecycle.test.ts b/src/__tests__/unit/provider-key-lifecycle.test.ts index 91a273d6..6f2abd9c 100644 --- a/src/__tests__/unit/provider-key-lifecycle.test.ts +++ b/src/__tests__/unit/provider-key-lifecycle.test.ts @@ -144,6 +144,21 @@ describe('Provider key lifecycle — Codex follow-ups', () => { ); }); + it('connection tests use model mappings before falling back to aliases', () => { + assert.ok( + source.match(/const testModelName[\s\S]*modelName\.trim\(\)[\s\S]*mapSonnet\.trim\(\)[\s\S]*mapOpus\.trim\(\)[\s\S]*mapHaiku\.trim\(\)/), + 'test connection should prefer user-entered model mappings over default aliases', + ); + assert.ok( + source.match(/modelName:\s*testModelName/), + 'test request body should send the resolved test model name', + ); + assert.ok( + !source.match(/modelName:\s*modelName\s*\|\|\s*undefined/), + 'test request body should not ignore model mapping fields', + ); + }); + it('test button disabled prop references canTest', () => { assert.ok( source.match(/disabled=\{saving \|\| testing \|\| !canTest\}/), diff --git a/src/components/settings/PresetConnectDialog.tsx b/src/components/settings/PresetConnectDialog.tsx index 2c0860c3..12fa853e 100644 --- a/src/components/settings/PresetConnectDialog.tsx +++ b/src/components/settings/PresetConnectDialog.tsx @@ -132,6 +132,14 @@ export function PresetConnectDialog({ return false; })(); + const testModelName = (() => { + if (modelName.trim()) return modelName.trim(); + if (preset?.fields.includes("model_mapping")) { + return mapSonnet.trim() || mapOpus.trim() || mapHaiku.trim() || undefined; + } + return undefined; + })(); + const handleTestConnection = async () => { // Belt-and-suspenders: the button disabled state already enforces // this, but guard here in case something bypasses the UI (keyboard @@ -155,7 +163,7 @@ export function PresetConnectDialog({ protocol: preset?.protocol || 'anthropic', authStyle: preset?.key === 'anthropic-thirdparty' ? authStyle : (preset?.authStyle || authStyle), envOverrides, - modelName: modelName || undefined, + modelName: testModelName, providerName: name || preset?.name, }; if (isEdit && editProvider) { diff --git a/src/lib/error-classifier.ts b/src/lib/error-classifier.ts index 954da7ad..83c6dcdf 100644 --- a/src/lib/error-classifier.ts +++ b/src/lib/error-classifier.ts @@ -61,6 +61,7 @@ export type ClaudeErrorCategory = | 'AUTH_FORBIDDEN' | 'AUTH_STYLE_MISMATCH' | 'RATE_LIMITED' + | 'PROVIDER_UNAVAILABLE' | 'NETWORK_UNREACHABLE' | 'ENDPOINT_NOT_FOUND' | 'MODEL_NOT_AVAILABLE' @@ -227,6 +228,15 @@ const ERROR_PATTERNS: ErrorPattern[] = [ retryable: true, }, + // ── Provider temporarily unavailable (503) ── + { + category: 'PROVIDER_UNAVAILABLE', + patterns: ['503', 'service temporarily unavailable', 'service unavailable', 'temporarily unavailable'], + userMessage: (ctx) => `Provider is temporarily unavailable${providerHint(ctx)}.`, + actionHint: () => 'Retry later. If this happens only during connection testing, check that the model name mapping matches the provider-supported model IDs.', + retryable: true, + }, + // ── Model not available ── { category: 'MODEL_NOT_AVAILABLE', @@ -433,8 +443,10 @@ function buildRecoveryActions(category: ClaudeErrorCategory, ctx: ErrorContext): actions.push({ label: 'Open Settings', action: 'open_settings' }); break; case 'RATE_LIMITED': + case 'PROVIDER_UNAVAILABLE': actions.push({ label: 'Retry', action: 'retry' }); if (meta?.pricingUrl) actions.push({ label: 'Upgrade Plan', url: meta.pricingUrl }); + if (meta?.docsUrl) actions.push({ label: 'View Docs', url: meta.docsUrl }); break; case 'MODEL_NOT_AVAILABLE': case 'ENDPOINT_NOT_FOUND':