From 9a4fece78d1bde540c7fe9ebde6ba65fa4ce7077 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 08:07:53 +0700 Subject: [PATCH 01/25] Avoid persisting unauthenticated Zen fallback --- .../codexAppServerBridge.archive.test.ts | 34 ++++++++++++++++++- src/server/codexAppServerBridge.ts | 10 ++---- src/server/freeMode.test.ts | 4 +-- tests.md | 2 ++ 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index 3e931a0f8..06f166c54 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -1,9 +1,10 @@ -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, isUnauthenticatedRateLimitError, @@ -161,3 +162,34 @@ 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-free-mode.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-free-mode.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 }) + } + }) +}) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 3372e347d..ca9aa0ac4 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' @@ -3270,17 +3270,13 @@ 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())) { 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 { diff --git a/src/server/freeMode.test.ts b/src/server/freeMode.test.ts index a3b512e18..813e361d9 100644 --- a/src/server/freeMode.test.ts +++ b/src/server/freeMode.test.ts @@ -10,7 +10,7 @@ import { } 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) @@ -65,7 +65,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/tests.md b/tests.md index 2558d0dfa..4a3f08bb0 100644 --- a/tests.md +++ b/tests.md @@ -5272,6 +5272,8 @@ 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-free-mode.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"`. - `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. From 91f8562bbb21153a6c55be3366740f6905434ce4 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 08:12:38 +0700 Subject: [PATCH 02/25] Rename custom provider state file --- .../codexAppServerBridge.archive.test.ts | 26 +++++++++++++++++-- src/server/freeMode.ts | 2 +- tests.md | 13 +++++----- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index 06f166c54..85057f0e8 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -166,7 +166,7 @@ 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-free-mode.json') + const statePath = join(codexHome, 'webui-custom-providers.json') process.env.CODEX_HOME = codexHome try { const state = ensureDefaultFreeModeStateForMissingAuthSync(statePath) @@ -181,7 +181,7 @@ describe('ensureDefaultFreeModeStateForMissingAuthSync', () => { 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-free-mode.json') + 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' } })) @@ -192,4 +192,26 @@ describe('ensureDefaultFreeModeStateForMissingAuthSync', () => { 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/freeMode.ts b/src/server/freeMode.ts index 1889394da..ffe7f1d5a 100644 --- a/src/server/freeMode.ts +++ b/src/server/freeMode.ts @@ -147,7 +147,7 @@ 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' diff --git a/tests.md b/tests.md index 4a3f08bb0..95370bc40 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`) +- No config files outside the project are modified (state stored in `~/.codex/webui-custom-providers.json`) ### env_key Authentication for Custom Providers (codex CLI v0.93.0) @@ -5272,8 +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-free-mode.json`. +- 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. From 545c0decc35fef18034f2ce62338c773deac68b6 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 08:54:20 +0700 Subject: [PATCH 03/25] Handle pending first-turn live state --- .../codexAppServerBridge.archive.test.ts | 14 ++++++++ src/server/codexAppServerBridge.ts | 16 ++++++++++ tests.md | 32 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index 85057f0e8..d205ee00e 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -7,6 +7,7 @@ import { ensureDefaultFreeModeStateForMissingAuthSync, hasUsableCodexAuth, isEmptyThreadReadError, + isThreadMaterializationPendingError, isUnauthenticatedRateLimitError, } from './codexAppServerBridge' @@ -119,6 +120,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-')) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index ca9aa0ac4..be3561910 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -932,6 +932,11 @@ 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') +} + const warnedCodexAuthReadFailures = new Set() function getErrorCode(error: unknown): string | null { @@ -6427,6 +6432,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/tests.md b/tests.md index 95370bc40..81b2a5a7b 100644 --- a/tests.md +++ b/tests.md @@ -5346,3 +5346,35 @@ 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. From 2eaf4bd325da4e8c932cfe6bea7cbea2275c269a Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 09:11:57 +0700 Subject: [PATCH 04/25] Load provider models before Codex model list --- src/api/codexGateway.test.ts | 65 +++++++++++++++++++++++++++++++++++- src/api/codexGateway.ts | 58 +++++++++++++++++--------------- tests.md | 27 +++++++++++++++ 3 files changed, 122 insertions(+), 28 deletions(-) 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..3658ffc4b 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 @@ -1886,51 +1887,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/tests.md b/tests.md index 81b2a5a7b..fbc1d3196 100644 --- a/tests.md +++ b/tests.md @@ -5378,3 +5378,30 @@ Docker authenticated first-turn live-state pending read handling. #### 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. From 6405a6e43eda0cd28071904233c89b909221021e Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 09:14:31 +0700 Subject: [PATCH 05/25] Document Zen Docker auth model fixes --- ...pencode-zen-docker-auth-provider-models.md | 78 +++++++++++++++++++ .../wiki/concepts/opencode-zen-big-pickle.md | 13 ++++ llm-wiki/wiki/entities/codex-web-local.md | 3 + llm-wiki/wiki/index.md | 3 +- llm-wiki/wiki/log.md | 6 ++ llm-wiki/wiki/overview.md | 3 + 6 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 llm-wiki/raw/fixes/opencode-zen-docker-auth-provider-models.md 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/wiki/concepts/opencode-zen-big-pickle.md b/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md index 9ef4e7e93..8c573f9e2 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,18 @@ 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. + ## 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) - [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..e2f3f0d28 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,10 @@ - [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) - [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..83f3949b0 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,4 @@ - [../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. diff --git a/llm-wiki/wiki/log.md b/llm-wiki/wiki/log.md index c081c064f..bf9904a3d 100644 --- a/llm-wiki/wiki/log.md +++ b/llm-wiki/wiki/log.md @@ -1,5 +1,11 @@ # Log +## [2026-05-13] ingest | OpenCode Zen Docker auth and provider models +- Added source: `raw/fixes/opencode-zen-docker-auth-provider-models.md`. +- Updated wiki page: `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`, `entities/codex-web-local.md`, and `index.md`. + ## [2026-05-02] ingest | Directory Hub Composio and Skills search - Added source: `raw/features/directory-hub-composio-skills-search.md`. - Created wiki page: `concepts/directory-hub-composio-skills.md`. diff --git a/llm-wiki/wiki/overview.md b/llm-wiki/wiki/overview.md index 3e46f72c6..0f4fa5560 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,7 @@ 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) ## Linked pages - [Entity: codex-web-local](./entities/codex-web-local.md) @@ -25,3 +27,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) From abbc073484156e89c5468b27856ba200dbfd9c5e Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 09:31:30 +0700 Subject: [PATCH 06/25] Show failed auth turn errors in chat --- src/api/normalizers/v2.test.ts | 26 ++++++++++ src/api/normalizers/v2.ts | 16 ++++++ src/server/codexAppServerBridge.ts | 83 ++++++++++++++++++++++++++++-- tests.md | 29 +++++++++++ 4 files changed, 150 insertions(+), 4 deletions(-) diff --git a/src/api/normalizers/v2.test.ts b/src/api/normalizers/v2.test.ts index 49f410d09..579191535 100644 --- a/src/api/normalizers/v2.test.ts +++ b/src/api/normalizers/v2.test.ts @@ -105,4 +105,30 @@ 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, + }) + }) }) diff --git a/src/api/normalizers/v2.ts b/src/api/normalizers/v2.ts index a19973ff1..6a8a7ab6a 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 @@ -631,6 +636,17 @@ export function normalizeThreadMessagesV2(payload: ThreadReadResponse, baseTurnI messages.push({ ...msg, turnId, turnIndex }) } } + const errorText = readTurnErrorText(turn) + if (turn.status === 'failed' && errorText) { + messages.push({ + id: `${turnId ?? `turn-${turnIndex}`}-error`, + role: 'system', + text: errorText, + messageType: 'turnError', + turnId, + turnIndex, + }) + } } return messages } diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index be3561910..2964037cc 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -937,6 +937,78 @@ export function isThreadMaterializationPendingError(error: unknown): boolean { 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 { @@ -6246,7 +6318,10 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { } throw error } - const trimmedResult = trimThreadTurnsInRpcResult(body.method, rpcResult) + const errorMergedResult = THREAD_METHODS_WITH_TURNS.has(body.method) + ? mergeStreamTurnErrorsIntoThreadResult(appServer, rpcResult) + : rpcResult + const trimmedResult = trimThreadTurnsInRpcResult(body.method, errorMergedResult) const sanitizedResult = await sanitizeThreadTurnsInlinePayloads(body.method, trimmedResult) const result = THREAD_METHODS_WITH_TURNS.has(body.method) ? await mergeSessionSkillInputsIntoThreadResult(sanitizedResult) @@ -6276,7 +6351,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) { @@ -6376,10 +6451,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) diff --git a/tests.md b/tests.md index fbc1d3196..c8879b637 100644 --- a/tests.md +++ b/tests.md @@ -5405,3 +5405,32 @@ Provider-backed model selector startup loading. #### 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 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. + +#### Rollback/Cleanup +- Stop the invalid-auth Docker container after verification. From 7771c39249d1935386b54289dbd6fdab59d082a4 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 09:53:59 +0700 Subject: [PATCH 07/25] Add feedback action to auth errors --- src/components/content/ThreadConversation.vue | 24 +++++++++++++++++++ .../codexAppServerBridge.archive.test.ts | 8 +++++++ src/server/codexAppServerBridge.ts | 2 ++ src/style.css | 4 ++++ tests.md | 2 ++ 5 files changed, 40 insertions(+) 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/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index d205ee00e..dd173b780 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -101,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) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 2964037cc..93090bca3 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -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 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 c8879b637..aa4dfa113 100644 --- a/tests.md +++ b/tests.md @@ -5428,9 +5428,11 @@ Invalid Codex auth failed-turn error rendering. #### 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. - 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. From 9f329635dc19a5ad350e55b1a1fa8390977dc1b6 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 10:11:51 +0700 Subject: [PATCH 08/25] Hide duplicate persisted auth error overlay --- src/composables/useDesktopState.ts | 4 +++- tests.md | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index 2a37fee55..5443d8dd2 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -1521,7 +1521,9 @@ export function useDesktopState() { const reasoningText = isInProgress ? (liveReasoningTextByThreadId.value[threadId] ?? '').trim() : '' - const errorText = (turnErrorByThreadId.value[threadId]?.message ?? '').trim() + const hasPersistedTurnError = !isInProgress && (persistedMessagesByThreadId.value[threadId] ?? []) + .some((message) => message.messageType === 'turnError') + const errorText = hasPersistedTurnError ? '' : (turnErrorByThreadId.value[threadId]?.message ?? '').trim() if (!activity && !reasoningText && !errorText) return null return { diff --git a/tests.md b/tests.md index aa4dfa113..4ec12ac5b 100644 --- a/tests.md +++ b/tests.md @@ -5429,6 +5429,7 @@ Invalid Codex auth failed-turn error rendering. #### 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. From dbb820f4c46d104c9734375982080052e357f7bb Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 10:15:48 +0700 Subject: [PATCH 09/25] Document Docker provider auth regression workflow --- AGENTS.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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. From aa10f66a99c9ee28e70f60aafc85fe56edfcc052 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 10:18:04 +0700 Subject: [PATCH 10/25] Add provider auth test checklist --- whatToTest.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 whatToTest.md diff --git a/whatToTest.md b/whatToTest.md new file mode 100644 index 000000000..62db957dc --- /dev/null +++ b/whatToTest.md @@ -0,0 +1,60 @@ +# What To Test + +## Docker Provider/Auth Regression + +- [ ] Build packaged app: + - `pnpm run build` + - `pnpm pack --pack-destination /tmp` + - Build Docker image that installs the packed `codexapp` tarball plus `@openai/codex`. +- [ ] No-auth Docker startup: + - Start a fresh container with no `/codex-home/auth.json`. + - Verify `config/read` returns `model_provider="opencode-zen"` and `model="big-pickle"`. + - Send `hi`; wait for assistant reply. + - Capture screenshot. +- [ ] Provider switch: + - Start from OpenCode Zen. + - Send `hi`; wait for assistant reply. + - Switch the Settings provider selector to OpenRouter. Do not change the model dropdown directly. + - Send `hi`; wait for assistant reply. + - Verify composer model changes to an OpenRouter model. + - Capture screenshot. +- [ ] Invalid/expired auth file: + - Mount `/codex-home/auth.json` with invalid token fields and old `last_refresh`. + - Verify startup uses Codex provider path, not Zen fallback. + - Send `hi`; wait for final 401/auth error in chat. + - Verify `Send feedback` appears on the persisted error row. + - Reload the same thread; verify the error persists. + - Verify there is no duplicate live `Thinking` error overlay after persistence. + - Capture light and dark screenshots. +- [ ] Malformed auth file: + - Mount invalid JSON as `/codex-home/auth.json`. + - Verify it is treated as unusable auth and falls back to OpenCode Zen. + - Send `hi`; wait for assistant reply. + - Capture screenshot. + +## Model Loading + +- [ ] Open the model dropdown after no-auth startup. +- [ ] Confirm Zen provider models load before Codex model list. +- [ ] Confirm stale models from a previous provider do not appear after provider switch. +- [ ] Confirm provider-scoped selected model persistence: + - Pick a Zen model. + - Switch to OpenRouter and pick an OpenRouter model. + - Switch back to Zen. + - Verify the previous Zen model is restored. + +## Error Handling + +- [ ] Confirm first-turn live-state materialization pending does not show a false chat error. +- [ ] Confirm failed turns render as persisted system chat messages. +- [ ] Confirm persisted failed-turn errors include the final non-retry error text, not only transient reconnect text. +- [ ] Confirm feedback mailto includes recent diagnostics and visible page text. + +## Review/PR Checks + +- [ ] Run focused unit tests: + - `pnpm test:unit src/api/codexGateway.test.ts src/composables/useDesktopState.test.ts src/server/freeMode.test.ts src/server/codexAppServerBridge.archive.test.ts src/api/normalizers/v2.test.ts` +- [ ] Run production build: + - `pnpm run build` +- [ ] Post PR comment `/review` after pushing changes. +- [ ] Re-check Qodo/CodeRabbit comments and fix only confirmed issues. From 8d63a4dfe53709749f8b21da96ce711a91bc9e50 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 10:39:32 +0700 Subject: [PATCH 11/25] Fix live error overlay de-duplication --- src/composables/useDesktopState.test.ts | 92 +++++++++++++++++++++++++ src/composables/useDesktopState.ts | 18 ++++- src/server/codexAppServerBridge.ts | 8 +-- tests.md | 35 ++++++++++ whatToTest.md | 59 +--------------- 5 files changed, 147 insertions(+), 65 deletions(-) diff --git a/src/composables/useDesktopState.test.ts b/src/composables/useDesktopState.test.ts index b8ab329cc..f037f2b93 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({ diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index 5443d8dd2..9f9b372f6 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -1521,9 +1521,21 @@ export function useDesktopState() { const reasoningText = isInProgress ? (liveReasoningTextByThreadId.value[threadId] ?? '').trim() : '' - const hasPersistedTurnError = !isInProgress && (persistedMessagesByThreadId.value[threadId] ?? []) - .some((message) => message.messageType === 'turnError') - const errorText = hasPersistedTurnError ? '' : (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 { diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 93090bca3..3275600a8 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -6320,11 +6320,11 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { } throw error } + const trimmedResult = trimThreadTurnsInRpcResult(body.method, rpcResult) const errorMergedResult = THREAD_METHODS_WITH_TURNS.has(body.method) - ? mergeStreamTurnErrorsIntoThreadResult(appServer, rpcResult) - : rpcResult - const trimmedResult = trimThreadTurnsInRpcResult(body.method, errorMergedResult) - const sanitizedResult = await sanitizeThreadTurnsInlinePayloads(body.method, trimmedResult) + ? mergeStreamTurnErrorsIntoThreadResult(appServer, trimmedResult) + : trimmedResult + const sanitizedResult = await sanitizeThreadTurnsInlinePayloads(body.method, errorMergedResult) const result = THREAD_METHODS_WITH_TURNS.has(body.method) ? await mergeSessionSkillInputsIntoThreadResult(sanitizedResult) : sanitizedResult diff --git a/tests.md b/tests.md index 4ec12ac5b..8af7521fa 100644 --- a/tests.md +++ b/tests.md @@ -5437,3 +5437,38 @@ Invalid Codex auth failed-turn error rendering. #### 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. +- 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`. diff --git a/whatToTest.md b/whatToTest.md index 62db957dc..d05c74a82 100644 --- a/whatToTest.md +++ b/whatToTest.md @@ -1,60 +1,3 @@ # What To Test -## Docker Provider/Auth Regression - -- [ ] Build packaged app: - - `pnpm run build` - - `pnpm pack --pack-destination /tmp` - - Build Docker image that installs the packed `codexapp` tarball plus `@openai/codex`. -- [ ] No-auth Docker startup: - - Start a fresh container with no `/codex-home/auth.json`. - - Verify `config/read` returns `model_provider="opencode-zen"` and `model="big-pickle"`. - - Send `hi`; wait for assistant reply. - - Capture screenshot. -- [ ] Provider switch: - - Start from OpenCode Zen. - - Send `hi`; wait for assistant reply. - - Switch the Settings provider selector to OpenRouter. Do not change the model dropdown directly. - - Send `hi`; wait for assistant reply. - - Verify composer model changes to an OpenRouter model. - - Capture screenshot. -- [ ] Invalid/expired auth file: - - Mount `/codex-home/auth.json` with invalid token fields and old `last_refresh`. - - Verify startup uses Codex provider path, not Zen fallback. - - Send `hi`; wait for final 401/auth error in chat. - - Verify `Send feedback` appears on the persisted error row. - - Reload the same thread; verify the error persists. - - Verify there is no duplicate live `Thinking` error overlay after persistence. - - Capture light and dark screenshots. -- [ ] Malformed auth file: - - Mount invalid JSON as `/codex-home/auth.json`. - - Verify it is treated as unusable auth and falls back to OpenCode Zen. - - Send `hi`; wait for assistant reply. - - Capture screenshot. - -## Model Loading - -- [ ] Open the model dropdown after no-auth startup. -- [ ] Confirm Zen provider models load before Codex model list. -- [ ] Confirm stale models from a previous provider do not appear after provider switch. -- [ ] Confirm provider-scoped selected model persistence: - - Pick a Zen model. - - Switch to OpenRouter and pick an OpenRouter model. - - Switch back to Zen. - - Verify the previous Zen model is restored. - -## Error Handling - -- [ ] Confirm first-turn live-state materialization pending does not show a false chat error. -- [ ] Confirm failed turns render as persisted system chat messages. -- [ ] Confirm persisted failed-turn errors include the final non-retry error text, not only transient reconnect text. -- [ ] Confirm feedback mailto includes recent diagnostics and visible page text. - -## Review/PR Checks - -- [ ] Run focused unit tests: - - `pnpm test:unit src/api/codexGateway.test.ts src/composables/useDesktopState.test.ts src/server/freeMode.test.ts src/server/codexAppServerBridge.archive.test.ts src/api/normalizers/v2.test.ts` -- [ ] Run production build: - - `pnpm run build` -- [ ] Post PR comment `/review` after pushing changes. -- [ ] Re-check Qodo/CodeRabbit comments and fix only confirmed issues. +No remaining test tasks. From 7ee94f83aaf130789e6a66e9c55c4fe03ded5ab0 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 11:39:30 +0700 Subject: [PATCH 12/25] Promote copied auth to Codex provider --- src/App.vue | 23 +++++++++- src/api/codexGateway.ts | 1 + src/composables/useDesktopState.test.ts | 32 ++++++++++++++ src/composables/useDesktopState.ts | 13 +++--- .../codexAppServerBridge.archive.test.ts | 43 +++++++++++++++++++ src/server/codexAppServerBridge.ts | 8 +++- src/server/freeMode.test.ts | 21 +++++++++ src/server/freeMode.ts | 11 +++++ tests.md | 34 +++++++++++++++ 9 files changed, 176 insertions(+), 10 deletions(-) diff --git a/src/App.vue b/src/App.vue index 034797e70..e75da3582 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,7 @@ 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 externalAuthImportAttempted = false let existingFolderBrowseRequestId = 0 const routeThreadId = computed(() => { @@ -4019,6 +4020,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 +4039,23 @@ async function loadFreeModeStatus(): Promise { } else { selectedProvider.value = 'codex' } + if (status.hasCodexAuth === true && accounts.value.length === 0 && !externalAuthImportAttempted) { + externalAuthImportAttempted = true + void refreshAccountsFromAuth() + .then((result) => { + accounts.value = result.accounts + }) + .catch(() => { + void loadAccountsState({ silent: true }) + }) + } + if (selectedProvider.value !== previousProvider) { + void refreshAll({ + includeSelectedThreadMessages: false, + providerChanged: true, + awaitAncillaryRefreshes: true, + }) + } } catch { // Ignore — free mode status unknown } diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index 3658ffc4b..3fbf6f68d 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -1836,6 +1836,7 @@ export async function setCodexSpeedMode(mode: SpeedMode): Promise { export interface FreeModeStatus { enabled: boolean + hasCodexAuth?: boolean keyCount: number models: string[] currentModel: string | null diff --git a/src/composables/useDesktopState.test.ts b/src/composables/useDesktopState.test.ts index f037f2b93..231f83458 100644 --- a/src/composables/useDesktopState.test.ts +++ b/src/composables/useDesktopState.test.ts @@ -622,6 +622,38 @@ describe('provider model selection', () => { '__new-thread-provider__::opencode-zen': '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': 'gpt-5.5', + }) + }) }) describe('findAdjacentThreadId', () => { diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index 9f9b372f6..25287bbbf 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -1588,12 +1588,11 @@ export function useDesktopState() { 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 + ? normalizeStoredModelId(selectedModelIdByContext.value[providerContextId]) + : '' + if (providerModelId) return providerModelId } return readSelectedModel(selectedModelIdByContext.value, threadId).trim() } @@ -1630,7 +1629,7 @@ 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 diff --git a/src/server/codexAppServerBridge.archive.test.ts b/src/server/codexAppServerBridge.archive.test.ts index dd173b780..1750d293c 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -215,6 +215,49 @@ describe('ensureDefaultFreeModeStateForMissingAuthSync', () => { } }) + 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') diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 3275600a8..e6e2bed96 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -30,6 +30,7 @@ import { getFreeModeConfigArgs, getFreeModeEnvVars, shouldCreateDefaultFreeModeStateForMissingAuth, + shouldSuppressCommunityFreeModeForCodexAuth, type FreeModeState, } from './freeMode.js' import { handleOpenRouterProxyRequest } from './openRouterProxy.js' @@ -3351,7 +3352,11 @@ function readFreeModeStateSync(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 } @@ -6033,6 +6038,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { } setJson(res, 200, { enabled: state.enabled, + hasCodexAuth: hasUsableCodexAuthSync(), keyCount: getFreeKeyCount(), models, currentModel, diff --git a/src/server/freeMode.test.ts b/src/server/freeMode.test.ts index 813e361d9..330335ce4 100644 --- a/src/server/freeMode.test.ts +++ b/src/server/freeMode.test.ts @@ -7,6 +7,7 @@ import { createDefaultOpenCodeZenFreeModeState, getFreeModeConfigArgs, shouldCreateDefaultFreeModeStateForMissingAuth, + shouldSuppressCommunityFreeModeForCodexAuth, } from './freeMode' describe('unauthenticated free mode defaults', () => { @@ -33,6 +34,26 @@ describe('unauthenticated free mode defaults', () => { expect(args).toContain(`model_providers.${OPENCODE_ZEN_PROVIDER_ID}.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) + }) + it('uses the OpenCode Zen default model when persisted Zen state has an empty model', () => { const args = getFreeModeConfigArgs({ ...createDefaultOpenCodeZenFreeModeState(), diff --git a/src/server/freeMode.ts b/src/server/freeMode.ts index ffe7f1d5a..de78fd894 100644 --- a/src/server/freeMode.ts +++ b/src/server/freeMode.ts @@ -202,6 +202,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 {} diff --git a/tests.md b/tests.md index 8af7521fa..a3a763ff3 100644 --- a/tests.md +++ b/tests.md @@ -5472,3 +5472,37 @@ Docker provider/auth checklist execution and live error overlay de-duplication. #### 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. +- 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. From 4d62f2c7062ba3a32c2397d394962d725ea8f253 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 11:41:35 +0700 Subject: [PATCH 13/25] Update wiki for copied auth provider promotion --- .../fixes/copied-auth-provider-promotion.md | 88 +++++++++++++++++++ .../wiki/concepts/opencode-zen-big-pickle.md | 16 ++++ llm-wiki/wiki/index.md | 1 + llm-wiki/wiki/log.md | 6 ++ 4 files changed, 111 insertions(+) create mode 100644 llm-wiki/raw/fixes/copied-auth-provider-promotion.md 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/wiki/concepts/opencode-zen-big-pickle.md b/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md index 8c573f9e2..d45d89834 100644 --- a/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md +++ b/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md @@ -70,8 +70,24 @@ Validated Docker states: 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. + ## 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) - [merge-to-main-workflow.md](./merge-to-main-workflow.md) diff --git a/llm-wiki/wiki/index.md b/llm-wiki/wiki/index.md index 83f3949b0..97edfc08f 100644 --- a/llm-wiki/wiki/index.md +++ b/llm-wiki/wiki/index.md @@ -27,3 +27,4 @@ - [../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. diff --git a/llm-wiki/wiki/log.md b/llm-wiki/wiki/log.md index bf9904a3d..c081975b6 100644 --- a/llm-wiki/wiki/log.md +++ b/llm-wiki/wiki/log.md @@ -1,5 +1,11 @@ # Log +## [2026-05-13] ingest | copied auth provider promotion +- Added source: `raw/fixes/copied-auth-provider-promotion.md`. +- Updated wiki page: `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`. + ## [2026-05-13] ingest | OpenCode Zen Docker auth and provider models - Added source: `raw/fixes/opencode-zen-docker-auth-provider-models.md`. - Updated wiki page: `concepts/opencode-zen-big-pickle.md`. From dfc0bd09c06287c1e9376b6f477713e663da1fcc Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 12:42:33 +0700 Subject: [PATCH 14/25] Fix Docker provider fallback runtime tests --- src/App.vue | 36 +++++++++++++++++++-------- src/server/codexAppServerBridge.ts | 1 - src/server/freeMode.test.ts | 13 +++++----- src/server/freeMode.ts | 40 +++++++++++++++++------------- tests.md | 4 ++- 5 files changed, 58 insertions(+), 36 deletions(-) diff --git a/src/App.vue b/src/App.vue index e75da3582..9d70afeca 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1589,6 +1589,7 @@ 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 @@ -2101,6 +2102,25 @@ watch(accounts, () => { }, 1500) }, { deep: true }) +watch(accountRateLimitSnapshots, () => { + void maybeImportExternalCodexAuthAccount() +}, { deep: true }) + +async function maybeImportExternalCodexAuthAccount(): Promise { + if (!externalCodexAuthAvailable) return + if (externalAuthImportAttempted) return + if (selectedProvider.value !== 'codex') return + if (accounts.value.length > 0) return + if (accountRateLimitSnapshots.value.length === 0) return + externalAuthImportAttempted = true + try { + const result = await refreshAccountsFromAuth() + accounts.value = result.accounts + } catch { + void loadAccountsState({ silent: true }) + } +} + function onSkillsChanged(): void { void refreshSkills() } @@ -4039,22 +4059,18 @@ async function loadFreeModeStatus(): Promise { } else { selectedProvider.value = 'codex' } - if (status.hasCodexAuth === true && accounts.value.length === 0 && !externalAuthImportAttempted) { - externalAuthImportAttempted = true - void refreshAccountsFromAuth() - .then((result) => { - accounts.value = result.accounts - }) - .catch(() => { - void loadAccountsState({ silent: true }) - }) + externalCodexAuthAvailable = status.hasCodexAuth === true + if (!externalCodexAuthAvailable) { + externalAuthImportAttempted = false } if (selectedProvider.value !== previousProvider) { void refreshAll({ includeSelectedThreadMessages: false, providerChanged: true, awaitAncillaryRefreshes: true, - }) + }).then(() => maybeImportExternalCodexAuthAccount()) + } else { + void maybeImportExternalCodexAuthAccount() } } catch { // Ignore — free mode status unknown diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index e6e2bed96..84a2e16b4 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -18,7 +18,6 @@ import { TelegramThreadBridge } from './telegramThreadBridge.js' import { getRandomFreeKey, getFreeKeyCount, - FREE_MODE_PROVIDER_ID, FREE_MODE_DEFAULT_MODEL, getCachedFreeModels, getFreeModels, diff --git a/src/server/freeMode.test.ts b/src/server/freeMode.test.ts index 330335ce4..0d47709cd 100644 --- a/src/server/freeMode.test.ts +++ b/src/server/freeMode.test.ts @@ -1,9 +1,7 @@ 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, @@ -27,11 +25,11 @@ 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', () => { @@ -72,8 +70,9 @@ 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('does not replace an intentionally disabled free mode state', () => { diff --git a/src/server/freeMode.ts b/src/server/freeMode.ts index de78fd894..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', @@ -151,6 +152,8 @@ 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' @@ -232,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/tests.md b/tests.md index a3a763ff3..bb00983fc 100644 --- a/tests.md +++ b/tests.md @@ -5464,6 +5464,7 @@ Docker provider/auth checklist execution and live error overlay de-duplication. #### 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. @@ -5500,7 +5501,8 @@ Runtime auth detection after starting without auth. - 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. +- 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. From d939a1775550008ba88485494effce1056e11d89 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 12:47:00 +0700 Subject: [PATCH 15/25] Address provider review findings --- llm-wiki/wiki/log.md | 12 ++++----- src/App.vue | 43 +++++++++++++++++++++--------- src/server/codexAppServerBridge.ts | 14 +++++----- src/server/freeMode.test.ts | 26 ++++++++++++++++++ tests.md | 2 +- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/llm-wiki/wiki/log.md b/llm-wiki/wiki/log.md index c081975b6..9db99cf1a 100644 --- a/llm-wiki/wiki/log.md +++ b/llm-wiki/wiki/log.md @@ -1,16 +1,16 @@ # Log ## [2026-05-13] ingest | copied auth provider promotion -- Added source: `raw/fixes/copied-auth-provider-promotion.md`. -- Updated wiki page: `concepts/opencode-zen-big-pickle.md`. +- 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`. +- 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`. -- Updated wiki page: `concepts/opencode-zen-big-pickle.md`. +- 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`, `entities/codex-web-local.md`, and `index.md`. +- Updated [overview.md](./overview.md), [entities/codex-web-local.md](./entities/codex-web-local.md), and [index.md](./index.md). ## [2026-05-02] ingest | Directory Hub Composio and Skills search - Added source: `raw/features/directory-hub-composio-skills-search.md`. diff --git a/src/App.vue b/src/App.vue index 9d70afeca..3dcd919f0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2103,22 +2103,32 @@ watch(accounts, () => { }, { deep: true }) watch(accountRateLimitSnapshots, () => { - void maybeImportExternalCodexAuthAccount() + void maybeImportExternalCodexAuthAccount().then((imported) => { + if (!imported) return + void refreshAll({ + includeSelectedThreadMessages: false, + providerChanged: true, + awaitAncillaryRefreshes: true, + }) + }) }, { deep: true }) -async function maybeImportExternalCodexAuthAccount(): Promise { - if (!externalCodexAuthAvailable) return - if (externalAuthImportAttempted) return - if (selectedProvider.value !== 'codex') return - if (accounts.value.length > 0) return - if (accountRateLimitSnapshots.value.length === 0) return +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 { - void loadAccountsState({ silent: true }) + await loadAccountsState({ silent: true }) } + const nextAccountsJson = JSON.stringify(accounts.value.map((account) => account.accountId).sort()) + return previousAccountsJson !== nextAccountsJson } function onSkillsChanged(): void { @@ -4063,14 +4073,21 @@ async function loadFreeModeStatus(): Promise { if (!externalCodexAuthAvailable) { externalAuthImportAttempted = false } - if (selectedProvider.value !== previousProvider) { - void refreshAll({ + const providerChanged = selectedProvider.value !== previousProvider + if (providerChanged) { + await refreshAll({ includeSelectedThreadMessages: false, providerChanged: true, awaitAncillaryRefreshes: true, - }).then(() => maybeImportExternalCodexAuthAccount()) - } else { - void maybeImportExternalCodexAuthAccount() + }) + } + const importedExternalAuth = await maybeImportExternalCodexAuthAccount() + if (importedExternalAuth) { + await refreshAll({ + includeSelectedThreadMessages: false, + providerChanged: providerChanged || importedExternalAuth, + awaitAncillaryRefreshes: true, + }) } } catch { // Ignore — free mode status unknown diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 84a2e16b4..14bd0a276 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -5898,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 @@ -5925,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 diff --git a/src/server/freeMode.test.ts b/src/server/freeMode.test.ts index 0d47709cd..24d97ff2f 100644 --- a/src/server/freeMode.test.ts +++ b/src/server/freeMode.test.ts @@ -50,6 +50,32 @@ describe('unauthenticated free mode defaults', () => { 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', () => { diff --git a/tests.md b/tests.md index bb00983fc..1996b7417 100644 --- a/tests.md +++ b/tests.md @@ -3373,7 +3373,7 @@ OpenCode Zen as built-in provider + API format selector for custom endpoints #### Rollback/Cleanup - Switch provider back to "Codex" to disable free mode -- No config files outside the project are modified (state stored in `~/.codex/webui-custom-providers.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) From b51f44e846bd4ca2d91a3f878cd84682b6c61e2f Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 13:09:24 +0700 Subject: [PATCH 16/25] Fix turn error id collisions --- llm-wiki/wiki/log.md | 24 +++++++++---------- src/api/normalizers/v2.test.ts | 43 ++++++++++++++++++++++++++++++++++ src/api/normalizers/v2.ts | 6 +++-- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/llm-wiki/wiki/log.md b/llm-wiki/wiki/log.md index 9db99cf1a..35bd82ed9 100644 --- a/llm-wiki/wiki/log.md +++ b/llm-wiki/wiki/log.md @@ -1,17 +1,5 @@ # Log -## [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-02] ingest | Directory Hub Composio and Skills search - Added source: `raw/features/directory-hub-composio-skills-search.md`. - Created wiki page: `concepts/directory-hub-composio-skills.md`. @@ -64,3 +52,15 @@ - 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). diff --git a/src/api/normalizers/v2.test.ts b/src/api/normalizers/v2.test.ts index 579191535..7e87c9a54 100644 --- a/src/api/normalizers/v2.test.ts +++ b/src/api/normalizers/v2.test.ts @@ -131,4 +131,47 @@ Reply with </instructions> and A & B 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 6a8a7ab6a..d76e1ba88 100644 --- a/src/api/normalizers/v2.ts +++ b/src/api/normalizers/v2.ts @@ -629,7 +629,8 @@ 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)) { @@ -638,8 +639,9 @@ export function normalizeThreadMessagesV2(payload: ThreadReadResponse, baseTurnI } const errorText = readTurnErrorText(turn) if (turn.status === 'failed' && errorText) { + const errorIdBase = turnId ?? `turn-${turnIndex}` messages.push({ - id: `${turnId ?? `turn-${turnIndex}`}-error`, + id: `${errorIdBase}-error`, role: 'system', text: errorText, messageType: 'turnError', From 51bff49ce63dbe3604c67acf7050682606a9ed06 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 17:52:29 +0700 Subject: [PATCH 17/25] Fix provider-scoped model selection drift --- src/composables/useDesktopState.test.ts | 143 +++++++++++++++++++++++- src/composables/useDesktopState.ts | 74 +++++++++--- tests.md | 33 ++++++ 3 files changed, 229 insertions(+), 21 deletions(-) diff --git a/src/composables/useDesktopState.test.ts b/src/composables/useDesktopState.test.ts index 231f83458..dc1f21601 100644 --- a/src/composables/useDesktopState.test.ts +++ b/src/composables/useDesktopState.test.ts @@ -581,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) }) @@ -619,7 +619,7 @@ 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' }, }) }) @@ -651,9 +651,146 @@ describe('provider model selection', () => { 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': 'gpt-5.5', + '__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') + }) }) describe('findAdjacentThreadId', () => { diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index 25287bbbf..af33d86a5 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), ) @@ -1584,17 +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) const providerContextId = toProviderModelContextId(normalizedProviderId) const providerModelId = providerContextId - ? normalizeStoredModelId(selectedModelIdByContext.value[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 { @@ -1635,7 +1672,7 @@ export function useDesktopState() { const selectedContextId = providerContextId || contextId if (normalizedModelId) { const nextModelMap = cloneStringKeyedRecord(selectedModelIdByContext.value) - nextModelMap[selectedContextId] = normalizedModelId + nextModelMap[selectedContextId] = createStoredModelSelection(normalizedProviderId, normalizedModelId) if (providerContextId) { delete nextModelMap[contextId] } @@ -1667,7 +1704,7 @@ 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) @@ -1883,18 +1920,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] + const extraModelIds = isProviderBacked ? [normalizedConfiguredModelId] : [normalizedConfiguredModelId] for (const modelId of extraModelIds) { if (modelId && !nextModelIds.includes(modelId)) { nextModelIds.push(modelId) @@ -1925,7 +1963,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/tests.md b/tests.md index 1996b7417..29499ea22 100644 --- a/tests.md +++ b/tests.md @@ -5508,3 +5508,36 @@ Runtime auth detection after starting without auth. #### 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. From cc6ddde9dfaf5d7037a11263d9adfdcd32f8abdf Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 17:59:03 +0700 Subject: [PATCH 18/25] Keep routed thread during provider refresh --- src/App.vue | 13 ------------- tests.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/App.vue b/src/App.vue index 3dcd919f0..f798b7d3a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4223,11 +4223,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 @@ -4251,14 +4246,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/tests.md b/tests.md index 29499ea22..1a1c8befb 100644 --- a/tests.md +++ b/tests.md @@ -5541,3 +5541,31 @@ Provider-tagged model selection storage and stale cross-provider model rejection #### 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. From dcffe94cf5902ae1c933570462923ca4d6d9ac45 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 18:18:16 +0700 Subject: [PATCH 19/25] Preserve thread route on provider switch --- src/App.vue | 3 --- whatToTest.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/App.vue b/src/App.vue index f798b7d3a..741075c7a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3946,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 { diff --git a/whatToTest.md b/whatToTest.md index d05c74a82..d7d3990d7 100644 --- a/whatToTest.md +++ b/whatToTest.md @@ -1,3 +1,65 @@ # What To Test -No remaining test tasks. +## Docker provider cycle failures from 2026-05-13 + +### Provider-switched historical thread cannot send + +- Environment: packaged Docker image `codexapp-provider-cycle:local`, auth-mounted container on port `4192`. +- Browser evidence: + - `output/playwright/docker-provider-switch-zen-dropdown-fixed.png` + - `output/playwright/docker-provider-switch-zen-result-fixed.png` + - `output/playwright/docker-provider-switch-openrouter-result.png` +- Repro: + 1. Start on an auth/Codex thread. + 2. Switch Settings provider from Codex to OpenCode Zen. + 3. Keep the same thread URL. + 4. Send `hi provider opencode zen`. +- Result: + - URL remains stable after the route-continuity fix. + - Conversation remains visible. + - Send fails with `RPC turn/start failed with HTTP 502: thread not found: `. +- Expected: + - Either the provider switch should make the routed thread runnable under the new backend session, or the UI should clearly explain that the existing thread cannot be continued under the new provider and provide a safe new-thread path. + +### OpenRouter provider can show selected while backend remains Codex + +- Environment: auth-mounted container on port `4192`. +- Browser evidence: + - `output/playwright/docker-provider-switch-openrouter-settings.png` + - `output/playwright/docker-provider-switch-openrouter-dropdown.png` +- Network evidence: + - `/codex-api/free-mode/status` returned `enabled=false`, `hasCodexAuth=true`, `provider=openrouter`. + - `/codex-api/provider-models` returned `source=provider`, `providerId=""`, `count=0`. +- Result: + - Settings showed OpenRouter selected. + - Composer model dropdown still showed Codex models (`GPT-5.5`, `GPT-5.4`, etc.). +- Expected: + - If OpenRouter is selected, it should either activate with an OpenRouter key/model list or show an explicit blocking state instead of leaving Codex models active. + +### Custom NVIDIA NIM chat provider does not drive the UI model dropdown and sends to Responses path + +- Environment: auth-mounted container on port `4192`, custom provider set through `/codex-api/free-mode/custom-provider`. +- Config: + - `baseUrl=https://integrate.api.nvidia.com/v1` + - `wireApi=chat` +- Browser evidence: + - `output/playwright/docker-provider-switch-custom-nim-dropdown.png` + - `output/playwright/docker-provider-switch-custom-nim-result.png` +- Network evidence: + - `/codex-api/free-mode/status` returned `enabled=true`, `provider=custom`, `customBaseUrl=https://integrate.api.nvidia.com/v1`, `wireApi=chat`. + - `/codex-api/provider-models` returned `source=custom`, `exclusive=true`, and 123 models. +- Result: + - UI model dropdown still showed Codex models. + - Searching `moonshotai/kimi-k2.5` returned no results even though NIM provider model discovery succeeded. + - 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: + - UI dropdown should show the custom NIM model list. + - Chat-completions providers should send to the chat proxy path and produce non-empty `messages`. + +### Groq custom provider not completed + +- Environment: local KeePass registry has OpenRouter and NVIDIA keys, but no Groq key entry was found. +- Result: + - Could not perform a valid Groq send test. +- Expected: + - Add or provide a Groq API key, then run the same packaged Docker Browser evidence flow with `baseUrl=https://api.groq.com/openai/v1`, `wireApi=chat`, model-list verification, and send verification. From d30089b9efef66ebcfa699a86215b45ae4b5a505 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 18:20:51 +0700 Subject: [PATCH 20/25] Update wiki for provider Docker cycle --- .../provider-selection-drift-docker-cycle.md | 113 ++++++++++++++++++ .../wiki/concepts/opencode-zen-big-pickle.md | 18 +++ llm-wiki/wiki/entities/codex-web-local.md | 1 + llm-wiki/wiki/index.md | 1 + llm-wiki/wiki/log.md | 6 + llm-wiki/wiki/overview.md | 1 + 6 files changed, 140 insertions(+) create mode 100644 llm-wiki/raw/fixes/provider-selection-drift-docker-cycle.md 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 d45d89834..a8ce5c2dc 100644 --- a/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md +++ b/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md @@ -85,9 +85,27 @@ User-configured provider state is preserved: OpenRouter with `customKey: true`, 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 e2f3f0d28..23fe58d02 100644 --- a/llm-wiki/wiki/entities/codex-web-local.md +++ b/llm-wiki/wiki/entities/codex-web-local.md @@ -26,6 +26,7 @@ - [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) diff --git a/llm-wiki/wiki/index.md b/llm-wiki/wiki/index.md index 97edfc08f..291ed10cb 100644 --- a/llm-wiki/wiki/index.md +++ b/llm-wiki/wiki/index.md @@ -28,3 +28,4 @@ - [../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 35bd82ed9..36fe49091 100644 --- a/llm-wiki/wiki/log.md +++ b/llm-wiki/wiki/log.md @@ -64,3 +64,9 @@ - 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 0f4fa5560..ec9c3ecfa 100644 --- a/llm-wiki/wiki/overview.md +++ b/llm-wiki/wiki/overview.md @@ -19,6 +19,7 @@ This wiki tracks knowledge about the `codex-web-local` project and related workf - [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) From b10419d107a5f542cefb7e0106165375bea408ec Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 18:23:16 +0700 Subject: [PATCH 21/25] Add Docker provider follow-up test tasks --- whatToTest.md | 219 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 165 insertions(+), 54 deletions(-) diff --git a/whatToTest.md b/whatToTest.md index d7d3990d7..c6c1d28ee 100644 --- a/whatToTest.md +++ b/whatToTest.md @@ -1,65 +1,176 @@ # What To Test -## Docker provider cycle failures from 2026-05-13 +## Open Docker Provider Tasks -### Provider-switched historical thread cannot send +These tasks come from the packaged Docker Browser cycle on 2026-05-13. Keep each task here until a fresh packaged Docker run proves it passes with browser screenshots and network evidence. -- Environment: packaged Docker image `codexapp-provider-cycle:local`, auth-mounted container on port `4192`. -- Browser evidence: +### [ ] P0 - Continue or safely block historical threads after provider switch + +**Environment** + +- Packaged Docker image built from the current branch. +- Auth-mounted container, preferably on `http://127.0.0.1:4192/#/`. + +**Current evidence** + +- Browser screenshots: - `output/playwright/docker-provider-switch-zen-dropdown-fixed.png` - `output/playwright/docker-provider-switch-zen-result-fixed.png` - `output/playwright/docker-provider-switch-openrouter-result.png` -- Repro: - 1. Start on an auth/Codex thread. - 2. Switch Settings provider from Codex to OpenCode Zen. - 3. Keep the same thread URL. - 4. Send `hi provider opencode zen`. -- Result: - - URL remains stable after the route-continuity fix. - - Conversation remains visible. - - Send fails with `RPC turn/start failed with HTTP 502: thread not found: `. -- Expected: - - Either the provider switch should make the routed thread runnable under the new backend session, or the UI should clearly explain that the existing thread cannot be continued under the new provider and provide a safe new-thread path. - -### OpenRouter provider can show selected while backend remains Codex - -- Environment: auth-mounted container on port `4192`. -- Browser evidence: +- URL remained stable after the route-continuity fix. +- Conversation remained visible. +- Sending after `Codex -> OpenCode Zen` failed with `RPC turn/start failed with HTTP 502: thread not found: `. + +**Repro** + +1. Start an auth/Codex container with a fresh isolated `CODEX_HOME`. +2. Open a Codex thread and record the full `#/thread/` URL. +3. Switch Settings provider from `Codex` to `OpenCode Zen`. +4. Confirm the URL still contains the same thread id and the conversation is visible. +5. Send `hi provider opencode zen`. + +**Pass criteria** + +- The URL stays on the same `#/thread/` route. +- The visible conversation remains in place. +- The UI either: + - successfully continues the thread using the active provider, or + - blocks sending before `turn/start` with a clear message and a safe new-thread path. +- The chat must not render a raw `thread not found` backend failure after the user presses Send. + +**Validation evidence to capture** + +- Browser screenshot before provider switch. +- Browser screenshot after provider switch with the same thread URL visible. +- Browser screenshot after sending or after the explicit blocked-send state. +- Network evidence for `/codex-api/free-mode/status`, `/codex-api/provider-models`, and the send path. + +### [ ] P0 - OpenRouter selected state must not fall back to Codex models silently + +**Environment** + +- Packaged Docker image built from the current branch. +- Auth-mounted container, preferably on `http://127.0.0.1:4192/#/`. +- OpenRouter key available to the container, or an explicit no-key state. + +**Current evidence** + +- Browser screenshots: - `output/playwright/docker-provider-switch-openrouter-settings.png` - `output/playwright/docker-provider-switch-openrouter-dropdown.png` -- Network evidence: - - `/codex-api/free-mode/status` returned `enabled=false`, `hasCodexAuth=true`, `provider=openrouter`. - - `/codex-api/provider-models` returned `source=provider`, `providerId=""`, `count=0`. -- Result: - - Settings showed OpenRouter selected. - - Composer model dropdown still showed Codex models (`GPT-5.5`, `GPT-5.4`, etc.). -- Expected: - - If OpenRouter is selected, it should either activate with an OpenRouter key/model list or show an explicit blocking state instead of leaving Codex models active. - -### Custom NVIDIA NIM chat provider does not drive the UI model dropdown and sends to Responses path - -- Environment: auth-mounted container on port `4192`, custom provider set through `/codex-api/free-mode/custom-provider`. -- Config: - - `baseUrl=https://integrate.api.nvidia.com/v1` - - `wireApi=chat` -- Browser evidence: +- `/codex-api/free-mode/status` returned `enabled=false`, `hasCodexAuth=true`, `provider=openrouter`. +- `/codex-api/provider-models` returned `source=provider`, `providerId=""`, `count=0`. +- Settings showed `OpenRouter`, but the composer dropdown still showed Codex models such as `GPT-5.5` and `GPT-5.4`. + +**Repro** + +1. Start an auth/Codex container with a fresh isolated `CODEX_HOME`. +2. Open Settings and switch provider from `Codex` to `OpenRouter`. +3. Open the composer model dropdown. +4. Inspect `/codex-api/free-mode/status` and `/codex-api/provider-models`. +5. Send `hi provider openrouter` only if OpenRouter is fully configured. + +**Pass criteria** + +- Settings, free-mode status, provider-models, and the model dropdown agree on the active provider state. +- If OpenRouter is configured, `/codex-api/provider-models` returns OpenRouter-compatible models and the dropdown does not show Codex-only catalog entries as the active list. +- If OpenRouter is not configured, the UI shows an explicit blocking state instead of retaining Codex models. +- No send after selecting OpenRouter uses a stale Codex, Groq, NIM, or previous-provider model. + +**Validation evidence to capture** + +- Browser screenshot of Settings showing `OpenRouter`. +- Browser screenshot of the model dropdown. +- Network evidence for `/codex-api/free-mode/status`, `/codex-api/provider-models`, and any send payload. +- Final chat reply or final visible provider configuration error. + +### [ ] P0 - Custom NVIDIA NIM chat provider must drive dropdown and send through chat proxy + +**Environment** + +- Packaged Docker image built from the current branch. +- Auth-mounted container, preferably on `http://127.0.0.1:4192/#/`. +- NVIDIA API key mounted or configured for the container. + +**Provider config** + +- Base URL: `https://integrate.api.nvidia.com/v1` +- Wire API: `chat` + +**Current evidence** + +- Browser screenshots: - `output/playwright/docker-provider-switch-custom-nim-dropdown.png` - `output/playwright/docker-provider-switch-custom-nim-result.png` -- Network evidence: - - `/codex-api/free-mode/status` returned `enabled=true`, `provider=custom`, `customBaseUrl=https://integrate.api.nvidia.com/v1`, `wireApi=chat`. - - `/codex-api/provider-models` returned `source=custom`, `exclusive=true`, and 123 models. -- Result: - - UI model dropdown still showed Codex models. - - Searching `moonshotai/kimi-k2.5` returned no results even though NIM provider model discovery succeeded. - - 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: - - UI dropdown should show the custom NIM model list. - - Chat-completions providers should send to the chat proxy path and produce non-empty `messages`. - -### Groq custom provider not completed - -- Environment: local KeePass registry has OpenRouter and NVIDIA keys, but no Groq key entry was found. -- Result: - - Could not perform a valid Groq send test. -- Expected: - - Add or provide a Groq API key, then run the same packaged Docker Browser evidence flow with `baseUrl=https://api.groq.com/openai/v1`, `wireApi=chat`, model-list verification, and send verification. +- `/codex-api/free-mode/status` returned `enabled=true`, `provider=custom`, `customBaseUrl=https://integrate.api.nvidia.com/v1`, `wireApi=chat`. +- `/codex-api/provider-models` returned `source=custom`, `exclusive=true`, and 123 models. +- UI dropdown still showed Codex models. +- Searching for `moonshotai/kimi-k2.5` returned no results even though model discovery succeeded. +- Sending failed against the Responses path: `http://127.0.0.1:4192/codex-api/custom-proxy/v1/responses`. + +**Repro** + +1. Start an auth/Codex container with a fresh isolated `CODEX_HOME`. +2. Configure custom provider with the NIM base URL and `wireApi=chat`. +3. Refresh provider models. +4. Open the composer model dropdown and search for a known NIM model from the returned model list. +5. Select a valid NIM chat model. +6. Send `hi provider nvidia nim`. + +**Pass criteria** + +- Settings and `/codex-api/free-mode/status` show `provider=custom` and `wireApi=chat`. +- `/codex-api/provider-models` returns an exclusive NIM model list. +- The composer dropdown shows the NIM list, not Codex models. +- 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 and no `/custom-proxy/v1/responses` request for the NIM chat provider. + +**Validation evidence to capture** + +- Browser screenshot of the custom provider settings. +- Browser screenshot of the NIM 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. + +### [ ] 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. +- No valid Groq send test was 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. From 56d2de09bc4c8cde722fa032b059cc1a62ed2311 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 18:32:53 +0700 Subject: [PATCH 22/25] Prune passed Docker provider test task --- whatToTest.md | 74 ++++++++++++++------------------------------------- 1 file changed, 20 insertions(+), 54 deletions(-) diff --git a/whatToTest.md b/whatToTest.md index c6c1d28ee..c9ed340f7 100644 --- a/whatToTest.md +++ b/whatToTest.md @@ -2,48 +2,9 @@ ## Open Docker Provider Tasks -These tasks come from the packaged Docker Browser cycle on 2026-05-13. Keep each task here until a fresh packaged Docker run proves it passes with browser screenshots and network evidence. +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. -### [ ] P0 - Continue or safely block historical threads after provider switch - -**Environment** - -- Packaged Docker image built from the current branch. -- Auth-mounted container, preferably on `http://127.0.0.1:4192/#/`. - -**Current evidence** - -- Browser screenshots: - - `output/playwright/docker-provider-switch-zen-dropdown-fixed.png` - - `output/playwright/docker-provider-switch-zen-result-fixed.png` - - `output/playwright/docker-provider-switch-openrouter-result.png` -- URL remained stable after the route-continuity fix. -- Conversation remained visible. -- Sending after `Codex -> OpenCode Zen` failed with `RPC turn/start failed with HTTP 502: thread not found: `. - -**Repro** - -1. Start an auth/Codex container with a fresh isolated `CODEX_HOME`. -2. Open a Codex thread and record the full `#/thread/` URL. -3. Switch Settings provider from `Codex` to `OpenCode Zen`. -4. Confirm the URL still contains the same thread id and the conversation is visible. -5. Send `hi provider opencode zen`. - -**Pass criteria** - -- The URL stays on the same `#/thread/` route. -- The visible conversation remains in place. -- The UI either: - - successfully continues the thread using the active provider, or - - blocks sending before `turn/start` with a clear message and a safe new-thread path. -- The chat must not render a raw `thread not found` backend failure after the user presses Send. - -**Validation evidence to capture** - -- Browser screenshot before provider switch. -- Browser screenshot after provider switch with the same thread URL visible. -- Browser screenshot after sending or after the explicit blocked-send state. -- Network evidence for `/codex-api/free-mode/status`, `/codex-api/provider-models`, and the send path. +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. ### [ ] P0 - OpenRouter selected state must not fall back to Codex models silently @@ -55,12 +16,14 @@ These tasks come from the packaged Docker Browser cycle on 2026-05-13. Keep each **Current evidence** +- Fresh run: packaged image `codexapp-provider-exec:local`, auth-mounted container on `http://127.0.0.1:4292/#/`. - Browser screenshots: - - `output/playwright/docker-provider-switch-openrouter-settings.png` - - `output/playwright/docker-provider-switch-openrouter-dropdown.png` -- `/codex-api/free-mode/status` returned `enabled=false`, `hasCodexAuth=true`, `provider=openrouter`. -- `/codex-api/provider-models` returned `source=provider`, `providerId=""`, `count=0`. -- Settings showed `OpenRouter`, but the composer dropdown still showed Codex models such as `GPT-5.5` and `GPT-5.4`. + - `output/playwright/docker-exec-openrouter-before-dropdown.png` + - `output/playwright/docker-exec-openrouter-dropdown.png` +- `/codex-api/free-mode/status` returned `enabled=true`, `hasCodexAuth=true`, `provider=openrouter`, `currentModel=big-pickle`, `wireApi=responses`. +- `/codex-api/provider-models` returned `exclusive=true`, `count=26`, first models including `openrouter/free`, `inclusionai/ring-2.6-1t:free`, and `baidu/cobuddy:free`. +- The composer model button still showed stale `big-pickle`. +- The dropdown showed OpenRouter models but also appended stale `big-pickle`. **Repro** @@ -99,14 +62,16 @@ These tasks come from the packaged Docker Browser cycle on 2026-05-13. Keep each **Current evidence** +- Fresh run: packaged image `codexapp-provider-exec:local`, auth-mounted container on `http://127.0.0.1:4292/#/`. - Browser screenshots: - - `output/playwright/docker-provider-switch-custom-nim-dropdown.png` - - `output/playwright/docker-provider-switch-custom-nim-result.png` -- `/codex-api/free-mode/status` returned `enabled=true`, `provider=custom`, `customBaseUrl=https://integrate.api.nvidia.com/v1`, `wireApi=chat`. -- `/codex-api/provider-models` returned `source=custom`, `exclusive=true`, and 123 models. -- UI dropdown still showed Codex models. -- Searching for `moonshotai/kimi-k2.5` returned no results even though model discovery succeeded. -- Sending failed against the Responses path: `http://127.0.0.1:4192/codex-api/custom-proxy/v1/responses`. + - `output/playwright/docker-exec-nim-home-stale-model.png` + - `output/playwright/docker-exec-nim-dropdown-stale.png` + - `output/playwright/docker-exec-nim-new-thread-send.png` +- `/codex-api/free-mode/status` returned `enabled=true`, `provider=custom`, `currentModel=01-ai/yi-large`, `customBaseUrl=https://integrate.api.nvidia.com/v1`, `wireApi=chat`. +- `/codex-api/provider-models` returned `source=custom`, `exclusive=true`, `count=123`, first models including `01-ai/yi-large`, `abacusai/dracarys-llama-3.1-70b-instruct`, and `adept/fuyu-8b`. +- The home composer still showed stale `big-pickle` instead of the NIM model. +- The dropdown did not show `01-ai/yi-large` in the tested UI state. +- Sending a new thread with `hi provider nvidia nim new thread` failed against the Responses path: `http://127.0.0.1:4190/codex-api/custom-proxy/v1/responses`. **Repro** @@ -149,7 +114,8 @@ These tasks come from the packaged Docker Browser cycle on 2026-05-13. Keep each **Current evidence** - Local KeePass registry had OpenRouter and NVIDIA keys, but no Groq key entry was found during the 2026-05-13 run. -- No valid Groq send test was completed. +- A fresh 2026-05-13 key lookup still found no Groq key entry. +- No valid Groq send test has been completed. **Repro** From 7cb220295afdca817bba3028f15c5c0307f8a4ff Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 18:47:11 +0700 Subject: [PATCH 23/25] Enforce provider-backed model lists --- src/composables/useDesktopState.test.ts | 66 ++++++++++++++++++ src/composables/useDesktopState.ts | 21 ++++-- src/server/freeMode.test.ts | 17 +++++ tests.md | 36 ++++++++++ whatToTest.md | 92 +------------------------ 5 files changed, 136 insertions(+), 96 deletions(-) diff --git a/src/composables/useDesktopState.test.ts b/src/composables/useDesktopState.test.ts index dc1f21601..a31b1f397 100644 --- a/src/composables/useDesktopState.test.ts +++ b/src/composables/useDesktopState.test.ts @@ -791,6 +791,72 @@ describe('provider model selection', () => { 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') + }) }) describe('findAdjacentThreadId', () => { diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index af33d86a5..48e9f7fa6 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -1647,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, @@ -1686,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) } @@ -1709,7 +1718,7 @@ export function useDesktopState() { } else { selectedModelIdByContext.value = omitStringKeyedRecordKey(selectedModelIdByContext.value, normalizedThreadId) } - ensureAvailableModelIds(normalizedModelId) + ensureCompatibleAvailableModelIds(normalizedModelId) if (selectedThreadId.value === normalizedThreadId) { selectedModelId.value = readModelIdForThread(selectedThreadId.value) } @@ -1931,8 +1940,8 @@ export function useDesktopState() { availableModelIds.value = [...modelIds] const normalizedSelectedModelId = readModelIdForThread(selectedThreadId.value) const nextModelIds = [...modelIds] - if (!options?.providerChanged) { - const extraModelIds = isProviderBacked ? [normalizedConfiguredModelId] : [normalizedConfiguredModelId] + if (!options?.providerChanged && !isProviderBacked) { + const extraModelIds = [normalizedConfiguredModelId] for (const modelId of extraModelIds) { if (modelId && !nextModelIds.includes(modelId)) { nextModelIds.push(modelId) diff --git a/src/server/freeMode.test.ts b/src/server/freeMode.test.ts index 24d97ff2f..c618594db 100644 --- a/src/server/freeMode.test.ts +++ b/src/server/freeMode.test.ts @@ -101,6 +101,23 @@ describe('unauthenticated free mode defaults', () => { 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', () => { expect(shouldCreateDefaultFreeModeStateForMissingAuth({ enabled: false, diff --git a/tests.md b/tests.md index 1a1c8befb..495bb9381 100644 --- a/tests.md +++ b/tests.md @@ -5569,3 +5569,39 @@ Keep the currently routed thread selected when a refreshed thread list omits it. #### 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 index c9ed340f7..8950fe51d 100644 --- a/whatToTest.md +++ b/whatToTest.md @@ -6,97 +6,9 @@ These tasks come from packaged Docker Browser cycles. Keep each task here until 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. -### [ ] P0 - OpenRouter selected state must not fall back to Codex models silently +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`. -**Environment** - -- Packaged Docker image built from the current branch. -- Auth-mounted container, preferably on `http://127.0.0.1:4192/#/`. -- OpenRouter key available to the container, or an explicit no-key state. - -**Current evidence** - -- Fresh run: packaged image `codexapp-provider-exec:local`, auth-mounted container on `http://127.0.0.1:4292/#/`. -- Browser screenshots: - - `output/playwright/docker-exec-openrouter-before-dropdown.png` - - `output/playwright/docker-exec-openrouter-dropdown.png` -- `/codex-api/free-mode/status` returned `enabled=true`, `hasCodexAuth=true`, `provider=openrouter`, `currentModel=big-pickle`, `wireApi=responses`. -- `/codex-api/provider-models` returned `exclusive=true`, `count=26`, first models including `openrouter/free`, `inclusionai/ring-2.6-1t:free`, and `baidu/cobuddy:free`. -- The composer model button still showed stale `big-pickle`. -- The dropdown showed OpenRouter models but also appended stale `big-pickle`. - -**Repro** - -1. Start an auth/Codex container with a fresh isolated `CODEX_HOME`. -2. Open Settings and switch provider from `Codex` to `OpenRouter`. -3. Open the composer model dropdown. -4. Inspect `/codex-api/free-mode/status` and `/codex-api/provider-models`. -5. Send `hi provider openrouter` only if OpenRouter is fully configured. - -**Pass criteria** - -- Settings, free-mode status, provider-models, and the model dropdown agree on the active provider state. -- If OpenRouter is configured, `/codex-api/provider-models` returns OpenRouter-compatible models and the dropdown does not show Codex-only catalog entries as the active list. -- If OpenRouter is not configured, the UI shows an explicit blocking state instead of retaining Codex models. -- No send after selecting OpenRouter uses a stale Codex, Groq, NIM, or previous-provider model. - -**Validation evidence to capture** - -- Browser screenshot of Settings showing `OpenRouter`. -- Browser screenshot of the model dropdown. -- Network evidence for `/codex-api/free-mode/status`, `/codex-api/provider-models`, and any send payload. -- Final chat reply or final visible provider configuration error. - -### [ ] P0 - Custom NVIDIA NIM chat provider must drive dropdown and send through chat proxy - -**Environment** - -- Packaged Docker image built from the current branch. -- Auth-mounted container, preferably on `http://127.0.0.1:4192/#/`. -- NVIDIA API key mounted or configured for the container. - -**Provider config** - -- Base URL: `https://integrate.api.nvidia.com/v1` -- Wire API: `chat` - -**Current evidence** - -- Fresh run: packaged image `codexapp-provider-exec:local`, auth-mounted container on `http://127.0.0.1:4292/#/`. -- Browser screenshots: - - `output/playwright/docker-exec-nim-home-stale-model.png` - - `output/playwright/docker-exec-nim-dropdown-stale.png` - - `output/playwright/docker-exec-nim-new-thread-send.png` -- `/codex-api/free-mode/status` returned `enabled=true`, `provider=custom`, `currentModel=01-ai/yi-large`, `customBaseUrl=https://integrate.api.nvidia.com/v1`, `wireApi=chat`. -- `/codex-api/provider-models` returned `source=custom`, `exclusive=true`, `count=123`, first models including `01-ai/yi-large`, `abacusai/dracarys-llama-3.1-70b-instruct`, and `adept/fuyu-8b`. -- The home composer still showed stale `big-pickle` instead of the NIM model. -- The dropdown did not show `01-ai/yi-large` in the tested UI state. -- Sending a new thread with `hi provider nvidia nim new thread` failed against the Responses path: `http://127.0.0.1:4190/codex-api/custom-proxy/v1/responses`. - -**Repro** - -1. Start an auth/Codex container with a fresh isolated `CODEX_HOME`. -2. Configure custom provider with the NIM base URL and `wireApi=chat`. -3. Refresh provider models. -4. Open the composer model dropdown and search for a known NIM model from the returned model list. -5. Select a valid NIM chat model. -6. Send `hi provider nvidia nim`. - -**Pass criteria** - -- Settings and `/codex-api/free-mode/status` show `provider=custom` and `wireApi=chat`. -- `/codex-api/provider-models` returns an exclusive NIM model list. -- The composer dropdown shows the NIM list, not Codex models. -- 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 and no `/custom-proxy/v1/responses` request for the NIM chat provider. - -**Validation evidence to capture** - -- Browser screenshot of the custom provider settings. -- Browser screenshot of the NIM 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. +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`. ### [ ] P1 - Run Groq custom chat provider packaged Docker validation From 3d14db7a03349eb061c69d5eecf884f638983f67 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 18:48:19 +0700 Subject: [PATCH 24/25] Populate provider Docker test backlog --- whatToTest.md | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/whatToTest.md b/whatToTest.md index 8950fe51d..5621c1ecd 100644 --- a/whatToTest.md +++ b/whatToTest.md @@ -10,6 +10,169 @@ Passed and removed on 2026-05-13: OpenRouter selected state no longer falls back 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`. +### [ ] P0 - Fresh no-auth Docker fallback still defaults to OpenCode Zen + +**Environment** + +- Packaged Docker image built from the current branch. +- Empty isolated `CODEX_HOME`. +- Unique localhost port, for example `http://127.0.0.1:4591/#/`. + +**Repro** + +1. Start a no-auth container with no `/codex-home/auth.json`. +2. Open the app in Browser. +3. Open Settings and verify the selected provider. +4. Open the composer model dropdown. +5. Send `hi no auth fallback`. + +**Pass criteria** + +- Settings shows `OpenCode Zen`. +- Accounts shows no Codex auth. +- `/codex-api/free-mode/status` reports `provider=opencode-zen`, `enabled=true`, and `hasCodexAuth=false`. +- `/codex-api/provider-models` returns `exclusive=true` and includes `big-pickle`. +- Dropdown includes `big-pickle` and does not show Codex-only models as the authoritative list. +- Send produces an assistant reply or a final visible provider error in chat. + +**Validation evidence to capture** + +- Screenshot of Settings provider/account state. +- Screenshot of model dropdown. +- Screenshot of final chat reply/error. +- Network evidence for `free-mode/status`, `provider-models`, and send. + +### [ ] P0 - Fresh auth Docker startup still defaults to Codex-only models + +**Environment** + +- Packaged Docker image built from the current branch. +- Isolated `CODEX_HOME` with host `auth.json` mounted/copied. +- Unique localhost port, for example `http://127.0.0.1:4592/#/`. + +**Repro** + +1. Start an auth-mounted container with fresh state. +2. Open the app in Browser. +3. Open Settings and verify provider/account state. +4. Open the composer model dropdown. +5. Send `hi auth codex`. + +**Pass criteria** + +- Settings shows `Codex`. +- Accounts badge/count is at least `1`. +- `/codex-api/free-mode/status` reports `hasCodexAuth=true`. +- Dropdown shows Codex models only. +- Dropdown does not include `big-pickle`, Groq models, NIM models, or OpenRouter-only entries. +- Send uses the selected Codex model and returns an assistant reply or final auth/quota error in chat. + +**Validation evidence to capture** + +- Screenshot of Settings provider/account state. +- Screenshot of model dropdown. +- Screenshot of final chat reply/error in the same thread. +- Network evidence for `free-mode/status`, `provider-models`, and `turn/start`. + +### [ ] 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. + +**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. + +**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. + +**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** From 30033edd45a471ed90d7fcfdc3e6ee053ad9b0e5 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 13 May 2026 18:56:11 +0700 Subject: [PATCH 25/25] Update provider Docker test backlog after run --- whatToTest.md | 101 +++++++++++++++++++------------------------------- 1 file changed, 39 insertions(+), 62 deletions(-) diff --git a/whatToTest.md b/whatToTest.md index 5621c1ecd..9687e9e17 100644 --- a/whatToTest.md +++ b/whatToTest.md @@ -10,69 +10,9 @@ Passed and removed on 2026-05-13: OpenRouter selected state no longer falls back 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`. -### [ ] P0 - Fresh no-auth Docker fallback still defaults to OpenCode Zen +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`. -**Environment** - -- Packaged Docker image built from the current branch. -- Empty isolated `CODEX_HOME`. -- Unique localhost port, for example `http://127.0.0.1:4591/#/`. - -**Repro** - -1. Start a no-auth container with no `/codex-home/auth.json`. -2. Open the app in Browser. -3. Open Settings and verify the selected provider. -4. Open the composer model dropdown. -5. Send `hi no auth fallback`. - -**Pass criteria** - -- Settings shows `OpenCode Zen`. -- Accounts shows no Codex auth. -- `/codex-api/free-mode/status` reports `provider=opencode-zen`, `enabled=true`, and `hasCodexAuth=false`. -- `/codex-api/provider-models` returns `exclusive=true` and includes `big-pickle`. -- Dropdown includes `big-pickle` and does not show Codex-only models as the authoritative list. -- Send produces an assistant reply or a final visible provider error in chat. - -**Validation evidence to capture** - -- Screenshot of Settings provider/account state. -- Screenshot of model dropdown. -- Screenshot of final chat reply/error. -- Network evidence for `free-mode/status`, `provider-models`, and send. - -### [ ] P0 - Fresh auth Docker startup still defaults to Codex-only models - -**Environment** - -- Packaged Docker image built from the current branch. -- Isolated `CODEX_HOME` with host `auth.json` mounted/copied. -- Unique localhost port, for example `http://127.0.0.1:4592/#/`. - -**Repro** - -1. Start an auth-mounted container with fresh state. -2. Open the app in Browser. -3. Open Settings and verify provider/account state. -4. Open the composer model dropdown. -5. Send `hi auth codex`. - -**Pass criteria** - -- Settings shows `Codex`. -- Accounts badge/count is at least `1`. -- `/codex-api/free-mode/status` reports `hasCodexAuth=true`. -- Dropdown shows Codex models only. -- Dropdown does not include `big-pickle`, Groq models, NIM models, or OpenRouter-only entries. -- Send uses the selected Codex model and returns an assistant reply or final auth/quota error in chat. - -**Validation evidence to capture** - -- Screenshot of Settings provider/account state. -- Screenshot of model dropdown. -- Screenshot of final chat reply/error in the same thread. -- Network evidence for `free-mode/status`, `provider-models`, and `turn/start`. +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 @@ -81,6 +21,23 @@ Passed and removed on 2026-05-13: custom NVIDIA NIM provider now drives the drop - 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. @@ -118,6 +75,17 @@ Passed and removed on 2026-05-13: custom NVIDIA NIM provider now drives the drop - 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. @@ -152,6 +120,15 @@ Passed and removed on 2026-05-13: custom NVIDIA NIM provider now drives the drop - 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**