Skip to content

Commit 0214b5f

Browse files
committed
fix(foreground-fallback): recognize ProviderModelNotFoundError as failover (#1034)
1 parent 09e27a7 commit 0214b5f

2 files changed

Lines changed: 70 additions & 0 deletions

File tree

src/hooks/foreground-fallback/index.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,36 @@ describe('isFailoverError', () => {
298298
}),
299299
).toBe(false);
300300
});
301+
302+
test('returns true for OpenCode ProviderModelNotFoundError "Model not found" errors', () => {
303+
// Issue #1034: OpenCode's ProviderModelNotFoundError ("Model not found:
304+
// <model>") was not classified as a failover error, so a missing primary
305+
// model failed the task outright instead of advancing the fallback chain.
306+
// The reporter's error string always carries the message; the bare
307+
// camelCase class name "ProviderModelNotFoundError" (no spaces) does not
308+
// match /\bmodel not found\b/i and is intentionally not covered here.
309+
expect(
310+
isFailoverError(
311+
'ProviderModelNotFoundError: Model not found: custom/missing-model.',
312+
),
313+
).toBe(true);
314+
expect(
315+
isFailoverError({ message: 'Model not found: custom/missing-model' }),
316+
).toBe(true);
317+
});
318+
319+
test('returns true for existing model-outage patterns (regression guard)', () => {
320+
expect(isFailoverError('model not available')).toBe(true);
321+
expect(isFailoverError('unsupported model')).toBe(true);
322+
expect(isFailoverError('unknown model')).toBe(true);
323+
});
324+
325+
test('returns false for normal errors and model mentions without outage wording', () => {
326+
expect(isFailoverError('Cannot connect to database')).toBe(false);
327+
expect(isFailoverError({ message: 'invalid model configuration' })).toBe(
328+
false,
329+
);
330+
});
301331
});
302332

303333
// ---------------------------------------------------------------------------
@@ -483,6 +513,42 @@ describe('ForegroundFallbackManager session.error', () => {
483513
expect(call[0].body.model.modelID).toBe('gpt-4o');
484514
});
485515

516+
test('triggers fallback on ProviderModelNotFoundError session.error', async () => {
517+
await mgr.handleEvent({
518+
type: 'message.updated',
519+
properties: {
520+
info: {
521+
sessionID: 'sess-1',
522+
providerID: 'anthropic',
523+
modelID: 'claude-opus-4-5',
524+
role: 'assistant',
525+
},
526+
},
527+
});
528+
529+
await mgr.handleEvent({
530+
type: 'session.error',
531+
properties: {
532+
sessionID: 'sess-1',
533+
error: {
534+
message:
535+
'ProviderModelNotFoundError: Model not found: custom/missing-model.',
536+
},
537+
},
538+
});
539+
540+
expect(mocks.abort).not.toHaveBeenCalled();
541+
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
542+
543+
const call = mocks.promptAsync.mock.calls[0] as [
544+
{
545+
model: { providerID: string; modelID: string };
546+
},
547+
];
548+
expect(call[0].body.model.providerID).toBe('openai');
549+
expect(call[0].body.model.modelID).toBe('gpt-4o');
550+
});
551+
486552
test('marks the replayed user prompt as an internal initiator', async () => {
487553
await mgr.handleEvent({
488554
type: 'message.updated',

src/hooks/foreground-fallback/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ const PROVIDER_OUTAGE_PATTERNS = [
103103
/\bmodel is not available\b/i,
104104
/\bunsupported model\b/i,
105105
/\bunknown model\b/i,
106+
// OpenCode's ProviderModelNotFoundError uses "Model not found" wording; the
107+
// model may exist on a later entry in the configured chain, so treat it as a
108+
// provider outage and advance the fallback chain.
109+
/\bmodel not found\b/i,
106110
// Model retired/end-of-life (HTTP 410 Gone) — the model no longer exists,
107111
// so the next model must be tried instead of retrying the dead one.
108112
/\bend of life\b/i,

0 commit comments

Comments
 (0)