diff --git a/AGENTS.md b/AGENTS.md index d9846a917..8e16cba19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,6 +188,29 @@ - For UI work, include dark-theme evidence in addition to the default/light-theme evidence unless the task is explicitly light-only. - For refresh-persistence fixes, include a post-refresh screenshot that still shows the expected UI state. +## Docker Provider/Auth Regression Workflow + +- Use this workflow when a change touches Docker startup, Codex auth detection, OpenCode Zen/OpenRouter/custom providers, provider model loading, app-server config, chat send/reply handling, or failed-turn error rendering. +- Build and test a packaged Docker image, not only the Vite dev server: + 1. Run `pnpm run build`. + 2. Run `pnpm pack --pack-destination /tmp`. + 3. Build a local image that installs the packed `codexapp` tarball plus `@openai/codex`, with `CODEX_HOME=/codex-home` and command `codexapp --port ${PORT:-4190} --no-password --no-open --no-tunnel --no-login`. + 4. Use OrbStack/Docker CLI. Do not rely on Docker Desktop. +- Start fresh isolated containers on unique localhost ports for at least these cases: + - no auth file: no `/codex-home/auth.json`; expect runtime OpenCode Zen fallback, `model_provider="opencode-zen"`, `model="big-pickle"`, send `hi`, wait for an assistant reply. + - invalid/expired auth file: mount an `auth.json` with token fields containing invalid/expired strings; expect Codex provider path, send `hi`, wait for final 401/auth error rendered in chat, verify `Send feedback`, reload the thread, verify the error persists, and verify no duplicate live `Thinking` overlay remains after persistence. + - malformed auth file: mount invalid JSON as `/codex-home/auth.json`; expect it to be treated as unusable auth and fall back to Zen, then send `hi` and wait for a reply. + - provider switch: start from OpenCode Zen, send `hi` and wait for a reply, switch the Provider settings selector to OpenRouter (do not change the model dropdown directly), send `hi` again and wait for a reply. +- Browser assertions must inspect conversation rows, not sidebar previews. A test is not passing just because the sidebar contains the sent text. +- Save screenshots under `output/playwright/` for all Docker browser cases and show them inline in the completion report. +- Before reporting success, include: + - tested URLs/ports, + - provider/config summary for each container, + - exact build/test commands, + - screenshot absolute paths, + - whether invalid auth persisted after reload and whether duplicate live overlay count was zero. +- If any Docker edge case fails, fix it before requesting PR review or merge. + ## Mandatory CJS + TestChat Validation For Markdown/File-Link Features - For any markdown parsing, link parsing, file-link rendering, or browse-link encoding change, verification in `TestChat` is mandatory before reporting completion. diff --git a/llm-wiki/raw/fixes/copied-auth-provider-promotion.md b/llm-wiki/raw/fixes/copied-auth-provider-promotion.md new file mode 100644 index 000000000..33df97768 --- /dev/null +++ b/llm-wiki/raw/fixes/copied-auth-provider-promotion.md @@ -0,0 +1,88 @@ +# Copied Auth Provider Promotion Fix + +Date: 2026-05-13 + +## Problem + +In Docker no-auth mode, Codex Web Local starts with an OpenCode Zen runtime fallback. If the user switched the provider to OpenRouter while unauthenticated and then copied a valid `auth.json` into the mounted `CODEX_HOME`, the UI detected Codex auth but kept stale community-provider state. + +Observed issues: +- Provider stayed on OpenRouter after valid Codex auth appeared. +- The Accounts badge stayed at `0` until a manual account refresh. +- The new-thread composer could show a generic `Model` label after provider promotion. +- The Settings feedback row could show stale `Send feedback / Issue detected` after recovery even when there was no visible current error. +- Sending on Codex worked after manually switching provider, proving the copied auth file was valid. + +## Root Cause + +The server read `webui-custom-providers.json` as authoritative whenever it existed. That file can contain community fallback provider state created during the unauthenticated phase. After `auth.json` appeared, the fallback provider state still supplied app-server provider flags and `/codex-api/free-mode/status` data. + +The frontend also relied on the accounts snapshot for the Accounts count. A copied `auth.json` did not automatically import the active auth file into the accounts store. + +Finally, provider-scoped new-thread model persistence applied to non-Codex providers but not to Codex. After provider promotion, the home composer could temporarily fall back to the generic `Model` placeholder instead of a concrete Codex model. + +## Fix + +Commit: +- `7ee94f83 Promote copied auth to Codex provider` + +Implementation details: +- Added `shouldSuppressCommunityFreeModeForCodexAuth()` in `src/server/freeMode.ts`. +- `ensureDefaultFreeModeStateForMissingAuthSync()` now returns `null` when usable Codex auth exists and the existing provider state is only community fallback (`openrouter` or `opencode-zen` without a custom key). +- User-configured providers are preserved: + - OpenRouter with `customKey: true` + - OpenCode Zen with an explicit API key + - Custom endpoint provider +- `/codex-api/free-mode/status` now reports `hasCodexAuth`. +- `App.vue` uses `hasCodexAuth` to import a copied active `auth.json` into Accounts via `refreshAccountsFromAuth()` once. +- New-thread model persistence now uses provider-scoped slots for Codex as well as non-Codex providers. +- The Settings feedback row is shown only when a current visible error exists, not merely because historical diagnostics exist. + +## Docker Validation + +Fresh packaged image: + +```text +codexui-local:e5e9-auth-promote-final2 +``` + +Flow: +1. Start a fresh container with empty mounted `CODEX_HOME`. +2. Confirm initial provider is `opencode-zen`. +3. Switch provider to `openrouter`. +4. Copy `/Users/igor/.codex/auth.json` into the mounted `CODEX_HOME`. +5. Reload the UI. +6. Confirm provider changes to `codex`. +7. Confirm Accounts count becomes `1`. +8. Confirm the composer shows a concrete Codex model, not generic `Model`. +9. Confirm no stale `Send feedback / Issue detected` row appears. +10. Send `hi`; wait for a Codex reply. + +Final validation result: + +```json +{ + "initialProvider": "opencode-zen", + "afterSwitchProvider": "openrouter", + "afterCopyProvider": "codex", + "afterCopyAccounts": 1, + "afterCopyHasIssue": false, + "finalProvider": "codex", + "finalHasIssue": false, + "stillBusy": false +} +``` + +Screenshot artifacts: +- `output/playwright/auth-promote-final2-01-noauth.png` +- `output/playwright/auth-promote-final2-02-openrouter.png` +- `output/playwright/auth-promote-final2-03-after-copy.png` +- `output/playwright/auth-promote-final2-04-reply.png` + +## Verification Commands + +```bash +pnpm test:unit src/server/freeMode.test.ts src/server/codexAppServerBridge.archive.test.ts src/composables/useDesktopState.test.ts src/api/codexGateway.test.ts +pnpm run build +pnpm pack --pack-destination /tmp +``` diff --git a/llm-wiki/raw/fixes/opencode-zen-docker-auth-provider-models.md b/llm-wiki/raw/fixes/opencode-zen-docker-auth-provider-models.md new file mode 100644 index 000000000..67a5c315e --- /dev/null +++ b/llm-wiki/raw/fixes/opencode-zen-docker-auth-provider-models.md @@ -0,0 +1,78 @@ +# OpenCode Zen Docker Auth and Provider Models Fix + +Date: 2026-05-13 + +## Problem + +Codex Web Local had two Docker startup edge cases around OpenCode Zen fallback and Codex auth: + +1. In an authenticated Docker container, immediately polling `/codex-api/thread-live-state` after `turn/start` could return: + +```text +thread is not materialized yet; includeTurns is unavailable before first user message +``` + +The turn completed normally, but the bridge exposed this transient Codex state as `liveStateError.kind = "readFailed"`, making the chat look broken during first-turn startup. + +2. In an unauthenticated Docker container, the model selector could appear empty or stale because frontend model loading called Codex `model/list` before `/codex-api/provider-models`. In OpenCode Zen fallback mode, provider models are authoritative; `model/list` can be slow, return Codex models, or fail independently. + +## Root Cause + +The live-state endpoint treated every `thread/read includeTurns=true` failure as a real read failure. Codex can briefly create a thread before the first user message is materialized, so that exact error is a pending state, not a terminal failure. + +The model-loading helper fetched `model/list` first and only then attempted provider model discovery. This made no-auth Zen startup depend on a Codex model-list call that is not the source of truth for Zen models. + +## Fix + +Commits: +- `545c0dec Handle pending first-turn live state` +- `2eaf4bd3 Load provider models before Codex model list` + +Implementation details: +- Added `isThreadMaterializationPendingError()` in `src/server/codexAppServerBridge.ts`. +- `/codex-api/thread-live-state` now maps that specific pending-materialization error to: + - `conversationState: { turns: [] }` + - `liveStateError: null` + - `isInProgress: true` +- Real `thread/read` failures still surface through `liveStateError`. +- `getAvailableModelIds()` now fetches `/codex-api/provider-models` first when provider models are included. +- If provider models are `exclusive` or `requireProviderModels` is true, it returns provider models without waiting on Codex `model/list`. +- Optional provider-model loading still falls back to `model/list` if provider models are unavailable. + +## Docker Validation + +Fresh image: + +```text +codexui-local:e5e9-current +``` + +No-auth container: +- URL: `http://127.0.0.1:4191/#/` +- `config/read`: `model = "big-pickle"`, `model_provider = "opencode-zen"` +- App-server command includes Zen proxy flags. +- Sending `hi` returns an assistant reply. +- Model selector includes `big-pickle`, `deepseek-v4-flash-free`, and other Zen provider models. + +Auth-mounted container: +- URL: `http://127.0.0.1:4192/#/` +- Mounted `/Users/igor/.codex/auth.json` to `/codex-home/auth.json`. +- `config/read`: `model = null`, `model_provider = null` +- App-server command has no Zen proxy flags. +- Sending `hi` returns an assistant reply. +- First-turn live-state polling does not expose the transient materialization error as `liveStateError`. + +## Operational Notes + +- For Docker validation, install the packed `codexapp` artifact during image build instead of using `pnpm dlx` at container runtime. Runtime `pnpm dlx` can re-download and extract dependencies on every start and can be killed under memory pressure. +- When validating no-auth Zen mode, trust `/codex-api/provider-models` and `/codex-api/free-mode/status` for provider models; `model/list` may still return Codex catalog rows from the Codex CLI. +- Browser verification should include a screenshot of the opened model selector after loading `http://127.0.0.1:4191/#/`. + +## Verification Commands + +```bash +pnpm test:unit src/server/codexAppServerBridge.archive.test.ts +pnpm test:unit src/api/codexGateway.test.ts src/composables/useDesktopState.test.ts +pnpm run build +``` + diff --git a/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md b/llm-wiki/wiki/concepts/opencode-zen-big-pickle.md index 9ef4e7e93..d45d89834 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,34 @@ For thinking-mode models behind `big-pickle`, the proxy must preserve assistant This behavior was fixed in commit `47d52c8c` after a Docker repro using an empty `CODEX_HOME`, no login, and no Zen API key. +## Docker Auth and Model Loading + +Codex Web Local's unauthenticated Docker path should use OpenCode Zen only as a runtime fallback. It should not permanently write fallback provider configuration, and it should not depend on Codex `model/list` for the Zen selector. + +Validated Docker states: +- Empty `CODEX_HOME`: `config/read` reports `model = "big-pickle"` and `model_provider = "opencode-zen"`, the app-server command includes local Zen proxy flags, and the model selector loads provider models from `/codex-api/provider-models`. +- `auth.json` mounted into `CODEX_HOME`: `config/read` reports `model = null` and `model_provider = null`, the app-server command has no Zen flags, and sending `hi` uses the default Codex provider path. + +The first authenticated turn may briefly make `thread/read includeTurns=true` fail with `not materialized yet; includeTurns is unavailable before first user message`. The bridge maps that exact response to an in-progress empty live state with no `liveStateError`; real `thread/read` failures still surface as errors. + +## Copied Auth Promotion + +If a container starts without auth and later receives a valid `auth.json`, Codex auth should take precedence over community fallback provider state. This matters when a user starts in no-auth Zen mode, switches to OpenRouter, then copies auth into `CODEX_HOME`. + +Expected behavior after copying auth and reloading: +- Community fallback provider state (`openrouter` or `opencode-zen` without a custom key) is suppressed. +- Provider promotes to Codex. +- Accounts imports the copied active auth file and the badge updates from `0` to at least `1`. +- The new-thread composer shows a concrete Codex model, not a generic `Model` placeholder. +- Stale historical diagnostics do not show a `Send feedback / Issue detected` row unless a current visible error remains. + +User-configured provider state is preserved: OpenRouter with `customKey: true`, OpenCode Zen with an explicit API key, and custom endpoint providers should not be suppressed merely because Codex auth exists. + +This behavior was fixed in commit `7ee94f83` and validated in a packaged Docker image by running: no-auth Zen startup, switch to OpenRouter, copy host `auth.json`, reload, verify Codex provider + Accounts `1`, send `hi`, and wait for a Codex reply. + ## 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/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..97edfc08f 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,5 @@ - [../raw/projects/codex-web-local.md](../raw/projects/codex-web-local.md): immutable source snapshot for project facts. - [../raw/fixes/opencode-zen-big-pickle-codex-cli.md](../raw/fixes/opencode-zen-big-pickle-codex-cli.md): Big Pickle + Codex CLI fix details. - [../raw/fixes/opencode-zen-reasoning-content-proxy.md](../raw/fixes/opencode-zen-reasoning-content-proxy.md): Codex Web Local Zen proxy reasoning_content round-trip fix and Docker verification. +- [../raw/fixes/opencode-zen-docker-auth-provider-models.md](../raw/fixes/opencode-zen-docker-auth-provider-models.md): Docker auth/no-auth provider switching, first-turn live-state materialization, and provider-model loading fixes. +- [../raw/fixes/copied-auth-provider-promotion.md](../raw/fixes/copied-auth-provider-promotion.md): copied `auth.json` promotion from community fallback provider state to Codex, account import, model-label, and stale feedback-row fixes. diff --git a/llm-wiki/wiki/log.md b/llm-wiki/wiki/log.md index c081c064f..35bd82ed9 100644 --- a/llm-wiki/wiki/log.md +++ b/llm-wiki/wiki/log.md @@ -52,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/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) diff --git a/src/App.vue b/src/App.vue index 034797e70..3dcd919f0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -250,7 +250,7 @@ @@ -1417,7 +1417,6 @@ type AutomationEditRequest = { const sidebarThreadTreeRef = ref(null) const automationsPanelRef = ref(null) const { - hasFeedbackDiagnostics, buildFeedbackMailto, recordVisibleFailure, } = useFeedbackDiagnostics() @@ -1572,6 +1571,7 @@ const visibleFeedbackErrors = [ projectSetupError, existingFolderError, ] +const hasVisibleFeedbackError = computed(() => visibleFeedbackErrors.some((entry) => entry.value.trim().length > 0)) const telegramStatus = ref({ configured: false, active: false, @@ -1589,6 +1589,8 @@ const visualViewportOffsetTop = ref(typeof window !== 'undefined' ? window.visua const layoutViewportHeight = ref(typeof window !== 'undefined' ? window.innerHeight : 0) let accountStatePollTimer: number | null = null let isAccountStatePollInFlight = false +let externalCodexAuthAvailable = false +let externalAuthImportAttempted = false let existingFolderBrowseRequestId = 0 const routeThreadId = computed(() => { @@ -2100,6 +2102,35 @@ watch(accounts, () => { }, 1500) }, { deep: true }) +watch(accountRateLimitSnapshots, () => { + void maybeImportExternalCodexAuthAccount().then((imported) => { + if (!imported) return + void refreshAll({ + includeSelectedThreadMessages: false, + providerChanged: true, + awaitAncillaryRefreshes: true, + }) + }) +}, { deep: true }) + +async function maybeImportExternalCodexAuthAccount(): Promise { + if (!externalCodexAuthAvailable) return false + if (externalAuthImportAttempted) return false + if (selectedProvider.value !== 'codex') return false + if (accounts.value.length > 0) return false + if (accountRateLimitSnapshots.value.length === 0) return false + externalAuthImportAttempted = true + const previousAccountsJson = JSON.stringify(accounts.value.map((account) => account.accountId).sort()) + try { + const result = await refreshAccountsFromAuth() + accounts.value = result.accounts + } catch { + await loadAccountsState({ silent: true }) + } + const nextAccountsJson = JSON.stringify(accounts.value.map((account) => account.accountId).sort()) + return previousAccountsJson !== nextAccountsJson +} + function onSkillsChanged(): void { void refreshSkills() } @@ -4019,6 +4050,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 +4069,26 @@ async function loadFreeModeStatus(): Promise { } else { selectedProvider.value = 'codex' } + externalCodexAuthAvailable = status.hasCodexAuth === true + if (!externalCodexAuthAvailable) { + externalAuthImportAttempted = false + } + const providerChanged = selectedProvider.value !== previousProvider + if (providerChanged) { + await refreshAll({ + includeSelectedThreadMessages: false, + providerChanged: true, + awaitAncillaryRefreshes: true, + }) + } + const importedExternalAuth = await maybeImportExternalCodexAuthAccount() + if (importedExternalAuth) { + await refreshAll({ + includeSelectedThreadMessages: false, + providerChanged: providerChanged || importedExternalAuth, + awaitAncillaryRefreshes: true, + }) + } } catch { // Ignore — free mode status unknown } diff --git a/src/api/codexGateway.test.ts b/src/api/codexGateway.test.ts index 98e1ed75e..c43bff5c0 100644 --- a/src/api/codexGateway.test.ts +++ b/src/api/codexGateway.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { listDirectoryComposioConnectors, startThreadTurn } from './codexGateway' +import { getAvailableModelIds, listDirectoryComposioConnectors, startThreadTurn } from './codexGateway' function mockRpcFetch(): { requests: Array<{ method: string, params: Record }> } { const requests: Array<{ method: string, params: Record }> = [] @@ -87,3 +87,66 @@ describe('listDirectoryComposioConnectors', () => { expect(requests).toEqual(['/codex-api/composio/connectors?query=instagram&cursor=50&limit=25']) }) }) + +describe('getAvailableModelIds', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses provider models without waiting for model/list when provider models are required', async () => { + const requests: string[] = [] + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + requests.push(String(input)) + if (String(input) === '/codex-api/provider-models') { + return new Response(JSON.stringify({ + data: ['big-pickle', 'deepseek-v4-flash-free'], + exclusive: true, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + throw new Error(`unexpected request ${String(input)}`) + })) + + await expect(getAvailableModelIds({ + includeProviderModels: true, + requireProviderModels: true, + })).resolves.toEqual(['big-pickle', 'deepseek-v4-flash-free']) + expect(requests).toEqual(['/codex-api/provider-models']) + }) + + it('falls back to model/list when provider models are optional and unavailable', async () => { + const requests: string[] = [] + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(String(input)) + if (String(input) === '/codex-api/provider-models') { + return new Response(JSON.stringify({ data: [] }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const body = typeof init?.body === 'string' + ? JSON.parse(init.body) as { method: string } + : { method: '' } + expect(body.method).toBe('model/list') + return new Response(JSON.stringify({ + result: { + data: [ + { id: 'gpt-5.5' }, + { model: 'gpt-5.4-mini' }, + ], + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + })) + + await expect(getAvailableModelIds({ + includeProviderModels: true, + })).resolves.toEqual(['gpt-5.5', 'gpt-5.4-mini']) + expect(requests).toEqual(['/codex-api/provider-models', '/codex-api/rpc']) + }) +}) diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index c7bea2275..3fbf6f68d 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -248,6 +248,7 @@ type DirectoryComposioConnectorPage = { type ProviderModelsResponse = { data?: unknown + exclusive?: unknown } const PROVIDER_MODELS_FETCH_TIMEOUT_MS = 5_000 @@ -1835,6 +1836,7 @@ export async function setCodexSpeedMode(mode: SpeedMode): Promise { export interface FreeModeStatus { enabled: boolean + hasCodexAuth?: boolean keyCount: number models: string[] currentModel: string | null @@ -1886,51 +1888,54 @@ export async function setCustomProvider( return await response.json() as { ok: boolean } } -export async function getAvailableModelIds(options: { includeProviderModels?: boolean; requireProviderModels?: boolean } = {}): Promise { - const payload = await callRpc('model/list', {}) - const ids: string[] = [] - for (const row of payload.data) { - const candidate = row.id || row.model - if (!candidate || ids.includes(candidate)) continue - ids.push(candidate) - } - - if (options.includeProviderModels === false) { - return ids - } - - let sawProviderModels = false +async function fetchProviderModelIds(): Promise<{ ids: string[], exclusive: boolean } | null> { try { const response = await fetch('/codex-api/provider-models', { signal: AbortSignal.timeout(PROVIDER_MODELS_FETCH_TIMEOUT_MS), }) - let providerPayload: (ProviderModelsResponse & { exclusive?: boolean }) | null = null + let providerPayload: ProviderModelsResponse | null = null try { - providerPayload = await response.json() as ProviderModelsResponse & { exclusive?: boolean } + providerPayload = await response.json() as ProviderModelsResponse } catch { providerPayload = null } if (response.ok && Array.isArray(providerPayload?.data)) { - sawProviderModels = true - if (providerPayload.exclusive) { - return providerPayload.data.filter((c): c is string => typeof c === 'string' && c.trim().length > 0) - } - for (const candidate of providerPayload.data) { - if (typeof candidate !== 'string') continue - const normalized = candidate.trim() - if (!normalized || ids.includes(normalized)) continue - ids.push(normalized) + return { + ids: providerPayload.data + .map((candidate) => typeof candidate === 'string' ? candidate.trim() : '') + .filter((candidate, index, candidates): candidate is string => + candidate.length > 0 && candidates.indexOf(candidate) === index), + exclusive: providerPayload.exclusive === true, } } } catch { // Keep Codex usable when the provider-models endpoint is unavailable. } + return null +} + +export async function getAvailableModelIds(options: { includeProviderModels?: boolean; requireProviderModels?: boolean } = {}): Promise { + const shouldIncludeProviderModels = options.includeProviderModels !== false + const providerModels = shouldIncludeProviderModels ? await fetchProviderModelIds() : null + + if (providerModels?.exclusive || options.requireProviderModels) { + return providerModels?.ids ?? [] + } - if (options.requireProviderModels && !sawProviderModels) { - return [] + const payload = await callRpc('model/list', {}) + const ids: string[] = [] + for (const row of payload.data) { + const candidate = row.id || row.model + if (!candidate || ids.includes(candidate)) continue + ids.push(candidate) } + if (!shouldIncludeProviderModels || !providerModels) return ids + + for (const candidate of providerModels.ids) { + if (!ids.includes(candidate)) ids.push(candidate) + } return ids } diff --git a/src/api/normalizers/v2.test.ts b/src/api/normalizers/v2.test.ts index 49f410d09..7e87c9a54 100644 --- a/src/api/normalizers/v2.test.ts +++ b/src/api/normalizers/v2.test.ts @@ -105,4 +105,73 @@ Reply with </instructions> and A & B turnIndex: 12, }) }) + + it('renders failed turn errors as chat system messages', () => { + const response = threadReadResponseWithContent([{ + type: 'userMessage', + id: 'user-4', + content: [{ type: 'text', text: 'hi', text_elements: [] }], + }]) + response.thread.turns[0].status = 'failed' + response.thread.turns[0].error = { + message: 'unexpected status 401 Unauthorized: Missing bearer or basic authentication in header', + codexErrorInfo: null, + additionalDetails: null, + } + + const messages = normalizeThreadMessagesV2(response) + + expect(messages).toHaveLength(2) + expect(messages[1]).toMatchObject({ + id: 'turn-1-error', + role: 'system', + text: 'unexpected status 401 Unauthorized: Missing bearer or basic authentication in header', + messageType: 'turnError', + turnId: 'turn-1', + turnIndex: 0, + }) + }) + + it('uses turn index fallback ids for failed turns with blank ids', () => { + const response = threadReadResponseWithContent([]) + response.thread.turns = [ + { + id: '', + status: 'failed', + error: { + message: 'first failed turn', + codexErrorInfo: null, + additionalDetails: null, + }, + items: [], + }, + { + id: ' ', + status: 'failed', + error: { + message: 'second failed turn', + codexErrorInfo: null, + additionalDetails: null, + }, + items: [], + }, + ] + + const messages = normalizeThreadMessagesV2(response, 8) + + expect(messages).toEqual([ + expect.objectContaining({ + id: 'turn-8-error', + text: 'first failed turn', + turnId: undefined, + turnIndex: 8, + }), + expect.objectContaining({ + id: 'turn-9-error', + text: 'second failed turn', + turnId: undefined, + turnIndex: 9, + }), + ]) + }) }) diff --git a/src/api/normalizers/v2.ts b/src/api/normalizers/v2.ts index a19973ff1..d76e1ba88 100644 --- a/src/api/normalizers/v2.ts +++ b/src/api/normalizers/v2.ts @@ -31,6 +31,11 @@ function toRawPayload(value: unknown): string { } } +function readTurnErrorText(turn: Turn): string { + const error = turn.error as { message?: unknown } | null + return typeof error?.message === 'string' ? error.message.trim() : '' +} + const FILE_ATTACHMENT_LINE = /^##\s+(.+?):\s+(.+?)\s*$/ const FILES_MENTIONED_MARKER = /^#\s*files mentioned by the user\s*:?\s*$/i const ASSISTANT_FILE_CHANGE_HEADING = /^(?:#{1,6}\s*)?(?:本次修改文件(?:和操作)?(?:如下)?|修改文件和操作)\s*[::]?\s*$/u @@ -624,13 +629,26 @@ export function normalizeThreadMessagesV2(payload: ThreadReadResponse, baseTurnI for (let turnOffset = 0; turnOffset < turns.length; turnOffset++) { const turnIndex = baseTurnIndex + turnOffset const turn = turns[turnOffset] - const turnId = typeof turn?.id === 'string' ? turn.id : undefined + const rawTurnId = typeof turn?.id === 'string' ? turn.id.trim() : '' + const turnId = rawTurnId.length > 0 ? rawTurnId : undefined const items = Array.isArray(turn.items) ? turn.items : [] for (const item of items) { for (const msg of toUiMessages(item)) { messages.push({ ...msg, turnId, turnIndex }) } } + const errorText = readTurnErrorText(turn) + if (turn.status === 'failed' && errorText) { + const errorIdBase = turnId ?? `turn-${turnIndex}` + messages.push({ + id: `${errorIdBase}-error`, + role: 'system', + text: errorText, + messageType: 'turnError', + turnId, + turnIndex, + }) + } } return messages } diff --git a/src/components/content/ThreadConversation.vue b/src/components/content/ThreadConversation.vue index 7f7976bf2..74efcd7b0 100644 --- a/src/components/content/ThreadConversation.vue +++ b/src/components/content/ThreadConversation.vue @@ -572,6 +572,14 @@ + + Send feedback +
@@ -910,6 +918,14 @@ function prepareLiveErrorFeedback(event: MouseEvent, message: string): void { } } +function prepareTurnErrorFeedback(event: MouseEvent, message: string): void { + recordVisibleFailure(message) + const target = event.currentTarget + if (target instanceof HTMLAnchorElement) { + target.href = buildFeedbackMailto() + } +} + function parsePlanFromMessageText(text: string): { explanation: string; steps: UiPlanStep[] } | null { const normalized = text.replace(/\r\n/g, '\n').trim() if (!normalized) return null @@ -965,6 +981,10 @@ function isPlanMessage(message: UiMessage): boolean { return message.messageType === 'plan' || message.messageType === 'plan.live' } +function isTurnErrorMessage(message: UiMessage): boolean { + return message.messageType === 'turnError' +} + function buildPlanMessageText(explanation: string, steps: UiPlanStep[]): string { const lines: string[] = [] if (explanation.trim()) { @@ -4433,6 +4453,10 @@ onBeforeUnmount(() => { @apply shrink-0 rounded-full border border-rose-200 bg-white px-2.5 py-1 text-xs font-semibold leading-none text-rose-700 transition hover:bg-rose-100 focus:outline-none focus:ring-2 focus:ring-rose-300; } +.turn-error-feedback { + @apply mt-3 inline-flex w-fit rounded-full border border-rose-200 bg-white px-2.5 py-1 text-xs font-semibold leading-none text-rose-700 transition hover:bg-rose-100 focus:outline-none focus:ring-2 focus:ring-rose-300; +} + .message-body { @apply flex flex-col min-w-0 max-w-full; width: fit-content; diff --git a/src/composables/useDesktopState.test.ts b/src/composables/useDesktopState.test.ts index b8ab329cc..231f83458 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({ @@ -530,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 2a37fee55..25287bbbf 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -1521,7 +1521,21 @@ export function useDesktopState() { const reasoningText = isInProgress ? (liveReasoningTextByThreadId.value[threadId] ?? '').trim() : '' - const errorText = (turnErrorByThreadId.value[threadId]?.message ?? '').trim() + const liveErrorText = (turnErrorByThreadId.value[threadId]?.message ?? '').trim() + let latestPersistedTurnErrorText = '' + if (!isInProgress && liveErrorText) { + const persistedMessages = persistedMessagesByThreadId.value[threadId] ?? [] + for (let index = persistedMessages.length - 1; index >= 0; index -= 1) { + const message = persistedMessages[index] + if (message.messageType !== 'turnError') continue + latestPersistedTurnErrorText = normalizeMessageText(message.text) + break + } + } + const errorText = + !isInProgress && liveErrorText && latestPersistedTurnErrorText === liveErrorText + ? '' + : liveErrorText if (!activity && !reasoningText && !errorText) return null return { @@ -1574,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() } @@ -1616,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 3e931a0f8..1750d293c 100644 --- a/src/server/codexAppServerBridge.archive.test.ts +++ b/src/server/codexAppServerBridge.archive.test.ts @@ -1,11 +1,13 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { callRpcWithArchiveRecovery, + ensureDefaultFreeModeStateForMissingAuthSync, hasUsableCodexAuth, isEmptyThreadReadError, + isThreadMaterializationPendingError, isUnauthenticatedRateLimitError, } from './codexAppServerBridge' @@ -99,6 +101,14 @@ describe('isUnauthenticatedRateLimitError', () => { expect(isUnauthenticatedRateLimitError(new Error('codex account authentication required to read rate limits'))).toBe(true) }) + it('matches direct message fields from Codex stream errors', () => { + expect(isUnauthenticatedRateLimitError({ + message: 'codex account authentication required to read rate limits', + codexErrorInfo: 'other', + additionalDetails: null, + })).toBe(true) + }) + it('does not match unrelated authentication failures', () => { expect(isUnauthenticatedRateLimitError(new Error('codex account authentication required to send messages'))).toBe(false) expect(isUnauthenticatedRateLimitError(new Error('failed to read rate limits'))).toBe(false) @@ -118,6 +128,19 @@ describe('isEmptyThreadReadError', () => { }) }) +describe('isThreadMaterializationPendingError', () => { + it('matches Codex live-state reads before the first message is materialized', () => { + expect(isThreadMaterializationPendingError(new Error( + 'thread 019e1f04-dca4-7823-8b9a-554b9bd22f57 is not materialized yet; includeTurns is unavailable before first user message', + ))).toBe(true) + }) + + it('does not match unrelated thread read failures', () => { + expect(isThreadMaterializationPendingError(new Error('thread read failed: permission denied'))).toBe(false) + expect(isThreadMaterializationPendingError(new Error('not materialized yet'))).toBe(false) + }) +}) + describe('hasUsableCodexAuth', () => { it('returns false when auth.json is missing or does not contain usable tokens', async () => { const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-no-token-')) @@ -161,3 +184,99 @@ describe('hasUsableCodexAuth', () => { } }) }) + +describe('ensureDefaultFreeModeStateForMissingAuthSync', () => { + it('uses OpenCode Zen as a runtime fallback without creating a state file', async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-runtime-zen-')) + const statePath = join(codexHome, 'webui-custom-providers.json') + process.env.CODEX_HOME = codexHome + try { + const state = ensureDefaultFreeModeStateForMissingAuthSync(statePath) + + expect(state?.enabled).toBe(true) + expect(state?.provider).toBe('opencode-zen') + await expect(stat(statePath)).rejects.toThrow() + } finally { + await rm(codexHome, { recursive: true, force: true }) + } + }) + + it('does not synthesize OpenCode Zen after Codex auth exists and no state file is present', async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-auth-no-state-')) + const statePath = join(codexHome, 'webui-custom-providers.json') + process.env.CODEX_HOME = codexHome + try { + await writeFile(join(codexHome, 'auth.json'), JSON.stringify({ tokens: { access_token: 'access-token' } })) + + expect(ensureDefaultFreeModeStateForMissingAuthSync(statePath)).toBeNull() + await expect(stat(statePath)).rejects.toThrow() + } finally { + await rm(codexHome, { recursive: true, force: true }) + } + }) + + it('ignores community provider state after Codex auth appears', async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-auth-community-provider-')) + const statePath = join(codexHome, 'webui-custom-providers.json') + process.env.CODEX_HOME = codexHome + try { + await writeFile(join(codexHome, 'auth.json'), JSON.stringify({ tokens: { access_token: 'access-token' } })) + await writeFile(statePath, JSON.stringify({ + enabled: true, + apiKey: 'community-openrouter-key', + model: 'openrouter/free', + customKey: false, + provider: 'openrouter', + wireApi: 'responses', + })) + + expect(ensureDefaultFreeModeStateForMissingAuthSync(statePath)).toBeNull() + } finally { + await rm(codexHome, { recursive: true, force: true }) + } + }) + + it('keeps user configured provider state after Codex auth appears', async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-auth-custom-provider-')) + const statePath = join(codexHome, 'webui-custom-providers.json') + process.env.CODEX_HOME = codexHome + try { + await writeFile(join(codexHome, 'auth.json'), JSON.stringify({ tokens: { access_token: 'access-token' } })) + const configuredState = { + enabled: true, + apiKey: 'user-openrouter-key', + model: 'openrouter/model', + customKey: true, + provider: 'openrouter', + wireApi: 'responses', + } + await writeFile(statePath, JSON.stringify(configuredState)) + + expect(ensureDefaultFreeModeStateForMissingAuthSync(statePath)).toEqual(configuredState) + } finally { + await rm(codexHome, { recursive: true, force: true }) + } + }) + + it('ignores the legacy free-mode state filename instead of migrating it', async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'codex-home-legacy-free-mode-')) + const legacyStatePath = join(codexHome, 'webui-free-mode.json') + const statePath = join(codexHome, 'webui-custom-providers.json') + process.env.CODEX_HOME = codexHome + try { + await writeFile(legacyStatePath, JSON.stringify({ + enabled: true, + apiKey: null, + model: 'legacy-model', + provider: 'opencode-zen', + wireApi: 'responses', + })) + await writeFile(join(codexHome, 'auth.json'), JSON.stringify({ tokens: { access_token: 'access-token' } })) + + expect(ensureDefaultFreeModeStateForMissingAuthSync(statePath)).toBeNull() + await expect(stat(statePath)).rejects.toThrow() + } finally { + await rm(codexHome, { recursive: true, force: true }) + } + }) +}) diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 3372e347d..14bd0a276 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -1,7 +1,7 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' import { createHash, randomBytes } from 'node:crypto' import { mkdtemp, readFile, readdir, rename, rm, mkdir, stat, cp, lstat, readlink, symlink } from 'node:fs/promises' -import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createReadStream, existsSync, readFileSync } from 'node:fs' import type { IncomingMessage, ServerResponse } from 'node:http' import { request as httpRequest } from 'node:http' import { request as httpsRequest } from 'node:https' @@ -18,7 +18,6 @@ import { TelegramThreadBridge } from './telegramThreadBridge.js' import { getRandomFreeKey, getFreeKeyCount, - FREE_MODE_PROVIDER_ID, FREE_MODE_DEFAULT_MODEL, getCachedFreeModels, getFreeModels, @@ -30,6 +29,7 @@ import { getFreeModeConfigArgs, getFreeModeEnvVars, shouldCreateDefaultFreeModeStateForMissingAuth, + shouldSuppressCommunityFreeModeForCodexAuth, type FreeModeState, } from './freeMode.js' import { handleOpenRouterProxyRequest } from './openRouterProxy.js' @@ -911,6 +911,8 @@ function getErrorMessage(payload: unknown, fallback: string): string { const record = asRecord(payload) if (!record) return fallback + if (typeof record.message === 'string' && record.message.length > 0) return record.message + const error = record.error if (typeof error === 'string' && error.length > 0) return error @@ -932,6 +934,83 @@ export function isEmptyThreadReadError(error: unknown): boolean { return message.includes('failed to read thread') && message.includes('rollout') && message.includes('is empty') } +export function isThreadMaterializationPendingError(error: unknown): boolean { + const message = getErrorMessage(error, '').toLowerCase() + return message.includes('not materialized yet') && message.includes('includeturns is unavailable before first user message') +} + +function readStreamTurnId(params: Record): string { + const directTurnId = readNonEmptyString(params.turnId) || readNonEmptyString(params.turn_id) + if (directTurnId) return directTurnId + const turn = asRecord(params.turn) + return readNonEmptyString(turn?.id) +} + +function readStreamTurnErrorMessage(frame: StreamEventFrame): { turnId: string; message: string } | null { + const params = asRecord(frame.params) + if (!params) return null + const turnId = readStreamTurnId(params) + if (!turnId) return null + + if (frame.method === 'turn/completed') { + const turn = asRecord(params.turn) + if (turn?.status !== 'failed') return null + const message = getErrorMessage(turn.error, '') + return message ? { turnId, message } : null + } + + if (frame.method === 'error' && params.willRetry !== true) { + const message = getErrorMessage(params.error, '') || readNonEmptyString(params.message) + return message ? { turnId, message } : null + } + + return null +} + +function mergeStreamTurnErrorsIntoThreadResult(appServer: AppServerProcess, result: unknown): unknown { + const record = asRecord(result) + const thread = asRecord(record?.thread) + const threadId = readNonEmptyString(thread?.id) + const turns = Array.isArray(thread?.turns) ? thread.turns : null + if (!record || !thread || !threadId || !turns || turns.length === 0) return result + + const errorsByTurnId = new Map() + for (const frame of appServer.getStreamEvents(threadId, STREAM_EVENT_BUFFER_LIMIT)) { + const error = readStreamTurnErrorMessage(frame) + if (error) errorsByTurnId.set(error.turnId, error.message) + } + if (errorsByTurnId.size === 0) return result + + let changed = false + const mergedTurns = turns.map((turn) => { + const turnRecord = asRecord(turn) + const turnId = readNonEmptyString(turnRecord?.id) + const message = turnId ? errorsByTurnId.get(turnId) : '' + if (!turnRecord || !turnId || !message) return turn + const existingErrorMessage = getErrorMessage(turnRecord.error, '') + if (turnRecord.status === 'failed' && existingErrorMessage) return turn + changed = true + return { + ...turnRecord, + status: 'failed', + error: { + message, + codexErrorInfo: null, + additionalDetails: null, + }, + } + }) + + if (!changed) return result + return { + ...record, + thread: { + ...thread, + turns: mergedTurns, + }, + } +} + const warnedCodexAuthReadFailures = new Set() function getErrorCode(error: unknown): string | null { @@ -3270,17 +3349,17 @@ function readFreeModeStateSync(statePath: string): FreeModeState | null { } } -function ensureDefaultFreeModeStateForMissingAuthSync(statePath: string): FreeModeState | null { +export function ensureDefaultFreeModeStateForMissingAuthSync(statePath: string): FreeModeState | null { const current = readFreeModeStateSync(statePath) - if (!shouldCreateDefaultFreeModeStateForMissingAuth(current, hasUsableCodexAuthSync())) { + const hasUsableCodexAuth = hasUsableCodexAuthSync() + if (shouldSuppressCommunityFreeModeForCodexAuth(current, hasUsableCodexAuth)) { + return null + } + if (!shouldCreateDefaultFreeModeStateForMissingAuth(current, hasUsableCodexAuth)) { return current } - const fallback = createDefaultOpenCodeZenFreeModeState() - - mkdirSync(dirname(statePath), { recursive: true }) - writeFileSync(statePath, JSON.stringify(fallback), { encoding: 'utf8', mode: 0o600 }) - return fallback + return createDefaultOpenCodeZenFreeModeState() } function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean { @@ -5819,9 +5898,9 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { let bearerToken = '' let wireApi: 'responses' | 'chat' = 'chat' try { - const state = JSON.parse(readFileSync(statePath, 'utf8')) as FreeModeState - bearerToken = state.apiKey ?? '' - wireApi = state.wireApi === 'responses' ? 'responses' : 'chat' + const state = ensureDefaultFreeModeStateForMissingAuthSync(statePath) + bearerToken = state?.apiKey ?? '' + wireApi = state?.wireApi === 'responses' ? 'responses' : 'chat' } catch { /* use empty */ } handleZenProxyRequest(req, res, bearerToken, wireApi) return @@ -5846,10 +5925,10 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { let wireApi: 'responses' | 'chat' = 'responses' let baseUrl = '' try { - const state = JSON.parse(readFileSync(statePath, 'utf8')) as FreeModeState - bearerToken = state.apiKey ?? '' - wireApi = state.wireApi === 'chat' ? 'chat' : 'responses' - baseUrl = state.customBaseUrl ?? '' + const state = ensureDefaultFreeModeStateForMissingAuthSync(statePath) + bearerToken = state?.apiKey ?? '' + wireApi = state?.wireApi === 'chat' ? 'chat' : 'responses' + baseUrl = state?.customBaseUrl ?? '' } catch { /* use empty */ } handleCustomEndpointProxyRequest(req, res, { baseUrl, bearerToken, wireApi }) return @@ -5958,6 +6037,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { } setJson(res, 200, { enabled: state.enabled, + hasCodexAuth: hasUsableCodexAuthSync(), keyCount: getFreeKeyCount(), models, currentModel, @@ -6246,7 +6326,10 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { throw error } const trimmedResult = trimThreadTurnsInRpcResult(body.method, rpcResult) - const sanitizedResult = await sanitizeThreadTurnsInlinePayloads(body.method, trimmedResult) + const errorMergedResult = THREAD_METHODS_WITH_TURNS.has(body.method) + ? mergeStreamTurnErrorsIntoThreadResult(appServer, trimmedResult) + : trimmedResult + const sanitizedResult = await sanitizeThreadTurnsInlinePayloads(body.method, errorMergedResult) const result = THREAD_METHODS_WITH_TURNS.has(body.method) ? await mergeSessionSkillInputsIntoThreadResult(sanitizedResult) : sanitizedResult @@ -6275,7 +6358,7 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { return } - const threadReadResult = await appServer.readThreadForTurnPage(threadId) + const threadReadResult = mergeStreamTurnErrorsIntoThreadResult(appServer, await appServer.readThreadForTurnPage(threadId)) const record = asRecord(threadReadResult) const thread = asRecord(record?.thread) if (!record || !thread) { @@ -6375,10 +6458,10 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { } try { - const threadReadResult = await appServer.rpc('thread/read', { + const threadReadResult = mergeStreamTurnErrorsIntoThreadResult(appServer, await appServer.rpc('thread/read', { threadId, includeTurns: true, - }) + })) const sanitized = await sanitizeThreadTurnsInlinePayloads('thread/read', threadReadResult) appServer.storeThreadReadSnapshot(threadId, sanitized) @@ -6431,6 +6514,17 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { setJson(res, 200, responseData) } catch (error) { + if (isThreadMaterializationPendingError(error)) { + setJson(res, 200, { + threadId, + conversationState: { turns: [] }, + ownerClientId: null, + liveStateError: null, + isInProgress: true, + }) + return + } + const snapshot = appServer.getLastThreadReadSnapshot(threadId) if (snapshot) { const record = asRecord(snapshot) diff --git a/src/server/freeMode.test.ts b/src/server/freeMode.test.ts index a3b512e18..24d97ff2f 100644 --- a/src/server/freeMode.test.ts +++ b/src/server/freeMode.test.ts @@ -1,16 +1,15 @@ import { describe, expect, it } from 'vitest' import { FREE_MODE_DEFAULT_MODEL, - FREE_MODE_PROVIDER_ID, OPENCODE_ZEN_DEFAULT_MODEL, - OPENCODE_ZEN_PROVIDER_ID, createDefaultOpenCodeZenFreeModeState, getFreeModeConfigArgs, shouldCreateDefaultFreeModeStateForMissingAuth, + shouldSuppressCommunityFreeModeForCodexAuth, } from './freeMode' describe('unauthenticated free mode defaults', () => { - it('creates an enabled OpenCode Zen state for unauthenticated startup', () => { + it('builds an enabled OpenCode Zen runtime fallback for unauthenticated startup', () => { const state = createDefaultOpenCodeZenFreeModeState() expect(state.enabled).toBe(true) @@ -26,11 +25,57 @@ describe('unauthenticated free mode defaults', () => { const args = getFreeModeConfigArgs(state, 4173) - expect(args).toContain(`model_provider="${OPENCODE_ZEN_PROVIDER_ID}"`) + expect(args).toContain('model_provider="opencode_zen"') expect(args).toContain(`model="${OPENCODE_ZEN_DEFAULT_MODEL}"`) - expect(args).toContain(`model_providers.${OPENCODE_ZEN_PROVIDER_ID}.base_url="http://127.0.0.1:4173/codex-api/zen-proxy/v1"`) - expect(args).toContain(`model_providers.${OPENCODE_ZEN_PROVIDER_ID}.wire_api="responses"`) - expect(args).toContain(`model_providers.${OPENCODE_ZEN_PROVIDER_ID}.experimental_bearer_token="zen-proxy-token"`) + expect(args).toContain('model_providers.opencode_zen.base_url="http://127.0.0.1:4173/codex-api/zen-proxy/v1"') + expect(args).toContain('model_providers.opencode_zen.wire_api="responses"') + expect(args).toContain('model_providers.opencode_zen.experimental_bearer_token="zen-proxy-token"') + }) + + it('suppresses community fallback providers when Codex auth appears', () => { + expect(shouldSuppressCommunityFreeModeForCodexAuth({ + enabled: true, + apiKey: 'community-key', + model: FREE_MODE_DEFAULT_MODEL, + customKey: false, + provider: 'openrouter', + wireApi: 'responses', + }, true)).toBe(true) + + expect(shouldSuppressCommunityFreeModeForCodexAuth({ + enabled: true, + apiKey: 'user-key', + model: FREE_MODE_DEFAULT_MODEL, + customKey: true, + provider: 'openrouter', + wireApi: 'responses', + }, true)).toBe(false) + + expect(shouldSuppressCommunityFreeModeForCodexAuth({ + enabled: true, + apiKey: 'zen-user-key', + model: OPENCODE_ZEN_DEFAULT_MODEL, + customKey: false, + provider: 'opencode-zen', + wireApi: 'responses', + }, true)).toBe(false) + + expect(shouldSuppressCommunityFreeModeForCodexAuth({ + enabled: false, + apiKey: null, + model: FREE_MODE_DEFAULT_MODEL, + provider: 'openrouter', + wireApi: 'responses', + }, true)).toBe(false) + + expect(shouldSuppressCommunityFreeModeForCodexAuth({ + enabled: true, + apiKey: 'community-key', + model: FREE_MODE_DEFAULT_MODEL, + customKey: false, + provider: 'openrouter', + wireApi: 'responses', + }, false)).toBe(false) }) it('uses the OpenCode Zen default model when persisted Zen state has an empty model', () => { @@ -51,8 +96,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', () => { @@ -65,7 +111,7 @@ describe('unauthenticated free mode defaults', () => { }, false)).toBe(false) }) - it('creates the default only when state is absent and Codex auth is missing', () => { + it('uses the runtime default only when state is absent and Codex auth is missing', () => { expect(shouldCreateDefaultFreeModeStateForMissingAuth(null, false)).toBe(true) expect(shouldCreateDefaultFreeModeStateForMissingAuth(null, true)).toBe(false) }) diff --git a/src/server/freeMode.ts b/src/server/freeMode.ts index 1889394da..507b734c4 100644 --- a/src/server/freeMode.ts +++ b/src/server/freeMode.ts @@ -93,6 +93,7 @@ export function getFreeKeyCount(): number { export const FREE_MODE_PROVIDER_ID = 'openrouter-free' export const FREE_MODE_BASE_URL = 'https://openrouter.ai/api/v1' +const FREE_MODE_RUNTIME_PROVIDER_ID = 'openrouter_free' const FALLBACK_FREE_MODELS = [ 'openrouter/free', @@ -147,10 +148,12 @@ export function refreshFreeModelsInBackground(): void { export const FREE_MODE_DEFAULT_MODEL = 'openrouter/free' -export const FREE_MODE_STATE_FILE = 'webui-free-mode.json' +export const FREE_MODE_STATE_FILE = 'webui-custom-providers.json' export const CUSTOM_PROVIDER_ID = 'custom-endpoint' export const OPENCODE_ZEN_PROVIDER_ID = 'opencode-zen' +const CUSTOM_RUNTIME_PROVIDER_ID = 'custom_endpoint' +const OPENCODE_ZEN_RUNTIME_PROVIDER_ID = 'opencode_zen' export const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1' export const OPENCODE_ZEN_DEFAULT_MODEL = 'big-pickle' @@ -202,6 +205,17 @@ export function shouldCreateDefaultFreeModeStateForMissingAuth( return current == null && !hasUsableCodexAuth } +export function shouldSuppressCommunityFreeModeForCodexAuth( + current: FreeModeState | null, + hasUsableCodexAuth: boolean, +): boolean { + if (!hasUsableCodexAuth || !current?.enabled) return false + if (current.provider === 'custom') return false + if (current.customKey === true) return false + if (current.provider === 'opencode-zen' && current.apiKey?.trim()) return false + return current.provider === 'openrouter' || current.provider === 'opencode-zen' || !current.provider +} + export function getFreeModeEnvVars(state: FreeModeState): Record { if (!state.enabled) return {} @@ -221,55 +235,58 @@ export function getFreeModeConfigArgs(state: FreeModeState, serverPort?: number) if (state.provider === 'opencode-zen') { const model = state.model?.trim() || OPENCODE_ZEN_DEFAULT_MODEL + const providerConfigKey = `model_providers.${OPENCODE_ZEN_RUNTIME_PROVIDER_ID}` const baseUrl = serverPort ? `http://127.0.0.1:${serverPort}/codex-api/zen-proxy/v1` : OPENCODE_ZEN_BASE_URL const wireApi = serverPort ? 'responses' : (state.wireApi || 'chat') const authArgs: string[] = serverPort - ? ['-c', `model_providers.${OPENCODE_ZEN_PROVIDER_ID}.experimental_bearer_token="zen-proxy-token"`] - : ['-c', `model_providers.${OPENCODE_ZEN_PROVIDER_ID}.env_key="OPENCODE_ZEN_API_KEY"`] + ? ['-c', `${providerConfigKey}.experimental_bearer_token="zen-proxy-token"`] + : ['-c', `${providerConfigKey}.env_key="OPENCODE_ZEN_API_KEY"`] return [ '-c', `model="${model}"`, - '-c', `model_provider="${OPENCODE_ZEN_PROVIDER_ID}"`, - '-c', `model_providers.${OPENCODE_ZEN_PROVIDER_ID}.name="OpenCode Zen"`, - '-c', `model_providers.${OPENCODE_ZEN_PROVIDER_ID}.base_url="${baseUrl}"`, - '-c', `model_providers.${OPENCODE_ZEN_PROVIDER_ID}.wire_api="${wireApi}"`, + '-c', `model_provider="${OPENCODE_ZEN_RUNTIME_PROVIDER_ID}"`, + '-c', `${providerConfigKey}.name="OpenCode Zen"`, + '-c', `${providerConfigKey}.base_url="${baseUrl}"`, + '-c', `${providerConfigKey}.wire_api="${wireApi}"`, ...authArgs, ] } if (state.provider === 'custom' && state.customBaseUrl) { + const providerConfigKey = `model_providers.${CUSTOM_RUNTIME_PROVIDER_ID}` const baseUrl = serverPort ? `http://127.0.0.1:${serverPort}/codex-api/custom-proxy/v1` : state.customBaseUrl const wireApi = serverPort ? 'responses' : (state.wireApi || 'responses') const authArgs: string[] = serverPort - ? ['-c', `model_providers.${CUSTOM_PROVIDER_ID}.experimental_bearer_token="custom-proxy-token"`] - : ['-c', `model_providers.${CUSTOM_PROVIDER_ID}.env_key="CUSTOM_ENDPOINT_API_KEY"`] + ? ['-c', `${providerConfigKey}.experimental_bearer_token="custom-proxy-token"`] + : ['-c', `${providerConfigKey}.env_key="CUSTOM_ENDPOINT_API_KEY"`] const modelArgs: string[] = state.model?.trim() ? ['-c', `model="${state.model.trim()}"`] : [] return [ ...modelArgs, - '-c', `model_provider="${CUSTOM_PROVIDER_ID}"`, - '-c', `model_providers.${CUSTOM_PROVIDER_ID}.name="Custom Endpoint"`, - '-c', `model_providers.${CUSTOM_PROVIDER_ID}.base_url="${baseUrl}"`, - '-c', `model_providers.${CUSTOM_PROVIDER_ID}.wire_api="${wireApi}"`, + '-c', `model_provider="${CUSTOM_RUNTIME_PROVIDER_ID}"`, + '-c', `${providerConfigKey}.name="Custom Endpoint"`, + '-c', `${providerConfigKey}.base_url="${baseUrl}"`, + '-c', `${providerConfigKey}.wire_api="${wireApi}"`, ...authArgs, ] } if (!state.apiKey) return [] + const providerConfigKey = `model_providers.${FREE_MODE_RUNTIME_PROVIDER_ID}` const baseUrl = serverPort ? `http://127.0.0.1:${serverPort}/codex-api/openrouter-proxy/v1` : FREE_MODE_BASE_URL const bearerToken = serverPort ? 'openrouter-proxy-token' : state.apiKey return [ '-c', `model="${state.model}"`, - '-c', `model_provider="${FREE_MODE_PROVIDER_ID}"`, - '-c', `model_providers.${FREE_MODE_PROVIDER_ID}.name="OpenRouter Free"`, - '-c', `model_providers.${FREE_MODE_PROVIDER_ID}.base_url="${baseUrl}"`, - '-c', `model_providers.${FREE_MODE_PROVIDER_ID}.wire_api="responses"`, - '-c', `model_providers.${FREE_MODE_PROVIDER_ID}.experimental_bearer_token="${bearerToken}"`, + '-c', `model_provider="${FREE_MODE_RUNTIME_PROVIDER_ID}"`, + '-c', `${providerConfigKey}.name="OpenRouter Free"`, + '-c', `${providerConfigKey}.base_url="${baseUrl}"`, + '-c', `${providerConfigKey}.wire_api="responses"`, + '-c', `${providerConfigKey}.experimental_bearer_token="${bearerToken}"`, ] } diff --git a/src/style.css b/src/style.css index 65087d0ca..ab1517541 100644 --- a/src/style.css +++ b/src/style.css @@ -315,6 +315,10 @@ @apply border-rose-800/80 bg-rose-950 text-rose-100 hover:bg-rose-900 focus:ring-rose-700; } +:root.dark .turn-error-feedback { + @apply border-rose-800/80 bg-rose-950 text-rose-100 hover:bg-rose-900 focus:ring-rose-700; +} + :root.dark .header-git-feedback { @apply border-rose-800/80 bg-rose-950 text-rose-100 hover:bg-rose-900 focus:ring-rose-700; } diff --git a/tests.md b/tests.md index 2558d0dfa..1996b7417 100644 --- a/tests.md +++ b/tests.md @@ -3084,7 +3084,7 @@ stays at `source: "NoValues"` permanently. Feature gate `505458` (worktree) retu ### Free Mode (OpenRouter) #### Feature -Toggle "Free mode" in settings to use free OpenRouter models without an OpenAI API key. Uses XOR-encrypted community keys that rotate randomly per request. Default model is `openrouter/free` — OpenRouter's meta-model that auto-routes to the least-loaded free model, avoiding per-model rate limits. Model selector shows only free models when free mode is on. Config is isolated from `~/.codex/config.toml` — state stored in `~/.codex/webui-free-mode.json` and passed to app-server via `-c` CLI args. +Toggle "Free mode" in settings to use free OpenRouter models without an OpenAI API key. Uses XOR-encrypted community keys that rotate randomly per request. Default model is `openrouter/free` — OpenRouter's meta-model that auto-routes to the least-loaded free model, avoiding per-model rate limits. Model selector shows only free models when free mode is on. Config is isolated from `~/.codex/config.toml` — state stored in `~/.codex/webui-custom-providers.json` and passed to app-server via `-c` CLI args. #### Prerequisites - Project built: `pnpm run build`. @@ -3098,7 +3098,7 @@ Toggle "Free mode" in settings to use free OpenRouter models without an OpenAI A 5. Verify the toggle turns on and model dropdown changes to `openrouter/free`. 6. Click the model dropdown — verify it shows **only** free models (gemma, llama, qwen, etc.) and no GPT/OpenAI default models. 7. Verify `~/.codex/config.toml` was NOT modified (no `model_provider` or `model` entries added). -8. Verify `~/.codex/webui-free-mode.json` exists and contains `{"enabled":true,"apiKey":"sk-or-v1-...","model":"openrouter/free"}`. +8. Verify `~/.codex/webui-custom-providers.json` exists and contains `{"enabled":true,"apiKey":"sk-or-v1-...","model":"openrouter/free"}`. 9. Open a new thread and send a message (e.g. "Say hello"). 10. Verify a response comes back from a free OpenRouter model (may be rate-limited during high demand). 11. Toggle **Free mode (OpenRouter)** OFF. @@ -3139,7 +3139,7 @@ Toggle "Free mode" in settings to use free OpenRouter models without an OpenAI A #### Rollback/Cleanup - Remove `src/server/freeMode.ts`, revert changes in `codexAppServerBridge.ts`, `codexGateway.ts`, and `App.vue`. -- Delete `~/.codex/webui-free-mode.json` to clear free mode state. +- Delete `~/.codex/webui-custom-providers.json` to clear free mode state. ### Feature: Codex.app Thread Provider Filter Patch (fix-codex-thread-filter.sh) @@ -3368,12 +3368,12 @@ OpenCode Zen as built-in provider + API format selector for custom endpoints - OpenCode Zen appears in provider dropdown alongside Codex/OpenRouter/Custom - OpenCode Zen defaults to `wire_api = "chat"` (Chat Completions API) - Custom endpoints show an API format selector; default is "Responses API" -- Provider selection and wireApi are persisted in `~/.codex/webui-free-mode.json` +- Provider selection and wireApi are persisted in `~/.codex/webui-custom-providers.json` - Model list for OpenCode Zen is fetched from `https://opencode.ai/zen/v1/models` #### Rollback/Cleanup - Switch provider back to "Codex" to disable free mode -- No config files outside the project are modified (state stored in `~/.codex/webui-free-mode.json`) +- Project config files are not modified; only user-level state is written to `~/.codex/webui-custom-providers.json` ### env_key Authentication for Custom Providers (codex CLI v0.93.0) @@ -5272,6 +5272,9 @@ Android `codexui-android` startup passes the bound server port to app-server fre #### Expected Results - `config/read` returns `200` and includes `model_providers.opencode-zen.base_url` pointing at `http://127.0.0.1:17923/codex-api/zen-proxy/v1`. - `config/read` includes `model_providers.opencode-zen.wire_api` as `responses`, not `chat`. +- Fresh no-auth startup uses OpenCode Zen as a runtime fallback without creating `~/.codex/webui-custom-providers.json`. +- After a usable Codex `auth.json` is added and the server restarts with no saved free-mode state, startup does not keep forcing `model_provider="opencode-zen"`. +- Existing `~/.codex/webui-free-mode.json` files are ignored and not migrated to `~/.codex/webui-custom-providers.json`. - `model/list` returns `200` with model data instead of `502 codex app-server exited unexpectedly`. - The model selector is usable in both light theme and dark theme. - A first home-composer message creates a thread and receives a response without visible startup RPC errors. @@ -5343,3 +5346,165 @@ Thread conversation incremental older-turn loading. #### Rollback/Cleanup - None. + +--- + +### Docker auth startup live-state pending read + +#### Feature/Change Name +Docker authenticated first-turn live-state pending read handling. + +#### Prerequisites/Setup +1. Build the project with `pnpm run build`. +2. Build a fresh Docker image that installs `@openai/codex` and runs the packed `codexapp` artifact. +3. Prepare two isolated `CODEX_HOME` states: one empty and one with only `auth.json` mounted. + +#### Steps +1. Start the no-auth container and open the app in light theme. +2. Confirm `config/read` uses `model_provider="opencode-zen"` and `model="big-pickle"`. +3. Send `hi` and wait for the assistant reply. +4. Start the auth-mounted container and open the app in light theme. +5. Confirm `config/read` has `model_provider=null` and no Zen provider override. +6. Send `hi` and poll `/codex-api/thread-live-state?threadId=` while the first turn is starting. +7. Confirm early live-state responses do not expose `liveStateError.kind="readFailed"` for `not materialized yet; includeTurns is unavailable before first user message`. +8. Wait for the assistant reply, then switch to dark theme and repeat the visual checks for the composer/thread area. + +#### Expected Results +- No-auth Docker startup falls back to Zen at runtime and returns a `hi` response. +- Auth-mounted Docker startup uses the default Codex provider path without Zen flags and returns a `hi` response. +- The transient first-turn materialization window is represented as an in-progress empty live state, not a visible chat error. +- Real `thread/read` failures still surface through `liveStateError`. +- Light theme and dark theme keep the chat/composer readable throughout the first-turn transition. + +#### Rollback/Cleanup +- Stop temporary containers with `docker rm -f codexui-noauth-test codexui-auth-test` when finished. + +--- + +### Provider models load without Codex model-list dependency + +#### Feature/Change Name +Provider-backed model selector startup loading. + +#### Prerequisites/Setup +1. Build the project with `pnpm run build`. +2. Run a no-auth Docker container so Codex Web Local starts with OpenCode Zen fallback. +3. Open `http://127.0.0.1:/#/` in the browser. + +#### Steps +1. In light theme, open the home screen and wait for initial model loading. +2. Open the model selector. +3. Confirm Zen provider models are visible even if Codex `model/list` is slow or unavailable. +4. Confirm the selector starts with `big-pickle` and includes current Zen models such as `deepseek-v4-flash-free`. +5. Switch to dark theme and repeat steps 2 through 4. + +#### Expected Results +- Provider-backed model loading asks `/codex-api/provider-models` before depending on `model/list`. +- OpenCode Zen models populate the selector without falling back to a blank list or stale Codex-only model list. +- The selector remains readable and usable in light theme and dark theme. + +#### Rollback/Cleanup +- Stop the temporary Docker container when finished. + +--- + +### Invalid or expired auth errors appear in chat + +#### Feature/Change Name +Invalid Codex auth failed-turn error rendering. + +#### Prerequisites/Setup +1. Build the project with `pnpm run build`. +2. Build a fresh Docker image from the packed artifact. +3. Start a Docker container with an invalid or expired `auth.json` mounted into `CODEX_HOME`. +4. Open the container URL in the browser. + +#### Steps +1. Confirm `config/read` uses the default Codex provider path, not OpenCode Zen fallback. +2. Send `hi` from the composer. +3. Wait until the turn stops running. +4. Reload or reopen the same thread. +5. Repeat in dark theme and light theme. + +#### Expected Results +- The failed turn displays the final auth error in the chat, including the HTTP 401/unauthorized message from Codex. +- The failed turn includes a visible `Send feedback` button next to the persisted chat error. +- Once the failed turn is persisted, the live `Thinking` error overlay is gone so the final auth error is not duplicated. +- The conversation does not silently show only the user message after a failed turn. +- Reloaded thread history preserves the failed-turn error message. +- Transient retry messages may appear while reconnecting, but the final non-retry error remains visible after completion. +- In dark theme and light theme, the feedback button remains readable and opens a feedback mailto with the visible auth error included in the diagnostic body. + +#### Rollback/Cleanup +- Stop the invalid-auth Docker container after verification. + +--- + +### Docker provider checklist and live error overlay regression + +#### Feature/Change Name +Docker provider/auth checklist execution and live error overlay de-duplication. + +#### Prerequisites/Setup +1. Run `pnpm run build`. +2. Run `pnpm pack --pack-destination /tmp`. +3. Build a Docker image from the packed `codexapp` tarball with `@openai/codex` installed. +4. Start three isolated containers: + - no auth file + - invalid or expired `auth.json` + - malformed `auth.json` + +#### Steps +1. In light theme, open the no-auth container, confirm the composer starts on `big-pickle`, send `hi`, and wait for an assistant reply. +2. Switch the Settings provider selector to OpenRouter, send `hi` again, and wait for a reply or provider-scoped response. +3. Open the invalid-auth container, send `hi`, wait for the final 401/auth error, and confirm `Send feedback` is visible. +4. Reload the invalid-auth thread and confirm the persisted error remains without a duplicate live `Thinking` error overlay. +5. Switch the invalid-auth thread to dark theme and confirm the persisted error and feedback button remain readable. +6. Open the malformed-auth container, confirm it falls back to `big-pickle`, send `hi`, and wait for an assistant reply. + +#### Expected Results +- No-auth startup uses the OpenCode Zen runtime fallback and sends successfully. +- Runtime `-c` provider config uses underscore-safe provider ids, so Zen/OpenRouter/custom providers are actually registered with Codex app-server. +- Provider switching is scoped to the selected provider and does not require changing the model dropdown directly. +- Invalid/expired auth stays on the Codex provider path and renders the final auth failure as a persisted chat error. +- A new live error is still visible when an older persisted turn error exists, but the same live error is suppressed after that exact error has persisted. +- Feedback mailto diagnostics include recent diagnostics, visible page text, and the visible auth error. +- Malformed auth is treated as unusable auth and falls back to Zen. + +#### Rollback/Cleanup +- Stop temporary containers with `docker rm -f codexui-what-noauth codexui-what-invalid-auth codexui-what-malformed-auth`. + +--- + +### Copied auth promotes community fallback to Codex + +#### Feature/Change Name +Runtime auth detection after starting without auth. + +#### Prerequisites/Setup +1. Run `pnpm run build`. +2. Run `pnpm pack --pack-destination /tmp`. +3. Build a Docker image from the packed `codexapp` tarball with `@openai/codex` installed. +4. Start a fresh no-auth container with an empty mounted `CODEX_HOME`. +5. Keep a valid host `auth.json` available to copy into that mounted `CODEX_HOME`. + +#### Steps +1. Open the no-auth container and confirm the provider is OpenCode Zen with `big-pickle`. +2. Switch the Settings provider selector to OpenRouter while still unauthenticated. +3. Copy a valid `auth.json` into the mounted `CODEX_HOME`. +4. Reload the page. +5. Confirm the provider has moved to Codex, the composer shows a concrete Codex model instead of a generic `Model` placeholder, and the Accounts count imports the active auth account. +6. Confirm the sidebar does not show a stale `Send feedback` / `Issue detected` row when there is no current visible error. +7. Send `hi` on the Codex provider and wait for an assistant reply. + +#### Expected Results +- Community fallback providers are suppressed once usable Codex auth appears. +- User-configured providers with a custom key or custom endpoint remain available and are not suppressed. +- The app refreshes model metadata after provider promotion so the composer does not stay on a generic `Model` label. +- The copied auth file is imported into the accounts list without requiring a manual Reload click after Codex quota metadata loads successfully. +- Invalid or expired copied auth is not imported during startup before a successful quota read, so the first failed send still renders a chat error instead of leaving the thread empty. +- The Settings feedback row is hidden after provider/account recovery unless there is still a visible error. +- The Codex provider can send a message successfully after auth promotion. + +#### Rollback/Cleanup +- Stop the temporary container and remove its mounted `CODEX_HOME` directory. diff --git a/whatToTest.md b/whatToTest.md new file mode 100644 index 000000000..d05c74a82 --- /dev/null +++ b/whatToTest.md @@ -0,0 +1,3 @@ +# What To Test + +No remaining test tasks.