diff --git a/AGENTS.md b/AGENTS.md index d9846a917..8e16cba19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,6 +188,29 @@ - For UI work, include dark-theme evidence in addition to the default/light-theme evidence unless the task is explicitly light-only. - For refresh-persistence fixes, include a post-refresh screenshot that still shows the expected UI state. +## Docker Provider/Auth Regression Workflow + +- Use this workflow when a change touches Docker startup, Codex auth detection, OpenCode Zen/OpenRouter/custom providers, provider model loading, app-server config, chat send/reply handling, or failed-turn error rendering. +- Build and test a packaged Docker image, not only the Vite dev server: + 1. Run `pnpm run build`. + 2. Run `pnpm pack --pack-destination /tmp`. + 3. Build a local image that installs the packed `codexapp` tarball plus `@openai/codex`, with `CODEX_HOME=/codex-home` and command `codexapp --port ${PORT:-4190} --no-password --no-open --no-tunnel --no-login`. + 4. Use OrbStack/Docker CLI. Do not rely on Docker Desktop. +- Start fresh isolated containers on unique localhost ports for at least these cases: + - no auth file: no `/codex-home/auth.json`; expect runtime OpenCode Zen fallback, `model_provider="opencode-zen"`, `model="big-pickle"`, send `hi`, wait for an assistant reply. + - invalid/expired auth file: mount an `auth.json` with token fields containing invalid/expired strings; expect Codex provider path, send `hi`, wait for final 401/auth error rendered in chat, verify `Send feedback`, reload the thread, verify the error persists, and verify no duplicate live `Thinking` overlay remains after persistence. + - malformed auth file: mount invalid JSON as `/codex-home/auth.json`; expect it to be treated as unusable auth and fall back to Zen, then send `hi` and wait for a reply. + - provider switch: start from OpenCode Zen, send `hi` and wait for a reply, switch the Provider settings selector to OpenRouter (do not change the model dropdown directly), send `hi` again and wait for a reply. +- Browser assertions must inspect conversation rows, not sidebar previews. A test is not passing just because the sidebar contains the sent text. +- Save screenshots under `output/playwright/` for all Docker browser cases and show them inline in the completion report. +- Before reporting success, include: + - tested URLs/ports, + - provider/config summary for each container, + - exact build/test commands, + - screenshot absolute paths, + - whether invalid auth persisted after reload and whether duplicate live overlay count was zero. +- If any Docker edge case fails, fix it before requesting PR review or merge. + ## Mandatory CJS + TestChat Validation For Markdown/File-Link Features - For any markdown parsing, link parsing, file-link rendering, or browse-link encoding change, verification in `TestChat` is mandatory before reporting completion. diff --git a/llm-wiki/raw/fixes/copied-auth-provider-promotion.md b/llm-wiki/raw/fixes/copied-auth-provider-promotion.md new file mode 100644 index 000000000..33df97768 --- /dev/null +++ b/llm-wiki/raw/fixes/copied-auth-provider-promotion.md @@ -0,0 +1,88 @@ +# Copied Auth Provider Promotion Fix + +Date: 2026-05-13 + +## Problem + +In Docker no-auth mode, Codex Web Local starts with an OpenCode Zen runtime fallback. If the user switched the provider to OpenRouter while unauthenticated and then copied a valid `auth.json` into the mounted `CODEX_HOME`, the UI detected Codex auth but kept stale community-provider state. + +Observed issues: +- Provider stayed on OpenRouter after valid Codex auth appeared. +- The Accounts badge stayed at `0` until a manual account refresh. +- The new-thread composer could show a generic `Model` label after provider promotion. +- The Settings feedback row could show stale `Send feedback / Issue detected` after recovery even when there was no visible current error. +- Sending on Codex worked after manually switching provider, proving the copied auth file was valid. + +## Root Cause + +The server read `webui-custom-providers.json` as authoritative whenever it existed. That file can contain community fallback provider state created during the unauthenticated phase. After `auth.json` appeared, the fallback provider state still supplied app-server provider flags and `/codex-api/free-mode/status` data. + +The frontend also relied on the accounts snapshot for the Accounts count. A copied `auth.json` did not automatically import the active auth file into the accounts store. + +Finally, provider-scoped new-thread model persistence applied to non-Codex providers but not to Codex. After provider promotion, the home composer could temporarily fall back to the generic `Model` placeholder instead of a concrete Codex model. + +## Fix + +Commit: +- `7ee94f83 Promote copied auth to Codex provider` + +Implementation details: +- Added `shouldSuppressCommunityFreeModeForCodexAuth()` in `src/server/freeMode.ts`. +- `ensureDefaultFreeModeStateForMissingAuthSync()` now returns `null` when usable Codex auth exists and the existing provider state is only community fallback (`openrouter` or `opencode-zen` without a custom key). +- User-configured providers are preserved: + - OpenRouter with `customKey: true` + - OpenCode Zen with an explicit API key + - Custom endpoint provider +- `/codex-api/free-mode/status` now reports `hasCodexAuth`. +- `App.vue` uses `hasCodexAuth` to import a copied active `auth.json` into Accounts via `refreshAccountsFromAuth()` once. +- New-thread model persistence now uses provider-scoped slots for Codex as well as non-Codex providers. +- The Settings feedback row is shown only when a current visible error exists, not merely because historical diagnostics exist. + +## Docker Validation + +Fresh packaged image: + +```text +codexui-local:e5e9-auth-promote-final2 +``` + +Flow: +1. Start a fresh container with empty mounted `CODEX_HOME`. +2. Confirm initial provider is `opencode-zen`. +3. Switch provider to `openrouter`. +4. Copy `/Users/igor/.codex/auth.json` into the mounted `CODEX_HOME`. +5. Reload the UI. +6. Confirm provider changes to `codex`. +7. Confirm Accounts count becomes `1`. +8. Confirm the composer shows a concrete Codex model, not generic `Model`. +9. Confirm no stale `Send feedback / Issue detected` row appears. +10. Send `hi`; wait for a Codex reply. + +Final validation result: + +```json +{ + "initialProvider": "opencode-zen", + "afterSwitchProvider": "openrouter", + "afterCopyProvider": "codex", + "afterCopyAccounts": 1, + "afterCopyHasIssue": false, + "finalProvider": "codex", + "finalHasIssue": false, + "stillBusy": false +} +``` + +Screenshot artifacts: +- `output/playwright/auth-promote-final2-01-noauth.png` +- `output/playwright/auth-promote-final2-02-openrouter.png` +- `output/playwright/auth-promote-final2-03-after-copy.png` +- `output/playwright/auth-promote-final2-04-reply.png` + +## Verification Commands + +```bash +pnpm test:unit src/server/freeMode.test.ts src/server/codexAppServerBridge.archive.test.ts src/composables/useDesktopState.test.ts src/api/codexGateway.test.ts +pnpm run build +pnpm pack --pack-destination /tmp +``` diff --git a/llm-wiki/raw/fixes/opencode-zen-docker-auth-provider-models.md b/llm-wiki/raw/fixes/opencode-zen-docker-auth-provider-models.md new file mode 100644 index 000000000..67a5c315e --- /dev/null +++ b/llm-wiki/raw/fixes/opencode-zen-docker-auth-provider-models.md @@ -0,0 +1,78 @@ +# OpenCode Zen Docker Auth and Provider Models Fix + +Date: 2026-05-13 + +## Problem + +Codex Web Local had two Docker startup edge cases around OpenCode Zen fallback and Codex auth: + +1. In an authenticated Docker container, immediately polling `/codex-api/thread-live-state` after `turn/start` could return: + +```text +thread is not materialized yet; includeTurns is unavailable before first user message +``` + +The turn completed normally, but the bridge exposed this transient Codex state as `liveStateError.kind = "readFailed"`, making the chat look broken during first-turn startup. + +2. In an unauthenticated Docker container, the model selector could appear empty or stale because frontend model loading called Codex `model/list` before `/codex-api/provider-models`. In OpenCode Zen fallback mode, provider models are authoritative; `model/list` can be slow, return Codex models, or fail independently. + +## Root Cause + +The live-state endpoint treated every `thread/read includeTurns=true` failure as a real read failure. Codex can briefly create a thread before the first user message is materialized, so that exact error is a pending state, not a terminal failure. + +The model-loading helper fetched `model/list` first and only then attempted provider model discovery. This made no-auth Zen startup depend on a Codex model-list call that is not the source of truth for Zen models. + +## Fix + +Commits: +- `545c0dec Handle pending first-turn live state` +- `2eaf4bd3 Load provider models before Codex model list` + +Implementation details: +- Added `isThreadMaterializationPendingError()` in `src/server/codexAppServerBridge.ts`. +- `/codex-api/thread-live-state` now maps that specific pending-materialization error to: + - `conversationState: { turns: [] }` + - `liveStateError: null` + - `isInProgress: true` +- Real `thread/read` failures still surface through `liveStateError`. +- `getAvailableModelIds()` now fetches `/codex-api/provider-models` first when provider models are included. +- If provider models are `exclusive` or `requireProviderModels` is true, it returns provider models without waiting on Codex `model/list`. +- Optional provider-model loading still falls back to `model/list` if provider models are unavailable. + +## Docker Validation + +Fresh image: + +```text +codexui-local:e5e9-current +``` + +No-auth container: +- URL: `http://127.0.0.1:4191/#/` +- `config/read`: `model = "big-pickle"`, `model_provider = "opencode-zen"` +- App-server command includes Zen proxy flags. +- Sending `hi` returns an assistant reply. +- Model selector includes `big-pickle`, `deepseek-v4-flash-free`, and other Zen provider models. + +Auth-mounted container: +- URL: `http://127.0.0.1:4192/#/` +- Mounted `/Users/igor/.codex/auth.json` to `/codex-home/auth.json`. +- `config/read`: `model = null`, `model_provider = null` +- App-server command has no Zen proxy flags. +- Sending `hi` returns an assistant reply. +- First-turn live-state polling does not expose the transient materialization error as `liveStateError`. + +## Operational Notes + +- For Docker validation, install the packed `codexapp` artifact during image build instead of using `pnpm dlx` at container runtime. Runtime `pnpm dlx` can re-download and extract dependencies on every start and can be killed under memory pressure. +- When validating no-auth Zen mode, trust `/codex-api/provider-models` and `/codex-api/free-mode/status` for provider models; `model/list` may still return Codex catalog rows from the Codex CLI. +- Browser verification should include a screenshot of the opened model selector after loading `http://127.0.0.1:4191/#/`. + +## Verification Commands + +```bash +pnpm test:unit src/server/codexAppServerBridge.archive.test.ts +pnpm test:unit src/api/codexGateway.test.ts src/composables/useDesktopState.test.ts +pnpm run build +``` + diff --git a/llm-wiki/raw/fixes/provider-selection-drift-docker-cycle.md b/llm-wiki/raw/fixes/provider-selection-drift-docker-cycle.md new file mode 100644 index 000000000..0e6a1b5a5 --- /dev/null +++ b/llm-wiki/raw/fixes/provider-selection-drift-docker-cycle.md @@ -0,0 +1,113 @@ +# Provider Selection Drift Docker Cycle + +Captured on 2026-05-13 from branch `codex/provider-model-selection-drift`. + +## Scope + +This source records the packaged Docker provider validation for provider/model selection drift, thread continuity during provider switches, and custom provider behavior. + +## Build and Containers + +The test used a freshly packaged artifact from the current branch: + +- `pnpm run build` +- `pnpm pack --pack-destination /tmp` +- Temporary Docker image `codexapp-provider-cycle:local` +- Image installed packed `codexapp-0.1.87.tgz` plus `@openai/codex@latest` +- Docker context: OrbStack/Docker CLI + +Containers: + +- No-auth: empty `CODEX_HOME`, port `4191` +- Auth-mounted: host `/Users/igor/.codex/auth.json` copied into isolated `CODEX_HOME`, port `4192` + +## Confirmed Fixes + +Two frontend changes were made before or during this cycle: + +- Model selections are now stored as provider-tagged objects such as `{ "providerId": "opencode-zen", "modelId": "big-pickle" }`. +- Provider switching no longer runs `router.push({ name: 'home' })` after refresh, so a routed thread URL can remain stable through provider changes. + +Related commits in this branch: + +- `51bff49c` - provider-scoped model selection storage and validation. +- `cc6ddde9` - route sync no longer redirects home merely because the current sidebar list omits the routed thread. +- `dcffe94c` - provider switch handler no longer forces home navigation. + +## Passing Evidence + +No-auth Docker startup on `4191`: + +- Settings provider was `OpenCode Zen`. +- Accounts badge was `0`. +- `/codex-api/free-mode/status` reported `provider=opencode-zen` and `hasCodexAuth=false`. +- `/codex-api/provider-models` reported `exclusive=true`, `source=opencode-zen`, and included `big-pickle`. +- Sending `hi` produced an assistant reply. + +Auth-mounted Docker startup on `4192`: + +- Settings provider was `Codex`. +- Accounts badge was `1`. +- Model dropdown contained Codex models only, including `GPT-5.5`, `GPT-5.4`, `GPT-5.4-mini`, `GPT-5.3-codex`, `GPT-5.3-codex-spark`, and `GPT-5.2`. +- The Codex model dropdown did not include `big-pickle`. +- Sending `hi` produced an assistant reply. + +Thread continuity after provider switch: + +- Starting URL: `http://localhost:4192/#/thread/019e2109-aacb-7612-a6cc-3740757594e0` +- After switching Codex to OpenCode Zen, the URL remained the same thread route. +- The existing conversation remained visible. + +## Failing Evidence + +Historical thread send after provider switch: + +- With the URL preserved after switching Codex to OpenCode Zen, sending `hi provider opencode zen` failed in chat. +- Visible error: `RPC turn/start failed with HTTP 502: thread not found: 019e2109-aacb-7612-a6cc-3740757594e0`. +- This shows UI route continuity was fixed, but the backend session could not run that historical thread after provider switch. + +OpenRouter selection mismatch: + +- Settings could show `OpenRouter` selected while `/codex-api/free-mode/status` reported `enabled=false`, `hasCodexAuth=true`, and `provider=openrouter`. +- `/codex-api/provider-models` returned `source=provider`, `providerId=""`, and no models. +- The composer dropdown still showed Codex models. +- Expected behavior is either an activated OpenRouter model list or a clear blocking state, not Codex models under an OpenRouter selection. + +NVIDIA NIM custom provider mismatch: + +- Custom provider was set through `/codex-api/free-mode/custom-provider`. +- Config was `baseUrl=https://integrate.api.nvidia.com/v1` and `wireApi=chat`. +- `/codex-api/free-mode/status` reported `enabled=true`, `provider=custom`, `customBaseUrl=https://integrate.api.nvidia.com/v1`, and `wireApi=chat`. +- `/codex-api/provider-models` reported `source=custom`, `exclusive=true`, and 123 models. +- UI dropdown still showed Codex models. +- Searching `moonshotai/kimi-k2.5` in the UI model dropdown returned no results, despite successful NIM model discovery. +- Sending `hi provider nvidia nim` failed with `unexpected status 404 Not Found: 404 page not found, url: http://127.0.0.1:4192/codex-api/custom-proxy/v1/responses`. +- Expected behavior is for chat-completions providers to send through the chat proxy path with non-empty `messages`. + +Groq custom provider: + +- The local KeePass registry had OpenRouter and NVIDIA keys but no Groq key entry. +- A valid Groq send test was not completed. + +## Screenshot Artifacts + +Screenshots were captured under `output/playwright/`, including: + +- `docker-noauth-settings.png` +- `docker-noauth-model-dropdown.png` +- `docker-noauth-hi-result.png` +- `docker-auth-settings.png` +- `docker-auth-model-dropdown.png` +- `docker-auth-hi-result.png` +- `docker-provider-switch-zen-result-fixed.png` +- `docker-provider-switch-openrouter-settings.png` +- `docker-provider-switch-custom-nim-result.png` + +## Follow-up Test Inventory + +The unresolved failures were copied into `whatToTest.md`: + +- Provider-switched historical thread cannot send. +- OpenRouter provider can show selected while backend remains Codex. +- Custom NVIDIA NIM chat provider does not drive the UI model dropdown and sends to the Responses path. +- Groq custom provider was not completed due to missing key. diff --git a/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md b/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md index 9ef4e7e93..a8ce5c2dc 100644 --- a/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md +++ b/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md @@ -45,6 +45,8 @@ model_provider = "opencode-zen" - Codex CLI deprecation warning for `wire_api = "chat"` is safe to ignore on v0.93.0 - In Codex Web Local's Zen proxy, DeepSeek thinking-mode responses must round-trip `reasoning_content` into later Chat Completions messages. Missing this field can produce `The reasoning_content in the thinking mode must be passed back to the API`. - Chat-shaped Zen proxy payloads must be posted to `/v1/chat/completions`, even when the incoming local request uses the Responses-shaped `/responses` route. +- In no-auth Docker mode, OpenCode Zen provider models are authoritative. Fetch `/codex-api/provider-models` before relying on Codex `model/list`, because `model/list` may return Codex catalog rows or fail independently. +- During authenticated Docker first-turn startup, `thread ... is not materialized yet; includeTurns is unavailable before first user message` is a transient in-progress state, not a chat error. ## Codex Web Local Proxy Behavior @@ -58,7 +60,52 @@ For thinking-mode models behind `big-pickle`, the proxy must preserve assistant This behavior was fixed in commit `47d52c8c` after a Docker repro using an empty `CODEX_HOME`, no login, and no Zen API key. +## Docker Auth and Model Loading + +Codex Web Local's unauthenticated Docker path should use OpenCode Zen only as a runtime fallback. It should not permanently write fallback provider configuration, and it should not depend on Codex `model/list` for the Zen selector. + +Validated Docker states: +- Empty `CODEX_HOME`: `config/read` reports `model = "big-pickle"` and `model_provider = "opencode-zen"`, the app-server command includes local Zen proxy flags, and the model selector loads provider models from `/codex-api/provider-models`. +- `auth.json` mounted into `CODEX_HOME`: `config/read` reports `model = null` and `model_provider = null`, the app-server command has no Zen flags, and sending `hi` uses the default Codex provider path. + +The first authenticated turn may briefly make `thread/read includeTurns=true` fail with `not materialized yet; includeTurns is unavailable before first user message`. The bridge maps that exact response to an in-progress empty live state with no `liveStateError`; real `thread/read` failures still surface as errors. + +## Copied Auth Promotion + +If a container starts without auth and later receives a valid `auth.json`, Codex auth should take precedence over community fallback provider state. This matters when a user starts in no-auth Zen mode, switches to OpenRouter, then copies auth into `CODEX_HOME`. + +Expected behavior after copying auth and reloading: +- Community fallback provider state (`openrouter` or `opencode-zen` without a custom key) is suppressed. +- Provider promotes to Codex. +- Accounts imports the copied active auth file and the badge updates from `0` to at least `1`. +- The new-thread composer shows a concrete Codex model, not a generic `Model` placeholder. +- Stale historical diagnostics do not show a `Send feedback / Issue detected` row unless a current visible error remains. + +User-configured provider state is preserved: OpenRouter with `customKey: true`, OpenCode Zen with an explicit API key, and custom endpoint providers should not be suppressed merely because Codex auth exists. + +This behavior was fixed in commit `7ee94f83` and validated in a packaged Docker image by running: no-auth Zen startup, switch to OpenRouter, copy host `auth.json`, reload, verify Codex provider + Accounts `1`, send `hi`, and wait for a Codex reply. + +## Provider Selection Drift Docker Cycle + +Provider/model selection state now needs to be treated as provider-scoped data, not as a bare model string. Stored model selections use the object shape `{ providerId, modelId }`, while legacy string values are accepted only when compatible with the active provider's model list. + +Validated passing states from the packaged Docker cycle: +- Empty `CODEX_HOME` on port `4191` selected OpenCode Zen, had Accounts `0`, loaded exclusive Zen provider models with `big-pickle`, and sent `hi` successfully. +- Auth-mounted `CODEX_HOME` on port `4192` selected Codex, had Accounts `1`, loaded a Codex-only dropdown without `big-pickle`, and sent `hi` successfully. +- Provider switching no longer forces navigation to home; the routed thread URL stayed stable after switching Codex to OpenCode Zen. + +Known unresolved provider-switch failures: +- A historical Codex thread kept its URL after switching to Zen, but sending on it failed with `RPC turn/start failed with HTTP 502: thread not found`. +- OpenRouter can appear selected while backend status remains `enabled=false` and the composer dropdown still shows Codex models. +- A custom NVIDIA NIM provider can successfully expose 123 models through `/codex-api/provider-models`, but the UI dropdown can still show Codex models and sending can incorrectly hit `/codex-api/custom-proxy/v1/responses` despite `wireApi=chat`. +- Groq custom-provider send validation still needs a Groq API key. + +These unresolved findings are tracked in `whatToTest.md` until fixed and revalidated. + ## Related - Source: [opencode-zen-big-pickle-codex-cli.md](../../raw/fixes/opencode-zen-big-pickle-codex-cli.md) - Source: [opencode-zen-reasoning-content-proxy.md](../../raw/fixes/opencode-zen-reasoning-content-proxy.md) +- Source: [opencode-zen-docker-auth-provider-models.md](../../raw/fixes/opencode-zen-docker-auth-provider-models.md) +- Source: [copied-auth-provider-promotion.md](../../raw/fixes/copied-auth-provider-promotion.md) +- Source: [provider-selection-drift-docker-cycle.md](../../raw/fixes/provider-selection-drift-docker-cycle.md) - [merge-to-main-workflow.md](./merge-to-main-workflow.md) diff --git a/llm-wiki/wiki/entities/codex-web-local.md b/llm-wiki/wiki/entities/codex-web-local.md index ca54e5d0d..23fe58d02 100644 --- a/llm-wiki/wiki/entities/codex-web-local.md +++ b/llm-wiki/wiki/entities/codex-web-local.md @@ -17,6 +17,7 @@ - User-visible UI work is expected to include dark-theme verification, not only light-theme checks - Worktree dev startup may reuse a shared `node_modules` tree; forcing reinstall is not always the right default - Directory Hub is the `#/skills` surface for Plugins, Apps, Composio, MCPs, Skills search, and installed local skills +- Unauthenticated Docker startup can use OpenCode Zen as a runtime fallback, while an auth-mounted `CODEX_HOME` should switch back to the default Codex provider path without Zen flags ## Source links - [Source snapshot](../../raw/projects/codex-web-local.md) @@ -24,8 +25,11 @@ - [Directory Hub Composio and Skills search source](../../raw/features/directory-hub-composio-skills-search.md) - [Realtime chat rendering source](../../raw/features/realtime-chat-rendering-inline-media.md) - [Skills route UI + first-launch card source](../../raw/features/skills-route-ui-and-first-launch-card.md) +- [OpenCode Zen Docker auth/provider models source](../../raw/fixes/opencode-zen-docker-auth-provider-models.md) +- [Provider selection drift Docker cycle source](../../raw/fixes/provider-selection-drift-docker-cycle.md) - [Integrated terminal concept](../concepts/integrated-terminal.md) - [Directory Hub, Composio, and Skills Search concept](../concepts/directory-hub-composio-skills.md) - [Realtime chat rendering concept](../concepts/realtime-chat-rendering.md) - [Merge-to-main workflow concept](../concepts/merge-to-main-workflow.md) - [Skills route UI concept](../concepts/skills-route-ui.md) +- [OpenCode Zen + Big Pickle concept](../concepts/opencode-zen-big-pickle.md) diff --git a/llm-wiki/wiki/index.md b/llm-wiki/wiki/index.md index 21349b557..291ed10cb 100644 --- a/llm-wiki/wiki/index.md +++ b/llm-wiki/wiki/index.md @@ -10,7 +10,7 @@ - [concepts/integrated-terminal.md](./concepts/integrated-terminal.md): Codex.app-style integrated xterm/PTY terminal architecture, edge cases, and verification. - [concepts/directory-hub-composio-skills.md](./concepts/directory-hub-composio-skills.md): Directory Hub tab routing, Composio connector behavior, Skills search/install semantics, and edge-case testing. - [concepts/merge-to-main-workflow.md](./concepts/merge-to-main-workflow.md): branch integration and conflict-resolution workflow. -- [concepts/opencode-zen-big-pickle.md](./concepts/opencode-zen-big-pickle.md): OpenCode Zen Big Pickle model configuration for Codex CLI and OpenCode CLI. +- [concepts/opencode-zen-big-pickle.md](./concepts/opencode-zen-big-pickle.md): OpenCode Zen Big Pickle model configuration, local proxy behavior, Docker auth switching, and provider model loading. - [concepts/realtime-chat-rendering.md](./concepts/realtime-chat-rendering.md): realtime chat rendering, sync-churn reduction, and inline media sanitization. - [concepts/skills-route-ui.md](./concepts/skills-route-ui.md): Skills route naming, first-launch Plugins card persistence, dark-theme fixes, and verification lessons. - [concepts/thread-heartbeat-automations.md](./concepts/thread-heartbeat-automations.md): thread-scoped heartbeat automation storage, multi-automation management, and manual run behavior. @@ -26,3 +26,6 @@ - [../raw/projects/codex-web-local.md](../raw/projects/codex-web-local.md): immutable source snapshot for project facts. - [../raw/fixes/opencode-zen-big-pickle-codex-cli.md](../raw/fixes/opencode-zen-big-pickle-codex-cli.md): Big Pickle + Codex CLI fix details. - [../raw/fixes/opencode-zen-reasoning-content-proxy.md](../raw/fixes/opencode-zen-reasoning-content-proxy.md): Codex Web Local Zen proxy reasoning_content round-trip fix and Docker verification. +- [../raw/fixes/opencode-zen-docker-auth-provider-models.md](../raw/fixes/opencode-zen-docker-auth-provider-models.md): Docker auth/no-auth provider switching, first-turn live-state materialization, and provider-model loading fixes. +- [../raw/fixes/copied-auth-provider-promotion.md](../raw/fixes/copied-auth-provider-promotion.md): copied `auth.json` promotion from community fallback provider state to Codex, account import, model-label, and stale feedback-row fixes. +- [../raw/fixes/provider-selection-drift-docker-cycle.md](../raw/fixes/provider-selection-drift-docker-cycle.md): packaged Docker provider/model drift cycle, route-continuity fix, passing no-auth/auth checks, and unresolved OpenRouter/custom provider failures. diff --git a/llm-wiki/wiki/log.md b/llm-wiki/wiki/log.md index c081c064f..36fe49091 100644 --- a/llm-wiki/wiki/log.md +++ b/llm-wiki/wiki/log.md @@ -52,3 +52,21 @@ - Updated project cron automation notes for the combined Automations panel. - Updated Automations panel notes for active/newest sorting and direct edit buttons. - Updated project cron automation notes for absolute cwd validation and multi-cwd preservation. + +## [2026-05-13] ingest | copied auth provider promotion +- Added source: [raw/fixes/copied-auth-provider-promotion.md](../raw/fixes/copied-auth-provider-promotion.md). +- Updated wiki page: [concepts/opencode-zen-big-pickle.md](./concepts/opencode-zen-big-pickle.md). +- Documents: suppressing community fallback provider state after valid Codex auth appears, preserving user-configured providers, importing copied auth into Accounts, provider-scoped Codex model persistence, stale feedback-row cleanup, and packaged Docker validation. +- Updated [index.md](./index.md). + +## [2026-05-13] ingest | OpenCode Zen Docker auth and provider models +- Added source: [raw/fixes/opencode-zen-docker-auth-provider-models.md](../raw/fixes/opencode-zen-docker-auth-provider-models.md). +- Updated wiki page: [concepts/opencode-zen-big-pickle.md](./concepts/opencode-zen-big-pickle.md). +- Documents: no-auth Zen runtime fallback, auth-mounted Docker switching back to Codex defaults, first-turn materialization as a transient live-state condition, provider-model-first loading, and the build-time Docker install workaround for runtime `pnpm dlx` OOM risk. +- Updated [overview.md](./overview.md), [entities/codex-web-local.md](./entities/codex-web-local.md), and [index.md](./index.md). + +## [2026-05-13] ingest | provider selection drift Docker cycle +- Added source: [raw/fixes/provider-selection-drift-docker-cycle.md](../raw/fixes/provider-selection-drift-docker-cycle.md). +- Updated wiki page: [concepts/opencode-zen-big-pickle.md](./concepts/opencode-zen-big-pickle.md). +- Documents: provider-tagged model selection storage, no-auth Zen and auth Codex packaged Docker passes, provider-switch route-continuity fix, and unresolved historical-thread, OpenRouter, NVIDIA NIM, and Groq validation failures. +- Updated [overview.md](./overview.md), [entities/codex-web-local.md](./entities/codex-web-local.md), and [index.md](./index.md). diff --git a/llm-wiki/wiki/overview.md b/llm-wiki/wiki/overview.md index 3e46f72c6..ec9c3ecfa 100644 --- a/llm-wiki/wiki/overview.md +++ b/llm-wiki/wiki/overview.md @@ -10,6 +10,7 @@ This wiki tracks knowledge about the `codex-web-local` project and related workf - Realtime chat rendering performance and inline media sanitization - Skills route UI, first-launch Plugins card behavior, and dark-theme verification lessons - Directory Hub tab routing, Composio connector behavior, and Skills registry search/install semantics +- OpenCode Zen fallback, Docker auth switching, and provider-backed model loading ## Primary source - [codex-web-local project snapshot](../raw/projects/codex-web-local.md) @@ -17,6 +18,8 @@ This wiki tracks knowledge about the `codex-web-local` project and related workf - [directory hub Composio and Skills search source](../raw/features/directory-hub-composio-skills-search.md) - [realtime chat rendering and inline media source](../raw/features/realtime-chat-rendering-inline-media.md) - [skills route UI and first-launch card source](../raw/features/skills-route-ui-and-first-launch-card.md) +- [OpenCode Zen Docker auth/provider models source](../raw/fixes/opencode-zen-docker-auth-provider-models.md) +- [Provider selection drift Docker cycle source](../raw/fixes/provider-selection-drift-docker-cycle.md) ## Linked pages - [Entity: codex-web-local](./entities/codex-web-local.md) @@ -25,3 +28,4 @@ This wiki tracks knowledge about the `codex-web-local` project and related workf - [Concept: Realtime chat rendering](./concepts/realtime-chat-rendering.md) - [Concept: Merge-to-main workflow](./concepts/merge-to-main-workflow.md) - [Concept: Skills route UI](./concepts/skills-route-ui.md) +- [Concept: OpenCode Zen + Big Pickle](./concepts/opencode-zen-big-pickle.md) diff --git a/src/App.vue b/src/App.vue index 034797e70..741075c7a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -250,7 +250,7 @@ @@ -1417,7 +1417,6 @@ type AutomationEditRequest = { const sidebarThreadTreeRef = ref(null) const automationsPanelRef = ref(null) const { - hasFeedbackDiagnostics, buildFeedbackMailto, recordVisibleFailure, } = useFeedbackDiagnostics() @@ -1572,6 +1571,7 @@ const visibleFeedbackErrors = [ projectSetupError, existingFolderError, ] +const hasVisibleFeedbackError = computed(() => visibleFeedbackErrors.some((entry) => entry.value.trim().length > 0)) const telegramStatus = ref({ configured: false, active: false, @@ -1589,6 +1589,8 @@ const visualViewportOffsetTop = ref(typeof window !== 'undefined' ? window.visua const layoutViewportHeight = ref(typeof window !== 'undefined' ? window.innerHeight : 0) let accountStatePollTimer: number | null = null let isAccountStatePollInFlight = false +let externalCodexAuthAvailable = false +let externalAuthImportAttempted = false let existingFolderBrowseRequestId = 0 const routeThreadId = computed(() => { @@ -2100,6 +2102,35 @@ watch(accounts, () => { }, 1500) }, { deep: true }) +watch(accountRateLimitSnapshots, () => { + void maybeImportExternalCodexAuthAccount().then((imported) => { + if (!imported) return + void refreshAll({ + includeSelectedThreadMessages: false, + providerChanged: true, + awaitAncillaryRefreshes: true, + }) + }) +}, { deep: true }) + +async function maybeImportExternalCodexAuthAccount(): Promise { + 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 +} + function onSkillsChanged(): void { void refreshSkills() } @@ -3915,9 +3946,6 @@ async function onProviderChange(provider: string): Promise { } providerError.value = '' await refreshAll({ includeSelectedThreadMessages: false, providerChanged: true, awaitAncillaryRefreshes: true }) - if (route.name === 'thread') { - void router.push({ name: 'home' }) - } } catch (err) { providerError.value = err instanceof Error ? err.message : 'Failed to switch provider' } finally { @@ -4019,6 +4047,7 @@ async function clearFreeModeCustomKey(): Promise { async function loadFreeModeStatus(): Promise { try { + const previousProvider = selectedProvider.value const status = await getFreeModeStatus() freeModeEnabled.value = status.enabled freeModeHasCustomKey.value = status.customKey ?? false @@ -4037,6 +4066,26 @@ async function loadFreeModeStatus(): Promise { } else { selectedProvider.value = 'codex' } + externalCodexAuthAvailable = status.hasCodexAuth === true + if (!externalCodexAuthAvailable) { + externalAuthImportAttempted = false + } + const providerChanged = selectedProvider.value !== previousProvider + if (providerChanged) { + await refreshAll({ + includeSelectedThreadMessages: false, + providerChanged: true, + awaitAncillaryRefreshes: true, + }) + } + const importedExternalAuth = await maybeImportExternalCodexAuthAccount() + if (importedExternalAuth) { + await refreshAll({ + includeSelectedThreadMessages: false, + providerChanged: providerChanged || importedExternalAuth, + awaitAncillaryRefreshes: true, + }) + } } catch { // Ignore — free mode status unknown } @@ -4171,11 +4220,6 @@ async function initialize(): Promise { startPolling() } -function threadExistsInSidebar(threadId: string): boolean { - if (!threadId) return false - return projectGroups.value.some((group) => group.threads.some((thread) => thread.id === threadId)) -} - async function syncThreadSelectionWithRoute(): Promise { if (isRouteSyncInProgress.value) { hasPendingRouteSync = true @@ -4199,14 +4243,6 @@ async function syncThreadSelectionWithRoute(): Promise { if (!threadId) continue if (selectedThreadId.value !== threadId) { - if (!threadExistsInSidebar(threadId)) { - if (selectedThreadId.value) { - await router.replace({ name: 'thread', params: { threadId: selectedThreadId.value } }) - } else { - await router.replace({ name: 'home' }) - } - continue - } await selectThread(threadId) } else { void ensureThreadMessagesLoaded(threadId, { silent: true }) diff --git a/src/api/codexGateway.test.ts b/src/api/codexGateway.test.ts index 98e1ed75e..c43bff5c0 100644 --- a/src/api/codexGateway.test.ts +++ b/src/api/codexGateway.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { listDirectoryComposioConnectors, startThreadTurn } from './codexGateway' +import { getAvailableModelIds, listDirectoryComposioConnectors, startThreadTurn } from './codexGateway' function mockRpcFetch(): { requests: Array<{ method: string, params: Record }> } { const requests: Array<{ method: string, params: Record }> = [] @@ -87,3 +87,66 @@ describe('listDirectoryComposioConnectors', () => { expect(requests).toEqual(['/codex-api/composio/connectors?query=instagram&cursor=50&limit=25']) }) }) + +describe('getAvailableModelIds', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses provider models without waiting for model/list when provider models are required', async () => { + const requests: string[] = [] + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + requests.push(String(input)) + if (String(input) === '/codex-api/provider-models') { + return new Response(JSON.stringify({ + data: ['big-pickle', 'deepseek-v4-flash-free'], + exclusive: true, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + throw new Error(`unexpected request ${String(input)}`) + })) + + await expect(getAvailableModelIds({ + includeProviderModels: true, + requireProviderModels: true, + })).resolves.toEqual(['big-pickle', 'deepseek-v4-flash-free']) + expect(requests).toEqual(['/codex-api/provider-models']) + }) + + it('falls back to model/list when provider models are optional and unavailable', async () => { + const requests: string[] = [] + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(String(input)) + if (String(input) === '/codex-api/provider-models') { + return new Response(JSON.stringify({ data: [] }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const body = typeof init?.body === 'string' + ? JSON.parse(init.body) as { method: string } + : { method: '' } + expect(body.method).toBe('model/list') + return new Response(JSON.stringify({ + result: { + data: [ + { id: 'gpt-5.5' }, + { model: 'gpt-5.4-mini' }, + ], + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + })) + + await expect(getAvailableModelIds({ + includeProviderModels: true, + })).resolves.toEqual(['gpt-5.5', 'gpt-5.4-mini']) + expect(requests).toEqual(['/codex-api/provider-models', '/codex-api/rpc']) + }) +}) diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index c7bea2275..3fbf6f68d 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -248,6 +248,7 @@ type DirectoryComposioConnectorPage = { type ProviderModelsResponse = { data?: unknown + exclusive?: unknown } const PROVIDER_MODELS_FETCH_TIMEOUT_MS = 5_000 @@ -1835,6 +1836,7 @@ export async function setCodexSpeedMode(mode: SpeedMode): Promise { export interface FreeModeStatus { enabled: boolean + hasCodexAuth?: boolean keyCount: number models: string[] currentModel: string | null @@ -1886,51 +1888,54 @@ export async function setCustomProvider( return await response.json() as { ok: boolean } } -export async function getAvailableModelIds(options: { includeProviderModels?: boolean; requireProviderModels?: boolean } = {}): Promise { - const payload = await callRpc('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 (options.includeProviderModels === false) { - return ids - } - - let sawProviderModels = false +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 { + 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('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 + + for (const candidate of providerModels.ids) { + if (!ids.includes(candidate)) ids.push(candidate) + } return ids } diff --git a/src/api/normalizers/v2.test.ts b/src/api/normalizers/v2.test.ts index 49f410d09..7e87c9a54 100644 --- a/src/api/normalizers/v2.test.ts +++ b/src/api/normalizers/v2.test.ts @@ -105,4 +105,73 @@ Reply with </instructions> and A & B turnIndex: 12, }) }) + + it('renders failed turn errors as chat system messages', () => { + const response = threadReadResponseWithContent([{ + type: 'userMessage', + id: 'user-4', + content: [{ type: 'text', text: 'hi', text_elements: [] }], + }]) + response.thread.turns[0].status = 'failed' + response.thread.turns[0].error = { + message: 'unexpected status 401 Unauthorized: Missing bearer or basic authentication in header', + codexErrorInfo: null, + additionalDetails: null, + } + + const messages = normalizeThreadMessagesV2(response) + + expect(messages).toHaveLength(2) + expect(messages[1]).toMatchObject({ + id: 'turn-1-error', + role: 'system', + text: 'unexpected status 401 Unauthorized: Missing bearer or basic authentication in header', + messageType: 'turnError', + turnId: 'turn-1', + turnIndex: 0, + }) + }) + + it('uses turn index fallback ids for failed turns with blank ids', () => { + const response = threadReadResponseWithContent([]) + response.thread.turns = [ + { + id: '', + status: 'failed', + error: { + message: 'first failed turn', + codexErrorInfo: null, + additionalDetails: null, + }, + items: [], + }, + { + id: ' ', + status: 'failed', + error: { + message: 'second failed turn', + codexErrorInfo: null, + additionalDetails: null, + }, + items: [], + }, + ] + + const messages = normalizeThreadMessagesV2(response, 8) + + expect(messages).toEqual([ + expect.objectContaining({ + id: 'turn-8-error', + text: 'first failed turn', + turnId: undefined, + turnIndex: 8, + }), + expect.objectContaining({ + id: 'turn-9-error', + text: 'second failed turn', + turnId: undefined, + turnIndex: 9, + }), + ]) + }) }) diff --git a/src/api/normalizers/v2.ts b/src/api/normalizers/v2.ts index a19973ff1..d76e1ba88 100644 --- a/src/api/normalizers/v2.ts +++ b/src/api/normalizers/v2.ts @@ -31,6 +31,11 @@ function toRawPayload(value: unknown): string { } } +function readTurnErrorText(turn: Turn): string { + const error = turn.error as { message?: unknown } | null + return typeof error?.message === 'string' ? error.message.trim() : '' +} + const FILE_ATTACHMENT_LINE = /^##\s+(.+?):\s+(.+?)\s*$/ const FILES_MENTIONED_MARKER = /^#\s*files mentioned by the user\s*:?\s*$/i const ASSISTANT_FILE_CHANGE_HEADING = /^(?:#{1,6}\s*)?(?:本次修改文件(?:和操作)?(?:如下)?|修改文件和操作)\s*[::]?\s*$/u @@ -624,13 +629,26 @@ export function normalizeThreadMessagesV2(payload: ThreadReadResponse, baseTurnI for (let turnOffset = 0; turnOffset < turns.length; turnOffset++) { const turnIndex = baseTurnIndex + turnOffset const turn = turns[turnOffset] - const turnId = typeof turn?.id === 'string' ? turn.id : undefined + const rawTurnId = typeof turn?.id === 'string' ? turn.id.trim() : '' + const turnId = rawTurnId.length > 0 ? rawTurnId : undefined const items = Array.isArray(turn.items) ? turn.items : [] for (const item of items) { for (const msg of toUiMessages(item)) { messages.push({ ...msg, turnId, turnIndex }) } } + const errorText = readTurnErrorText(turn) + if (turn.status === 'failed' && errorText) { + const errorIdBase = turnId ?? `turn-${turnIndex}` + messages.push({ + id: `${errorIdBase}-error`, + role: 'system', + text: errorText, + messageType: 'turnError', + turnId, + turnIndex, + }) + } } return messages } diff --git a/src/components/content/ThreadConversation.vue b/src/components/content/ThreadConversation.vue index 7f7976bf2..74efcd7b0 100644 --- a/src/components/content/ThreadConversation.vue +++ b/src/components/content/ThreadConversation.vue @@ -572,6 +572,14 @@ + + Send feedback +
@@ -910,6 +918,14 @@ function prepareLiveErrorFeedback(event: MouseEvent, message: string): void { } } +function prepareTurnErrorFeedback(event: MouseEvent, message: string): void { + recordVisibleFailure(message) + const target = event.currentTarget + if (target instanceof HTMLAnchorElement) { + target.href = buildFeedbackMailto() + } +} + function parsePlanFromMessageText(text: string): { explanation: string; steps: UiPlanStep[] } | null { const normalized = text.replace(/\r\n/g, '\n').trim() if (!normalized) return null @@ -965,6 +981,10 @@ function isPlanMessage(message: UiMessage): boolean { return message.messageType === 'plan' || message.messageType === 'plan.live' } +function isTurnErrorMessage(message: UiMessage): boolean { + return message.messageType === 'turnError' +} + function buildPlanMessageText(explanation: string, steps: UiPlanStep[]): string { const lines: string[] = [] if (explanation.trim()) { @@ -4433,6 +4453,10 @@ onBeforeUnmount(() => { @apply shrink-0 rounded-full border border-rose-200 bg-white px-2.5 py-1 text-xs font-semibold leading-none text-rose-700 transition hover:bg-rose-100 focus:outline-none focus:ring-2 focus:ring-rose-300; } +.turn-error-feedback { + @apply mt-3 inline-flex w-fit rounded-full border border-rose-200 bg-white px-2.5 py-1 text-xs font-semibold leading-none text-rose-700 transition hover:bg-rose-100 focus:outline-none focus:ring-2 focus:ring-rose-300; +} + .message-body { @apply flex flex-col min-w-0 max-w-full; width: fit-content; diff --git a/src/composables/useDesktopState.test.ts b/src/composables/useDesktopState.test.ts index b8ab329cc..a31b1f397 100644 --- a/src/composables/useDesktopState.test.ts +++ b/src/composables/useDesktopState.test.ts @@ -450,6 +450,98 @@ describe('Codex CLI availability', () => { }) }) +describe('live error overlay', () => { + it('keeps a new live error visible when an older persisted turn error exists', async () => { + installTestWindow() + let notificationHandler: (notification: { method: string; params?: unknown }) => void = () => {} + gatewayMocks.subscribeCodexNotifications.mockImplementation((handler) => { + notificationHandler = handler + return vi.fn() + }) + gatewayMocks.getPendingServerRequests.mockResolvedValue([]) + gatewayMocks.resumeThread.mockResolvedValue(null) + gatewayMocks.getThreadDetail.mockResolvedValue({ + messages: [ + { + id: 'old-error', + role: 'system', + text: 'old persisted failure', + messageType: 'turnError', + }, + ], + inProgress: false, + activeTurnId: '', + turnIndexByTurnId: {}, + hasMoreOlder: false, + }) + + const state = useDesktopState() + state.primeSelectedThread('thread-with-errors') + await state.loadMessages('thread-with-errors') + state.startPolling() + + notificationHandler?.({ + method: 'turn/completed', + params: { + threadId: 'thread-with-errors', + turnId: 'new-turn', + turn: { + id: 'new-turn', + status: 'failed', + error: { message: 'new live failure' }, + }, + }, + }) + + expect(state.selectedLiveOverlay.value?.errorText).toBe('new live failure') + }) + + it('suppresses a live error only after that same error has persisted', async () => { + installTestWindow() + let notificationHandler: (notification: { method: string; params?: unknown }) => void = () => {} + gatewayMocks.subscribeCodexNotifications.mockImplementation((handler) => { + notificationHandler = handler + return vi.fn() + }) + gatewayMocks.getPendingServerRequests.mockResolvedValue([]) + gatewayMocks.resumeThread.mockResolvedValue(null) + gatewayMocks.getThreadDetail.mockResolvedValue({ + messages: [ + { + id: 'persisted-error', + role: 'system', + text: 'same failure', + messageType: 'turnError', + }, + ], + inProgress: false, + activeTurnId: '', + turnIndexByTurnId: {}, + hasMoreOlder: false, + }) + + const state = useDesktopState() + state.primeSelectedThread('thread-with-persisted-error') + await state.loadMessages('thread-with-persisted-error') + state.startPolling() + + notificationHandler?.({ + method: 'turn/completed', + params: { + threadId: 'thread-with-persisted-error', + turnId: 'same-turn', + turn: { + id: 'same-turn', + status: 'failed', + error: { message: 'same failure' }, + }, + }, + }) + + expect(state.selectedLiveOverlay.value).toBe(null) + }) +}) + describe('provider model selection', () => { it('ignores global selected-model localStorage when OpenCode Zen is the active provider', async () => { installTestWindow({ @@ -489,7 +581,7 @@ describe('provider model selection', () => { expect(state.selectedModelId.value).toBe('big-pickle') expect(state.readModelIdForThread('').trim()).toBe('big-pickle') expect(JSON.parse(window.localStorage.getItem('codex-web-local.selected-model-by-context.v1') ?? '{}')).toEqual({ - '__new-thread-provider__::opencode-zen': 'big-pickle', + '__new-thread-provider__::opencode-zen': { providerId: 'opencode-zen', modelId: 'big-pickle' }, }) expect(window.localStorage.getItem('codex-web-local.selected-model-id.v1')).toBe(null) }) @@ -527,8 +619,243 @@ describe('provider model selection', () => { expect(state.selectedModelId.value).toBe('ring-2.6-1t-free') expect(state.readModelIdForThread('').trim()).toBe('ring-2.6-1t-free') expect(JSON.parse(window.localStorage.getItem('codex-web-local.selected-model-by-context.v1') ?? '{}')).toEqual({ - '__new-thread-provider__::opencode-zen': 'ring-2.6-1t-free', + '__new-thread-provider__::opencode-zen': { providerId: 'opencode-zen', modelId: 'ring-2.6-1t-free' }, + }) + }) + + it('stores the new-thread Codex model in a provider-scoped slot', async () => { + installTestWindow({ + 'codex-web-local.selected-model-by-context.v1': JSON.stringify({ + '__new-thread-provider__::openrouter-free': 'openrouter/free', + }), + }) + gatewayMocks.getThreadGroupsPage.mockResolvedValue({ groups: [], nextCursor: null }) + gatewayMocks.getAvailableCollaborationModes.mockResolvedValue([{ value: 'default', label: 'Default' }]) + gatewayMocks.getSkillsList.mockResolvedValue([]) + gatewayMocks.getAccountRateLimits.mockResolvedValue(null) + gatewayMocks.getCurrentModelConfig.mockResolvedValue({ + model: 'gpt-5.5', + providerId: '', + reasoningEffort: 'medium', + speedMode: 'standard', + }) + gatewayMocks.getAvailableModelIds.mockResolvedValue([ + 'gpt-5.5', + 'gpt-5.4-mini', + ]) + + const state = useDesktopState() + await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) + + expect(state.selectedModelId.value).toBe('gpt-5.5') + expect(state.readModelIdForThread('').trim()).toBe('gpt-5.5') + expect(JSON.parse(window.localStorage.getItem('codex-web-local.selected-model-by-context.v1') ?? '{}')).toEqual({ + '__new-thread-provider__::openrouter-free': 'openrouter/free', + '__new-thread-provider__::codex': { providerId: 'codex', modelId: 'gpt-5.5' }, + }) + }) + + it('ignores a legacy OpenCode Zen model when Codex is the active provider and rewrites to a Codex model', async () => { + installTestWindow({ + 'codex-web-local.selected-model-by-context.v1': JSON.stringify({ + '__new-thread__': 'big-pickle', + }), + }) + gatewayMocks.getThreadGroupsPage.mockResolvedValue({ groups: [], nextCursor: null }) + gatewayMocks.getAvailableCollaborationModes.mockResolvedValue([{ value: 'default', label: 'Default' }]) + gatewayMocks.getSkillsList.mockResolvedValue([]) + gatewayMocks.getAccountRateLimits.mockResolvedValue(null) + gatewayMocks.getCurrentModelConfig.mockResolvedValue({ + model: 'gpt-5.5', + providerId: '', + reasoningEffort: 'medium', + speedMode: 'standard', + }) + gatewayMocks.getAvailableModelIds.mockResolvedValue([ + 'gpt-5.5', + 'gpt-5.4-mini', + ]) + + const state = useDesktopState() + await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) + + expect(state.availableModelIds.value).toEqual(['gpt-5.5', 'gpt-5.4-mini']) + expect(state.selectedModelId.value).toBe('gpt-5.5') + expect(state.readModelIdForThread('').trim()).toBe('gpt-5.5') + expect(JSON.parse(window.localStorage.getItem('codex-web-local.selected-model-by-context.v1') ?? '{}')).toEqual({ + '__new-thread-provider__::codex': { providerId: 'codex', modelId: 'gpt-5.5' }, + }) + }) + + it('ignores an OpenCode Zen selection object when Codex is the active provider', async () => { + installTestWindow({ + 'codex-web-local.selected-model-by-context.v1': JSON.stringify({ + '__new-thread__': { providerId: 'opencode-zen', modelId: 'big-pickle' }, + }), + }) + gatewayMocks.getThreadGroupsPage.mockResolvedValue({ groups: [], nextCursor: null }) + gatewayMocks.getAvailableCollaborationModes.mockResolvedValue([{ value: 'default', label: 'Default' }]) + gatewayMocks.getSkillsList.mockResolvedValue([]) + gatewayMocks.getAccountRateLimits.mockResolvedValue(null) + gatewayMocks.getCurrentModelConfig.mockResolvedValue({ + model: 'gpt-5.5', + providerId: 'codex', + reasoningEffort: 'medium', + speedMode: 'standard', + }) + gatewayMocks.getAvailableModelIds.mockResolvedValue(['gpt-5.5', 'gpt-5.4-mini']) + + const state = useDesktopState() + await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) + + expect(state.availableModelIds.value).toEqual(['gpt-5.5', 'gpt-5.4-mini']) + expect(state.selectedModelId.value).toBe('gpt-5.5') + expect(state.readModelIdForThread('').trim()).toBe('gpt-5.5') + }) + + it('restores a matching OpenCode Zen selection object when OpenCode Zen is active', async () => { + installTestWindow({ + 'codex-web-local.selected-model-by-context.v1': JSON.stringify({ + '__new-thread-provider__::opencode-zen': { providerId: 'opencode-zen', modelId: 'big-pickle' }, + }), + }) + gatewayMocks.getThreadGroupsPage.mockResolvedValue({ groups: [], nextCursor: null }) + gatewayMocks.getAvailableCollaborationModes.mockResolvedValue([{ value: 'default', label: 'Default' }]) + gatewayMocks.getSkillsList.mockResolvedValue([]) + gatewayMocks.getAccountRateLimits.mockResolvedValue(null) + gatewayMocks.getCurrentModelConfig.mockResolvedValue({ + model: 'ring-2.6-1t-free', + providerId: 'opencode-zen', + reasoningEffort: 'medium', + speedMode: 'standard', + }) + gatewayMocks.getAvailableModelIds.mockResolvedValue([ + 'big-pickle', + 'ring-2.6-1t-free', + ]) + + const state = useDesktopState() + await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) + + expect(state.selectedModelId.value).toBe('big-pickle') + expect(state.readModelIdForThread('').trim()).toBe('big-pickle') + }) + + it('does not carry custom or OpenRouter models into the Codex dropdown during provider switches', async () => { + installTestWindow({ + 'codex-web-local.selected-model-by-context.v1': JSON.stringify({ + '__new-thread-provider__::custom': { providerId: 'custom', modelId: 'custom-model' }, + '__new-thread-provider__::openrouter': { providerId: 'openrouter', modelId: 'openrouter-model' }, + }), + }) + gatewayMocks.getThreadGroupsPage.mockResolvedValue({ groups: [], nextCursor: null }) + gatewayMocks.getAvailableCollaborationModes.mockResolvedValue([{ value: 'default', label: 'Default' }]) + gatewayMocks.getSkillsList.mockResolvedValue([]) + gatewayMocks.getAccountRateLimits.mockResolvedValue(null) + gatewayMocks.getCurrentModelConfig + .mockResolvedValueOnce({ + model: 'custom-model', + providerId: 'custom', + reasoningEffort: 'medium', + speedMode: 'standard', + }) + .mockResolvedValueOnce({ + model: 'openrouter-model', + providerId: 'openrouter', + reasoningEffort: 'medium', + speedMode: 'standard', + }) + .mockResolvedValueOnce({ + model: 'gpt-5.5', + providerId: 'codex', + reasoningEffort: 'medium', + speedMode: 'standard', + }) + gatewayMocks.getAvailableModelIds + .mockResolvedValueOnce(['custom-model']) + .mockResolvedValueOnce(['openrouter-model']) + .mockResolvedValueOnce(['gpt-5.5', 'gpt-5.4-mini']) + + const state = useDesktopState() + await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true, providerChanged: true }) + expect(state.availableModelIds.value).toEqual(['custom-model']) + expect(state.selectedModelId.value).toBe('custom-model') + + await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true, providerChanged: true }) + expect(state.availableModelIds.value).toEqual(['openrouter-model']) + expect(state.selectedModelId.value).toBe('openrouter-model') + + await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true, providerChanged: true }) + expect(state.availableModelIds.value).toEqual(['gpt-5.5', 'gpt-5.4-mini']) + expect(state.availableModelIds.value).not.toContain('custom-model') + expect(state.availableModelIds.value).not.toContain('openrouter-model') + expect(state.selectedModelId.value).toBe('gpt-5.5') + }) + + it('does not append a stale configured OpenCode Zen model to OpenRouter models', async () => { + installTestWindow({ + 'codex-web-local.selected-model-by-context.v1': JSON.stringify({ + '__new-thread-provider__::opencode-zen': { providerId: 'opencode-zen', modelId: 'big-pickle' }, + }), + }) + gatewayMocks.getThreadGroupsPage.mockResolvedValue({ groups: [], nextCursor: null }) + gatewayMocks.getAvailableCollaborationModes.mockResolvedValue([{ value: 'default', label: 'Default' }]) + gatewayMocks.getSkillsList.mockResolvedValue([]) + gatewayMocks.getAccountRateLimits.mockResolvedValue(null) + gatewayMocks.getCurrentModelConfig.mockResolvedValue({ + model: 'big-pickle', + providerId: 'openrouter', + reasoningEffort: 'medium', + speedMode: 'standard', + }) + gatewayMocks.getAvailableModelIds.mockResolvedValue([ + 'openrouter/free', + 'inclusionai/ring-2.6-1t:free', + ]) + + const state = useDesktopState() + await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) + + expect(state.availableModelIds.value).toEqual([ + 'openrouter/free', + 'inclusionai/ring-2.6-1t:free', + ]) + expect(state.availableModelIds.value).not.toContain('big-pickle') + expect(state.selectedModelId.value).toBe('openrouter/free') + expect(state.readModelIdForThread('').trim()).toBe('openrouter/free') + }) + + it('does not append a stale configured model to custom provider models', async () => { + installTestWindow({ + 'codex-web-local.selected-model-by-context.v1': JSON.stringify({ + '__new-thread-provider__::opencode-zen': { providerId: 'opencode-zen', modelId: 'big-pickle' }, + }), }) + gatewayMocks.getThreadGroupsPage.mockResolvedValue({ groups: [], nextCursor: null }) + gatewayMocks.getAvailableCollaborationModes.mockResolvedValue([{ value: 'default', label: 'Default' }]) + gatewayMocks.getSkillsList.mockResolvedValue([]) + gatewayMocks.getAccountRateLimits.mockResolvedValue(null) + gatewayMocks.getCurrentModelConfig.mockResolvedValue({ + model: 'big-pickle', + providerId: 'custom_endpoint', + reasoningEffort: 'medium', + speedMode: 'standard', + }) + gatewayMocks.getAvailableModelIds.mockResolvedValue([ + '01-ai/yi-large', + 'abacusai/dracarys-llama-3.1-70b-instruct', + ]) + + const state = useDesktopState() + await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) + + expect(state.availableModelIds.value).toEqual([ + '01-ai/yi-large', + 'abacusai/dracarys-llama-3.1-70b-instruct', + ]) + expect(state.availableModelIds.value).not.toContain('big-pickle') + expect(state.selectedModelId.value).toBe('01-ai/yi-large') + expect(state.readModelIdForThread('').trim()).toBe('01-ai/yi-large') }) }) diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index 2a37fee55..48e9f7fa6 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -155,8 +155,22 @@ function normalizeCollaborationMode(value: unknown): CollaborationModeKind { return value === 'plan' ? 'plan' : 'default' } +type StoredModelSelection = string | { + providerId: string + modelId: string +} + function normalizeStoredModelId(value: unknown): string { - return typeof value === 'string' ? value.trim() : '' + if (typeof value === 'string') return value.trim() + if (!value || typeof value !== 'object' || Array.isArray(value)) return '' + const record = value as Record + return typeof record.modelId === 'string' ? record.modelId.trim() : '' +} + +function normalizeStoredModelProviderId(value: unknown): string { + if (!value || typeof value !== 'object' || Array.isArray(value)) return '' + const record = value as Record + return typeof record.providerId === 'string' ? normalizeProviderContextId(record.providerId) : '' } function createStringKeyedRecord(): Record { @@ -222,21 +236,31 @@ function toThreadContextId(threadId: string): string { return normalizedThreadId || NEW_THREAD_COLLABORATION_MODE_CONTEXT } -function loadSelectedModelMap(): Record { - if (typeof window === 'undefined') return createStringKeyedRecord() +function createStoredModelSelection(providerId: string, modelId: string): StoredModelSelection { + return { + providerId: normalizeProviderContextId(providerId), + modelId: modelId.trim(), + } +} + +function loadSelectedModelMap(): Record { + if (typeof window === 'undefined') return createStringKeyedRecord() try { const raw = window.localStorage.getItem(SELECTED_MODEL_BY_CONTEXT_STORAGE_KEY) if (raw) { const parsed = JSON.parse(raw) as unknown - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return createStringKeyedRecord() + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return createStringKeyedRecord() - const next = createStringKeyedRecord() + const next = createStringKeyedRecord() for (const [contextId, value] of Object.entries(parsed as Record)) { if (typeof contextId !== 'string' || contextId.length === 0) continue const normalizedModelId = normalizeStoredModelId(value) if (normalizedModelId) { - next[contextId] = normalizedModelId + const normalizedProviderId = normalizeStoredModelProviderId(value) + next[contextId] = normalizedProviderId + ? createStoredModelSelection(normalizedProviderId, normalizedModelId) + : normalizedModelId } } return next @@ -246,7 +270,7 @@ function loadSelectedModelMap(): Record { } const legacyModelId = normalizeStoredModelId(window.localStorage.getItem(LEGACY_SELECTED_MODEL_STORAGE_KEY)) - const next = createStringKeyedRecord() + const next = createStringKeyedRecord() if (legacyModelId) { next[NEW_THREAD_COLLABORATION_MODE_CONTEXT] = legacyModelId } @@ -254,7 +278,7 @@ function loadSelectedModelMap(): Record { } function readSelectedModel( - state: Record, + state: Record, threadId: string, ): string { const contextId = toThreadContextId(threadId) @@ -263,7 +287,7 @@ function readSelectedModel( return normalizeStoredModelId(state[NEW_THREAD_COLLABORATION_MODE_CONTEXT]) } -function saveSelectedModelMap(state: Record): void { +function saveSelectedModelMap(state: Record): void { if (typeof window === 'undefined') return try { if (Object.keys(state).length === 0) { @@ -1386,7 +1410,7 @@ export function useDesktopState() { const selectedCollaborationModeByContext = ref>( loadSelectedCollaborationModeMap(), ) - const selectedModelIdByContext = ref>(loadSelectedModelMap()) + const selectedModelIdByContext = ref>(loadSelectedModelMap()) const selectedCollaborationMode = ref( readSelectedCollaborationMode(selectedCollaborationModeByContext.value, selectedThreadId.value), ) @@ -1521,7 +1545,21 @@ export function useDesktopState() { const reasoningText = isInProgress ? (liveReasoningTextByThreadId.value[threadId] ?? '').trim() : '' - const errorText = (turnErrorByThreadId.value[threadId]?.message ?? '').trim() + 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 if (!activity && !reasoningText && !errorText) return null return { @@ -1570,18 +1608,30 @@ export function useDesktopState() { return '' } + 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 readModelIdForThread(threadId: string): string { const contextId = toThreadContextId(threadId) if (contextId === NEW_THREAD_COLLABORATION_MODE_CONTEXT) { const normalizedProviderId = normalizeProviderContextId(activeProviderId.value) - if (normalizedProviderId !== 'codex') { - const providerContextId = toProviderModelContextId(normalizedProviderId) - return providerContextId - ? normalizeStoredModelId(selectedModelIdByContext.value[providerContextId]) - : '' - } + const providerContextId = toProviderModelContextId(normalizedProviderId) + const providerModelId = providerContextId + ? readCompatibleStoredModelId(selectedModelIdByContext.value[providerContextId]) + : '' + if (providerModelId) return providerModelId } - return readSelectedModel(selectedModelIdByContext.value, threadId).trim() + const contextModelId = readCompatibleStoredModelId(selectedModelIdByContext.value[contextId]) + if (contextModelId) return contextModelId + return readCompatibleStoredModelId(selectedModelIdByContext.value[NEW_THREAD_COLLABORATION_MODE_CONTEXT]) } function ensureAvailableModelIds(...modelIds: string[]): void { @@ -1597,12 +1647,21 @@ export function useDesktopState() { } } + function ensureCompatibleAvailableModelIds(...modelIds: string[]): void { + const compatibleModelIds = modelIds + .map((modelId) => modelId.trim()) + .filter((modelId) => modelId && availableModelIds.value.includes(modelId)) + if (compatibleModelIds.length > 0) { + ensureAvailableModelIds(...compatibleModelIds) + } + } + function setSelectedThreadId(nextThreadId: string): void { if (selectedThreadId.value === nextThreadId) return selectedThreadId.value = nextThreadId saveSelectedThreadId(nextThreadId) selectedModelId.value = readModelIdForThread(nextThreadId) - ensureAvailableModelIds(selectedModelId.value) + ensureCompatibleAvailableModelIds(selectedModelId.value) selectedCollaborationMode.value = readSelectedCollaborationMode( selectedCollaborationModeByContext.value, nextThreadId, @@ -1616,13 +1675,13 @@ export function useDesktopState() { const contextId = toThreadContextId(threadId) const normalizedProviderId = normalizeProviderContextId(activeProviderId.value) const providerContextId = - contextId === NEW_THREAD_COLLABORATION_MODE_CONTEXT && normalizedProviderId !== 'codex' + contextId === NEW_THREAD_COLLABORATION_MODE_CONTEXT ? toProviderModelContextId(normalizedProviderId) : '' const selectedContextId = providerContextId || contextId if (normalizedModelId) { const nextModelMap = cloneStringKeyedRecord(selectedModelIdByContext.value) - nextModelMap[selectedContextId] = normalizedModelId + nextModelMap[selectedContextId] = createStoredModelSelection(normalizedProviderId, normalizedModelId) if (providerContextId) { delete nextModelMap[contextId] } @@ -1636,9 +1695,9 @@ export function useDesktopState() { } if (threadId.trim() === selectedThreadId.value) { selectedModelId.value = readModelIdForThread(selectedThreadId.value) - ensureAvailableModelIds(selectedModelId.value) + ensureCompatibleAvailableModelIds(selectedModelId.value) } else { - ensureAvailableModelIds(normalizedModelId) + ensureCompatibleAvailableModelIds(normalizedModelId) } saveSelectedModelMap(selectedModelIdByContext.value) } @@ -1654,12 +1713,12 @@ export function useDesktopState() { const normalizedModelId = modelId.trim() if (normalizedModelId) { const nextModelMap = cloneStringKeyedRecord(selectedModelIdByContext.value) - nextModelMap[normalizedThreadId] = normalizedModelId + nextModelMap[normalizedThreadId] = createStoredModelSelection(activeProviderId.value, normalizedModelId) selectedModelIdByContext.value = nextModelMap } else { selectedModelIdByContext.value = omitStringKeyedRecordKey(selectedModelIdByContext.value, normalizedThreadId) } - ensureAvailableModelIds(normalizedModelId) + ensureCompatibleAvailableModelIds(normalizedModelId) if (selectedThreadId.value === normalizedThreadId) { selectedModelId.value = readModelIdForThread(selectedThreadId.value) } @@ -1870,18 +1929,19 @@ export function useDesktopState() { const normalizedProviderId = normalizeProviderContextId(currentConfig.providerId) const isProviderBacked = normalizedProviderId !== 'codex' activeProviderId.value = normalizedProviderId - const normalizedSelectedModelId = readModelIdForThread(selectedThreadId.value) const modelIds = await getAvailableModelIds({ includeProviderModels: options?.includeProviderModels !== false || isProviderBacked, requireProviderModels: isProviderBacked, }) const providerModelContextId = toProviderModelContextId(normalizedProviderId) const providerScopedModelId = providerModelContextId - ? normalizeStoredModelId(selectedModelIdByContext.value[providerModelContextId]) + ? readCompatibleStoredModelId(selectedModelIdByContext.value[providerModelContextId]) : '' + availableModelIds.value = [...modelIds] + const normalizedSelectedModelId = readModelIdForThread(selectedThreadId.value) const nextModelIds = [...modelIds] - if (!options?.providerChanged) { - const extraModelIds = isProviderBacked ? [normalizedConfiguredModelId] : [normalizedSelectedModelId, normalizedConfiguredModelId] + if (!options?.providerChanged && !isProviderBacked) { + const extraModelIds = [normalizedConfiguredModelId] for (const modelId of extraModelIds) { if (modelId && !nextModelIds.includes(modelId)) { nextModelIds.push(modelId) @@ -1912,7 +1972,7 @@ export function useDesktopState() { } if (providerModelContextId && selectedModelId.value.trim().length > 0) { const nextModelMap = cloneStringKeyedRecord(selectedModelIdByContext.value) - nextModelMap[providerModelContextId] = selectedModelId.value.trim() + nextModelMap[providerModelContextId] = createStoredModelSelection(normalizedProviderId, selectedModelId.value.trim()) selectedModelIdByContext.value = nextModelMap saveSelectedModelMap(selectedModelIdByContext.value) } diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index 3e931a0f8..1750d293c 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -1,11 +1,13 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { callRpcWithArchiveRecovery, + ensureDefaultFreeModeStateForMissingAuthSync, hasUsableCodexAuth, isEmptyThreadReadError, + isThreadMaterializationPendingError, isUnauthenticatedRateLimitError, } from './codexAppServerBridge' @@ -99,6 +101,14 @@ describe('isUnauthenticatedRateLimitError', () => { expect(isUnauthenticatedRateLimitError(new Error('codex account authentication required to read rate limits'))).toBe(true) }) + it('matches direct message fields from Codex stream errors', () => { + expect(isUnauthenticatedRateLimitError({ + message: 'codex account authentication required to read rate limits', + codexErrorInfo: 'other', + additionalDetails: null, + })).toBe(true) + }) + it('does not match unrelated authentication failures', () => { expect(isUnauthenticatedRateLimitError(new Error('codex account authentication required to send messages'))).toBe(false) expect(isUnauthenticatedRateLimitError(new Error('failed to read rate limits'))).toBe(false) @@ -118,6 +128,19 @@ describe('isEmptyThreadReadError', () => { }) }) +describe('isThreadMaterializationPendingError', () => { + it('matches Codex live-state reads before the first message is materialized', () => { + expect(isThreadMaterializationPendingError(new Error( + 'thread 019e1f04-dca4-7823-8b9a-554b9bd22f57 is not materialized yet; includeTurns is unavailable before first user message', + ))).toBe(true) + }) + + it('does not match unrelated thread read failures', () => { + expect(isThreadMaterializationPendingError(new Error('thread read failed: permission denied'))).toBe(false) + expect(isThreadMaterializationPendingError(new Error('not materialized yet'))).toBe(false) + }) +}) + describe('hasUsableCodexAuth', () => { it('returns false when auth.json is missing or does not contain usable tokens', async () => { const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-no-token-')) @@ -161,3 +184,99 @@ describe('hasUsableCodexAuth', () => { } }) }) + +describe('ensureDefaultFreeModeStateForMissingAuthSync', () => { + it('uses OpenCode Zen as a runtime fallback without creating a state file', async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-runtime-zen-')) + const statePath = join(codexHome, 'webui-custom-providers.json') + process.env.CODEX_HOME = codexHome + try { + const state = ensureDefaultFreeModeStateForMissingAuthSync(statePath) + + expect(state?.enabled).toBe(true) + expect(state?.provider).toBe('opencode-zen') + await expect(stat(statePath)).rejects.toThrow() + } finally { + await rm(codexHome, { recursive: true, force: true }) + } + }) + + it('does not synthesize OpenCode Zen after Codex auth exists and no state file is present', async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-auth-no-state-')) + const statePath = join(codexHome, 'webui-custom-providers.json') + process.env.CODEX_HOME = codexHome + try { + await writeFile(join(codexHome, 'auth.json'), JSON.stringify({ tokens: { access_token: 'access-token' } })) + + expect(ensureDefaultFreeModeStateForMissingAuthSync(statePath)).toBeNull() + await expect(stat(statePath)).rejects.toThrow() + } finally { + await rm(codexHome, { recursive: true, force: true }) + } + }) + + it('ignores community provider state after Codex auth appears', async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-auth-community-provider-')) + const statePath = join(codexHome, 'webui-custom-providers.json') + process.env.CODEX_HOME = codexHome + try { + await writeFile(join(codexHome, 'auth.json'), JSON.stringify({ tokens: { access_token: 'access-token' } })) + await writeFile(statePath, JSON.stringify({ + enabled: true, + apiKey: 'community-openrouter-key', + model: 'openrouter/free', + customKey: false, + provider: 'openrouter', + wireApi: 'responses', + })) + + expect(ensureDefaultFreeModeStateForMissingAuthSync(statePath)).toBeNull() + } finally { + await rm(codexHome, { recursive: true, force: true }) + } + }) + + it('keeps user configured provider state after Codex auth appears', async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-auth-custom-provider-')) + const statePath = join(codexHome, 'webui-custom-providers.json') + process.env.CODEX_HOME = codexHome + try { + await writeFile(join(codexHome, 'auth.json'), JSON.stringify({ tokens: { access_token: 'access-token' } })) + const configuredState = { + enabled: true, + apiKey: 'user-openrouter-key', + model: 'openrouter/model', + customKey: true, + provider: 'openrouter', + wireApi: 'responses', + } + await writeFile(statePath, JSON.stringify(configuredState)) + + expect(ensureDefaultFreeModeStateForMissingAuthSync(statePath)).toEqual(configuredState) + } finally { + await rm(codexHome, { recursive: true, force: true }) + } + }) + + it('ignores the legacy free-mode state filename instead of migrating it', async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-legacy-free-mode-')) + const legacyStatePath = join(codexHome, 'webui-free-mode.json') + const statePath = join(codexHome, 'webui-custom-providers.json') + process.env.CODEX_HOME = codexHome + try { + await writeFile(legacyStatePath, JSON.stringify({ + enabled: true, + apiKey: null, + model: 'legacy-model', + provider: 'opencode-zen', + wireApi: 'responses', + })) + await writeFile(join(codexHome, 'auth.json'), JSON.stringify({ tokens: { access_token: 'access-token' } })) + + expect(ensureDefaultFreeModeStateForMissingAuthSync(statePath)).toBeNull() + await expect(stat(statePath)).rejects.toThrow() + } finally { + await rm(codexHome, { recursive: true, force: true }) + } + }) +}) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 3372e347d..14bd0a276 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -1,7 +1,7 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' import { createHash, randomBytes } from 'node:crypto' import { mkdtemp, readFile, readdir, rename, rm, mkdir, stat, cp, lstat, readlink, symlink } from 'node:fs/promises' -import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createReadStream, existsSync, readFileSync } from 'node:fs' import type { IncomingMessage, ServerResponse } from 'node:http' import { request as httpRequest } from 'node:http' import { request as httpsRequest } from 'node:https' @@ -18,7 +18,6 @@ import { TelegramThreadBridge } from './telegramThreadBridge.js' import { getRandomFreeKey, getFreeKeyCount, - FREE_MODE_PROVIDER_ID, FREE_MODE_DEFAULT_MODEL, getCachedFreeModels, getFreeModels, @@ -30,6 +29,7 @@ import { getFreeModeConfigArgs, getFreeModeEnvVars, shouldCreateDefaultFreeModeStateForMissingAuth, + shouldSuppressCommunityFreeModeForCodexAuth, type FreeModeState, } from './freeMode.js' import { handleOpenRouterProxyRequest } from './openRouterProxy.js' @@ -911,6 +911,8 @@ function getErrorMessage(payload: unknown, fallback: string): string { const record = asRecord(payload) if (!record) return fallback + if (typeof record.message === 'string' && record.message.length > 0) return record.message + const error = record.error if (typeof error === 'string' && error.length > 0) return error @@ -932,6 +934,83 @@ export function isEmptyThreadReadError(error: unknown): boolean { return message.includes('failed to read thread') && message.includes('rollout') && message.includes('is empty') } +export function isThreadMaterializationPendingError(error: unknown): boolean { + const message = getErrorMessage(error, '').toLowerCase() + return message.includes('not materialized yet') && message.includes('includeturns is unavailable before first user message') +} + +function readStreamTurnId(params: Record): string { + const directTurnId = readNonEmptyString(params.turnId) || readNonEmptyString(params.turn_id) + if (directTurnId) return directTurnId + const turn = asRecord(params.turn) + return readNonEmptyString(turn?.id) +} + +function readStreamTurnErrorMessage(frame: StreamEventFrame): { turnId: string; message: string } | null { + const params = asRecord(frame.params) + if (!params) return null + const turnId = readStreamTurnId(params) + if (!turnId) return null + + if (frame.method === 'turn/completed') { + const turn = asRecord(params.turn) + if (turn?.status !== 'failed') return null + const message = getErrorMessage(turn.error, '') + return message ? { turnId, message } : null + } + + if (frame.method === 'error' && params.willRetry !== true) { + const message = getErrorMessage(params.error, '') || readNonEmptyString(params.message) + return message ? { turnId, message } : null + } + + return null +} + +function mergeStreamTurnErrorsIntoThreadResult(appServer: AppServerProcess, result: unknown): unknown { + const record = asRecord(result) + const thread = asRecord(record?.thread) + const threadId = readNonEmptyString(thread?.id) + const turns = Array.isArray(thread?.turns) ? thread.turns : null + if (!record || !thread || !threadId || !turns || turns.length === 0) return result + + const errorsByTurnId = new Map() + for (const frame of appServer.getStreamEvents(threadId, STREAM_EVENT_BUFFER_LIMIT)) { + const error = readStreamTurnErrorMessage(frame) + if (error) errorsByTurnId.set(error.turnId, error.message) + } + if (errorsByTurnId.size === 0) return result + + let changed = false + const mergedTurns = turns.map((turn) => { + const turnRecord = asRecord(turn) + const turnId = readNonEmptyString(turnRecord?.id) + const message = turnId ? errorsByTurnId.get(turnId) : '' + if (!turnRecord || !turnId || !message) return turn + const existingErrorMessage = getErrorMessage(turnRecord.error, '') + if (turnRecord.status === 'failed' && existingErrorMessage) return turn + changed = true + return { + ...turnRecord, + status: 'failed', + error: { + message, + codexErrorInfo: null, + additionalDetails: null, + }, + } + }) + + if (!changed) return result + return { + ...record, + thread: { + ...thread, + turns: mergedTurns, + }, + } +} + const warnedCodexAuthReadFailures = new Set() function getErrorCode(error: unknown): string | null { @@ -3270,17 +3349,17 @@ function readFreeModeStateSync(statePath: string): FreeModeState | null { } } -function ensureDefaultFreeModeStateForMissingAuthSync(statePath: string): FreeModeState | null { +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() } function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean { @@ -5819,9 +5898,9 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { let bearerToken = '' let wireApi: 'responses' | 'chat' = 'chat' try { - const state = JSON.parse(readFileSync(statePath, 'utf8')) as FreeModeState - bearerToken = state.apiKey ?? '' - wireApi = state.wireApi === 'responses' ? 'responses' : 'chat' + const state = ensureDefaultFreeModeStateForMissingAuthSync(statePath) + bearerToken = state?.apiKey ?? '' + wireApi = state?.wireApi === 'responses' ? 'responses' : 'chat' } catch { /* use empty */ } handleZenProxyRequest(req, res, bearerToken, wireApi) return @@ -5846,10 +5925,10 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { let wireApi: 'responses' | 'chat' = 'responses' let baseUrl = '' try { - const state = JSON.parse(readFileSync(statePath, 'utf8')) as FreeModeState - bearerToken = state.apiKey ?? '' - wireApi = state.wireApi === 'chat' ? 'chat' : 'responses' - baseUrl = state.customBaseUrl ?? '' + const state = ensureDefaultFreeModeStateForMissingAuthSync(statePath) + bearerToken = state?.apiKey ?? '' + wireApi = state?.wireApi === 'chat' ? 'chat' : 'responses' + baseUrl = state?.customBaseUrl ?? '' } catch { /* use empty */ } handleCustomEndpointProxyRequest(req, res, { baseUrl, bearerToken, wireApi }) return @@ -5958,6 +6037,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { } setJson(res, 200, { enabled: state.enabled, + hasCodexAuth: hasUsableCodexAuthSync(), keyCount: getFreeKeyCount(), models, currentModel, @@ -6246,7 +6326,10 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { throw error } const trimmedResult = trimThreadTurnsInRpcResult(body.method, rpcResult) - const sanitizedResult = await sanitizeThreadTurnsInlinePayloads(body.method, trimmedResult) + const errorMergedResult = THREAD_METHODS_WITH_TURNS.has(body.method) + ? mergeStreamTurnErrorsIntoThreadResult(appServer, trimmedResult) + : trimmedResult + const sanitizedResult = await sanitizeThreadTurnsInlinePayloads(body.method, errorMergedResult) const result = THREAD_METHODS_WITH_TURNS.has(body.method) ? await mergeSessionSkillInputsIntoThreadResult(sanitizedResult) : sanitizedResult @@ -6275,7 +6358,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { return } - const threadReadResult = await appServer.readThreadForTurnPage(threadId) + const threadReadResult = mergeStreamTurnErrorsIntoThreadResult(appServer, await appServer.readThreadForTurnPage(threadId)) const record = asRecord(threadReadResult) const thread = asRecord(record?.thread) if (!record || !thread) { @@ -6375,10 +6458,10 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { } try { - const threadReadResult = await appServer.rpc('thread/read', { + const threadReadResult = mergeStreamTurnErrorsIntoThreadResult(appServer, await appServer.rpc('thread/read', { threadId, includeTurns: true, - }) + })) const sanitized = await sanitizeThreadTurnsInlinePayloads('thread/read', threadReadResult) appServer.storeThreadReadSnapshot(threadId, sanitized) @@ -6431,6 +6514,17 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { setJson(res, 200, responseData) } catch (error) { + if (isThreadMaterializationPendingError(error)) { + setJson(res, 200, { + threadId, + conversationState: { turns: [] }, + ownerClientId: null, + liveStateError: null, + isInProgress: true, + }) + return + } + const snapshot = appServer.getLastThreadReadSnapshot(threadId) if (snapshot) { const record = asRecord(snapshot) diff --git a/src/server/freeMode.test.ts b/src/server/freeMode.test.ts index a3b512e18..c618594db 100644 --- a/src/server/freeMode.test.ts +++ b/src/server/freeMode.test.ts @@ -1,16 +1,15 @@ import { describe, expect, it } from 'vitest' import { FREE_MODE_DEFAULT_MODEL, - FREE_MODE_PROVIDER_ID, OPENCODE_ZEN_DEFAULT_MODEL, - OPENCODE_ZEN_PROVIDER_ID, createDefaultOpenCodeZenFreeModeState, getFreeModeConfigArgs, shouldCreateDefaultFreeModeStateForMissingAuth, + shouldSuppressCommunityFreeModeForCodexAuth, } from './freeMode' describe('unauthenticated free mode defaults', () => { - it('creates an enabled OpenCode Zen state for unauthenticated startup', () => { + it('builds an enabled OpenCode Zen runtime fallback for unauthenticated startup', () => { const state = createDefaultOpenCodeZenFreeModeState() expect(state.enabled).toBe(true) @@ -26,11 +25,57 @@ describe('unauthenticated free mode defaults', () => { const args = getFreeModeConfigArgs(state, 4173) - expect(args).toContain(`model_provider="${OPENCODE_ZEN_PROVIDER_ID}"`) + expect(args).toContain('model_provider="opencode_zen"') expect(args).toContain(`model="${OPENCODE_ZEN_DEFAULT_MODEL}"`) - expect(args).toContain(`model_providers.${OPENCODE_ZEN_PROVIDER_ID}.base_url="http://127.0.0.1:4173/codex-api/zen-proxy/v1"`) - expect(args).toContain(`model_providers.${OPENCODE_ZEN_PROVIDER_ID}.wire_api="responses"`) - expect(args).toContain(`model_providers.${OPENCODE_ZEN_PROVIDER_ID}.experimental_bearer_token="zen-proxy-token"`) + expect(args).toContain('model_providers.opencode_zen.base_url="http://127.0.0.1:4173/codex-api/zen-proxy/v1"') + expect(args).toContain('model_providers.opencode_zen.wire_api="responses"') + expect(args).toContain('model_providers.opencode_zen.experimental_bearer_token="zen-proxy-token"') + }) + + it('suppresses community fallback providers when Codex auth appears', () => { + expect(shouldSuppressCommunityFreeModeForCodexAuth({ + enabled: true, + apiKey: 'community-key', + model: FREE_MODE_DEFAULT_MODEL, + customKey: false, + provider: 'openrouter', + wireApi: 'responses', + }, true)).toBe(true) + + expect(shouldSuppressCommunityFreeModeForCodexAuth({ + enabled: true, + apiKey: 'user-key', + model: FREE_MODE_DEFAULT_MODEL, + customKey: true, + provider: 'openrouter', + wireApi: 'responses', + }, true)).toBe(false) + + expect(shouldSuppressCommunityFreeModeForCodexAuth({ + enabled: true, + apiKey: 'zen-user-key', + model: OPENCODE_ZEN_DEFAULT_MODEL, + customKey: false, + provider: 'opencode-zen', + wireApi: 'responses', + }, true)).toBe(false) + + expect(shouldSuppressCommunityFreeModeForCodexAuth({ + enabled: false, + apiKey: null, + model: FREE_MODE_DEFAULT_MODEL, + provider: 'openrouter', + wireApi: 'responses', + }, true)).toBe(false) + + expect(shouldSuppressCommunityFreeModeForCodexAuth({ + enabled: true, + apiKey: 'community-key', + model: FREE_MODE_DEFAULT_MODEL, + customKey: false, + provider: 'openrouter', + wireApi: 'responses', + }, false)).toBe(false) }) it('uses the OpenCode Zen default model when persisted Zen state has an empty model', () => { @@ -51,8 +96,26 @@ describe('unauthenticated free mode defaults', () => { wireApi: 'responses', }, 4173) - expect(args).toContain(`model_provider="${FREE_MODE_PROVIDER_ID}"`) + expect(args).toContain('model_provider="openrouter_free"') expect(args).toContain(`model="${FREE_MODE_DEFAULT_MODEL}"`) + expect(args).toContain('model_providers.openrouter_free.base_url="http://127.0.0.1:4173/codex-api/openrouter-proxy/v1"') + }) + + it('keeps Codex app-server on responses wire API for custom chat providers', () => { + const args = getFreeModeConfigArgs({ + enabled: true, + apiKey: 'nvapi-test', + model: '01-ai/yi-large', + customKey: true, + provider: 'custom', + customBaseUrl: 'https://integrate.api.nvidia.com/v1', + wireApi: 'chat', + }, 4173) + + expect(args).toContain('model_provider="custom_endpoint"') + expect(args).toContain('model="01-ai/yi-large"') + expect(args).toContain('model_providers.custom_endpoint.base_url="http://127.0.0.1:4173/codex-api/custom-proxy/v1"') + expect(args).toContain('model_providers.custom_endpoint.wire_api="responses"') }) it('does not replace an intentionally disabled free mode state', () => { @@ -65,7 +128,7 @@ describe('unauthenticated free mode defaults', () => { }, false)).toBe(false) }) - it('creates the default only when state is absent and Codex auth is missing', () => { + it('uses the runtime default only when state is absent and Codex auth is missing', () => { expect(shouldCreateDefaultFreeModeStateForMissingAuth(null, false)).toBe(true) expect(shouldCreateDefaultFreeModeStateForMissingAuth(null, true)).toBe(false) }) diff --git a/src/server/freeMode.ts b/src/server/freeMode.ts index 1889394da..507b734c4 100644 --- a/src/server/freeMode.ts +++ b/src/server/freeMode.ts @@ -93,6 +93,7 @@ export function getFreeKeyCount(): number { export const FREE_MODE_PROVIDER_ID = 'openrouter-free' export const FREE_MODE_BASE_URL = 'https://openrouter.ai/api/v1' +const FREE_MODE_RUNTIME_PROVIDER_ID = 'openrouter_free' const FALLBACK_FREE_MODELS = [ 'openrouter/free', @@ -147,10 +148,12 @@ export function refreshFreeModelsInBackground(): void { 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' export const CUSTOM_PROVIDER_ID = 'custom-endpoint' export const OPENCODE_ZEN_PROVIDER_ID = 'opencode-zen' +const CUSTOM_RUNTIME_PROVIDER_ID = 'custom_endpoint' +const OPENCODE_ZEN_RUNTIME_PROVIDER_ID = 'opencode_zen' export const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1' export const OPENCODE_ZEN_DEFAULT_MODEL = 'big-pickle' @@ -202,6 +205,17 @@ export function shouldCreateDefaultFreeModeStateForMissingAuth( return current == null && !hasUsableCodexAuth } +export function shouldSuppressCommunityFreeModeForCodexAuth( + current: FreeModeState | null, + hasUsableCodexAuth: boolean, +): boolean { + if (!hasUsableCodexAuth || !current?.enabled) return false + if (current.provider === 'custom') return false + if (current.customKey === true) return false + if (current.provider === 'opencode-zen' && current.apiKey?.trim()) return false + return current.provider === 'openrouter' || current.provider === 'opencode-zen' || !current.provider +} + export function getFreeModeEnvVars(state: FreeModeState): Record { if (!state.enabled) return {} @@ -221,55 +235,58 @@ export function getFreeModeConfigArgs(state: FreeModeState, serverPort?: number) 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}"`, ] } diff --git a/src/style.css b/src/style.css index 65087d0ca..ab1517541 100644 --- a/src/style.css +++ b/src/style.css @@ -315,6 +315,10 @@ @apply border-rose-800/80 bg-rose-950 text-rose-100 hover:bg-rose-900 focus:ring-rose-700; } +:root.dark .turn-error-feedback { + @apply border-rose-800/80 bg-rose-950 text-rose-100 hover:bg-rose-900 focus:ring-rose-700; +} + :root.dark .header-git-feedback { @apply border-rose-800/80 bg-rose-950 text-rose-100 hover:bg-rose-900 focus:ring-rose-700; } diff --git a/tests.md b/tests.md index 2558d0dfa..495bb9381 100644 --- a/tests.md +++ b/tests.md @@ -3084,7 +3084,7 @@ stays at `source: "NoValues"` permanently. Feature gate `505458` (worktree) retu ### Free Mode (OpenRouter) #### Feature -Toggle "Free mode" in settings to use free OpenRouter models without an OpenAI API key. Uses XOR-encrypted community keys that rotate randomly per request. Default model is `openrouter/free` — OpenRouter's meta-model that auto-routes to the least-loaded free model, avoiding per-model rate limits. Model selector shows only free models when free mode is on. Config is isolated from `~/.codex/config.toml` — state stored in `~/.codex/webui-free-mode.json` and passed to app-server via `-c` CLI args. +Toggle "Free mode" in settings to use free OpenRouter models without an OpenAI API key. Uses XOR-encrypted community keys that rotate randomly per request. Default model is `openrouter/free` — OpenRouter's meta-model that auto-routes to the least-loaded free model, avoiding per-model rate limits. Model selector shows only free models when free mode is on. Config is isolated from `~/.codex/config.toml` — state stored in `~/.codex/webui-custom-providers.json` and passed to app-server via `-c` CLI args. #### Prerequisites - Project built: `pnpm run build`. @@ -3098,7 +3098,7 @@ Toggle "Free mode" in settings to use free OpenRouter models without an OpenAI A 5. Verify the toggle turns on and model dropdown changes to `openrouter/free`. 6. Click the model dropdown — verify it shows **only** free models (gemma, llama, qwen, etc.) and no GPT/OpenAI default models. 7. Verify `~/.codex/config.toml` was NOT modified (no `model_provider` or `model` entries added). -8. Verify `~/.codex/webui-free-mode.json` exists and contains `{"enabled":true,"apiKey":"sk-or-v1-...","model":"openrouter/free"}`. +8. Verify `~/.codex/webui-custom-providers.json` exists and contains `{"enabled":true,"apiKey":"sk-or-v1-...","model":"openrouter/free"}`. 9. Open a new thread and send a message (e.g. "Say hello"). 10. Verify a response comes back from a free OpenRouter model (may be rate-limited during high demand). 11. Toggle **Free mode (OpenRouter)** OFF. @@ -3139,7 +3139,7 @@ Toggle "Free mode" in settings to use free OpenRouter models without an OpenAI A #### Rollback/Cleanup - Remove `src/server/freeMode.ts`, revert changes in `codexAppServerBridge.ts`, `codexGateway.ts`, and `App.vue`. -- Delete `~/.codex/webui-free-mode.json` to clear free mode state. +- Delete `~/.codex/webui-custom-providers.json` to clear free mode state. ### Feature: Codex.app Thread Provider Filter Patch (fix-codex-thread-filter.sh) @@ -3368,12 +3368,12 @@ OpenCode Zen as built-in provider + API format selector for custom endpoints - OpenCode Zen appears in provider dropdown alongside Codex/OpenRouter/Custom - OpenCode Zen defaults to `wire_api = "chat"` (Chat Completions API) - Custom endpoints show an API format selector; default is "Responses API" -- Provider selection and wireApi are persisted in `~/.codex/webui-free-mode.json` +- Provider selection and wireApi are persisted in `~/.codex/webui-custom-providers.json` - Model list for OpenCode Zen is fetched from `https://opencode.ai/zen/v1/models` #### Rollback/Cleanup - Switch provider back to "Codex" to disable free mode -- No config files outside the project are modified (state stored in `~/.codex/webui-free-mode.json`) +- Project config files are not modified; only user-level state is written to `~/.codex/webui-custom-providers.json` ### env_key Authentication for Custom Providers (codex CLI v0.93.0) @@ -5272,6 +5272,9 @@ Android `codexui-android` startup passes the bound server port to app-server fre #### Expected Results - `config/read` returns `200` and includes `model_providers.opencode-zen.base_url` pointing at `http://127.0.0.1:17923/codex-api/zen-proxy/v1`. - `config/read` includes `model_providers.opencode-zen.wire_api` as `responses`, not `chat`. +- Fresh no-auth startup uses OpenCode Zen as a runtime fallback without creating `~/.codex/webui-custom-providers.json`. +- After a usable Codex `auth.json` is added and the server restarts with no saved free-mode state, startup does not keep forcing `model_provider="opencode-zen"`. +- Existing `~/.codex/webui-free-mode.json` files are ignored and not migrated to `~/.codex/webui-custom-providers.json`. - `model/list` returns `200` with model data instead of `502 codex app-server exited unexpectedly`. - The model selector is usable in both light theme and dark theme. - A first home-composer message creates a thread and receives a response without visible startup RPC errors. @@ -5343,3 +5346,262 @@ Thread conversation incremental older-turn loading. #### Rollback/Cleanup - None. + +--- + +### Docker auth startup live-state pending read + +#### Feature/Change Name +Docker authenticated first-turn live-state pending read handling. + +#### Prerequisites/Setup +1. Build the project with `pnpm run build`. +2. Build a fresh Docker image that installs `@openai/codex` and runs the packed `codexapp` artifact. +3. Prepare two isolated `CODEX_HOME` states: one empty and one with only `auth.json` mounted. + +#### Steps +1. Start the no-auth container and open the app in light theme. +2. Confirm `config/read` uses `model_provider="opencode-zen"` and `model="big-pickle"`. +3. Send `hi` and wait for the assistant reply. +4. Start the auth-mounted container and open the app in light theme. +5. Confirm `config/read` has `model_provider=null` and no Zen provider override. +6. Send `hi` and poll `/codex-api/thread-live-state?threadId=` while the first turn is starting. +7. Confirm early live-state responses do not expose `liveStateError.kind="readFailed"` for `not materialized yet; includeTurns is unavailable before first user message`. +8. Wait for the assistant reply, then switch to dark theme and repeat the visual checks for the composer/thread area. + +#### Expected Results +- No-auth Docker startup falls back to Zen at runtime and returns a `hi` response. +- Auth-mounted Docker startup uses the default Codex provider path without Zen flags and returns a `hi` response. +- The transient first-turn materialization window is represented as an in-progress empty live state, not a visible chat error. +- Real `thread/read` failures still surface through `liveStateError`. +- Light theme and dark theme keep the chat/composer readable throughout the first-turn transition. + +#### Rollback/Cleanup +- Stop temporary containers with `docker rm -f codexui-noauth-test codexui-auth-test` when finished. + +--- + +### Provider models load without Codex model-list dependency + +#### Feature/Change Name +Provider-backed model selector startup loading. + +#### Prerequisites/Setup +1. Build the project with `pnpm run build`. +2. Run a no-auth Docker container so Codex Web Local starts with OpenCode Zen fallback. +3. Open `http://127.0.0.1:/#/` in the browser. + +#### Steps +1. In light theme, open the home screen and wait for initial model loading. +2. Open the model selector. +3. Confirm Zen provider models are visible even if Codex `model/list` is slow or unavailable. +4. Confirm the selector starts with `big-pickle` and includes current Zen models such as `deepseek-v4-flash-free`. +5. Switch to dark theme and repeat steps 2 through 4. + +#### Expected Results +- Provider-backed model loading asks `/codex-api/provider-models` before depending on `model/list`. +- OpenCode Zen models populate the selector without falling back to a blank list or stale Codex-only model list. +- The selector remains readable and usable in light theme and dark theme. + +#### Rollback/Cleanup +- Stop the temporary Docker container when finished. + +--- + +### Invalid or expired auth errors appear in chat + +#### Feature/Change Name +Invalid Codex auth failed-turn error rendering. + +#### Prerequisites/Setup +1. Build the project with `pnpm run build`. +2. Build a fresh Docker image from the packed artifact. +3. Start a Docker container with an invalid or expired `auth.json` mounted into `CODEX_HOME`. +4. Open the container URL in the browser. + +#### Steps +1. Confirm `config/read` uses the default Codex provider path, not OpenCode Zen fallback. +2. Send `hi` from the composer. +3. Wait until the turn stops running. +4. Reload or reopen the same thread. +5. Repeat in dark theme and light theme. + +#### Expected Results +- The failed turn displays the final auth error in the chat, including the HTTP 401/unauthorized message from Codex. +- The failed turn includes a visible `Send feedback` button next to the persisted chat error. +- Once the failed turn is persisted, the live `Thinking` error overlay is gone so the final auth error is not duplicated. +- The conversation does not silently show only the user message after a failed turn. +- Reloaded thread history preserves the failed-turn error message. +- Transient retry messages may appear while reconnecting, but the final non-retry error remains visible after completion. +- In dark theme and light theme, the feedback button remains readable and opens a feedback mailto with the visible auth error included in the diagnostic body. + +#### Rollback/Cleanup +- Stop the invalid-auth Docker container after verification. + +--- + +### Docker provider checklist and live error overlay regression + +#### Feature/Change Name +Docker provider/auth checklist execution and live error overlay de-duplication. + +#### Prerequisites/Setup +1. Run `pnpm run build`. +2. Run `pnpm pack --pack-destination /tmp`. +3. Build a Docker image from the packed `codexapp` tarball with `@openai/codex` installed. +4. Start three isolated containers: + - no auth file + - invalid or expired `auth.json` + - malformed `auth.json` + +#### Steps +1. In light theme, open the no-auth container, confirm the composer starts on `big-pickle`, send `hi`, and wait for an assistant reply. +2. Switch the Settings provider selector to OpenRouter, send `hi` again, and wait for a reply or provider-scoped response. +3. Open the invalid-auth container, send `hi`, wait for the final 401/auth error, and confirm `Send feedback` is visible. +4. Reload the invalid-auth thread and confirm the persisted error remains without a duplicate live `Thinking` error overlay. +5. Switch the invalid-auth thread to dark theme and confirm the persisted error and feedback button remain readable. +6. Open the malformed-auth container, confirm it falls back to `big-pickle`, send `hi`, and wait for an assistant reply. + +#### Expected Results +- No-auth startup uses the OpenCode Zen runtime fallback and sends successfully. +- Runtime `-c` provider config uses underscore-safe provider ids, so Zen/OpenRouter/custom providers are actually registered with Codex app-server. +- Provider switching is scoped to the selected provider and does not require changing the model dropdown directly. +- Invalid/expired auth stays on the Codex provider path and renders the final auth failure as a persisted chat error. +- A new live error is still visible when an older persisted turn error exists, but the same live error is suppressed after that exact error has persisted. +- Feedback mailto diagnostics include recent diagnostics, visible page text, and the visible auth error. +- Malformed auth is treated as unusable auth and falls back to Zen. + +#### Rollback/Cleanup +- Stop temporary containers with `docker rm -f codexui-what-noauth codexui-what-invalid-auth codexui-what-malformed-auth`. + +--- + +### Copied auth promotes community fallback to Codex + +#### Feature/Change Name +Runtime auth detection after starting without auth. + +#### Prerequisites/Setup +1. Run `pnpm run build`. +2. Run `pnpm pack --pack-destination /tmp`. +3. Build a Docker image from the packed `codexapp` tarball with `@openai/codex` installed. +4. Start a fresh no-auth container with an empty mounted `CODEX_HOME`. +5. Keep a valid host `auth.json` available to copy into that mounted `CODEX_HOME`. + +#### Steps +1. Open the no-auth container and confirm the provider is OpenCode Zen with `big-pickle`. +2. Switch the Settings provider selector to OpenRouter while still unauthenticated. +3. Copy a valid `auth.json` into the mounted `CODEX_HOME`. +4. Reload the page. +5. Confirm the provider has moved to Codex, the composer shows a concrete Codex model instead of a generic `Model` placeholder, and the Accounts count imports the active auth account. +6. Confirm the sidebar does not show a stale `Send feedback` / `Issue detected` row when there is no current visible error. +7. Send `hi` on the Codex provider and wait for an assistant reply. + +#### Expected Results +- Community fallback providers are suppressed once usable Codex auth appears. +- User-configured providers with a custom key or custom endpoint remain available and are not suppressed. +- The app refreshes model metadata after provider promotion so the composer does not stay on a generic `Model` label. +- The copied auth file is imported into the accounts list without requiring a manual Reload click after Codex quota metadata loads successfully. +- Invalid or expired copied auth is not imported during startup before a successful quota read, so the first failed send still renders a chat error instead of leaving the thread empty. +- The Settings feedback row is hidden after provider/account recovery unless there is still a visible error. +- The Codex provider can send a message successfully after auth promotion. + +#### Rollback/Cleanup +- Stop the temporary container and remove its mounted `CODEX_HOME` directory. + +--- + +### Provider/model selection metadata guard + +#### Feature/Change Name +Provider-tagged model selection storage and stale cross-provider model rejection. + +#### Prerequisites/Setup +1. Start the app with `pnpm run dev --host 127.0.0.1 --port 4173`. +2. Have at least one Codex provider model available. +3. For fallback checks, run a no-auth Docker container so OpenCode Zen models are available. + +#### Steps +1. In light theme, set `localStorage["codex-web-local.selected-model-by-context.v1"]` to `{"__new-thread__":"big-pickle"}` and refresh with Provider set to Codex. +2. Open the model dropdown and confirm `big-pickle` is absent. +3. Set `localStorage["codex-web-local.selected-model-by-context.v1"]` to `{"__new-thread__":{"providerId":"opencode-zen","modelId":"big-pickle"}}` and refresh with Provider set to Codex. +4. Open the model dropdown and confirm the selected model is a Codex model and `big-pickle` is absent. +5. Switch Provider from Custom to OpenRouter to Codex and open the model dropdown after each switch. +6. Repeat steps 1-5 in dark theme. +7. In the no-auth Docker container, confirm Provider is OpenCode Zen and the dropdown still shows Zen models such as `big-pickle`. + +#### Expected Results +- Stored model selections are saved as `{ "providerId": "", "modelId": "" }` after the next write. +- Legacy string selections are accepted only when the model exists in the active provider model list. +- Object selections are accepted only when their `providerId` matches the active provider. +- Switching providers replaces incompatible selected models with the active provider scoped model, configured model, or first available active-provider model. +- Codex dropdowns never include stale Zen, Custom, or OpenRouter models. +- Light theme and dark theme render the dropdown labels and selection state clearly. +- No-auth Docker fallback still preserves OpenCode Zen model selection and does not lose `big-pickle`. + +#### Rollback/Cleanup +- Remove any manually injected `codex-web-local.selected-model-by-context.v1` localStorage values after testing. + +--- + +### Routed thread retention during provider refresh + +#### Feature/Change Name +Keep the currently routed thread selected when a refreshed thread list omits it. + +#### Prerequisites/Setup +1. Start the app with `pnpm run dev --host 127.0.0.1 --port 4173`. +2. Open an existing thread route such as `http://127.0.0.1:4173/#/thread/`. +3. Have provider switching available from Settings. + +#### Steps +1. In light theme, open the target thread route and confirm its messages are visible. +2. Switch providers so the app refreshes model and thread metadata. +3. Simulate or reproduce a backend refresh where `thread/list` does not include the current route's ``. +4. Confirm the URL remains `#/thread/` and the UI keeps or reloads that thread instead of navigating to home. +5. Repeat steps 1-4 in dark theme. + +#### Expected Results +- The route remains on the current thread even when the refreshed sidebar list omits that thread. +- The app calls the thread read/resume path for the routed thread instead of replacing the route with home or another selected thread. +- If the thread can still be read, its messages remain visible after provider refresh. +- Light theme and dark theme retain readable thread content and composer controls. + +#### Rollback/Cleanup +- Restore the original provider after verification if it was changed for the test. + +--- + +### Provider-backed model list authority + +#### Feature/Change Name +Provider-backed model lists reject stale configured and resumed models. + +#### Prerequisites/Setup +1. Build the packaged app with `pnpm run build` and `pnpm pack --pack-destination /tmp`. +2. Build a Docker image from the packed tarball plus `@openai/codex`. +3. Start an auth-mounted container with isolated `CODEX_HOME` on a unique localhost port. +4. Have OpenRouter and NVIDIA NIM keys available for the container. + +#### Steps +1. In light theme, configure OpenRouter with a valid key and refresh the app. +2. Open the model dropdown. +3. Confirm the selected model is `openrouter/free` or another OpenRouter model, and `big-pickle` is absent. +4. Configure custom provider with `baseUrl=https://integrate.api.nvidia.com/v1` and `wireApi=chat`. +5. Open the model dropdown. +6. Confirm the selected model is `01-ai/yi-large` or another NIM model, and `big-pickle` is absent. +7. Send `hi provider nvidia nim fixed`. +8. Repeat dropdown checks in dark theme. + +#### Expected Results +- Provider-backed model lists are authoritative and do not append stale configured models from another provider. +- OpenRouter dropdown contains OpenRouter models only. +- NIM dropdown contains NIM models only. +- Custom chat providers keep Codex app-server configured as local Responses while the custom proxy translates to chat completions upstream. +- NIM send produces either an assistant reply or the exact upstream error in chat. +- There is no `messages field cannot be empty` error. +- Light theme and dark theme render selected model labels and dropdown rows clearly. + +#### Rollback/Cleanup +- Stop the temporary container and remove its isolated `CODEX_HOME`. +- Remove temporary API key files. diff --git a/whatToTest.md b/whatToTest.md new file mode 100644 index 000000000..9687e9e17 --- /dev/null +++ b/whatToTest.md @@ -0,0 +1,194 @@ +# What To Test + +## Open Docker Provider Tasks + +These tasks come from packaged Docker Browser cycles. Keep each task here until a fresh packaged Docker run proves it passes with browser screenshots and network evidence. + +Passed and removed on 2026-05-13: continuing a historical Codex thread after `Codex -> OpenCode Zen` provider switch. Fresh evidence showed the same `#/thread/019e2117-b1dc-7401-804f-b86fadc97604` route stayed visible and `hi provider opencode zen` returned an assistant reply without a raw `thread not found` error. + +Passed and removed on 2026-05-13: OpenRouter selected state no longer falls back to stale `big-pickle`. Fresh packaged Docker evidence on `http://127.0.0.1:4492/#/` showed `currentModel=openrouter/free`, 26 exclusive OpenRouter models, no `big-pickle`, and screenshot `output/playwright/docker-fix-openrouter-dropdown.png`. + +Passed and removed on 2026-05-13: custom NVIDIA NIM provider now drives the dropdown and sends without the empty-messages failure. Fresh packaged Docker evidence on `http://127.0.0.1:4492/#/` showed `currentModel=01-ai/yi-large`, 123 exclusive NIM models, no `big-pickle`, screenshot `output/playwright/docker-fix2-nim-dropdown.png`, and a final upstream NIM 404 rendered in chat without `messages field cannot be empty` in `output/playwright/docker-fix2-nim-send.png`. + +Passed and removed on 2026-05-13: fresh no-auth Docker fallback still defaults to OpenCode Zen. Fresh evidence on `http://127.0.0.1:4591/#/` showed `provider=opencode-zen`, `enabled=true`, `hasCodexAuth=false`, `big-pickle` in the dropdown, and a reply to `hi no auth fallback` in `output/playwright/what-noauth-send.png`. + +Passed and removed on 2026-05-13: fresh auth Docker startup still defaults to Codex-only models. Fresh evidence on `http://127.0.0.1:4592/#/` showed `hasCodexAuth=true`, Codex dropdown with `GPT-5.5`, no `big-pickle` or `openrouter/free`, and a reply to `hi auth codex` in `output/playwright/what-auth-send.png`. + +### [ ] P0 - Provider switch chain keeps URL stable and provider models scoped + +**Environment** + +- Auth-mounted packaged Docker container on a fresh unique port. +- OpenRouter and NVIDIA NIM keys available. + +**Current evidence** + +- Fresh run: packaged image `codexapp-what-test:local`, auth-mounted container on `http://127.0.0.1:4592/#/`. +- OpenCode Zen step passed: + - `/codex-api/free-mode/status` returned `provider=opencode-zen`, `currentModel=big-pickle`. + - `/codex-api/provider-models` returned `exclusive=true`, `count=41`, including `big-pickle`. + - Browser screenshots: + - `output/playwright/what-chain-zen-dropdown.png` + - `output/playwright/what-chain-zen-send.png` +- OpenRouter step failed: + - After `OpenCode Zen -> OpenRouter`, `/codex-api/free-mode/status` returned `provider=openrouter`, `enabled=true`, but `currentModel=big-pickle`. + - `/codex-api/provider-models` returned `exclusive=true`, `count=26`, and did not include `big-pickle`. + - Browser did not show `openrouter/free`; the send attempt did not submit `hi provider openrouter` and the thread rendered `RPC turn/start failed with HTTP 502: thread not found: 019e212d-35ee-73f2-8724-75d3a006f445`. + - Browser screenshots: + - `output/playwright/what-chain-openrouter-dropdown.png` + - `output/playwright/what-chain-openrouter-send.png` + +**Repro** + +1. Start on a Codex thread and record the full `#/thread/` URL. +2. Switch Settings provider `Codex -> OpenCode Zen`. +3. Open dropdown, send `hi provider opencode zen`, and wait for reply/error. +4. Switch `OpenCode Zen -> OpenRouter`. +5. Open dropdown, send `hi provider openrouter`, and wait for reply/error. +6. Switch `OpenRouter -> Custom endpoint` with `https://integrate.api.nvidia.com/v1`, `wireApi=chat`. +7. Open dropdown, send `hi provider nvidia nim`, and wait for reply/error. +8. Switch `Custom endpoint -> Codex`. +9. Open dropdown, send `hi provider codex`, and wait for reply/error. + +**Pass criteria** + +- The URL stays on the original `#/thread/` route after every switch. +- Visible conversation remains in place after every switch. +- OpenCode Zen dropdown includes `big-pickle`. +- OpenRouter dropdown contains OpenRouter models and excludes `big-pickle`, Codex-only, Groq, and NIM entries. +- NIM dropdown contains NIM models and excludes stale `big-pickle`, OpenRouter, Codex-only, and Groq entries. +- Codex dropdown contains Codex models only and excludes `big-pickle`, OpenRouter, Groq, and NIM entries. +- Every send returns an assistant reply or exact final provider error in chat. +- No send after switching uses a stale previous-provider model. + +**Validation evidence to capture** + +- Screenshot after each provider switch with the thread URL still visible. +- Screenshot of each provider dropdown. +- Screenshot of each final reply/error. +- Network evidence for provider setting calls, `provider-models`, and send payloads. + +### [ ] P0 - OpenRouter send uses active OpenRouter model after stale-model guard + +**Environment** + +- Auth-mounted packaged Docker container. +- Valid OpenRouter key configured. + +**Current evidence** + +- Fresh run: packaged image `codexapp-what-test:local`, auth-mounted container on `http://127.0.0.1:4592/#/`. +- After configuring OpenRouter from an OpenCode Zen state: + - `/codex-api/free-mode/status` returned `provider=openrouter`, `enabled=true`, `currentModel=big-pickle`. + - `/codex-api/provider-models` returned `exclusive=true`, `count=26`, did not include `big-pickle`, and started with `openrouter/free`. +- The browser did not show `openrouter/free` as the composer model in the tested state. +- Browser screenshots: + - `output/playwright/what-chain-openrouter-dropdown.png` + - `output/playwright/what-chain-openrouter-send.png` + +**Repro** + +1. Configure OpenRouter through Settings or the equivalent app endpoint. +2. Confirm `/codex-api/free-mode/status` shows `provider=openrouter`, `enabled=true`, and `currentModel=openrouter/free` or another OpenRouter model. +3. Confirm `/codex-api/provider-models` is exclusive and does not include `big-pickle`. +4. Open the dropdown and select an OpenRouter model. +5. Send `hi provider openrouter fixed`. + +**Pass criteria** + +- Composer selected model is OpenRouter-scoped. +- Dropdown does not contain `big-pickle`. +- `turn/start` sends an OpenRouter model, not `big-pickle`, Codex, NIM, or Groq stale models. +- Chat shows assistant reply or exact final OpenRouter provider error. + +**Validation evidence to capture** + +- Screenshot of OpenRouter dropdown. +- Screenshot of final reply/error. +- Network evidence for status, model list, and send payload. + +### [ ] P0 - NVIDIA NIM custom send should use a known working chat model + +**Environment** + +- Auth-mounted packaged Docker container. +- Valid NVIDIA API key configured. +- Custom provider base URL `https://integrate.api.nvidia.com/v1`, `wireApi=chat`. + +**Current evidence** + +- Fresh packaged Docker evidence on `http://127.0.0.1:4492/#/` showed dropdown scoping was fixed. +- Sending with default `01-ai/yi-large` reached the upstream and rendered a final upstream 404 for a missing NIM function. +- There was no `messages field cannot be empty` error. +- Fresh run on `http://127.0.0.1:4592/#/` after OpenRouter showed a regression: + - `/codex-api/free-mode/status` returned `provider=custom`, `currentModel=01-ai/yi-large`, `wireApi=chat`. + - `/codex-api/provider-models` returned `source=custom`, `exclusive=true`, `count=123`, first model `01-ai/yi-large`, and no `big-pickle`. + - Browser composer still showed stale `big-pickle` and the dropdown did not show `01-ai/yi-large`. + - Send rendered `unexpected status 404 Not Found: 404 page not found, url: http://127.0.0.1:4190/codex-api/custom-proxy/v1/responses`. + - There was still no `messages field cannot be empty` error. + - Browser screenshots: + - `output/playwright/what-nim-dropdown.png` + - `output/playwright/what-nim-send.png` + +**Repro** + +1. Configure NVIDIA NIM custom provider. +2. Inspect `/codex-api/provider-models` and pick a known working NIM chat model if available. +3. Select that model from the dropdown. +4. Send `hi provider nvidia nim working model`. + +**Pass criteria** + +- Dropdown is NIM-only and excludes stale models. +- Send reaches upstream through the custom proxy translation path. +- Request body sent upstream has a non-empty `messages` array. +- Chat shows an assistant reply, or an exact final upstream error that is not caused by empty messages or wrong `/responses` upstream routing. + +**Validation evidence to capture** + +- Screenshot of NIM dropdown with the selected model. +- Screenshot of final reply/error. +- Network evidence for custom proxy request path and translated payload. + +### [ ] P1 - Run Groq custom chat provider packaged Docker validation + +**Environment** + +- Packaged Docker image built from the current branch. +- Auth-mounted or no-auth container on a unique port. +- Groq API key available to the container. + +**Provider config** + +- Base URL: `https://api.groq.com/openai/v1` +- Wire API: `chat` + +**Current evidence** + +- Local KeePass registry had OpenRouter and NVIDIA keys, but no Groq key entry was found during the 2026-05-13 run. +- A fresh 2026-05-13 key lookup still found no Groq key entry. +- No valid Groq send test has been completed. + +**Repro** + +1. Add or provide a Groq API key for the packaged Docker container. +2. Configure custom provider with the Groq base URL and `wireApi=chat`. +3. Refresh provider models. +4. Open the composer model dropdown and select a valid Groq chat model. +5. Send `hi provider groq`. + +**Pass criteria** + +- `/codex-api/free-mode/status` shows `provider=custom` and `wireApi=chat`. +- `/codex-api/provider-models` returns an exclusive Groq model list. +- The composer dropdown shows Groq models, not Codex/OpenRouter/NIM stale entries. +- Sending uses the chat-completions proxy path and sends a non-empty `messages` array. +- The final chat state is either an assistant reply or the exact upstream error rendered in the conversation. +- There is no `messages field cannot be empty` error. + +**Validation evidence to capture** + +- Browser screenshot of the custom provider settings. +- Browser screenshot of the Groq model dropdown. +- Browser screenshot of the reply or exact final error in the same thread. +- Network evidence for `/codex-api/free-mode/status`, `/codex-api/provider-models`, and the custom proxy send request body/path.