OpenGUI · Model selection architecture

Model switching has two owners

Picking a model in the dialog works, yet switching from gpt-5.6-sol to gpt-5.6-luna can leave the persisted slug untouched. The picker is not the problem. The problem is that “the model” is written by four uncoordinated call sites and read back from two disagreeing authorities — a frontend-optimistic selectedModel and the host session snapshot.

Four writers, two authorities Plan only · no implementation Repro: gpt-5.6-sol → gpt-5.6-luna

1. Symptom

What you see

Open the picker, choose gpt-5.6-luna, continue chatting.

  • Picker highlightluna
  • Persisted session slugsol
  • Sidebar / session row modelsol

The optimistic picker state says luna. The host session — and every surface that reads the session — still says sol.

What “works”

A brand-new session created after the pick.

  • New-session modelluna
  • Prompt-time contextluna
  • Transcript footerluna

Creation paths read the live selectedModel. Everything that reads the stored session — or gets re-synced by refreshModels — can disagree.

2. Two mental models never merged

The codebase carries two incompatible definitions of “a model”, and the switch between them is only half-finished. The legacy model — a base model plus variants, agents, and provider defaults — came from the pre-Host harness bridge. The current model — offerings that route to a backend + upstream id — came from ADR 0014. The frontend keeps the legacy shape while the Host speaks the new one.

Legacy shape (kept, stubbed)

  • SelectedModel { providerID, modelID }
  • variantSelections keyed per model
  • cycleVariant() / revertVariant()
  • agents, providerDefaults

Host truth (ADR 0014)

  • Offering slug gpt-5.6-luna selected by user
  • Route backendId + upstreamModelId resolved on Host
  • Session stores { connectionId, modelId }
  • Entitlements attach to the slug

Frontend reality

  • Fabricates opengui-offering provider from offerings list
  • Maps offering slug → model option in the picker
  • Writes selectedModel from four places
  • Never joins it back with the stored session
Cmd+T is the tell. The shortcut the UI wires to cycleVariant() is a () => {} no-op in HostProvider, while variantSelections and currentVariant are read by no live component. A user who “switches model” with the shortcut that used to cycle variants now gets silence.

3. Root causes

1

The offering provider is fabricated client-side

refreshModels in HostProvider folds listModelOfferings() into a synthetic opengui-offering host connection: modelIds = offering slugs, modelCapabilities = display names. Selection identity is therefore derived twice: the slug the user picked and the backend/upstream route the Host resolves live on opposite sides of the wire with no shared contract.

If the offerings list is empty, unauthorized, or not yet loaded, the offering provider disappears entirely and any slug selection fails the “still visible” check.

2

refreshModels rewrites the selection asynchronously

Every boot and refreshProviders() call runs setSelectedModel with a fallback that snaps to connections[0].defaultModelId when the current selection is “not visible” in the freshly built provider list. Combined with cause 1, a slug whose offering list shifted disappears and the picker snaps back — the user’s pick is silently undone, which reads as “the slug did not update”.

3

Two storage authorities are never joined

The live selectedModel (frontend-optimistic) and the stored session model (session.model / activeSnapshotRef) are written and read independently. setModel updates the optimistic state and the snapshot ref, but never refreshes the sessions list. Any surface rendered from sessions — sidebar rows, session list — keeps the old slug indefinitely.

4

Four writers, no reducer, no race handling

setSelectedModel is called from refreshModels fallback, hydrateTranscript, the optimistic setModel, and setPromptBoxSelection. In-flight host.setModel calls can interleave with refreshModels; whichever runs last wins, and the two authorities can end up holding different slugs with no reconciliation.

5

setModel never re-hydrates the session

After a successful switch the action sets activeSnapshotRef and returns. It does not call refreshSessions() or hydrateTranscript(), so the canonical stored slug and the surfaces that display it are never reconciled with the optimistic pick.

4. Repro flow

What the code does today
  1. User picks gpt-5.6-luna in the dialog.
  2. selectModelsetModel({providerID, modelID}).
  3. Action optimistically sets selectedModel = luna.
  4. host.setModel persists luna into the session.
  5. Returned snapshot lands in activeSnapshotRefonly.
  6. sessions list, sidebar rows keep sol.
  7. Next refreshModels re-derives providers and may re-snap selection.
Where it diverges
  1. Picker reads selectedModel → shows luna.
  2. Session surfaces read sessions / snapshot → show sol.
  3. If offerings reload is empty, refreshModels snaps to default sol.
  4. Result: the slug that “sticks” depends on which writer ran last.
The bug is not “the picker forgets the model”. It is that the persisted model slug and the displayed model slug are produced by different state, and nothing reconciles them after the optimistic write.

5. Target: one selection store, host-resolved

Make the Host the single authority for the model catalog and the resolved selection. The frontend keeps exactly one SelectedModel and derives all display from it — never from a re-derived provider join.

Single source

  • One selectedModel in one reducer
  • Written by one action: setModel
  • Read by picker, prompt box, context info
  • Reconciled after every host round-trip

Host owns catalog

  • Offerings + entitled connections returned as one list
  • Display name, slug, capabilities all from Host
  • No client-side opengui-offering fabrication

Host returns resolution

  • setModel response carries the canonical slug
  • Frontend writes it back into the single store
  • Sessions refresh after every switch

6. Contracts

Make the model identity explicit instead of two-string guessing:

// frontend + protocol
type SelectedModel =
  | { kind: "offering"; offeringId: string }        // slug: gpt-5.6-luna
  | { kind: "connection"; providerID: string; modelID: string };

// host setModel response (authoritative)
type SetModelResult = {
  canonical: SelectedModel;       // what to persist + display
  resolved?: { connectionId: string; modelId: string } | null; // owner/debug only
};
Rule Today Target
Catalog authority Frontend folds offerings + connections Host returns one catalog RPC
Selection state 4 writers to selectedModel 1 action, 1 store
Stored session slug Optimistic + snapshot, never joined Host response → store → refresh sessions
Variant layer Dead stub, Cmd+T no-op Removed or re-implemented on offerings

7. Phases

1

Single selection store

  • Introduce a useModelSelection reducer with one setModel action.
  • Route all four writers through it; delete direct setSelectedModel call sites.
  • Remove the opengui-offering fabrication; catalog comes from Host.

Exit: one writer, one reader; the picker and session surfaces can no longer disagree.

2

Host-returned resolution

  • Extend host.setModel response with canonical SelectedModel.
  • Frontend writes the canonical value back and refreshes sessions after each switch.
  • Hydration and refreshModels stop rewriting selection independently.

Exit: switching sol → luna persists luna and every surface shows luna.

3

Kill the legacy layer

  • Delete variantSelections / currentVariant / cycleVariant stubs.
  • Decide Cmd+T’s future: cycle entitled offerings, or remove the shortcut.
  • Remove unused agents / providerDefaults from the model context.

Exit: the model context exposes exactly the fields live components read.

8. Acceptance

Verdict

The fix is not another guard in the selector. It is consolidating model selection to one store, making the Host return the canonical resolution, and deleting the half-migrated legacy layer. That is the difference between a picker that works and a model system that can be reasoned about.