diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 1674479ed8..a258cb84e1 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -301,3 +301,30 @@ test('a quarantined model id is vetoed even when enabled and present in the inve id: 'nemotron-3-ultra-free', }); }); + +test('a quarantined stored default is dropped from the picker, not re-added as a missing-default row', () => { + // The retired `x-preview-f-free` was picker-visible before the quarantine, so + // an upgrade connection can carry it as `defaultModel` and enabled. `models` + // and `enabledModelIds` are filtered against `brokenModelIds`, but the raw + // `defaultModel` used to pass through unfiltered and `makeMissingDefaultEntry` + // re-added it as a selectable `provider_default` row — visible and pickable + // while `authorizeConnectionModel` vetoed the same id. The picker and the send + // authority must agree: neither offers it, and the live model still renders. + const connection = { + connectionId: 'connection-opencode-free', + slug: 'opencode-free', + name: 'OpenCode Free', + providerType: 'opencode-free' as ProviderType, + enabled: true, + defaultModel: 'x-preview-f-free', + enabledModelIds: ['x-preview-f-free', 'nemotron-3-ultra-free'], + models: [{ id: 'x-preview-f-free' }, { id: 'nemotron-3-ultra-free' }], + modelSource: 'fetched' as const, + createdAt: 1, + updatedAt: 1, + }; + const models = buildChatModelChoices([connection]).map(({ model }) => model); + assert.ok(!models.includes('x-preview-f-free'), 'quarantined default must not be offered'); + assert.ok(models.includes('nemotron-3-ultra-free'), 'live enabled model still renders'); + assert.equal(authorizeConnectionModel(connection, 'x-preview-f-free'), undefined); +}); diff --git a/packages/core/src/__tests__/provider-catalog-contract.test.ts b/packages/core/src/__tests__/provider-catalog-contract.test.ts index ef03f43fa2..2db51dde6a 100644 --- a/packages/core/src/__tests__/provider-catalog-contract.test.ts +++ b/packages/core/src/__tests__/provider-catalog-contract.test.ts @@ -213,3 +213,32 @@ describe('provider catalog contract — fallback lifecycle', () => { assert.deepEqual(regressed, []); }); }); + +describe('opencode-free retired-model quarantine', () => { + const opencodeFree = PROVIDER_REGISTRY['opencode-free']; + + // Ox Alpha Free (x-preview-f-free) was retired upstream on OpenCode Zen while + // models.dev still snapshots it free+active, so the derivation would keep + // offering it as a default-enabled, picker-visible row. Remove this assertion + // together with the OPENCODE_FREE_BROKEN_MODEL_IDS entry once the snapshot + // marks it deprecated (or upstream serves it again). + it('quarantines x-preview-f-free out of the offered free models', () => { + assert.ok(opencodeFree.brokenModelIds?.includes('x-preview-f-free')); + assert.ok(!(opencodeFree.fallbackModels ?? []).includes('x-preview-f-free')); + assert.ok(!(opencodeFree.defaultEnabledModelIds ?? []).includes('x-preview-f-free')); + }); + + // Mechanism guard, independent of which ids the deny-list holds: a quarantined + // id must never leak back into the offered candidates through either path. + it('never offers a quarantined broken id as a free candidate', () => { + const broken = new Set(opencodeFree.brokenModelIds ?? []); + const offered = [ + ...(opencodeFree.fallbackModels ?? []), + ...(opencodeFree.defaultEnabledModelIds ?? []), + ]; + assert.deepEqual( + offered.filter((id) => broken.has(id)), + [], + ); + }); +}); diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 72a56c67a4..ed2a5c7751 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -264,10 +264,21 @@ export function buildConnectionModelCatalogEntries( const fallbackModels = [...(catalogFallbackModels ?? defaults.fallbackModels)].filter( (id) => !broken.has(id), ); + // A quarantined id persisted as this connection's `defaultModel` must not + // re-enter the catalog either. `models` and `enabledModelIds` are filtered + // below, but a broken default reaches `makeMissingDefaultEntry` unfiltered and + // would be re-added as a selectable `provider_default` row — picker-visible + // and default-capable while `authorizeConnectionModel` vetoes the same id. A + // reachable persisted state: the id was picker-visible before the quarantine. + // Dropping it leaves the connection with no valid default (readiness reports + // `missing_model`), which is what a model that can no longer send warrants. + const defaultModel = broken.has((connection.defaultModel ?? '').trim()) + ? undefined + : connection.defaultModel; return buildModelCatalogEntries({ providerType: connection.providerType, connectionSlug: connection.slug, - defaultModel: connection.defaultModel, + defaultModel, models: connection.models?.filter(({ id }) => !broken.has(id)), modelSource: connection.modelSource, modelsFetchedAt: connection.modelsFetchedAt, diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 36c3c44d0a..b14019357b 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -673,7 +673,16 @@ const opencodeGoModelIds = toolCallingModelIds( // probes, max_tokens 8–200) — a failure shape that even "the send settles it" // cannot surface, which is why these ids are also vetoed in // `authorizeConnectionModel` rather than merely dropped from this derivation. -const OPENCODE_FREE_BROKEN_MODEL_IDS = new Set(['muse-spark-1.2-contributor-free']); +// 2026-08-30 x-preview-f-free (Ox Alpha Free): retired upstream — dropped from +// the anonymous /models listing and every completion returns HTTP 401 +// {"type":"ModelError","message":"Model x-preview-f-free is not supported"}. +// models.dev still snapshots it as free+active, so the derivation kept offering +// it as a default-enabled, picker-visible row until this quarantine. Remove +// once the snapshot marks it deprecated (or upstream serves it again). +const OPENCODE_FREE_BROKEN_MODEL_IDS = new Set([ + 'muse-spark-1.2-contributor-free', + 'x-preview-f-free', +]); const opencodeFreeModelIds = toolCallingModelIds( 'OpenCode Free', Object.fromEntries(