From 7c719d65018cca41d8b450555c63be28a462ea62 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Sun, 30 Aug 2026 03:31:33 +0800 Subject: [PATCH 1/2] fix(core): quarantine retired opencode-free model x-preview-f-free Ox Alpha Free (x-preview-f-free) was retired upstream on OpenCode Zen: it is gone from the anonymous /models listing and every completion returns HTTP 401 "Model x-preview-f-free is not supported". models.dev still snapshots it as free+active, so the derived free list kept offering it as a default-enabled, picker-visible row. Add it to OPENCODE_FREE_BROKEN_MODEL_IDS so it is dropped from the derivation, hidden from the picker catalog, and vetoed in authorizeConnectionModel. Remove once the models.dev snapshot marks it deprecated (or upstream serves it again). Fixes #4214 Generated-by: Claude Code (Opus 4.8) --- .../provider-catalog-contract.test.ts | 29 +++++++++++++++++++ packages/core/src/provider-registry.ts | 11 ++++++- 2 files changed, 39 insertions(+), 1 deletion(-) 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/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( From b84a2d047aa66f377aaa7cff30d2543796ef2033 Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 15:59:10 +0800 Subject: [PATCH 2/2] fix(core): drop quarantined opencode-free default from the model picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildConnectionModelCatalogEntries filtered connection.models and enabledModelIds against brokenModelIds but passed connection.defaultModel through unfiltered. A retired id persisted as a connection's defaultModel (a reachable upgrade state — x-preview-f-free was picker-visible before the quarantine) then reached makeMissingDefaultEntry and was re-added as a selectable provider_default row, so buildChatModelChoices offered a model that authorizeConnectionModel vetoes. Drop the broken id from the catalog's defaultModel source too, closing the picker path the quarantine already claimed. Add a buildChatModelChoices regression with the retired id present as the stored default, enabled model, and inventory row. Generated-by: Claude Code (Opus 4.8) --- .../src/__tests__/llm-connections.test.ts | 27 +++++++++++++++++++ packages/core/src/model-catalog.ts | 13 ++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) 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/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,