Skip to content

fix(settings): enforce "usable provider ⇒ concrete model" invariant (#580)#581

Merged
cosarah merged 6 commits into
mainfrom
fix/580-model-selection-invariant
May 18, 2026
Merged

fix(settings): enforce "usable provider ⇒ concrete model" invariant (#580)#581
cosarah merged 6 commits into
mainfrom
fix/580-model-selection-invariant

Conversation

@wyuc
Copy link
Copy Markdown
Contributor

@wyuc wyuc commented May 17, 2026

Closes #580.

Establishes the invariant (usable provider) ⟺ (a concrete model of that provider is always selected) for LLM / image / video, resolving provider and model atomically at the source. The previously-representable (usable provider, empty modelId) state is eliminated.

Layer 1 — code

  • New pure resolveSelectedModel() — like validateModel but no empty-input short-circuit, so it returns '' only when the provider has no models.
  • fetchServerProviders reconcile: symmetric LLM/image/video provider-recovery + resolveSelectedModel; removed the asymmetric [codex] fix server LLM model auto-selection #577 isServerConfigured-gated LLM tail and the separate first-load block (now also covers the client-API-key path [Bug]: 配置不成功 #498/[codex] fix server LLM model auto-selection #577 missed).
  • One shared resolveLLMSelection() used by both setProviderConfig (single edit: key add/clear, model-list edit) and setProvidersConfig (bulk: delete provider, import, reset) — keep current provider if still usable, else first usable, else State A. Image/video config setters get the symmetric treatment.

Layer 2 — UX (states now unreachable)

  • Shared isLLMProviderConfigured / hasUsableLLMProvider; landing page gates generation on a usable provider and drops the modelNotConfigured toast + forced settings dialog. Toolbar uses the shared predicate; dead "Select Model" label/tooltip removed.
  • Deleted unused components/settings/model-selector.tsx; pruned the orphan settings.selectModel i18n key (6 locales).

Deviations from the issue's literal table (please confirm)

  • Kept generation-toolbar providerEntries.length === 0 branch — it is reachable via search (search-no-results), not the dead no-provider state the issue assumed.
  • Kept settings.modelNotConfigured i18n key — still used by the chat path (use-chat-sessions.ts), a separate reachable surface.
  • Keyless providers (ollama/lemonade) now require an explicit user baseUrl to count as "configured" (registry defaultBaseUrl alone isn't user intent) — without this, hasUsableProvider was true while the reconcile never selected them, re-enabling generation with no model.

Testing

  • New resolveSelectedModel / hasUsableLLMProvider unit tests; server-sync invariant tests (client-key resolution, atomic API-key entry/clear, provider switch, custom-model delete, bulk delete, serverModels-preference + first-load regression guards). 96/96 store tests, full suite green (the only failures are pre-existing environmental ssrf-guard DNS tests, untouched here). tsc + eslint clean. Added an e2e spec (State A / State B).

Based on main@2ed39da; will rebase before merge if main advances.

🤖 Generated with Claude Code

wyuc and others added 5 commits May 17, 2026 07:35
…580)

Layer 1 — resolve provider AND model atomically at every source:
- add resolveSelectedModel(): like validateModel but no empty-input
  short-circuit, so it returns '' only when the provider has no models
- fetchServerProviders: symmetric LLM/image/video provider recovery +
  resolveSelectedModel; remove the asymmetric #577 isServerConfigured-gated
  LLM tail and the separate first-load auto-select block (now subsumed,
  and covers client-API-key providers too)
- setProviderConfig: entering an API key for the active/unset LLM provider
  resolves a concrete model in the same set()
- setVideoProvider: resolve a model on switch (was id-only); setImageProvider
  routed through the shared resolver

Layer 2 — remove states unreachable under the invariant:
- add shared isLLMProviderConfigured / hasUsableLLMProvider; gate the
  landing-page generate button on a usable provider (state A vs B) and drop
  the modelNotConfigured toast + forced SettingsDialog
- generation-toolbar: use the shared predicate; drop the dead "Select Model"
  label/tooltip fallback
- delete dead components/settings/model-selector.tsx (zero refs) and the
  now-orphan settings.selectModel i18n key (6 locales)

Note: generation-toolbar's `providerEntries.length === 0` branch is KEPT —
it is reachable via search (search-no-results), not the dead no-provider
state the issue assumed. modelNotConfigured i18n key kept (still used by
the chat path in use-chat-sessions).

Tests: resolveSelectedModel + hasUsableLLMProvider unit tests; server-sync
invariant tests (client-key resolution, atomic API-key entry, provider
switch, serverModels-preference + first-load regression guards).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…olution

Code review Important #1: fold atomic model resolution into
setImageProviderConfig / setVideoProviderConfig (design doc Layer 1 step 2
scoped these). Deleting the selected custom image/video model on the active
provider now resolves to a valid model in the same set() instead of leaving
it stale until the next server sync — restores LLM/image/video symmetry.

Minor #2/#3: document the deliberate store-level (credential path) vs
UX-level (incl. ≥1 model) "usable" split on isProviderUsable; clarify that
setProviderConfig also adopts providerId on the first-load API-key path.

Tests: +2 invariant tests (delete selected custom image/video model →
falls back to a valid model). 89/89 store tests pass, tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…able (#580)

Code-review/e2e follow-up. ollama/lemonade ship requiresApiKey:false +
a registry defaultBaseUrl, so the old isLLMProviderConfigured counted them
as configured on a fresh install — but buildFallback/reconcile never
auto-selects keyless providers, so hasUsableProvider could be true while
modelId stayed '', re-enabling generation with no model (the exact #498/#580
class of bug, just via the keyless path).

Narrow isLLMProviderConfigured to be consistent with isProviderUsable:
keyless ⇒ usable only once the user sets an explicit baseUrl (registry
defaultBaseUrl alone is not user intent); server-config and key-requiring
paths unchanged. Now gate + toolbar + reconcile agree, and genuine State A
(no usable provider) is reachable again.

Add e2e/tests/model-invariant-580.spec.ts: State A (no provider → generate
disabled + single "Set up model" affordance, no toast/forced dialog) and
State B (server-configured → concrete model auto-resolved, generation
enabled). +3 keyless unit tests. 92/92 store tests + 2 e2e pass, tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
)

Self-test found: deleting the selected provider in Settings left the model
selector pointing at the deleted/invalid provider (or, via the component's
hand-rolled fallback, at an unusable built-in like openai with no key).

Root cause: setProvidersConfig (the bulk setter behind delete-provider /
delete-model) did ZERO re-resolution — Layer 1 covered setProviderConfig
(singular) + image/video setters + the reconcile, but not this path. The
component's confirmDeleteProvider then picked Object.keys()[0] (always a
built-in, ignoring usability), the exact per-call-site hand-rolled shape
#580 condemns.

Fix at the source: setProvidersConfig now re-resolves (providerId, modelId)
via the canonical isLLMProviderConfigured + resolveSelectedModel — keep the
current provider if still usable, else first usable, else State A. Removed
the brittle confirmDeleteProvider override (kept only its local Settings-tab
selection). Now every bulk config mutation (delete, import, reset) holds the
invariant.

Tests: +2 store tests (delete selected/only-usable provider → State A, not
the deleted/invalid one; delete non-selected → keep usable selection).
94/94 store tests, full suite green (only pre-existing ssrf DNS), tsc/eslint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…#580)

Clarified repro: the realistic case isn't deleting a custom provider — it's
clearing the SELECTED provider's API key so it becomes invalid. setProvider
Config only re-resolved when the edit made a provider *usable*; the
"became unusable" transition fell through (return base) → the now-invalid
provider stayed selected. The previous commit fixed only the bulk path.

Root-cause fix: extract one shared `resolveLLMSelection(config, providerId,
modelId)` — keep current provider if still usable, else first usable, else
State A; then resolve the model. Use it in BOTH setProviderConfig (single
edit: key add/clear, model-list edit) and setProvidersConfig (bulk: delete,
import, reset). The two paths can no longer diverge — exactly the per-call-
site asymmetry #580 set out to eliminate. Drops the now-unused
isProviderUsable import.

Tests: +2 store tests (clear selected provider key → drops stale selection;
clear non-selected key → keeps usable selection). 96/96 store tests, full
suite green except pre-existing ssrf DNS; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@wyuc wyuc marked this pull request as ready for review May 17, 2026 13:58
@wyuc wyuc requested a review from cosarah May 17, 2026 13:58
@wyuc wyuc marked this pull request as draft May 17, 2026 14:13
CI 'Lint, Typecheck & Unit Tests' ran prettier . --check and failed on the
two added test specs (only tsc/eslint/vitest were run locally). Pure
whitespace reformat, no logic change — 96/96 store tests still green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@wyuc wyuc marked this pull request as ready for review May 17, 2026 14:16
Copy link
Copy Markdown
Collaborator

@cosarah cosarah left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Code: shared resolveLLMSelection symmetrically applied to single + bulk config edits + image/video setters; isLLMProviderConfigured consolidates the gate; resolveSelectedModel cleanly separates selection-resolution from validation. Comments explain the invariant well.

Locally tested on pr-581 worktree (port 3001):

  • State A (no usable provider): generate button disabled, single "Set up model" CTA, no toast, no forced dialog.
  • State B: toggling requiresApiKey on the active provider with no apiKey immediately switches providerId to the next usable one (or to State A) — capsule updates without page refresh. Earlier mis-report on my end was caused by testing against an older dev server.
  • Clearing/adding apiKey, deleting the selected custom model, and bulk provider delete all re-resolve atomically as described.
  • Keyless providers correctly require an explicit user baseUrl before counting as configured.

Unit + store tests 364/365 (the one failure is the pre-existing ssrf-guard DNS test, unrelated). tsc clean.

@cosarah cosarah merged commit d9aecf8 into main May 18, 2026
3 checks passed
@wyuc wyuc deleted the fix/580-model-selection-invariant branch May 18, 2026 08:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up (#577/#498): make "usable provider ⇒ a concrete model is always selected" an invariant, and align it across code + UX

2 participants