Skip to content
Open
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
17 changes: 17 additions & 0 deletions src/__tests__/unit/error-classifier.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
15 changes: 15 additions & 0 deletions src/__tests__/unit/provider-key-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\}/),
Expand Down
10 changes: 9 additions & 1 deletion src/components/settings/PresetConnectDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions src/lib/error-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type ClaudeErrorCategory =
| 'AUTH_FORBIDDEN'
| 'AUTH_STYLE_MISMATCH'
| 'RATE_LIMITED'
| 'PROVIDER_UNAVAILABLE'
| 'NETWORK_UNREACHABLE'
| 'ENDPOINT_NOT_FOUND'
| 'MODEL_NOT_AVAILABLE'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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':
Expand Down