What changed, and why
The old model picker listed every model the OpenAI account could see. Some worked for theme generation, some 404'd, none came with guidance. This PR replaces that with a short curated catalog and a sensible default, and — because the v2 rewrite put a port between the application and the OpenAI SDK — adds Anthropic as a real second provider.
The shape of the change is the point. A new Provider axis runs through the domain; Model becomes {provider, id}; the single OpenAI gateway becomes a provider-blind ModelGateway that dispatches to per-provider adapters. Most of the diff is additive — a new Anthropic adapter, a dispatcher, a provider parameter threaded through the key and secret layers — which is exactly the payoff the hexagonal architecture was supposed to buy.
Read it in the direction dependencies point: the pure domain first (Provider, Model, ApiKey), then the ports that name the new boundary, then the adapters that satisfy it, then the application flow that ties it together, and finally the VS Code surface and wiring. Blast radius is contained to the model/key path; the streaming parser, theme application, and reset flows are untouched.
The new axis: Provider
Everything starts here. Provider is a closed two-variant union, and providerInfo is the single place that knows a provider's display name and key prefix. Adding a third provider later is one variant plus one adapter — nothing in the pure core changes shape.
A closed union, a values list for iteration, and per-provider metadata in one spot.
src/domain/provider.ts · 22 lines
⋯ 6 lines hidden (lines 1–6)
Model is now provider + id, chosen from a curated catalog
A Model carries the two facts downstream code needs: which provider routes the request, and which key unlocks it. The id stays a branded free-text string, so a custom model id is still expressible — but the recommended path is the small CATALOG.
Model, its smart constructor, and structural equality by provider + id.
src/domain/model.ts · 62 lines
⋯ 13 lines hidden (lines 1–13)
⋯ 30 lines hidden (lines 33–62)
The curated catalog and the default. Note the comment on the missing mini.
src/domain/model.ts · 62 lines
⋯ 40 lines hidden (lines 1–40)
Provider-aware key validation
Keys are validated per provider, and the prefix check is where a subtle bug would hide: OpenAI's sk- is a prefix of Anthropic's sk-ant-, so a naive startsWith would accept an Anthropic key as an OpenAI one and silently misroute it. hasValidPrefix excludes the foreign prefix explicitly.
The prefix guard, then the validating constructor that returns a Redacted key.
src/domain/apiKey.ts · 60 lines
⋯ 24 lines hidden (lines 1–24)
⋯ 10 lines hidden (lines 51–60)
The ports: a provider-blind ModelGateway
The application talks to one ModelGateway and never names a provider's SDK. A ProviderAdapter is what a single provider implements (verify a key, open a streamTheme); the gateway is the façade that dispatches to the right one by the provider on the request. OpenAiError became the provider-neutral ProviderError.
A closed, provider-neutral error union — no OpenAI-specific variants.
src/ports/errors.ts · 99 lines
⋯ 36 lines hidden (lines 1–36)
⋯ 58 lines hidden (lines 42–99)
ProviderRequest / ProviderAdapter (per-provider) vs GenerationRequest / ModelGateway (the façade).
src/ports/ports.ts · 165 lines
⋯ 56 lines hidden (lines 1–56)
⋯ 75 lines hidden (lines 91–165)
SecretStore is now keyed by provider; clearAll backs the 'Clear API Keys' command.
src/ports/ports.ts · 165 lines
⋯ 45 lines hidden (lines 1–45)
⋯ 110 lines hidden (lines 56–165)
The dispatcher and the two adapters
The dispatcher is tiny — index a record of adapters by provider and forward the call. The two adapters are where the SDKs live, and they're the only files that import openai / @anthropic-ai/sdk.
Record-indexed dispatch. The whole provider-blindness rests on this seam.
src/adapters/gateway.ts · 21 lines
⋯ 9 lines hidden (lines 1–9)
reasoning_effort: 'minimal' only for reasoning-family models; status-code error classification.
src/adapters/openai/gateway.ts · 69 lines
⋯ 25 lines hidden (lines 1–25)
⋯ 18 lines hidden (lines 32–49)
⋯ 1 line hidden (lines 69–69)
Messages API streaming; the static system prompt is sent with ephemeral cache_control.
src/adapters/anthropic/gateway.ts · 62 lines
⋯ 43 lines hidden (lines 1–43)
⋯ 1 line hidden (lines 62–62)
The application flow: select, provision, route
The use case stays provider-blind. It reads the selected model (falling back to the default), provisions that provider's key, then routes the stream through the gateway with the model's provider attached. No branch on provider anywhere in the application layer.
Resolve the model first; provision the key for its provider.
src/application/generateTheme.ts · 300 lines
⋯ 258 lines hidden (lines 1–258)
⋯ 31 lines hidden (lines 270–300)
The stream request carries provider + id; the gateway dispatches.
src/application/generateTheme.ts · 300 lines
⋯ 239 lines hidden (lines 1–239)
⋯ 54 lines hidden (lines 247–300)
Stored-and-valid → trust it; otherwise prompt, verify, store — all per provider.
src/application/provisionApiKey.ts · 98 lines
⋯ 65 lines hidden (lines 1–65)
⋯ 15 lines hidden (lines 84–98)
Select Model drives the curated catalog; the confirmation names the provider.
src/application/maintenance.ts · 50 lines
⋯ 35 lines hidden (lines 1–35)
Persistence, the picker, and the wiring
The VS Code adapters carry the provider through to storage and the UI. Secrets are keyed per provider; the selected model is persisted as {provider, id} and validated on read; the picker shows the catalog plus a custom escape hatch.
One storage slot per provider; clearAll deletes them all in parallel.
src/adapters/vscode/secrets.ts · 41 lines
⋯ 6 lines hidden (lines 1–6)
⋯ 23 lines hidden (lines 11–33)
Stored model is validated back into the domain — an unknown provider reads as None.
src/adapters/vscode/preferences.ts · 32 lines
⋯ 13 lines hidden (lines 1–13)
pickModel: the curated list with a 'current' check, plus the custom-id flow.
src/adapters/vscode/ui.ts · 237 lines
⋯ 128 lines hidden (lines 1–128)
⋯ 78 lines hidden (lines 160–237)
The composition root assembles the gateway from both adapters.
src/extension.ts · 48 lines
⋯ 25 lines hidden (lines 1–25)
⋯ 10 lines hidden (lines 39–48)
Verification
tsc --noEmit and eslint are clean; 53/53 tests pass under Node's built-in runner. Two new tests pin the behavior that the provider abstraction is supposed to guarantee.
Selecting a Claude model routes to Anthropic and stores an sk-ant- key (no provider branch leaks into the app).
test/generateTheme.test.ts · 160 lines
⋯ 69 lines hidden (lines 1–69)
⋯ 74 lines hidden (lines 87–160)
An Anthropic key is invalid for OpenAI and vice versa — the misroute guard, asserted.