Skip to content

Fix provider/model drift in Docker auth and free-mode selection#171

Open
friuns2 wants to merge 25 commits into
mainfrom
codex/provider-model-selection-drift
Open

Fix provider/model drift in Docker auth and free-mode selection#171
friuns2 wants to merge 25 commits into
mainfrom
codex/provider-model-selection-drift

Conversation

@friuns2
Copy link
Copy Markdown
Owner

@friuns2 friuns2 commented May 13, 2026

Summary

  • Preserve the selected provider/model pair across auth promotion and Docker free-mode fallback.
  • Normalize OpenCode Zen and related Codex gateway responses so model selection stays aligned with the active provider.
  • Update the thread UI and global styling to reflect the corrected state consistently.
  • Capture the behavior in wiki notes and add focused regression coverage.

Testing

  • Added and updated unit/integration tests around gateway normalization, provider selection, free-mode behavior, and server bridge handling.
  • Updated manual test guidance for the affected thread and Docker flows.
  • Not run (not requested).

Summary by CodeRabbit

  • New Features

    • Provider-scoped model selection persistence across sessions.
    • Error feedback mechanism for failed messages in chat.
    • Improved authentication handling in Docker environments.
  • Bug Fixes

    • Provider selection drift across Docker cycles.
    • Community fallback provider suppression when authenticated.
  • Documentation

    • Docker regression testing guidance and validation steps.
    • Authentication and provider switching edge cases in Docker.
    • Updated free mode and provider configuration documentation.

Review Change Stack

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Fix provider/model drift in Docker auth and free-mode selection

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation

Grey Divider

Walkthroughs

Description
• **Provider-scoped model selection**: Introduced StoredModelSelection type to store models with
  provider IDs, preventing cross-provider model leakage and ensuring model compatibility during
  provider switches.
• **Live error overlay deduplication**: Modified error handling to suppress duplicate errors only
  after persistence, keeping new live errors visible while preventing redundant display.
• **Thread materialization error handling**: Added detection of transient first-turn materialization
  errors and mapping them to in-progress states instead of visible errors.
• **Free-mode auth suppression**: Implemented logic to suppress community fallback providers when
  usable Codex auth exists, while preserving user-configured providers.
• **External auth detection and import**: Added automatic detection and import of copied auth.json
  into accounts list when Codex auth becomes available, triggering provider refresh.
• **Provider model list authority**: Refactored model fetching to respect exclusive flag from
  provider models endpoint, ensuring correct model list merging and deduplication.
• **Failed turn error rendering**: Added normalization and UI rendering of persisted turn errors as
  system messages with feedback links.
• **Comprehensive test coverage**: Added unit and integration tests for provider-scoped model
  selection, free-mode behavior, gateway normalization, and server bridge handling.
• **Documentation and wiki updates**: Captured Docker auth flows, provider selection drift behavior,
  and regression testing procedures in wiki notes and manual test guidance.
Diagram
flowchart LR
  A["No-auth Docker startup"] -->|"OpenCode Zen fallback"| B["Provider-scoped model selection"]
  C["Copied auth.json"] -->|"External auth detection"| D["Account import & provider refresh"]
  B -->|"Provider switch"| E["Reject incompatible models"]
  D -->|"Codex auth available"| F["Suppress community providers"]
  G["First-turn materialization"] -->|"Transient errors"| H["Map to in-progress state"]
  B -->|"Model list authority"| I["Respect exclusive flag"]
  J["Failed turns"] -->|"Error normalization"| K["Render as system messages"]
Loading

Grey Divider

File Changes

1. src/composables/useDesktopState.test.ts 🧪 Tests +329/-2

Live error overlay and provider-scoped model selection tests

• Added comprehensive test suite for live error overlay behavior, including tests for keeping new
 live errors visible when older persisted errors exist and suppressing live errors only after they
 persist.
• Updated provider model selection tests to validate provider-tagged model storage format `{
 providerId, modelId }` instead of plain strings.
• Added tests for model selection compatibility across provider switches, ensuring stale models from
 other providers are rejected.
• Added tests for OpenCode Zen model restoration and legacy model migration when switching between
 providers.

src/composables/useDesktopState.test.ts


2. src/composables/useDesktopState.ts ✨ Enhancement +90/-30

Provider-scoped model selection and live error deduplication

• Introduced StoredModelSelection type to store models as { providerId, modelId } objects
 instead of plain strings.
• Added normalizeStoredModelProviderId() and readCompatibleStoredModelId() functions to validate
 model selections against the active provider.
• Updated readModelIdForThread() to check provider compatibility before returning stored models,
 preventing cross-provider model leakage.
• Modified live error overlay logic to suppress duplicate errors only after they persist to the
 thread, keeping new live errors visible.
• Updated model persistence to use provider-scoped storage and reject incompatible models during
 provider switches.

src/composables/useDesktopState.ts


3. src/server/codexAppServerBridge.ts ✨ Enhancement +114/-20

Thread materialization error handling and free-mode auth detection

• Added isThreadMaterializationPendingError() to detect transient first-turn materialization
 errors and map them to in-progress empty states instead of visible errors.
• Implemented mergeStreamTurnErrorsIntoThreadResult() to capture error messages from stream events
 and merge them into thread turn results for persistence.
• Updated free-mode state handling to use ensureDefaultFreeModeStateForMissingAuthSync() which
 returns runtime fallback without writing files.
• Added hasCodexAuth field to free-mode status response to signal when external Codex auth is
 available.
• Removed unused filesystem imports (mkdirSync, writeFileSync) related to free-mode state file
 writing.

src/server/codexAppServerBridge.ts


View more (21)
4. src/server/codexAppServerBridge.archive.test.ts 🧪 Tests +120/-1

Thread materialization and free-mode state sync tests

• Added tests for isThreadMaterializationPendingError() to validate detection of transient
 first-turn materialization failures.
• Added comprehensive test suite for ensureDefaultFreeModeStateForMissingAuthSync() covering
 runtime fallback creation, auth suppression of community providers, and preservation of
 user-configured providers.
• Updated existing error detection tests to validate direct message field extraction from Codex
 stream errors.

src/server/codexAppServerBridge.archive.test.ts


5. src/server/freeMode.ts ⚙️ Configuration changes +35/-18

Free-mode state file rename and Codex auth suppression logic

• Changed free-mode state filename from webui-free-mode.json to webui-custom-providers.json to
 reflect broader provider configuration scope.
• Added shouldSuppressCommunityFreeModeForCodexAuth() to suppress community fallback providers
 when usable Codex auth exists, while preserving user-configured providers.
• Updated runtime provider IDs to use underscores (opencode_zen, openrouter_free,
 custom_endpoint) for Codex app-server compatibility.
• Modified getFreeModeConfigArgs() to use runtime provider IDs and provider-config key variables
 for cleaner argument generation.

src/server/freeMode.ts


6. src/server/freeMode.test.ts 🧪 Tests +72/-9

Free-mode tests updated for auth suppression and runtime IDs

• Updated test descriptions to reflect runtime fallback behavior instead of permanent state
 creation.
• Added test for shouldSuppressCommunityFreeModeForCodexAuth() validating suppression of community
 providers while preserving custom-key and custom-endpoint configurations.
• Updated config args tests to use new runtime provider IDs and validate underscore-based provider
 naming.
• Added test for custom chat provider wire API override to responses for local Codex app-server
 compatibility.

src/server/freeMode.test.ts


7. src/api/codexGateway.ts ✨ Enhancement +32/-27

Provider model list fetching and exclusive model handling

• Refactored getAvailableModelIds() to extract provider model fetching into
 fetchProviderModelIds() helper function.
• Updated logic to respect exclusive flag from provider models endpoint, using provider models
 exclusively when available and required.
• Improved model deduplication and filtering to handle both exclusive and merged model lists
 correctly.
• Added exclusive field to ProviderModelsResponse type definition.

src/api/codexGateway.ts


8. src/api/codexGateway.test.ts 🧪 Tests +64/-1

Provider model list fetching tests

• Added test for exclusive provider models that skips model/list RPC when provider models are
 required and exclusive.
• Added test for fallback to model/list when provider models are optional and unavailable.
• Validates correct model list merging and deduplication behavior.

src/api/codexGateway.test.ts


9. src/api/normalizers/v2.test.ts 🧪 Tests +69/-0

Failed turn error message normalization tests

• Added tests for rendering failed turn errors as chat system messages with `messageType:
 'turnError'`.
• Added test for using turn index fallback IDs when turns have blank or whitespace-only IDs.

src/api/normalizers/v2.test.ts


10. src/api/normalizers/v2.ts ✨ Enhancement +19/-1

Failed turn error message normalization

• Added readTurnErrorText() helper to extract error messages from turn error objects.
• Updated normalizeThreadMessagesV2() to create system messages for failed turns with error text,
 using turn ID or index-based fallback IDs.

src/api/normalizers/v2.ts


11. src/App.vue ✨ Enhancement +54/-18

External auth detection and account import on provider promotion

• Replaced hasFeedbackDiagnostics with hasVisibleFeedbackError computed property to show
 feedback row only when current visible errors exist.
• Added externalCodexAuthAvailable and externalAuthImportAttempted flags to track external auth
 detection and import state.
• Added maybeImportExternalCodexAuthAccount() to import copied auth.json into accounts list when
 Codex auth becomes available.
• Added watcher on accountRateLimitSnapshots to trigger external auth import and provider refresh
 when auth appears.
• Updated loadFreeModeStatus() to detect hasCodexAuth and trigger provider/account refresh when
 auth state changes.
• Removed home navigation redirect from onProviderChange() to preserve thread URL during provider
 switches.

src/App.vue


12. src/components/content/ThreadConversation.vue ✨ Enhancement +24/-0

Persisted turn error feedback link rendering

• Added isTurnErrorMessage() helper to identify persisted turn error messages.
• Added prepareTurnErrorFeedback() to record visible turn errors and update feedback mailto link.
• Added feedback link UI element for turn error messages with styling matching live error feedback
 buttons.

src/components/content/ThreadConversation.vue


13. src/style.css Formatting +4/-0

Dark theme styling for turn error feedback

• Added dark theme styling for .turn-error-feedback class to match live error feedback button
 appearance.

src/style.css


14. tests.md 📝 Documentation +267/-5

Manual test documentation for provider/auth Docker flows

• Updated free-mode state filename references from webui-free-mode.json to
 webui-custom-providers.json.
• Added comprehensive manual test guidance for Docker auth startup, live-state pending reads,
 provider model loading, invalid auth error rendering, and provider switching chains.
• Added test scenarios for external auth promotion, provider-model selection metadata, routed thread
 retention, and provider-backed model list authority.

tests.md


15. whatToTest.md 📝 Documentation +194/-0

Docker provider validation task inventory

• Created new file documenting open Docker provider validation tasks with current evidence and repro
 steps.
• Tracked passing tests removed on 2026-05-13 for no-auth Zen fallback, OpenRouter dropdown scoping,
 and NIM custom provider fixes.
• Documented P0 and P1 priority test cases for provider switch chains, OpenRouter model selection,
 NIM custom provider, and Groq validation.
• Included pass criteria and validation evidence requirements for each test case.

whatToTest.md


16. llm-wiki/raw/fixes/provider-selection-drift-docker-cycle.md 📝 Documentation +113/-0

Docker provider cycle validation wiki entry

• Created new wiki document capturing packaged Docker provider validation cycle from 2026-05-13.
• Documented confirmed fixes for provider-tagged model storage and route stability during provider
 switches.
• Recorded passing evidence for no-auth Zen startup and auth-mounted Codex startup.
• Documented failing evidence for historical thread sends after provider switch, OpenRouter
 selection mismatch, and NIM custom provider dropdown issues.

llm-wiki/raw/fixes/provider-selection-drift-docker-cycle.md


17. llm-wiki/raw/fixes/copied-auth-provider-promotion.md 📝 Documentation +88/-0

Copied auth provider promotion fix wiki entry

• Created new wiki document detailing the copied auth provider promotion fix from 2026-05-13.
• Documented problem of stale community provider state persisting after Codex auth appears.
• Explained root cause and implementation details of the fix including
 shouldSuppressCommunityFreeModeForCodexAuth() and account import logic.
• Included Docker validation flow and final validation result JSON.

llm-wiki/raw/fixes/copied-auth-provider-promotion.md


18. llm-wiki/wiki/concepts/opencode-zen-big-pickle.md 📝 Documentation +47/-0

OpenCode Zen wiki expanded with Docker auth and provider selection

• Added notes about provider model authority in no-auth Docker mode and transient first-turn
 materialization errors.
• Added new section "Docker Auth and Model Loading" documenting unauthenticated and authenticated
 Docker states.
• Added "Copied Auth Promotion" section explaining behavior when auth appears after no-auth startup.
• Added "Provider Selection Drift Docker Cycle" section with validation results and known unresolved
 failures.
• Updated related links to reference new wiki documents.

llm-wiki/wiki/concepts/opencode-zen-big-pickle.md


19. llm-wiki/wiki/index.md 📝 Documentation +4/-1

Wiki index updated with new provider/auth documentation

• Updated opencode-zen-big-pickle.md description to include Docker auth switching and provider
 model loading topics.
• Added three new wiki document links for Docker auth/provider fixes and provider selection drift
 cycle.

llm-wiki/wiki/index.md


20. llm-wiki/raw/fixes/opencode-zen-docker-auth-provider-models.md 📝 Documentation +78/-0

OpenCode Zen Docker auth and provider models fix documentation

• New wiki documentation file capturing the fix for Docker auth and provider model drift issues
• Documents two edge cases: transient live-state errors during first-turn startup and stale model
 selector in unauthenticated Docker
• Details root causes, implementation fixes (isThreadMaterializationPendingError() and
 provider-model-first loading), and Docker validation steps
• Includes operational notes on Docker image building, verification commands, and browser validation
 requirements

llm-wiki/raw/fixes/opencode-zen-docker-auth-provider-models.md


21. AGENTS.md 📝 Documentation +23/-0

Docker provider and auth regression testing workflow guidelines

• Added comprehensive "Docker Provider/Auth Regression Workflow" section with detailed testing
 procedures
• Specifies four test cases: no auth file, invalid/expired auth, malformed auth, and provider switch
 scenarios
• Includes browser assertion guidelines, screenshot requirements, and pre-completion checklist items
• Mandates packaged Docker image testing instead of Vite dev server for affected changes

AGENTS.md


22. llm-wiki/wiki/log.md 📝 Documentation +18/-0

Wiki ingestion log entries for provider and auth fixes

• Added three new wiki ingestion log entries dated 2026-05-13
• Documents ingestion of "copied auth provider promotion", "OpenCode Zen Docker auth and provider
 models", and "provider selection drift Docker cycle" sources
• Each entry references updated wiki pages and summarizes key topics covered

llm-wiki/wiki/log.md


23. llm-wiki/wiki/entities/codex-web-local.md 📝 Documentation +4/-0

Entity page updates for OpenCode Zen Docker auth behavior

• Added note about unauthenticated Docker startup using OpenCode Zen fallback vs auth-mounted
 switching to Codex provider
• Added source links to OpenCode Zen Docker auth/provider models and provider selection drift Docker
 cycle fixes
• Added link to OpenCode Zen + Big Pickle concept page

llm-wiki/wiki/entities/codex-web-local.md


24. llm-wiki/wiki/overview.md 📝 Documentation +4/-0

Overview page updates for provider and auth documentation

• Added OpenCode Zen fallback, Docker auth switching, and provider-backed model loading to overview
 topics
• Added primary source links to OpenCode Zen Docker auth/provider models and provider selection
 drift Docker cycle fixes
• Added link to OpenCode Zen + Big Pickle concept page in linked pages section

llm-wiki/wiki/overview.md


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects Bot commented May 13, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0)

Grey Divider


Remediation recommended

1. Provider id alias breaks restore 🐞 Bug ≡ Correctness
Description
getFreeModeConfigArgs() now sets app-server model_provider to underscore ids (e.g.
opencode_zen, openrouter_free), while the UI’s provider-scoped model persistence keys are built
directly from config/read’s model_provider without canonicalization. This can orphan existing
persisted selections (stored under hyphen ids like opencode-zen / openrouter-free) and make
provider/model continuity depend on which alias is encountered.
Code

src/server/freeMode.ts[R236-291]

  if (state.provider === 'opencode-zen') {
    const model = state.model?.trim() || OPENCODE_ZEN_DEFAULT_MODEL
+    const providerConfigKey = `model_providers.${OPENCODE_ZEN_RUNTIME_PROVIDER_ID}`
    const baseUrl = serverPort
      ? `http://127.0.0.1:${serverPort}/codex-api/zen-proxy/v1`
      : OPENCODE_ZEN_BASE_URL
    const wireApi = serverPort ? 'responses' : (state.wireApi || 'chat')
    const authArgs: string[] = serverPort
-      ? ['-c', `model_providers.${OPENCODE_ZEN_PROVIDER_ID}.experimental_bearer_token="zen-proxy-token"`]
-      : ['-c', `model_providers.${OPENCODE_ZEN_PROVIDER_ID}.env_key="OPENCODE_ZEN_API_KEY"`]
+      ? ['-c', `${providerConfigKey}.experimental_bearer_token="zen-proxy-token"`]
+      : ['-c', `${providerConfigKey}.env_key="OPENCODE_ZEN_API_KEY"`]
    return [
      '-c', `model="${model}"`,
-      '-c', `model_provider="${OPENCODE_ZEN_PROVIDER_ID}"`,
-      '-c', `model_providers.${OPENCODE_ZEN_PROVIDER_ID}.name="OpenCode Zen"`,
-      '-c', `model_providers.${OPENCODE_ZEN_PROVIDER_ID}.base_url="${baseUrl}"`,
-      '-c', `model_providers.${OPENCODE_ZEN_PROVIDER_ID}.wire_api="${wireApi}"`,
+      '-c', `model_provider="${OPENCODE_ZEN_RUNTIME_PROVIDER_ID}"`,
+      '-c', `${providerConfigKey}.name="OpenCode Zen"`,
+      '-c', `${providerConfigKey}.base_url="${baseUrl}"`,
+      '-c', `${providerConfigKey}.wire_api="${wireApi}"`,
      ...authArgs,
    ]
  }

  if (state.provider === 'custom' && state.customBaseUrl) {
+    const providerConfigKey = `model_providers.${CUSTOM_RUNTIME_PROVIDER_ID}`
    const baseUrl = serverPort
      ? `http://127.0.0.1:${serverPort}/codex-api/custom-proxy/v1`
      : state.customBaseUrl
    const wireApi = serverPort ? 'responses' : (state.wireApi || 'responses')
    const authArgs: string[] = serverPort
-      ? ['-c', `model_providers.${CUSTOM_PROVIDER_ID}.experimental_bearer_token="custom-proxy-token"`]
-      : ['-c', `model_providers.${CUSTOM_PROVIDER_ID}.env_key="CUSTOM_ENDPOINT_API_KEY"`]
+      ? ['-c', `${providerConfigKey}.experimental_bearer_token="custom-proxy-token"`]
+      : ['-c', `${providerConfigKey}.env_key="CUSTOM_ENDPOINT_API_KEY"`]
    const modelArgs: string[] = state.model?.trim()
      ? ['-c', `model="${state.model.trim()}"`]
      : []
    return [
      ...modelArgs,
-      '-c', `model_provider="${CUSTOM_PROVIDER_ID}"`,
-      '-c', `model_providers.${CUSTOM_PROVIDER_ID}.name="Custom Endpoint"`,
-      '-c', `model_providers.${CUSTOM_PROVIDER_ID}.base_url="${baseUrl}"`,
-      '-c', `model_providers.${CUSTOM_PROVIDER_ID}.wire_api="${wireApi}"`,
+      '-c', `model_provider="${CUSTOM_RUNTIME_PROVIDER_ID}"`,
+      '-c', `${providerConfigKey}.name="Custom Endpoint"`,
+      '-c', `${providerConfigKey}.base_url="${baseUrl}"`,
+      '-c', `${providerConfigKey}.wire_api="${wireApi}"`,
      ...authArgs,
    ]
  }

  if (!state.apiKey) return []
+  const providerConfigKey = `model_providers.${FREE_MODE_RUNTIME_PROVIDER_ID}`
  const baseUrl = serverPort
    ? `http://127.0.0.1:${serverPort}/codex-api/openrouter-proxy/v1`
    : FREE_MODE_BASE_URL
  const bearerToken = serverPort ? 'openrouter-proxy-token' : state.apiKey
  return [
    '-c', `model="${state.model}"`,
-    '-c', `model_provider="${FREE_MODE_PROVIDER_ID}"`,
-    '-c', `model_providers.${FREE_MODE_PROVIDER_ID}.name="OpenRouter Free"`,
-    '-c', `model_providers.${FREE_MODE_PROVIDER_ID}.base_url="${baseUrl}"`,
-    '-c', `model_providers.${FREE_MODE_PROVIDER_ID}.wire_api="responses"`,
-    '-c', `model_providers.${FREE_MODE_PROVIDER_ID}.experimental_bearer_token="${bearerToken}"`,
+    '-c', `model_provider="${FREE_MODE_RUNTIME_PROVIDER_ID}"`,
+    '-c', `${providerConfigKey}.name="OpenRouter Free"`,
+    '-c', `${providerConfigKey}.base_url="${baseUrl}"`,
+    '-c', `${providerConfigKey}.wire_api="responses"`,
+    '-c', `${providerConfigKey}.experimental_bearer_token="${bearerToken}"`,
  ]
Evidence
The server now emits underscore ids via app-server config (model_provider="opencode_zen" /
"openrouter_free"), while the frontend derives persistence keys by lowercasing the provider id
verbatim (no underscore/hyphen canonicalization). The existing tests show the persisted keys use
hyphenated provider ids, demonstrating the expected canonical form that would be missed if
config/read starts returning underscore ids.

src/server/freeMode.ts[94-97]
src/server/freeMode.ts[153-157]
src/server/freeMode.ts[233-291]
src/api/codexGateway.ts[1942-1949]
src/composables/useDesktopState.ts[219-232]
src/composables/useDesktopState.test.ts[581-585]
src/composables/useDesktopState.test.ts[626-655]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`model_provider` is configured with underscore-safe ids (`opencode_zen`, `openrouter_free`, `custom_endpoint`) but the frontend persists selections keyed by the provider id it reads from `config/read`. Without a canonical mapping, older persisted selections keyed under hyphenated ids won’t be read after this change.

## Issue Context
- Backend config args now use underscore runtime ids.
- Frontend uses `config/read` provider id to build `__new-thread-provider__::<provider>` keys.
- Tests and prior persisted state use hyphenated ids (e.g. `openrouter-free`, `opencode-zen`).

## Fix Focus Areas
- src/composables/useDesktopState.ts[219-232]
- src/server/freeMode.ts[233-291]

### Implementation guidance
- Add a canonicalization step in `normalizeProviderContextId()` (or immediately after `getCurrentModelConfig()`), mapping:
 - `opencode_zen` -> `opencode-zen`
 - `openrouter_free` -> `openrouter-free`
 - `custom_endpoint` -> `custom-endpoint` (or whichever canonical id the UI intends)
- Keep storing `{ providerId, modelId }` using the canonical provider id so existing localStorage keys remain valid.
- Add/adjust unit tests to cover the underscore->hyphen normalization path using a mocked `providerId: 'opencode_zen'` / `'openrouter_free'` and asserting the persisted key uses the canonical form.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Auth import is single-shot 🐞 Bug ☼ Reliability
Description
maybeImportExternalCodexAuthAccount() sets externalAuthImportAttempted = true before attempting
refreshAccountsFromAuth() and never resets it when the import fails or leaves accounts empty. A
transient failure can therefore permanently prevent auto-import for the remainder of the session,
leaving Accounts stuck at 0 despite hasCodexAuth=true.
Code

src/App.vue[R2116-2131]

+async function maybeImportExternalCodexAuthAccount(): Promise<boolean> {
+  if (!externalCodexAuthAvailable) return false
+  if (externalAuthImportAttempted) return false
+  if (selectedProvider.value !== 'codex') return false
+  if (accounts.value.length > 0) return false
+  if (accountRateLimitSnapshots.value.length === 0) return false
+  externalAuthImportAttempted = true
+  const previousAccountsJson = JSON.stringify(accounts.value.map((account) => account.accountId).sort())
+  try {
+    const result = await refreshAccountsFromAuth()
+    accounts.value = result.accounts
+  } catch {
+    await loadAccountsState({ silent: true })
+  }
+  const nextAccountsJson = JSON.stringify(accounts.value.map((account) => account.accountId).sort())
+  return previousAccountsJson !== nextAccountsJson
Evidence
The import guard is set unconditionally at the start of maybeImportExternalCodexAuthAccount(). The
only reset path shown is when hasCodexAuth becomes false; there is no reset on failure/no-op, so
later watcher executions will early-return and never retry within the same auth-present session.

src/App.vue[2105-2132]
src/App.vue[4069-4072]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The copied-auth account import is guarded by `externalAuthImportAttempted`. The guard is set to true before the network/file import completes, and it is only reset when `hasCodexAuth` becomes false. If the import fails transiently (fetch error, server hiccup), the app won’t retry and Accounts can remain empty.

## Issue Context
This is triggered when:
- `status.hasCodexAuth === true`
- provider is `codex`
- `accounts.length === 0`
- `accountRateLimitSnapshots.length > 0`

## Fix Focus Areas
- src/App.vue[2105-2132]
- src/App.vue[4069-4088]

### Implementation guidance
- Move `externalAuthImportAttempted = true` to after a successful import (or after a confirmed state change), OR
- Reset `externalAuthImportAttempted = false` when the attempt results in no accounts and no change (and optionally add a backoff timestamp to avoid tight retry loops).
- Consider distinguishing between “attempted and succeeded” vs “attempted and failed” so the watcher can retry on the next `accountRateLimitSnapshots` refresh.
- Add a focused unit/integration test (if available for App.vue logic) that simulates a throw in `refreshAccountsFromAuth()` and asserts a later snapshot change retries.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

3. Optional models fetch serialized 🐞 Bug ➹ Performance
Description
When provider models are optional, getAvailableModelIds() still awaits
/codex-api/provider-models (5s timeout) before calling model/list, which can add up to 5 seconds
of latency to the model dropdown if provider-model discovery is slow/unavailable. This is a
performance regression for the optional-path even though correctness is preserved via fallback.
Code

src/api/codexGateway.ts[R1891-1934]

+async function fetchProviderModelIds(): Promise<{ ids: string[], exclusive: boolean } | null> {
  try {
    const response = await fetch('/codex-api/provider-models', {
      signal: AbortSignal.timeout(PROVIDER_MODELS_FETCH_TIMEOUT_MS),
    })
-    let providerPayload: (ProviderModelsResponse & { exclusive?: boolean }) | null = null
+    let providerPayload: ProviderModelsResponse | null = null
    try {
-      providerPayload = await response.json() as ProviderModelsResponse & { exclusive?: boolean }
+      providerPayload = await response.json() as ProviderModelsResponse
    } catch {
      providerPayload = null
    }

    if (response.ok && Array.isArray(providerPayload?.data)) {
-      sawProviderModels = true
-      if (providerPayload.exclusive) {
-        return providerPayload.data.filter((c): c is string => typeof c === 'string' && c.trim().length > 0)
-      }
-      for (const candidate of providerPayload.data) {
-        if (typeof candidate !== 'string') continue
-        const normalized = candidate.trim()
-        if (!normalized || ids.includes(normalized)) continue
-        ids.push(normalized)
+      return {
+        ids: providerPayload.data
+          .map((candidate) => typeof candidate === 'string' ? candidate.trim() : '')
+          .filter((candidate, index, candidates): candidate is string =>
+            candidate.length > 0 && candidates.indexOf(candidate) === index),
+        exclusive: providerPayload.exclusive === true,
      }
    }
  } catch {
    // Keep Codex usable when the provider-models endpoint is unavailable.
  }
+  return null
+}
+
+export async function getAvailableModelIds(options: { includeProviderModels?: boolean; requireProviderModels?: boolean } = {}): Promise<string[]> {
+  const shouldIncludeProviderModels = options.includeProviderModels !== false
+  const providerModels = shouldIncludeProviderModels ? await fetchProviderModelIds() : null
+
+  if (providerModels?.exclusive || options.requireProviderModels) {
+    return providerModels?.ids ?? []
+  }

-  if (options.requireProviderModels && !sawProviderModels) {
-    return []
+  const payload = await callRpc<ModelListResponse>('model/list', {})
+  const ids: string[] = []
+  for (const row of payload.data) {
+    const candidate = row.id || row.model
+    if (!candidate || ids.includes(candidate)) continue
+    ids.push(candidate)
  }

+  if (!shouldIncludeProviderModels || !providerModels) return ids
Evidence
The code defines a 5-second timeout for provider-model discovery and awaits that request before
starting model/list, so slow provider-model resolution delays the first usable model list even
when fallback to model/list would otherwise be immediate.

src/api/codexGateway.ts[249-255]
src/api/codexGateway.ts[1891-1934]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`getAvailableModelIds()` blocks on provider-model discovery before it calls `model/list` even when provider models are optional, so the user can wait up to `PROVIDER_MODELS_FETCH_TIMEOUT_MS` before seeing any models.

## Issue Context
This ordering is important for no-auth/provider-backed modes, but for the optional path (Codex provider) we can keep the priority behavior while avoiding serial latency.

## Fix Focus Areas
- src/api/codexGateway.ts[1891-1939]

### Implementation guidance
- If `requireProviderModels` is false, kick off `fetchProviderModelIds()` and `callRpc('model/list')` in parallel (e.g. `Promise.allSettled`) and merge results when both resolve.
- Preserve the current behavior for `exclusive === true` / `requireProviderModels === true` (provider models authoritative).
- Optionally reduce the timeout when provider models are optional (or treat timeout as soft failure and proceed with `model/list` immediately).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

📝 Walkthrough

Walkthrough

This PR implements Docker provider/auth handling including provider-scoped model selection, turn error rendering, free-mode state refactoring, and external Codex auth import. Changes span provider-backed model loading with fallbacks, synthetic error messages in chat, server-side free-mode state cleanup, localStorage migration to structured selections, and app-level external auth account synchronization.

Changes

Docker Provider/Auth & Provider-Scoped Model Selection

Layer / File(s) Summary
Provider-backed model loading with fallback
src/api/codexGateway.ts, src/api/codexGateway.test.ts
ProviderModelsResponse gains an optional exclusive field; getAvailableModelIds() now prioritizes provider models when exclusive is set or requireProviderModels is enabled, otherwise falls back to model/list and merges provider IDs; tests verify both resolution paths.
Turn error detection and feedback rendering
src/api/normalizers/v2.ts, src/api/normalizers/v2.test.ts, src/components/content/ThreadConversation.vue, src/style.css
Failed turns with error messages generate synthetic turnError system messages; UI renders these as visible "Send feedback" links that record failures and populate feedback mailto; tests verify error text extraction and ID fallbacks for blank turn IDs.
Server-side free mode and thread error handling
src/server/codexAppServerBridge.ts, src/server/codexAppServerBridge.archive.test.ts, src/server/freeMode.ts, src/server/freeMode.test.ts
Export ensureDefaultFreeModeStateForMissingAuthSync as a pure state-decision function; detect "not materialized yet" thread errors; merge streaming turn errors into thread results; rename state file to webui-custom-providers.json; use runtime provider IDs in config; add hasCodexAuth to status; suppress community fallback when Codex auth available; extensive tests for state logic and config generation.
Provider-scoped model selection storage and compatibility
src/composables/useDesktopState.ts, src/composables/useDesktopState.test.ts
Store selected models as {providerId, modelId} objects; validate compatibility when switching providers; suppress live error overlays when same error persists; update in-memory and localStorage maps across all contexts; tests verify object shape, legacy migration, provider-scoped selection, and overlay deduplication.
App-level external auth import and provider handling
src/App.vue
Track external Codex auth availability; one-time import of external accounts via accountRateLimitSnapshots watcher; conditional refresh based on provider/account changes; change feedback visibility to hasVisibleFeedbackError from visible errors; remove automatic provider-switch navigation; remove sidebar-existence check from thread sync.
Documentation and test scenarios
AGENTS.md, llm-wiki/raw/fixes/*.md, llm-wiki/wiki/**/*.md, tests.md, whatToTest.md, src/style.css
Add Docker regression workflow; document copied auth promotion, OpenCode Zen edge cases, provider selection drift; expand wiki concepts with Docker auth/model details; add P0/P1 test plans covering first-turn pending, provider-backed models, invalid auth, provider switching, model selection guards, thread retention, and model authority.

Sequence Diagram(s)

sequenceDiagram
  participant UI as UI Layer
  participant App as App.vue
  participant Gateway as codexGateway
  participant Provider as Provider Models Endpoint
  participant Codex as Codex Server
  participant Accounts as Accounts Import
  
  UI->>App: user detected external auth
  App->>App: track externalCodexAuthAvailable
  App->>Accounts: maybeImportExternalCodexAuthAccount()
  Accounts->>Accounts: refreshAccountsFromAuth()
  Accounts->>App: conditionally refreshAll()
  
  Gateway->>Provider: fetchProviderModelIds()
  alt exclusive or requireProviderModels
    Provider-->>Gateway: exclusive: true, ids: [...]
    Gateway-->>UI: return provider IDs only
  else
    Provider-->>Gateway: 404/error
    Gateway->>Codex: model/list RPC
    Codex-->>Gateway: [modelIds]
    Gateway-->>UI: return merged IDs
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A tale of models and providers true,
Where Docker dances in a Docker-y brew,
With Zen as fallback when auth's not in sight,
And errors now render with feedback insight,
Provider-scoped selections keep choices in flight! 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: fixing provider/model selection persistence across Docker auth scenarios and free-mode fallback, which aligns with the core theme of all file changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/provider-model-selection-drift

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/codexAppServerBridge.ts (1)

6361-6408: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle pending materialization in thread-turn-page too.

This route still converts the same thread/read condition into a 500 that Lines 6517-6526 now normalize to an in-progress empty thread. A newly created thread can therefore recover in /codex-api/thread-live-state but still fail when the UI asks this endpoint for a turn page.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/codexAppServerBridge.ts` around lines 6361 - 6408, The
thread-turn-page handler currently treats a pending/materializing thread the
same as an invalid response and returns a 500; update the logic (after calling
mergeStreamTurnErrorsIntoThreadResult and computing record and thread using
asRecord) to detect the same "in-progress empty thread" condition handled
elsewhere (i.e. record && thread exist but thread.turns is empty because
materialization is pending) and return a 200 payload matching the normalized
in-progress response (result with the same record but thread.turns: [],
startTurnIndex: 0, hasMoreOlder: false) instead of proceeding to slice turns
and/or raising a 500; adjust before the slicing code that computes
turns/beforeIndex/startIndex so this early-return mirrors the behavior
implemented for live-state recovery and avoids failing
sanitizeThreadTurnsInlinePayloads or mergeSessionSkillInputsIntoThreadResult.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/api/codexGateway.ts`:
- Around line 1919-1923: The current logic skips calling fetchProviderModelIds
when options.includeProviderModels is false, which causes requireProviderModels
to be ignored; change the condition so that fetchProviderModelIds is invoked if
options.requireProviderModels is true OR options.includeProviderModels !== false
(e.g., compute a flag like shouldFetchProviderModels =
options.requireProviderModels || options.includeProviderModels !== false), use
that flag instead of shouldIncludeProviderModels when calling
fetchProviderModelIds, and keep the subsequent check of
providerModels?.exclusive or options.requireProviderModels to return
providerModels?.ids ?? [] as before; update references to
shouldIncludeProviderModels, fetchProviderModelIds,
options.includeProviderModels, and options.requireProviderModels accordingly.

In `@src/App.vue`:
- Around line 2116-2132: The function maybeImportExternalCodexAuthAccount sets
externalAuthImportAttempted too early, preventing retries after a failed
refresh; change the logic so externalAuthImportAttempted is only set when an
import actually succeeds (i.e., after refreshAccountsFromAuth resolves and
accounts are updated) or ensure the flag is cleared/reset inside the catch path
(where loadAccountsState is called) so subsequent calls can retry; update the
control flow around externalAuthImportAttempted in
maybeImportExternalCodexAuthAccount and keep
refreshAccountsFromAuth/loadAccountsState handling intact.

In `@src/composables/useDesktopState.ts`:
- Around line 1548-1562: The suppression logic currently hides live error
overlays when the persisted turnError text matches liveErrorText; change this to
suppress only when the persisted turnError belongs to the same turn by using the
turnId in TurnErrorState. In the block that scans persistedMessagesByThreadId
(used by persistedMessages and latestPersistedTurnErrorText), also read the live
turnId from turnErrorByThreadId.value[threadId]?.turnId and when iterating
messages only consider messages with messageType === 'turnError' and
message.turnId === liveTurnId before setting latestPersistedTurnErrorText;
finally, change the final errorText condition to compare persisted turnId match
(or the fact that latestPersistedTurnErrorText was set from the same turn)
rather than comparing message text so only errors from the same turn suppress
the live overlay.
- Around line 1611-1620: readCompatibleStoredModelId currently accepts a stored
{providerId, modelId} when the provider matches even if the modelId is not in
availableModelIds, allowing reuse of stale, unavailable models; update
readCompatibleStoredModelId (and the checks around normalizeStoredModelId,
normalizeStoredModelProviderId, normalizeProviderContextId) so that when a
storedProviderId exists you return normalizedModelId only if storedProviderId
=== currentProviderId AND availableModelIds.value includes(normalizedModelId);
otherwise return '' (keep the existing fallback behaviour when no
storedProviderId).

In `@src/server/codexAppServerBridge.ts`:
- Around line 3352-3363: ensureDefaultFreeModeStateForMissingAuthSync currently
relies on hasUsableCodexAuthSync which only treats access_token as usable;
mirror the async auth logic so refresh-token-only auth is recognized. Update
hasUsableCodexAuthSync (or add a sync helper used by
ensureDefaultFreeModeStateForMissingAuthSync) to implement the same checks as
the async path that sets hasCodexAuth (i.e., treat presence of a valid refresh
token as usable auth), then use that value in
shouldSuppressCommunityFreeModeForCodexAuth and
shouldCreateDefaultFreeModeStateForMissingAuth so the sync path reports
hasCodexAuth=true and avoids recreating community free-mode when only a refresh
token exists.

In `@src/server/freeMode.ts`:
- Line 151: The change to FREE_MODE_STATE_FILE needs a backward-compatibility
fallback: add a LEGACY_FREE_MODE_STATE_FILE constant (value
"webui-free-mode.json") and update the state-load logic (the server bridge /
state loader that reads FREE_MODE_STATE_FILE) to first attempt reading
FREE_MODE_STATE_FILE, and if it does not exist, attempt to read
LEGACY_FREE_MODE_STATE_FILE; if the legacy file is successfully loaded, persist
that data back to FREE_MODE_STATE_FILE so upgraded users keep their
provider/model state. Ensure references to FREE_MODE_STATE_FILE remain for
future reads/writes and only use the legacy constant as a fallback/migration
step.

---

Outside diff comments:
In `@src/server/codexAppServerBridge.ts`:
- Around line 6361-6408: The thread-turn-page handler currently treats a
pending/materializing thread the same as an invalid response and returns a 500;
update the logic (after calling mergeStreamTurnErrorsIntoThreadResult and
computing record and thread using asRecord) to detect the same "in-progress
empty thread" condition handled elsewhere (i.e. record && thread exist but
thread.turns is empty because materialization is pending) and return a 200
payload matching the normalized in-progress response (result with the same
record but thread.turns: [], startTurnIndex: 0, hasMoreOlder: false) instead of
proceeding to slice turns and/or raising a 500; adjust before the slicing code
that computes turns/beforeIndex/startIndex so this early-return mirrors the
behavior implemented for live-state recovery and avoids failing
sanitizeThreadTurnsInlinePayloads or mergeSessionSkillInputsIntoThreadResult.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5195ce66-6c27-4da6-ae65-07c433e13567

📥 Commits

Reviewing files that changed from the base of the PR and between 1c9dacd and 30033ed.

📒 Files selected for processing (24)
  • AGENTS.md
  • llm-wiki/raw/fixes/copied-auth-provider-promotion.md
  • llm-wiki/raw/fixes/opencode-zen-docker-auth-provider-models.md
  • llm-wiki/raw/fixes/provider-selection-drift-docker-cycle.md
  • llm-wiki/wiki/concepts/opencode-zen-big-pickle.md
  • llm-wiki/wiki/entities/codex-web-local.md
  • llm-wiki/wiki/index.md
  • llm-wiki/wiki/log.md
  • llm-wiki/wiki/overview.md
  • src/App.vue
  • src/api/codexGateway.test.ts
  • src/api/codexGateway.ts
  • src/api/normalizers/v2.test.ts
  • src/api/normalizers/v2.ts
  • src/components/content/ThreadConversation.vue
  • src/composables/useDesktopState.test.ts
  • src/composables/useDesktopState.ts
  • src/server/codexAppServerBridge.archive.test.ts
  • src/server/codexAppServerBridge.ts
  • src/server/freeMode.test.ts
  • src/server/freeMode.ts
  • src/style.css
  • tests.md
  • whatToTest.md

Comment thread src/api/codexGateway.ts
Comment on lines +1919 to +1923
const shouldIncludeProviderModels = options.includeProviderModels !== false
const providerModels = shouldIncludeProviderModels ? await fetchProviderModelIds() : null

if (providerModels?.exclusive || options.requireProviderModels) {
return providerModels?.ids ?? []
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Honor requireProviderModels even when includeProviderModels is false.

At Line 1919, includeProviderModels: false prevents fetching provider models entirely. If requireProviderModels: true is also set, Line 1922 returns [] without querying /codex-api/provider-models.

💡 Proposed fix
-export async function getAvailableModelIds(options: { includeProviderModels?: boolean; requireProviderModels?: boolean } = {}): Promise<string[]> {
-  const shouldIncludeProviderModels = options.includeProviderModels !== false
+export async function getAvailableModelIds(options: { includeProviderModels?: boolean; requireProviderModels?: boolean } = {}): Promise<string[]> {
+  const shouldIncludeProviderModels =
+    options.requireProviderModels === true || options.includeProviderModels !== false
   const providerModels = shouldIncludeProviderModels ? await fetchProviderModelIds() : null
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const shouldIncludeProviderModels = options.includeProviderModels !== false
const providerModels = shouldIncludeProviderModels ? await fetchProviderModelIds() : null
if (providerModels?.exclusive || options.requireProviderModels) {
return providerModels?.ids ?? []
const shouldIncludeProviderModels =
options.requireProviderModels === true || options.includeProviderModels !== false
const providerModels = shouldIncludeProviderModels ? await fetchProviderModelIds() : null
if (providerModels?.exclusive || options.requireProviderModels) {
return providerModels?.ids ?? []
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/codexGateway.ts` around lines 1919 - 1923, The current logic skips
calling fetchProviderModelIds when options.includeProviderModels is false, which
causes requireProviderModels to be ignored; change the condition so that
fetchProviderModelIds is invoked if options.requireProviderModels is true OR
options.includeProviderModels !== false (e.g., compute a flag like
shouldFetchProviderModels = options.requireProviderModels ||
options.includeProviderModels !== false), use that flag instead of
shouldIncludeProviderModels when calling fetchProviderModelIds, and keep the
subsequent check of providerModels?.exclusive or options.requireProviderModels
to return providerModels?.ids ?? [] as before; update references to
shouldIncludeProviderModels, fetchProviderModelIds,
options.includeProviderModels, and options.requireProviderModels accordingly.

Comment thread src/App.vue
Comment on lines +2116 to +2132
async function maybeImportExternalCodexAuthAccount(): Promise<boolean> {
if (!externalCodexAuthAvailable) return false
if (externalAuthImportAttempted) return false
if (selectedProvider.value !== 'codex') return false
if (accounts.value.length > 0) return false
if (accountRateLimitSnapshots.value.length === 0) return false
externalAuthImportAttempted = true
const previousAccountsJson = JSON.stringify(accounts.value.map((account) => account.accountId).sort())
try {
const result = await refreshAccountsFromAuth()
accounts.value = result.accounts
} catch {
await loadAccountsState({ silent: true })
}
const nextAccountsJson = JSON.stringify(accounts.value.map((account) => account.accountId).sort())
return previousAccountsJson !== nextAccountsJson
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Retry lockout after failed external auth import

Line 2122 sets externalAuthImportAttempted before a successful import. If the first import attempt fails, later snapshot updates cannot retry, so account sync can stay stuck empty.

Suggested fix
+let externalAuthImportInFlight = false
+
 async function maybeImportExternalCodexAuthAccount(): Promise<boolean> {
   if (!externalCodexAuthAvailable) return false
   if (externalAuthImportAttempted) return false
+  if (externalAuthImportInFlight) return false
   if (selectedProvider.value !== 'codex') return false
   if (accounts.value.length > 0) return false
   if (accountRateLimitSnapshots.value.length === 0) return false
-  externalAuthImportAttempted = true
+  externalAuthImportInFlight = true
   const previousAccountsJson = JSON.stringify(accounts.value.map((account) => account.accountId).sort())
   try {
     const result = await refreshAccountsFromAuth()
     accounts.value = result.accounts
   } catch {
     await loadAccountsState({ silent: true })
+  } finally {
+    externalAuthImportInFlight = false
   }
   const nextAccountsJson = JSON.stringify(accounts.value.map((account) => account.accountId).sort())
-  return previousAccountsJson !== nextAccountsJson
+  const imported = previousAccountsJson !== nextAccountsJson
+  if (imported) externalAuthImportAttempted = true
+  return imported
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/App.vue` around lines 2116 - 2132, The function
maybeImportExternalCodexAuthAccount sets externalAuthImportAttempted too early,
preventing retries after a failed refresh; change the logic so
externalAuthImportAttempted is only set when an import actually succeeds (i.e.,
after refreshAccountsFromAuth resolves and accounts are updated) or ensure the
flag is cleared/reset inside the catch path (where loadAccountsState is called)
so subsequent calls can retry; update the control flow around
externalAuthImportAttempted in maybeImportExternalCodexAuthAccount and keep
refreshAccountsFromAuth/loadAccountsState handling intact.

Comment on lines +1548 to +1562
const liveErrorText = (turnErrorByThreadId.value[threadId]?.message ?? '').trim()
let latestPersistedTurnErrorText = ''
if (!isInProgress && liveErrorText) {
const persistedMessages = persistedMessagesByThreadId.value[threadId] ?? []
for (let index = persistedMessages.length - 1; index >= 0; index -= 1) {
const message = persistedMessages[index]
if (message.messageType !== 'turnError') continue
latestPersistedTurnErrorText = normalizeMessageText(message.text)
break
}
}
const errorText =
!isInProgress && liveErrorText && latestPersistedTurnErrorText === liveErrorText
? ''
: liveErrorText
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Suppress duplicate error overlays by turn, not by message text.

This now hides the live overlay whenever the latest persisted turnError text matches the current live error. Repeating a common failure like a 401/auth error on a later turn will therefore hide the fresh failure until message sync catches up. Track the failing turnId in TurnErrorState and only suppress when the persisted error belongs to that same turn.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/useDesktopState.ts` around lines 1548 - 1562, The suppression
logic currently hides live error overlays when the persisted turnError text
matches liveErrorText; change this to suppress only when the persisted turnError
belongs to the same turn by using the turnId in TurnErrorState. In the block
that scans persistedMessagesByThreadId (used by persistedMessages and
latestPersistedTurnErrorText), also read the live turnId from
turnErrorByThreadId.value[threadId]?.turnId and when iterating messages only
consider messages with messageType === 'turnError' and message.turnId ===
liveTurnId before setting latestPersistedTurnErrorText; finally, change the
final errorText condition to compare persisted turnId match (or the fact that
latestPersistedTurnErrorText was set from the same turn) rather than comparing
message text so only errors from the same turn suppress the live overlay.

Comment on lines +1611 to +1620
function readCompatibleStoredModelId(value: StoredModelSelection | undefined): string {
const normalizedModelId = normalizeStoredModelId(value)
if (!normalizedModelId) return ''
const storedProviderId = normalizeStoredModelProviderId(value)
const currentProviderId = normalizeProviderContextId(activeProviderId.value)
if (storedProviderId) {
return storedProviderId === currentProviderId ? normalizedModelId : ''
}
return availableModelIds.value.includes(normalizedModelId) ? normalizedModelId : ''
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject provider-scoped models that are no longer in the active provider list.

readCompatibleStoredModelId() accepts any structured { providerId, modelId } whose provider matches, even when that modelId is absent from availableModelIds. That lets readModelIdForThread() reuse a stale model until refreshModelPreferences() happens to repair it, so a send can still go out with a model the active provider no longer offers.

Suggested fix
 function readCompatibleStoredModelId(value: StoredModelSelection | undefined): string {
   const normalizedModelId = normalizeStoredModelId(value)
   if (!normalizedModelId) return ''
   const storedProviderId = normalizeStoredModelProviderId(value)
   const currentProviderId = normalizeProviderContextId(activeProviderId.value)
+  const isAvailable = availableModelIds.value.includes(normalizedModelId)
   if (storedProviderId) {
-    return storedProviderId === currentProviderId ? normalizedModelId : ''
+    return storedProviderId === currentProviderId && isAvailable ? normalizedModelId : ''
   }
-  return availableModelIds.value.includes(normalizedModelId) ? normalizedModelId : ''
+  return isAvailable ? normalizedModelId : ''
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function readCompatibleStoredModelId(value: StoredModelSelection | undefined): string {
const normalizedModelId = normalizeStoredModelId(value)
if (!normalizedModelId) return ''
const storedProviderId = normalizeStoredModelProviderId(value)
const currentProviderId = normalizeProviderContextId(activeProviderId.value)
if (storedProviderId) {
return storedProviderId === currentProviderId ? normalizedModelId : ''
}
return availableModelIds.value.includes(normalizedModelId) ? normalizedModelId : ''
}
function readCompatibleStoredModelId(value: StoredModelSelection | undefined): string {
const normalizedModelId = normalizeStoredModelId(value)
if (!normalizedModelId) return ''
const storedProviderId = normalizeStoredModelProviderId(value)
const currentProviderId = normalizeProviderContextId(activeProviderId.value)
const isAvailable = availableModelIds.value.includes(normalizedModelId)
if (storedProviderId) {
return storedProviderId === currentProviderId && isAvailable ? normalizedModelId : ''
}
return isAvailable ? normalizedModelId : ''
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/useDesktopState.ts` around lines 1611 - 1620,
readCompatibleStoredModelId currently accepts a stored {providerId, modelId}
when the provider matches even if the modelId is not in availableModelIds,
allowing reuse of stale, unavailable models; update readCompatibleStoredModelId
(and the checks around normalizeStoredModelId, normalizeStoredModelProviderId,
normalizeProviderContextId) so that when a storedProviderId exists you return
normalizedModelId only if storedProviderId === currentProviderId AND
availableModelIds.value includes(normalizedModelId); otherwise return '' (keep
the existing fallback behaviour when no storedProviderId).

Comment on lines +3352 to 3363
export function ensureDefaultFreeModeStateForMissingAuthSync(statePath: string): FreeModeState | null {
const current = readFreeModeStateSync(statePath)
if (!shouldCreateDefaultFreeModeStateForMissingAuth(current, hasUsableCodexAuthSync())) {
const hasUsableCodexAuth = hasUsableCodexAuthSync()
if (shouldSuppressCommunityFreeModeForCodexAuth(current, hasUsableCodexAuth)) {
return null
}
if (!shouldCreateDefaultFreeModeStateForMissingAuth(current, hasUsableCodexAuth)) {
return current
}

const fallback = createDefaultOpenCodeZenFreeModeState()

mkdirSync(dirname(statePath), { recursive: true })
writeFileSync(statePath, JSON.stringify(fallback), { encoding: 'utf8', mode: 0o600 })
return fallback
return createDefaultOpenCodeZenFreeModeState()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mirror the async auth check here.

ensureDefaultFreeModeStateForMissingAuthSync() now drives both free-mode suppression and the new hasCodexAuth status field, but the sync path only treats access_token as usable auth. If auth.json only has a refresh token, this will still recreate/keep community free-mode state and report hasCodexAuth: false, which reintroduces the provider/model drift this PR is trying to eliminate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/codexAppServerBridge.ts` around lines 3352 - 3363,
ensureDefaultFreeModeStateForMissingAuthSync currently relies on
hasUsableCodexAuthSync which only treats access_token as usable; mirror the
async auth logic so refresh-token-only auth is recognized. Update
hasUsableCodexAuthSync (or add a sync helper used by
ensureDefaultFreeModeStateForMissingAuthSync) to implement the same checks as
the async path that sets hasCodexAuth (i.e., treat presence of a valid refresh
token as usable auth), then use that value in
shouldSuppressCommunityFreeModeForCodexAuth and
shouldCreateDefaultFreeModeStateForMissingAuth so the sync path reports
hasCodexAuth=true and avoids recreating community free-mode when only a refresh
token exists.

Comment thread src/server/freeMode.ts
export const FREE_MODE_DEFAULT_MODEL = 'openrouter/free'

export const FREE_MODE_STATE_FILE = 'webui-free-mode.json'
export const FREE_MODE_STATE_FILE = 'webui-custom-providers.json'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a legacy-state fallback before switching the persisted filename.

Changing FREE_MODE_STATE_FILE to a new filename without a read-fallback/migration can silently reset existing persisted provider/model state for upgraded users. Please keep backward compatibility by reading webui-free-mode.json when the new file is absent (and optionally writing back to the new file).

Suggested compatibility approach
-export const FREE_MODE_STATE_FILE = 'webui-custom-providers.json'
+export const FREE_MODE_STATE_FILE = 'webui-custom-providers.json'
+export const LEGACY_FREE_MODE_STATE_FILE = 'webui-free-mode.json'
// in the state-load path (server bridge/state loader):
// 1) try FREE_MODE_STATE_FILE
// 2) if missing, try LEGACY_FREE_MODE_STATE_FILE
// 3) if legacy is loaded successfully, persist to FREE_MODE_STATE_FILE
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/freeMode.ts` at line 151, The change to FREE_MODE_STATE_FILE needs
a backward-compatibility fallback: add a LEGACY_FREE_MODE_STATE_FILE constant
(value "webui-free-mode.json") and update the state-load logic (the server
bridge / state loader that reads FREE_MODE_STATE_FILE) to first attempt reading
FREE_MODE_STATE_FILE, and if it does not exist, attempt to read
LEGACY_FREE_MODE_STATE_FILE; if the legacy file is successfully loaded, persist
that data back to FREE_MODE_STATE_FILE so upgraded users keep their
provider/model state. Ensure references to FREE_MODE_STATE_FILE remain for
future reads/writes and only use the legacy constant as a fallback/migration
step.

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.

2 participants