From c311afddca6a3de40f73a9211112acd52d8285aa Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Wed, 17 Jun 2026 10:27:33 +0800 Subject: [PATCH] fix(codex): Fast mode (service tier) toggle + /fast command (closes #898) (#904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: reproduce issue #898 (Codex fast mode service tier) * fix(codex): add Fast mode (service tier) toggle and /fast command (closes #898) * feat(codex+web): Fast mode UI toggle with full persistence Wires the Codex Fast mode (service tier) end-to-end so it can be toggled from the web composer and survives reload/handoff: - shared: serviceTier on Session/SessionPatch, session-alive payload, resume target, and a SessionServiceTierRequest schema - cli: AgentSessionBase carries serviceTier through keepAlive; runCodex syncs it to the session instance - hub: service_tier column (schema v10 + migration), store setter, sessionCache + syncEngine plumbing, POST /sessions/:id/service-tier - web: api.setServiceTier + mutation, a Fast/Standard toggle in the composer settings (gated to Codex GPT-5.5/5.4), and StatusBar now reflects the real tier instead of the effort heuristic Refs #898 * fix(codex): preserve unset/persisted service tier on startup keepalive Addresses HAPI Bot [Major] on PR #904: applyCurrentConfigToSession ran setServiceTier(currentServiceTier ?? null) on wrapper-ready, collapsing the untouched `undefined` state into explicit Standard. The immediate setCollaborationMode keepalive then persisted serviceTier: null, silently downgrading resumed Fast sessions and disabling account-default Fast. - Seed currentServiceTier from the persisted session (sessionInfo.serviceTier), so a resumed Fast thread keeps running Fast. - Only call setServiceTier when the tier is explicit (!== undefined), preserving the three-state omit semantics at the keepalive boundary. - Add regression tests: persisted Fast is re-asserted; untouched omits the tier. * feat(codex+web): gate Fast toggle on catalog-advertised service tier The Fast toggle was gated on a model-name regex (gpt-5.5/5.4), which still showed a no-op control to API-key users — Fast credits only apply with ChatGPT login. Codex's model/list catalog advertises the service tiers actually available for each model in the current auth/plan context, so gate on that instead: - cli: capture serviceTiers (ids) per model in ModelListItem + normalizeModel - shared: CodexModelSummary.serviceTiers (flows through the existing getSessionCodexModels pass-through; no hub change needed) - web: codexModelAdvertisesFastTier(sessionModel, models) replaces the regex; SessionChat gates the toggle on it (hidden while the catalog is loading/errored). The toggle now only appears when toggling it will actually take effect. Refs #898 * fix(codex): make explicit Standard service tier sticky across resume Addresses HAPI Bot [Major] (round 2): a single persisted null conflated "untouched" with "explicit Standard". A user who turned Fast off persisted null, but startup mapped null -> undefined (untouched) and omitted serviceTier, so an account/thread-default Fast could silently return after restart/resume. Introduce a distinct stored representation: - 'fast' / 'standard' are explicit user choices; null/undefined = untouched. - Translate 'standard' -> Codex app-server serviceTier: null ONLY when building thread/turn params (toAppServerServiceTier); untouched omits the field. - /fast off now stores 'standard'; the web Standard option sends 'standard'. - Tighten SessionServiceTierRequest to enum(['fast','standard']) so stray tier strings are never forwarded. Tests: sticky-Standard-on-resume regression; turn/thread params translate 'standard'->null and omit on untouched; hub route applies fast/standard and rejects unsupported values + local sessions. Refs #898 * fix(codex): recognize real Fast tier (id 'priority', name 'Fast') in catalog gate Live E2E against an authed Codex session revealed the model catalog advertises the Fast tier with id 'priority' and display name 'Fast' (not id 'fast'), so the /fast/i gate — which only saw tier ids — wrongly hid the toggle for valid ChatGPT users on gpt-5.5/gpt-5.4. Capture both the tier id and name as lowercased tokens so the existing name-based match recognizes 'Fast'. The sent value stays 'fast' (the documented service_tier value / raw additionalSpeedTiers request tier). Verified end-to-end: gpt-5.5/gpt-5.4 gate on, gpt-5.4-mini off. Refs #898 * fix(codex): preserve service tier across session resume Resuming a Codex session spawns a fresh session (serviceTier null) and merges the old one in. Unlike model/effort/permissionMode, serviceTier was neither threaded through the resume spawn nor preserved in mergeSessionData, so a resumed Fast (or explicit Standard) session silently reverted to the account default. Thread serviceTier through the spawn path like its siblings: - hub: resumeSession passes session.serviceTier to spawnSession; rpcGateway + syncEngine carry it in the spawn RPC payload; mergeSessionData preserves it old->new (safety net). - cli: SpawnSessionOptions.serviceTier; apiMachine forwards it; buildCliArgs emits --service-tier for codex; the codex command parses it; runCodex seeds currentServiceTier from the spawn override first (opts.serviceTier ?? sessionInfo.serviceTier), so a resumed thread immediately runs the right tier. Verified end-to-end: set Fast -> kill process -> reopen -> resumed session (new id) still runs Fast. Tests: buildCliArgs --service-tier (codex only), runCodex spawn-override seed, mergeSessionData service-tier preservation. Refs #898 * fix(codex): send advertised 'priority' tier id for Fast, not 'fast' The model catalog advertises the Fast tier with request id 'priority' (display name 'Fast'), and OpenAI docs confirm service_tier='fast' maps to the request value 'priority'. The app-server serviceTier override is a raw request value that does not validate unknown strings (a live probe accepted 'bogus-xyz'), so sending 'fast' risks being silently ignored — no Fast applied. Translate the stored 'fast' state to app-server 'priority' at the thread/turn param boundary (toAppServerServiceTier); the stored/UI/command representation stays 'fast'/'standard'. Verified live: a turn with serviceTier='priority' runs and consumes the Fast-tier rate budget. Addresses HAPI Bot [Major]. Refs #898 * fix(codex): validate --service-tier CLI value (fast|standard) Addresses HAPI Bot [Minor]: the internal --service-tier spawn arg accepted any non-empty string, unlike the web /service-tier enum, so a malformed value could be seeded into currentServiceTier and persisted via keepalive. Parse it to 'fast'|'standard' and reject anything else, matching the web endpoint. Refs #898 --- cli/src/agent/sessionBase.ts | 14 ++++ cli/src/agent/sessionFactory.test.ts | 1 + cli/src/api/api.extraHeaders.test.ts | 1 + cli/src/api/api.ts | 2 + cli/src/api/apiMachine.ts | 3 +- cli/src/api/apiSession.ts | 1 + cli/src/codex/appServerTypes.ts | 16 ++++ cli/src/codex/loop.ts | 5 ++ cli/src/codex/runCodex.test.ts | 65 +++++++++++++++- cli/src/codex/runCodex.ts | 71 +++++++++++++++-- cli/src/codex/utils/appServerConfig.test.ts | 76 +++++++++++++++++++ cli/src/codex/utils/appServerConfig.ts | 34 +++++++++ cli/src/codex/utils/slashCommands.test.ts | 39 ++++++++++ cli/src/codex/utils/slashCommands.ts | 33 ++++++++ cli/src/commands/codex.test.ts | 27 +++++++ cli/src/commands/codex.ts | 17 +++++ cli/src/modules/common/codexModels.ts | 31 +++++++- cli/src/modules/common/rpcTypes.ts | 1 + cli/src/runner/buildCliArgs.test.ts | 17 +++++ cli/src/runner/run.ts | 3 + hub/src/notifications/notificationHub.test.ts | 1 + hub/src/serverchan/channel.test.ts | 1 + .../socket/handlers/cli/sessionHandlers.ts | 1 + hub/src/store/index.ts | 12 ++- hub/src/store/sessionStore.ts | 5 ++ hub/src/store/sessions.ts | 35 +++++++++ hub/src/store/types.ts | 1 + hub/src/sync/messageService.test.ts | 1 + hub/src/sync/rpcGateway.ts | 5 +- hub/src/sync/sessionCache.ts | 37 ++++++++- hub/src/sync/sessionModel.test.ts | 25 ++++++ hub/src/sync/syncEngine.ts | 12 ++- hub/src/telegram/sessionView.test.ts | 1 + hub/src/web/routes/sessions.test.ts | 66 ++++++++++++++++ hub/src/web/routes/sessions.ts | 37 +++++++++ shared/src/apiTypes.ts | 12 +++ shared/src/resume.ts | 1 + shared/src/schemas.ts | 2 + shared/src/sessionSummary.test.ts | 1 + shared/src/socket.ts | 1 + web/src/api/client.ts | 7 ++ .../AssistantChat/HappyComposer.tsx | 71 ++++++++++++++++- .../components/AssistantChat/StatusBar.tsx | 8 +- .../AssistantChat/codexFastMode.test.ts | 51 +++++++++++++ .../components/AssistantChat/codexFastMode.ts | 51 +++++++++++++ web/src/components/SessionChat.tsx | 25 +++++- web/src/hooks/mutations/useSessionActions.ts | 19 +++++ web/src/lib/locales/en.ts | 3 + web/src/lib/locales/zh-CN.ts | 3 + web/src/lib/sessionExport/markdown.test.ts | 1 + 50 files changed, 931 insertions(+), 22 deletions(-) create mode 100644 web/src/components/AssistantChat/codexFastMode.test.ts create mode 100644 web/src/components/AssistantChat/codexFastMode.ts diff --git a/cli/src/agent/sessionBase.ts b/cli/src/agent/sessionBase.ts index a0f47b46..48ba06e7 100644 --- a/cli/src/agent/sessionBase.ts +++ b/cli/src/agent/sessionBase.ts @@ -26,6 +26,7 @@ export type AgentSessionBaseOptions = { model?: SessionModel; modelReasoningEffort?: SessionModelReasoningEffort; effort?: SessionEffort; + serviceTier?: string | null; collaborationMode?: SessionCollaborationMode; }; @@ -50,6 +51,7 @@ export class AgentSessionBase { protected model?: SessionModel; protected modelReasoningEffort?: SessionModelReasoningEffort; protected effort?: SessionEffort; + protected serviceTier?: string | null; protected collaborationMode?: SessionCollaborationMode; constructor(opts: AgentSessionBaseOptions) { @@ -68,6 +70,7 @@ export class AgentSessionBase { this.model = opts.model; this.modelReasoningEffort = opts.modelReasoningEffort; this.effort = opts.effort; + this.serviceTier = opts.serviceTier; this.collaborationMode = opts.collaborationMode; this.queue.onBatchConsumed = (localIds) => this.client.emitMessagesConsumed(localIds); @@ -137,6 +140,7 @@ export class AgentSessionBase { model?: SessionModel modelReasoningEffort?: SessionModelReasoningEffort effort?: SessionEffort + serviceTier?: string | null collaborationMode?: SessionCollaborationMode } | undefined { if ( @@ -144,6 +148,7 @@ export class AgentSessionBase { && this.model === undefined && this.modelReasoningEffort === undefined && this.effort === undefined + && this.serviceTier === undefined && this.collaborationMode === undefined ) { return undefined; @@ -153,6 +158,7 @@ export class AgentSessionBase { model: this.model, modelReasoningEffort: this.modelReasoningEffort, effort: this.effort, + serviceTier: this.serviceTier, collaborationMode: this.collaborationMode }; } @@ -173,6 +179,14 @@ export class AgentSessionBase { return this.effort; } + getServiceTier(): string | null | undefined { + return this.serviceTier; + } + + setServiceTier(serviceTier: string | null): void { + this.serviceTier = serviceTier; + } + getCollaborationMode(): SessionCollaborationMode | undefined { return this.collaborationMode; } diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index a965d14d..70a7c31a 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -74,6 +74,7 @@ function createSession(): Session { model: null, modelReasoningEffort: null, effort: null, + serviceTier: null, permissionMode: undefined, collaborationMode: undefined } diff --git a/cli/src/api/api.extraHeaders.test.ts b/cli/src/api/api.extraHeaders.test.ts index a21587dc..c72e9a7c 100644 --- a/cli/src/api/api.extraHeaders.test.ts +++ b/cli/src/api/api.extraHeaders.test.ts @@ -137,6 +137,7 @@ describe('API extra headers integration', () => { model: null, modelReasoningEffort: null, effort: null, + serviceTier: null, permissionMode: undefined, collaborationMode: undefined }) diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index b8c80bbd..df52e454 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -98,6 +98,7 @@ export class ApiClient { model: raw.model, modelReasoningEffort: raw.modelReasoningEffort, effort: raw.effort, + serviceTier: raw.serviceTier, permissionMode: raw.permissionMode, collaborationMode: raw.collaborationMode } @@ -147,6 +148,7 @@ export class ApiClient { model: raw.model, modelReasoningEffort: raw.modelReasoningEffort, effort: raw.effort, + serviceTier: raw.serviceTier, permissionMode: raw.permissionMode, collaborationMode: raw.collaborationMode } diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 61f15fe1..88ba3596 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -249,7 +249,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => { - const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName } = params || {} + const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName } = params || {} if (!directory) { throw new Error('Directory is required') @@ -272,6 +272,7 @@ export class ApiMachineClient { modelReasoningEffort, yolo, permissionMode, + serviceTier, token, sessionType, worktreeName diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index d187eba2..370239b5 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -573,6 +573,7 @@ export class ApiSessionClient extends EventEmitter { model?: SessionModel modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: SessionCollaborationMode } ): void { diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index c5ceb1e3..e6cb42c5 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -34,6 +34,12 @@ export interface ModelListItem { description?: string; }>; defaultReasoningEffort?: string | null; + serviceTiers?: Array<{ + id?: string; + name?: string; + description?: string; + }>; + defaultServiceTier?: string | null; isDefault?: boolean; [key: string]: unknown; } @@ -63,6 +69,11 @@ export interface CollaborationModeListResponse { export interface ThreadStartParams { model?: string; modelProvider?: string; + /** + * Service tier override (e.g. 'fast'). `null` selects the standard tier + * explicitly; omit to inherit the account/thread default. + */ + serviceTier?: string | null; cwd?: string; approvalPolicy?: ApprovalPolicy; sandbox?: SandboxMode; @@ -161,6 +172,11 @@ export interface TurnStartParams { approvalPolicy?: ApprovalPolicy; sandboxPolicy?: SandboxPolicy; model?: string; + /** + * Service tier override for this turn and subsequent turns (e.g. 'fast'). + * `null` selects the standard tier explicitly; omit to leave it unchanged. + */ + serviceTier?: string | null; effort?: ReasoningEffort; summary?: ReasoningSummary; personality?: string; diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index aad56be4..ff60da6b 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -16,6 +16,11 @@ export interface EnhancedMode { model?: string; collaborationMode: CodexCollaborationMode; modelReasoningEffort?: ReasoningEffort; + /** + * Service tier override. `undefined` leaves it untouched (account default), + * `'fast'` enables Fast mode, `null` selects the standard tier explicitly. + */ + serviceTier?: string | null; } interface LoopOptions { diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts index 621119ae..0dca6b8f 100644 --- a/cli/src/codex/runCodex.test.ts +++ b/cli/src/codex/runCodex.test.ts @@ -5,6 +5,7 @@ const mockCodexSession = vi.hoisted(() => ({ setPermissionMode: vi.fn(), setModel: vi.fn(), setModelReasoningEffort: vi.fn(), + setServiceTier: vi.fn(), setCollaborationMode: vi.fn(), stopKeepAlive: vi.fn() })) @@ -12,6 +13,7 @@ const mockCodexSession = vi.hoisted(() => ({ const harness = vi.hoisted(() => ({ bootstrapArgs: [] as Array>, loopArgs: [] as Array>, + sessionInfo: { serviceTier: null as string | null } as Record, session: { onUserMessage: vi.fn(), onCancelQueuedMessage: vi.fn(), @@ -26,14 +28,16 @@ vi.mock('@/agent/sessionFactory', () => ({ harness.bootstrapArgs.push(options) return { api: {}, - session: harness.session + session: harness.session, + sessionInfo: harness.sessionInfo } }), bootstrapExistingSession: vi.fn(async (options: Record) => { harness.bootstrapArgs.push(options) return { api: {}, - session: harness.session + session: harness.session, + sessionInfo: harness.sessionInfo } }) })) @@ -103,12 +107,14 @@ describe('runCodex', () => { beforeEach(() => { harness.bootstrapArgs.length = 0 harness.loopArgs.length = 0 + harness.sessionInfo = { serviceTier: null } harness.session.onUserMessage.mockReset() harness.session.onCancelQueuedMessage.mockReset() harness.session.rpcHandlerManager.registerHandler.mockReset() mockCodexSession.setPermissionMode.mockReset() mockCodexSession.setModel.mockReset() mockCodexSession.setModelReasoningEffort.mockReset() + mockCodexSession.setServiceTier.mockReset() mockCodexSession.setCollaborationMode.mockReset() lifecycleMock.registerProcessHandlers.mockClear() lifecycleMock.cleanupAndExit.mockClear() @@ -140,6 +146,61 @@ describe('runCodex', () => { expect(mockCodexSession.setCollaborationMode).toHaveBeenLastCalledWith('plan') }) + it('preserves a persisted Fast service tier on startup', async () => { + harness.sessionInfo = { serviceTier: 'fast' } + + await runCodexImpl({ + existingSessionId: 'hapi-session-1', + workingDirectory: '/tmp/project', + resumeSessionId: 'codex-thread-1' + } as Parameters[0]) + + // The first keepalive sync must re-assert Fast, not collapse it. + expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('fast') + expect(mockCodexSession.setServiceTier).not.toHaveBeenCalledWith(null) + }) + + it('keeps an explicit Standard service tier sticky on startup', async () => { + harness.sessionInfo = { serviceTier: 'standard' } + + await runCodexImpl({ + existingSessionId: 'hapi-session-1', + workingDirectory: '/tmp/project', + resumeSessionId: 'codex-thread-1' + } as Parameters[0]) + + // Explicit Standard must survive resume (not be dropped to untouched), + // so later turns keep sending app-server serviceTier: null. + expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('standard') + }) + + it('prefers the spawn-time service tier override when resuming (hub passes Fast)', async () => { + // On resume the hub spawns a fresh session (serviceTier null in the new + // row) and passes the old tier via opts; the override must win so the + // resumed thread immediately runs Fast. + harness.sessionInfo = { serviceTier: null } + + await runCodexImpl({ + workingDirectory: '/tmp/project', + resumeSessionId: 'codex-thread-1', + serviceTier: 'fast' + } as Parameters[0]) + + expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('fast') + }) + + it('does not collapse an untouched service tier into explicit Standard on startup', async () => { + harness.sessionInfo = { serviceTier: null } + + await runCodexImpl({ + workingDirectory: '/tmp/project' + } as Parameters[0]) + + // Untouched (account-default) sessions must omit the tier entirely so + // the keepalive never persists serviceTier: null over the default. + expect(mockCodexSession.setServiceTier).not.toHaveBeenCalled() + }) + it('replays transcript history when attaching a new Hapi session to an existing Codex thread', async () => { await runCodexImpl({ workingDirectory: '/tmp/project', diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index bda6f08c..4958b6ac 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -31,6 +31,7 @@ export async function runCodex(opts: { resumeSessionId?: string; model?: string; modelReasoningEffort?: ReasoningEffort; + serviceTier?: string; collaborationMode?: EnhancedMode['collaborationMode']; existingSessionId?: string; workingDirectory?: string; @@ -58,7 +59,7 @@ export async function runCodex(opts: { model: opts.model, modelReasoningEffort: opts.modelReasoningEffort }); - const { api, session } = bootstrap; + const { api, session, sessionInfo } = bootstrap; const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local'; @@ -68,7 +69,8 @@ export async function runCodex(opts: { permissionMode: mode.permissionMode, model: mode.model, modelReasoningEffort: mode.modelReasoningEffort, - collaborationMode: mode.collaborationMode + collaborationMode: mode.collaborationMode, + serviceTier: mode.serviceTier })); const codexCliOverrides = parseCodexCliOverrides(opts.codexArgs); @@ -81,6 +83,13 @@ export async function runCodex(opts: { let currentModel = opts.model; let currentModelReasoningEffort: ReasoningEffort | undefined = opts.modelReasoningEffort; let currentCollaborationMode: EnhancedMode['collaborationMode'] = opts.collaborationMode ?? 'default'; + // Service tier (Fast mode), stored representation: `'fast'` and + // `'standard'` are explicit user choices, `undefined`/`null` mean untouched + // (use the account default). Prefer the spawn-time override (set by the hub + // when resuming a session, mirroring model/effort) so a resumed Fast/Standard + // thread immediately runs with the right tier; otherwise seed from the + // persisted session. A persisted/absent `null` stays untouched (omitted). + let currentServiceTier: string | null | undefined = opts.serviceTier ?? sessionInfo.serviceTier ?? undefined; const lifecycle = createRunnerLifecycle({ session, @@ -102,6 +111,12 @@ export async function runCodex(opts: { sessionInstance.setModel(currentModel ?? null); } sessionInstance.setModelReasoningEffort(currentModelReasoningEffort ?? null); + // Preserve the third state: only sync when the user/persisted session + // has an explicit tier. `undefined` means "omit" so the keepalive does + // not overwrite the account-default or persisted Fast tier with null. + if (currentServiceTier !== undefined) { + sessionInstance.setServiceTier(currentServiceTier); + } sessionInstance.setCollaborationMode(currentCollaborationMode); logger.debug( `[Codex] Synced session config for keepalive: ` + @@ -115,6 +130,7 @@ export async function runCodex(opts: { model?: string | null; modelReasoningEffort?: ReasoningEffort | null; collaborationMode?: EnhancedMode['collaborationMode']; + serviceTier?: string | null; } | undefined): void => { if (!updates) return; if (updates.permissionMode !== undefined) { @@ -129,6 +145,9 @@ export async function runCodex(opts: { if (updates.collaborationMode !== undefined) { currentCollaborationMode = updates.collaborationMode; } + if (updates.serviceTier !== undefined) { + currentServiceTier = updates.serviceTier; + } applyCurrentConfigToSession(); }; @@ -149,6 +168,10 @@ export async function runCodex(opts: { if (sessionCollaborationMode) { currentCollaborationMode = sessionCollaborationMode; } + const sessionServiceTier = sessionWrapperRef.current?.getServiceTier(); + if (sessionServiceTier !== undefined) { + currentServiceTier = sessionServiceTier; + } }; let userMessageChain: Promise = Promise.resolve(); @@ -164,7 +187,8 @@ export async function runCodex(opts: { permissionMode: currentPermissionMode, collaborationMode: currentCollaborationMode, model: currentModel, - modelReasoningEffort: currentModelReasoningEffort + modelReasoningEffort: currentModelReasoningEffort, + serviceTier: currentServiceTier }); if (slash.kind === 'goal') { if (slash.message) { @@ -183,7 +207,8 @@ export async function runCodex(opts: { permissionMode: currentPermissionMode ?? 'default', model: currentModel, modelReasoningEffort: currentModelReasoningEffort, - collaborationMode: currentCollaborationMode + collaborationMode: currentCollaborationMode, + serviceTier: currentServiceTier }, localId); return; } @@ -221,7 +246,8 @@ export async function runCodex(opts: { permissionMode: messagePermissionMode ?? 'default', model: currentModel, modelReasoningEffort: currentModelReasoningEffort, - collaborationMode: currentCollaborationMode + collaborationMode: currentCollaborationMode, + serviceTier: currentServiceTier }; if (isolatedCommandText) { messageQueue.pushIsolateAndClear(isolatedCommandText, enhancedMode, localId); @@ -234,7 +260,8 @@ export async function runCodex(opts: { permissionMode: currentPermissionMode ?? 'default', model: currentModel, modelReasoningEffort: currentModelReasoningEffort, - collaborationMode: currentCollaborationMode + collaborationMode: currentCollaborationMode, + serviceTier: currentServiceTier }; messageQueue.push(formatMessageWithAttachments(message.content.text, message.content.attachments), enhancedMode, localId); } @@ -297,11 +324,33 @@ export async function runCodex(opts: { return trimmedValue; }; + // Stored representation: `'fast'` and `'standard'` are explicit user + // choices; `null` means untouched (use the account default). The + // `'standard'` sentinel is only translated to the Codex app-server's + // `serviceTier: null` when building thread/turn params — see + // appServerConfig — so an explicit Fast-off stays sticky across resume. + const resolveServiceTier = (value: unknown): string | null => { + if (value === null) { + return null; + } + if (typeof value !== 'string') { + throw new Error('Invalid service tier'); + } + const trimmedValue = value.trim().toLowerCase(); + if (trimmedValue === 'fast' || trimmedValue === 'standard') { + return trimmedValue; + } + if (!trimmedValue || trimmedValue === 'default' || trimmedValue === 'auto') { + return null; + } + throw new Error('Invalid service tier'); + }; + session.rpcHandlerManager.registerHandler(RPC_METHODS.SetSessionConfig, async (payload: unknown) => { if (!payload || typeof payload !== 'object') { throw new Error('Invalid session config payload'); } - const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown; collaborationMode?: unknown }; + const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown; collaborationMode?: unknown; serviceTier?: unknown }; if (config.permissionMode !== undefined) { currentPermissionMode = resolvePermissionMode(config.permissionMode); @@ -320,16 +369,22 @@ export async function runCodex(opts: { currentCollaborationMode = resolveCollaborationMode(config.collaborationMode); } + if (config.serviceTier !== undefined) { + currentServiceTier = resolveServiceTier(config.serviceTier); + } + applyCurrentConfigToSession({ syncModel: shouldSyncModel }); const applied: { permissionMode: PermissionMode; model?: string | null; modelReasoningEffort: ReasoningEffort | null; collaborationMode: EnhancedMode['collaborationMode']; + serviceTier: string | null; } = { permissionMode: currentPermissionMode, modelReasoningEffort: currentModelReasoningEffort ?? null, - collaborationMode: currentCollaborationMode + collaborationMode: currentCollaborationMode, + serviceTier: currentServiceTier ?? null }; if (shouldSyncModel) { applied.model = currentModel ?? null; diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index 3a08c554..378cec11 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -137,6 +137,82 @@ describe('appServerConfig', () => { }); }); + it('translates Fast to the advertised app-server tier (priority) in thread params', () => { + const params = buildThreadStartParams({ + cwd: '/workspace/project', + mode: { permissionMode: 'default', collaborationMode: 'default', serviceTier: 'fast' }, + mcpServers + }); + + expect(params.serviceTier).toBe('priority'); + }); + + it('translates explicit Standard to app-server null in thread params', () => { + const params = buildThreadStartParams({ + cwd: '/workspace/project', + mode: { permissionMode: 'default', collaborationMode: 'default', serviceTier: 'standard' }, + mcpServers + }); + + expect(params.serviceTier).toBeNull(); + }); + + it('omits service tier from thread params when untouched (undefined or null)', () => { + const undefinedParams = buildThreadStartParams({ + cwd: '/workspace/project', + mode: { permissionMode: 'default', collaborationMode: 'default' }, + mcpServers + }); + expect('serviceTier' in undefinedParams).toBe(false); + + const nullParams = buildThreadStartParams({ + cwd: '/workspace/project', + mode: { permissionMode: 'default', collaborationMode: 'default', serviceTier: null }, + mcpServers + }); + expect('serviceTier' in nullParams).toBe(false); + }); + + it('translates Fast to the advertised app-server tier (priority) in turn params', () => { + const params = buildTurnStartParams({ + threadId: 'thread-1', + message: 'hello', + cwd: '/workspace/project', + mode: { permissionMode: 'default', model: 'gpt-5.5', collaborationMode: 'default', serviceTier: 'fast' } + }); + + expect(params.serviceTier).toBe('priority'); + }); + + it('translates explicit Standard to app-server null in turn params', () => { + const params = buildTurnStartParams({ + threadId: 'thread-1', + message: 'hello', + cwd: '/workspace/project', + mode: { permissionMode: 'default', model: 'gpt-5.5', collaborationMode: 'default', serviceTier: 'standard' } + }); + + expect(params.serviceTier).toBeNull(); + }); + + it('omits service tier from turn params when untouched (undefined or null)', () => { + const undefinedParams = buildTurnStartParams({ + threadId: 'thread-1', + message: 'hello', + cwd: '/workspace/project', + mode: { permissionMode: 'default', model: 'gpt-5.5', collaborationMode: 'default' } + }); + expect('serviceTier' in undefinedParams).toBe(false); + + const nullParams = buildTurnStartParams({ + threadId: 'thread-1', + message: 'hello', + cwd: '/workspace/project', + mode: { permissionMode: 'default', model: 'gpt-5.5', collaborationMode: 'default', serviceTier: null } + }); + expect('serviceTier' in nullParams).toBe(false); + }); + it('builds turn params with mode defaults', () => { const params = buildTurnStartParams({ threadId: 'thread-1', diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 84f8d813..9a694133 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -48,6 +48,30 @@ function resolveSandboxPolicyOverride(value: CodexCliOverrides['sandbox'] | unde } } +// The Codex model catalog advertises the Fast tier with request id `'priority'` +// (display name "Fast"); OpenAI's docs confirm the legacy `service_tier = "fast"` +// maps to the request value `priority`. The app-server `serviceTier` override is +// a raw request value and does not validate unknown strings, so sending `'fast'` +// would be silently ignored — we must send the advertised `'priority'` id. +const APP_SERVER_FAST_TIER = 'priority'; + +/** + * Translate HAPI's stored service-tier representation into the Codex + * app-server `serviceTier` field for thread/turn params: + * - `'fast'` → `'priority'` (the advertised Fast tier request value) + * - `'standard'` → `null` (explicit Standard tier) + * - anything else / untouched → `undefined` (omit; use account default) + */ +function toAppServerServiceTier(stored: string | null | undefined): string | null | undefined { + if (stored === 'fast') { + return APP_SERVER_FAST_TIER; + } + if (stored === 'standard') { + return null; + } + return undefined; +} + export function supportsReasoningSummary(model: string | undefined): boolean { const normalized = model?.trim().toLowerCase(); if (!normalized) return true; @@ -126,6 +150,11 @@ export function buildThreadStartParams(args: { params.model = args.mode.model; } + const threadServiceTier = toAppServerServiceTier(args.mode.serviceTier); + if (threadServiceTier !== undefined) { + params.serviceTier = threadServiceTier; + } + return params; } @@ -196,5 +225,10 @@ export function buildTurnStartParams(args: { params.model = model; } + const turnServiceTier = toAppServerServiceTier(args.mode?.serviceTier); + if (turnServiceTier !== undefined) { + params.serviceTier = turnServiceTier; + } + return params; } diff --git a/cli/src/codex/utils/slashCommands.test.ts b/cli/src/codex/utils/slashCommands.test.ts index 4a5dbbfa..aac27c18 100644 --- a/cli/src/codex/utils/slashCommands.test.ts +++ b/cli/src/codex/utils/slashCommands.test.ts @@ -46,6 +46,45 @@ describe('resolveCodexSlashCommand', () => { }); }); + it('enables Codex fast mode', () => { + expect(resolveCodexSlashCommand('/fast', state)).toEqual({ + kind: 'handled', + message: 'Codex Fast mode enabled', + updates: { serviceTier: 'fast' } + }); + expect(resolveCodexSlashCommand('/fast on', state)).toEqual({ + kind: 'handled', + message: 'Codex Fast mode enabled', + updates: { serviceTier: 'fast' } + }); + }); + + it('disables Codex fast mode with an explicit standard tier', () => { + expect(resolveCodexSlashCommand('/fast off', { ...state, serviceTier: 'fast' })).toEqual({ + kind: 'handled', + message: 'Codex Fast mode disabled', + updates: { serviceTier: 'standard' } + }); + }); + + it('shows Codex fast mode status', () => { + expect(resolveCodexSlashCommand('/fast status', { ...state, serviceTier: 'fast' })).toEqual({ + kind: 'handled', + message: 'Codex Fast mode: on' + }); + expect(resolveCodexSlashCommand('/fast status', state)).toEqual({ + kind: 'handled', + message: 'Codex Fast mode: off' + }); + }); + + it('rejects unknown Codex fast mode arguments', () => { + expect(resolveCodexSlashCommand('/fast turbo', state)).toEqual({ + kind: 'handled', + message: 'Usage: /fast [on|off|status]' + }); + }); + it('resolves Codex goal commands for native handling', () => { expect(resolveCodexSlashCommand('/goal', state)).toEqual({ kind: 'goal', diff --git a/cli/src/codex/utils/slashCommands.ts b/cli/src/codex/utils/slashCommands.ts index cfebbe59..62c714a7 100644 --- a/cli/src/codex/utils/slashCommands.ts +++ b/cli/src/codex/utils/slashCommands.ts @@ -32,6 +32,7 @@ export type CodexSlashResolution = permissionMode?: CodexPermissionMode; model?: string | null; modelReasoningEffort?: ReasoningEffort | null; + serviceTier?: string | null; }; } | { @@ -43,6 +44,7 @@ export type CodexSlashResolution = permissionMode?: CodexPermissionMode; model?: string | null; modelReasoningEffort?: ReasoningEffort | null; + serviceTier?: string | null; }; } | { @@ -60,6 +62,7 @@ export function resolveCodexSlashCommand( collaborationMode: EnhancedMode['collaborationMode']; model?: string; modelReasoningEffort?: ReasoningEffort; + serviceTier?: string | null; } ): CodexSlashResolution { const match = /^\s*\/([a-z0-9:_-]+)(?:\s+([\s\S]*))?$/i.exec(text); @@ -196,6 +199,35 @@ export function resolveCodexSlashCommand( }; } + if (command === 'fast') { + const arg = rest.toLowerCase(); + if (arg === '' || arg === 'on') { + return { + kind: 'handled', + message: 'Codex Fast mode enabled', + updates: { serviceTier: 'fast' } + }; + } + if (arg === 'off') { + return { + kind: 'handled', + message: 'Codex Fast mode disabled', + updates: { serviceTier: 'standard' } + }; + } + if (arg === 'status') { + const on = state.serviceTier === 'fast'; + return { + kind: 'handled', + message: `Codex Fast mode: ${on ? 'on' : 'off'}` + }; + } + return { + kind: 'handled', + message: 'Usage: /fast [on|off|status]' + }; + } + if (command === 'permissions' || command === 'permission') { if (!rest) { return { kind: 'handled', message: `Codex permission mode: ${state.permissionMode}` }; @@ -228,6 +260,7 @@ export function resolveCodexSlashCommand( '- `/status` — show current Codex session config', '- `/model [name|auto]` — show or set model', '- `/reasoning [low|medium|high|xhigh|default]` — show or set reasoning effort', + '- `/fast [on|off|status]` — toggle Fast mode (GPT-5.5 / GPT-5.4, ChatGPT login)', '- `/permissions [default|read-only|safe-yolo|yolo]` — show or set permission mode', '', 'Custom `/commands` from `.codex/prompts` are expanded before sending.' diff --git a/cli/src/commands/codex.test.ts b/cli/src/commands/codex.test.ts index a1182287..7e04fc33 100644 --- a/cli/src/commands/codex.test.ts +++ b/cli/src/commands/codex.test.ts @@ -80,6 +80,33 @@ describe('codexCommand', () => { }) }) + it('forwards a valid --service-tier to runCodex', async () => { + await codexCommand.run(createCommandContext(['--started-by', 'runner', '--service-tier', 'fast'])) + + expect(runCodexMock).toHaveBeenCalledWith({ + startedBy: 'runner', + serviceTier: 'fast' + }) + }) + + it('rejects an unsupported --service-tier value', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 'undefined'}`) + }) as never) + + try { + await expect( + codexCommand.run(createCommandContext(['--started-by', 'runner', '--service-tier', 'turbo'])) + ).rejects.toThrow('process.exit:1') + expect(runCodexMock).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), 'Invalid --service-tier value') + } finally { + consoleErrorSpy.mockRestore() + exitSpy.mockRestore() + } + }) + it('prints the upgrade error and exits when the local version check fails', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index 8db32de4..196adc94 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -22,6 +22,16 @@ function parseReasoningEffort(value: string): ReasoningEffort { } } +// Mirror the web /service-tier endpoint's enum so the internal resume spawn +// path can never seed/persist an unsupported tier string. +function parseServiceTier(value: string): 'fast' | 'standard' { + const normalized = value.trim().toLowerCase() + if (normalized === 'fast' || normalized === 'standard') { + return normalized + } + throw new Error('Invalid --service-tier value') +} + export const codexCommand: CommandDefinition = { name: 'codex', requiresRuntimeAssets: true, @@ -36,6 +46,7 @@ export const codexCommand: CommandDefinition = { resumeSessionId?: string model?: string modelReasoningEffort?: ReasoningEffort + serviceTier?: string } = {} const unknownArgs: string[] = [] let hasExplicitPermissionMode = false @@ -76,6 +87,12 @@ export const codexCommand: CommandDefinition = { throw new Error('Missing --model-reasoning-effort value') } options.modelReasoningEffort = parseReasoningEffort(effort) + } else if (arg === '--service-tier') { + const tier = commandArgs[++i] + if (!tier) { + throw new Error('Missing --service-tier value') + } + options.serviceTier = parseServiceTier(tier) } else { unknownArgs.push(arg) } diff --git a/cli/src/modules/common/codexModels.ts b/cli/src/modules/common/codexModels.ts index dadb4089..27d41859 100644 --- a/cli/src/modules/common/codexModels.ts +++ b/cli/src/modules/common/codexModels.ts @@ -30,6 +30,34 @@ function normalizeSupportedReasoningEfforts(value: unknown): string[] | undefine return efforts.length > 0 ? efforts : undefined; } +// The Codex model catalog advertises which service tiers are available for a +// model in the *current* account/auth context — e.g. an API-key session or a +// plan without Fast credits simply won't list a Fast tier. We surface the tier +// id AND display name as lowercased search tokens so the web can gate the +// Fast-mode toggle on real availability. The Fast tier's catalog id is +// `'priority'` but its name is `'Fast'`, so capturing the name is what lets a +// `/fast/i` match recognise it. (See OpenAI Codex speed docs: Fast maps to the +// request value `priority`.) +function normalizeServiceTiers(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const tokens = new Set(); + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + continue; + } + const record = entry as { id?: unknown; name?: unknown }; + const id = asNonEmptyString(record.id); + const name = asNonEmptyString(record.name); + if (id) tokens.add(id.toLowerCase()); + if (name) tokens.add(name.toLowerCase()); + } + + return tokens.size > 0 ? [...tokens] : undefined; +} + function normalizeModel(entry: unknown): CodexModelSummary | null { if (!entry || typeof entry !== 'object') { return null; @@ -46,7 +74,8 @@ function normalizeModel(entry: unknown): CodexModelSummary | null { displayName: asNonEmptyString(record.displayName) ?? id, isDefault: record.isDefault === true, defaultReasoningEffort: asNonEmptyString(record.defaultReasoningEffort), - supportedReasoningEfforts: normalizeSupportedReasoningEfforts(record.supportedReasoningEfforts) + supportedReasoningEfforts: normalizeSupportedReasoningEfforts(record.supportedReasoningEfforts), + serviceTiers: normalizeServiceTiers(record.serviceTiers) }; } diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 5e243eb8..11c5f076 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -12,6 +12,7 @@ export interface SpawnSessionOptions { modelReasoningEffort?: string yolo?: boolean permissionMode?: string + serviceTier?: string token?: string sessionType?: 'simple' | 'worktree' worktreeName?: string diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index 6d809b11..f80b6e22 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -71,6 +71,23 @@ describe('buildCliArgs', () => { expect(args).toContain('high') }) + it('passes --service-tier through for codex (resume preserves Fast/Standard)', () => { + const args = buildCliArgs('codex', { + directory: '/tmp', + serviceTier: 'fast', + }) + expect(args).toContain('--service-tier') + expect(args).toContain('fast') + }) + + it('does not pass --service-tier for non-codex agents', () => { + const args = buildCliArgs('claude', { + directory: '/tmp', + serviceTier: 'fast', + }) + expect(args).not.toContain('--service-tier') + }) + it('validates all known permission modes', () => { for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'read-only', 'safe-yolo', 'yolo']) { const args = buildCliArgs('claude', { diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index f49d3b15..9a19a87f 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1122,6 +1122,9 @@ export function buildCliArgs( if (options.modelReasoningEffort && (agent === 'codex' || agent === 'opencode')) { args.push('--model-reasoning-effort', options.modelReasoningEffort); } + if (options.serviceTier && agent === 'codex') { + args.push('--service-tier', options.serviceTier); + } if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) { args.push('--permission-mode', options.permissionMode); } else if (yolo) { diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts index b744deba..c2844388 100644 --- a/hub/src/notifications/notificationHub.test.ts +++ b/hub/src/notifications/notificationHub.test.ts @@ -71,6 +71,7 @@ function createSession(overrides: Partial = {}): Session { model: null, modelReasoningEffort: null, effort: null, + serviceTier: null, ...overrides } } diff --git a/hub/src/serverchan/channel.test.ts b/hub/src/serverchan/channel.test.ts index a7671447..b2fd6d4c 100644 --- a/hub/src/serverchan/channel.test.ts +++ b/hub/src/serverchan/channel.test.ts @@ -24,6 +24,7 @@ function createSession(overrides: Partial = {}): Session { model: null, modelReasoningEffort: null, effort: null, + serviceTier: null, ...overrides } } diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 67def89c..123af75a 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -22,6 +22,7 @@ type SessionAlivePayload = { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode } diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 95004905..b0b2c6b0 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -23,7 +23,7 @@ export { PushStore } from './pushStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION: number = 9 +const SCHEMA_VERSION: number = 10 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -123,6 +123,7 @@ export class Store { 6: () => this.migrateFromV6ToV7(), 7: () => this.migrateFromV7ToV8(), 8: () => this.migrateFromV8ToV9(), + 9: () => this.migrateFromV9ToV10(), }) if (currentVersion === 0) { @@ -184,6 +185,7 @@ export class Store { model TEXT, model_reasoning_effort TEXT, effort TEXT, + service_tier TEXT, todos TEXT, todos_updated_at INTEGER, team_state TEXT, @@ -425,6 +427,14 @@ export class Store { `) } + private migrateFromV9ToV10(): void { + const columns = this.getSessionColumnNames() + if (columns.size === 0) return + if (!columns.has('service_tier')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN service_tier TEXT') + } + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts index 4be487f3..0e18a859 100644 --- a/hub/src/store/sessionStore.ts +++ b/hub/src/store/sessionStore.ts @@ -11,6 +11,7 @@ import { setSessionEffort, setSessionModel, setSessionModelReasoningEffort, + setSessionServiceTier, setSessionTeamState, setSessionTodos, touchSessionUpdatedAt, @@ -81,6 +82,10 @@ export class SessionStore { return setSessionEffort(this.db, id, effort, namespace, options) } + setSessionServiceTier(id: string, serviceTier: string | null, namespace: string, options?: { touchUpdatedAt?: boolean }): boolean { + return setSessionServiceTier(this.db, id, serviceTier, namespace, options) + } + touchSessionUpdatedAt(id: string, updatedAt: number, namespace: string): boolean { return touchSessionUpdatedAt(this.db, id, updatedAt, namespace) } diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 44921403..537216c0 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -143,6 +143,7 @@ type DbSessionRow = { model: string | null model_reasoning_effort: string | null effort: string | null + service_tier: string | null todos: string | null todos_updated_at: number | null team_state: string | null @@ -167,6 +168,7 @@ function toStoredSession(row: DbSessionRow): StoredSession { model: row.model, modelReasoningEffort: row.model_reasoning_effort, effort: row.effort, + serviceTier: row.service_tier, todos: safeJsonParse(row.todos), todosUpdatedAt: row.todos_updated_at, teamState: safeJsonParse(row.team_state), @@ -446,6 +448,39 @@ export function setSessionModelReasoningEffort( } } +export function setSessionServiceTier( + db: Database, + id: string, + serviceTier: string | null, + namespace: string, + options?: { touchUpdatedAt?: boolean } +): boolean { + const now = Date.now() + const touchUpdatedAt = options?.touchUpdatedAt === true + + try { + const result = db.prepare(` + UPDATE sessions + SET service_tier = @service_tier, + updated_at = CASE WHEN @touch_updated_at = 1 THEN @updated_at ELSE updated_at END, + seq = seq + 1 + WHERE id = @id + AND namespace = @namespace + AND service_tier IS NOT @service_tier + `).run({ + id, + namespace, + service_tier: serviceTier, + updated_at: now, + touch_updated_at: touchUpdatedAt ? 1 : 0 + }) + + return result.changes === 1 + } catch { + return false + } +} + export function setSessionEffort( db: Database, id: string, diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index fb77d857..ae3f6482 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -12,6 +12,7 @@ export type StoredSession = { model: string | null modelReasoningEffort: string | null effort: string | null + serviceTier: string | null todos: unknown | null todosUpdatedAt: number | null teamState: unknown | null diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index f345e412..00af8472 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -49,6 +49,7 @@ function toProtocolSession(session: ReturnType): Session { model: session.model, modelReasoningEffort: session.modelReasoningEffort, effort: session.effort, + serviceTier: session.serviceTier, permissionMode: 'default', collaborationMode: 'default' } diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 8e038940..7811c901 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -117,13 +117,14 @@ export class RpcGateway { worktreeName?: string, resumeSessionId?: string, effort?: string, - permissionMode?: PermissionMode + permissionMode?: PermissionMode, + serviceTier?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { try { const result = await this.machineRpc( machineId, RPC_METHODS.SpawnHappySession, - { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode } + { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier } ) if (result && typeof result === 'object') { const obj = result as Record diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index ecfabd7e..b498cac0 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -7,7 +7,7 @@ import { extractTodoWriteTodosFromMessageContent, TodosSchema } from './todos' import { extractBackgroundTaskDelta } from './backgroundTasks' const QUEUED_MESSAGE_THINKING_GRACE_MS = 15_000 -type RuntimeConfigKey = 'permissionMode' | 'model' | 'modelReasoningEffort' | 'effort' | 'collaborationMode' +type RuntimeConfigKey = 'permissionMode' | 'model' | 'modelReasoningEffort' | 'effort' | 'serviceTier' | 'collaborationMode' export class SessionCache { private readonly sessions: Map = new Map() @@ -148,6 +148,7 @@ export class SessionCache { model: stored.model, modelReasoningEffort: stored.modelReasoningEffort, effort: stored.effort, + serviceTier: stored.serviceTier, permissionMode: existing?.permissionMode ?? metadata?.preferredPermissionMode, collaborationMode: existing?.collaborationMode } @@ -173,6 +174,7 @@ export class SessionCache { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode }): void { const t = clampAliveTime(payload.time) @@ -187,6 +189,7 @@ export class SessionCache { const previousModel = session.model const previousModelReasoningEffort = session.modelReasoningEffort const previousEffort = session.effort + const previousServiceTier = session.serviceTier const previousCollaborationMode = session.collaborationMode const pendingThinkingUntil = this.pendingThinkingUntilBySessionId.get(session.id) ?? 0 const requestedThinking = Boolean(payload.thinking) @@ -228,6 +231,14 @@ export class SessionCache { } session.effort = payload.effort } + if (payload.serviceTier !== undefined && !this.isStaleRuntimeKeepAlive(session.id, 'serviceTier', t)) { + if (payload.serviceTier !== session.serviceTier) { + this.store.sessions.setSessionServiceTier(payload.sid, payload.serviceTier, session.namespace, { + touchUpdatedAt: false + }) + } + session.serviceTier = payload.serviceTier + } if (payload.collaborationMode !== undefined && !this.isStaleRuntimeKeepAlive(session.id, 'collaborationMode', t)) { session.collaborationMode = payload.collaborationMode } @@ -238,6 +249,7 @@ export class SessionCache { || previousModel !== session.model || previousModelReasoningEffort !== session.modelReasoningEffort || previousEffort !== session.effort + || previousServiceTier !== session.serviceTier || previousCollaborationMode !== session.collaborationMode const shouldBroadcast = (!wasActive && session.active) || (wasThinking !== session.thinking) @@ -257,6 +269,7 @@ export class SessionCache { model: session.model, modelReasoningEffort: session.modelReasoningEffort, effort: session.effort, + serviceTier: session.serviceTier, collaborationMode: session.collaborationMode } satisfies SessionPatch }) @@ -407,6 +420,7 @@ export class SessionCache { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode } ): void { @@ -457,6 +471,18 @@ export class SessionCache { session.effort = config.effort this.markRuntimeConfigUpdated(sessionId, 'effort', appliedAt) } + if (config.serviceTier !== undefined) { + if (config.serviceTier !== session.serviceTier) { + const updated = this.store.sessions.setSessionServiceTier(sessionId, config.serviceTier, session.namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to update session service tier') + } + } + session.serviceTier = config.serviceTier + this.markRuntimeConfigUpdated(sessionId, 'serviceTier', appliedAt) + } if (config.collaborationMode !== undefined) { session.collaborationMode = config.collaborationMode this.markRuntimeConfigUpdated(sessionId, 'collaborationMode', appliedAt) @@ -751,6 +777,15 @@ export class SessionCache { } } + if (newStored.serviceTier === null && oldStored.serviceTier !== null) { + const updated = this.store.sessions.setSessionServiceTier(newSessionId, oldStored.serviceTier, namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to preserve session service tier during merge') + } + } + if (oldStored.todos !== null && oldStored.todosUpdatedAt !== null) { this.store.sessions.setSessionTodos( newSessionId, diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 4ec57f5a..5de15ce2 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -97,6 +97,31 @@ describe('session model', () => { expect(merged?.model).toBe('gpt-5.4') }) + it('preserves service tier from old session when merging into resumed session', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const oldSession = cache.getOrCreateSession( + 'session-tier-old', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + // Fast was selected on the original session before it was resumed. + store.sessions.setSessionServiceTier(oldSession.id, 'fast', 'default') + const newSession = cache.getOrCreateSession( + 'session-tier-new', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + await cache.mergeSessions(oldSession.id, newSession.id, 'default') + + expect(store.sessions.getSession(newSession.id)?.serviceTier).toBe('fast') + }) + it('persists applied session model updates, including clear-to-auto', () => { const store = new Store(':memory:') const events: SyncEvent[] = [] diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index a7f88c03..a1e73686 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -299,6 +299,7 @@ export class SyncEngine { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode }): void { this.sessionCache.handleSessionAlive(payload) @@ -620,6 +621,7 @@ export class SyncEngine { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode } ): Promise { @@ -643,6 +645,7 @@ export class SyncEngine { model?: Session['model'] modelReasoningEffort?: Session['modelReasoningEffort'] effort?: Session['effort'] + serviceTier?: Session['serviceTier'] collaborationMode?: Session['collaborationMode'] } } @@ -675,7 +678,8 @@ export class SyncEngine { worktreeName?: string, resumeSessionId?: string, effort?: string, - permissionMode?: PermissionMode + permissionMode?: PermissionMode, + serviceTier?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { return await this.rpcGateway.spawnSession( machineId, @@ -688,7 +692,8 @@ export class SyncEngine { worktreeName, resumeSessionId, effort, - permissionMode + permissionMode, + serviceTier ) } @@ -1141,7 +1146,8 @@ export class SyncEngine { undefined, resumeToken, session.effort ?? undefined, - preferredPermissionMode + preferredPermissionMode, + session.serviceTier ?? undefined ) if (spawnResult.type !== 'success') { diff --git a/hub/src/telegram/sessionView.test.ts b/hub/src/telegram/sessionView.test.ts index befeea52..a0567311 100644 --- a/hub/src/telegram/sessionView.test.ts +++ b/hub/src/telegram/sessionView.test.ts @@ -27,6 +27,7 @@ function createSession(overrides: Partial = {}): Session { model: null, modelReasoningEffort: null, effort: null, + serviceTier: null, ...overrides } } diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 6ec0be83..fc1a7032 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -31,6 +31,7 @@ function createSession(overrides?: Partial): Session { model: 'gpt-5.4', modelReasoningEffort: null, effort: null, + serviceTier: null, permissionMode: 'default', collaborationMode: 'default' } @@ -365,6 +366,71 @@ describe('sessions routes', () => { ]) }) + it('applies fast service tier changes for remote Codex sessions', async () => { + const { app, applySessionConfigCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/service-tier', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ serviceTier: 'fast' }) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { serviceTier: 'fast' }] + ]) + }) + + it('persists an explicit Standard service tier (distinct from untouched)', async () => { + const { app, applySessionConfigCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/service-tier', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ serviceTier: 'standard' }) + }) + + expect(response.status).toBe(200) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { serviceTier: 'standard' }] + ]) + }) + + it('rejects unsupported service tier values', async () => { + const { app, applySessionConfigCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/service-tier', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ serviceTier: 'turbo' }) + }) + + expect(response.status).toBe(400) + expect(applySessionConfigCalls).toEqual([]) + }) + + it('rejects service tier changes for local Codex sessions', async () => { + const { app, applySessionConfigCalls } = createApp( + createSession({ + agentState: { + controlledByUser: true, + requests: {}, + completedRequests: {} + } + }) + ) + + const response = await app.request('/api/sessions/session-1/service-tier', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ serviceTier: 'fast' }) + }) + + expect(response.status).toBe(409) + expect(applySessionConfigCalls).toEqual([]) + }) + it('applies model changes for remote Codex sessions', async () => { const { app, applySessionConfigCalls } = createApp(createSession()) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 3725a3bd..b6f84e66 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -8,6 +8,7 @@ import { SessionCollaborationModeRequestSchema, SessionEffortRequestSchema, SessionModelReasoningEffortRequestSchema, + SessionServiceTierRequestSchema, SessionModelRequestSchema, SessionPermissionModeRequestSchema, supportsModelChange, @@ -548,6 +549,42 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.post('/sessions/:id/service-tier', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'codex') { + return c.json({ error: 'Fast mode is only supported for Codex sessions' }, 400) + } + if (sessionResult.session.agentState?.controlledByUser === true) { + return c.json({ error: 'Fast mode can only be changed for remote sessions' }, 409) + } + + const body = await c.req.json().catch(() => null) + const parsed = SessionServiceTierRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + try { + await engine.applySessionConfig(sessionResult.sessionId, { + serviceTier: parsed.data.serviceTier + }) + return c.json({ ok: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to apply service tier' + return c.json({ error: message }, 409) + } + }) + app.patch('/sessions/:id', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 604e1a90..7262c618 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -141,6 +141,16 @@ export const SessionEffortRequestSchema = z.object({ export type SessionEffortRequest = z.infer +// Fast mode is an explicit two-way choice. `'standard'` (not `null`) is the +// stored sentinel for an explicit Fast-off so it stays distinct from +// "untouched" and survives restart/resume. Reject anything else so stray tier +// strings are never forwarded to the Codex app-server. +export const SessionServiceTierRequestSchema = z.object({ + serviceTier: z.enum(['fast', 'standard']) +}) + +export type SessionServiceTierRequest = z.infer + export const RenameSessionRequestSchema = z.object({ name: z.string().min(1).max(255) }) @@ -330,6 +340,8 @@ export type CodexModelSummary = { isDefault: boolean defaultReasoningEffort?: string | null supportedReasoningEfforts?: string[] + /** Service tier ids advertised for this model in the current auth/plan context (e.g. 'fast'). */ + serviceTiers?: string[] } export type CodexModelsResponse = { diff --git a/shared/src/resume.ts b/shared/src/resume.ts index 4c75c2cd..84af8af5 100644 --- a/shared/src/resume.ts +++ b/shared/src/resume.ts @@ -15,6 +15,7 @@ export const LocalResumeTargetSchema = z.object({ model: z.string().nullable().optional(), effort: z.string().nullable().optional(), modelReasoningEffort: z.string().nullable().optional(), + serviceTier: z.string().nullable().optional(), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional() }) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 22a6fd51..f1c7f271 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -214,6 +214,7 @@ export const SessionSchema = z.object({ model: z.string().nullable().optional().default(null), modelReasoningEffort: z.string().nullable().optional().default(null), effort: z.string().nullable().optional().default(null), + serviceTier: z.string().nullable().optional().default(null), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional() }) @@ -228,6 +229,7 @@ export const SessionPatchSchema = z.object({ model: z.string().nullable().optional(), modelReasoningEffort: z.string().nullable().optional(), effort: z.string().nullable().optional(), + serviceTier: z.string().nullable().optional(), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional(), backgroundTaskCount: z.number().optional() diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index a0c8845a..8698896f 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -18,6 +18,7 @@ function makeSession(overrides: Partial = {}): Session { model: null, modelReasoningEffort: null, effort: null, + serviceTier: null, ...overrides } } diff --git a/shared/src/socket.ts b/shared/src/socket.ts index 37e1b222..9d6e2e08 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -210,6 +210,7 @@ export interface ClientToServerEvents { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode }) => void 'session-end': (data: { sid: string; time: number; reason?: SessionEndReason }) => void diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 488e7ea2..ba33a892 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -518,6 +518,13 @@ export class ApiClient { }) } + async setServiceTier(sessionId: string, serviceTier: string | null): Promise { + await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/service-tier`, { + method: 'POST', + body: JSON.stringify({ serviceTier }) + }) + } + async approvePermission( sessionId: string, requestId: string, diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index d0bf4634..f5da9473 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -101,6 +101,10 @@ export function HappyComposer(props: { onModelEffortChange?: (wireId: string | null) => void onModelReasoningEffortChange?: (modelReasoningEffort: string | null) => void onEffortChange?: (effort: string | null) => void + /** Codex Fast mode (service tier): current value ('fast' or null/standard). */ + serviceTier?: string | null + /** When provided, a Fast-mode toggle renders (Codex GPT-5.5 / GPT-5.4 only). */ + onServiceTierChange?: (serviceTier: string | null) => void onSwitchToRemote?: () => void onTerminal?: () => void terminalUnsupported?: boolean @@ -158,6 +162,8 @@ export function HappyComposer(props: { onModelEffortChange, onModelReasoningEffortChange, onEffortChange, + serviceTier: rawServiceTier, + onServiceTierChange, onSwitchToRemote, onTerminal, terminalUnsupported = false, @@ -180,6 +186,7 @@ export function HappyComposer(props: { const model = rawModel ?? null const modelReasoningEffort = rawModelReasoningEffort ?? null const effort = rawEffort ?? null + const serviceTier = rawServiceTier ?? null const api = useAssistantApi() const { composerEnterBehavior } = useComposerEnterBehavior() @@ -615,6 +622,20 @@ export function HappyComposer(props: { haptic('light') }, [onEffortChange, controlsDisabled, haptic]) + const handleServiceTierChange = useCallback((nextServiceTier: string | null) => { + if (!onServiceTierChange || controlsDisabled) return + onServiceTierChange(nextServiceTier) + setShowSettings(false) + haptic('light') + }, [onServiceTierChange, controlsDisabled, haptic]) + + // 'standard' (not null) is the explicit Fast-off choice so it persists + // distinctly from an untouched/account-default session. + const fastModeOptions: Array<{ value: string; label: string }> = useMemo(() => [ + { value: 'standard', label: t('misc.fastModeStandard') }, + { value: 'fast', label: t('misc.fastModeFast') } + ], [t]) + const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor) && modelOptions.length > 0) @@ -625,6 +646,7 @@ export function HappyComposer(props: { ) const showModelReasoningEffortSettings = Boolean(onModelReasoningEffortChange && codexReasoningEffortOptions.length > 0) const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor)) + const showFastModeSettings = Boolean(onServiceTierChange) const showSettingsButton = Boolean( showCollaborationSettings || showPermissionSettings @@ -632,6 +654,7 @@ export function HappyComposer(props: { || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings + || showFastModeSettings ) const showAbortButton = true const voiceEnabled = Boolean(onVoiceToggle) @@ -651,7 +674,7 @@ export function HappyComposer(props: { }, [api]) const overlays = useMemo(() => { - if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings)) { + if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) { return (
@@ -901,6 +924,47 @@ export function HappyComposer(props: { ))}
) : null} + + {(showModelReasoningEffortSettings || showEffortSettings) && showFastModeSettings ? ( +
+ ) : null} + + {showFastModeSettings ? ( +
+
+ {t('misc.fastMode')} +
+ {fastModeOptions.map((option) => ( + + ))} +
+ ) : null}
) @@ -932,9 +996,11 @@ export function HappyComposer(props: { selectedModelVariant, showModelReasoningEffortSettings, showEffortSettings, + showFastModeSettings, modelOptions, codexReasoningEffortOptions, claudeEffortOptions, + fastModeOptions, suggestions, selectedIndex, controlsDisabled, @@ -943,6 +1009,7 @@ export function HappyComposer(props: { model, modelReasoningEffort, effort, + serviceTier, collaborationModeOptions, permissionModeOptions, handleCollaborationChange, @@ -950,6 +1017,7 @@ export function HappyComposer(props: { handleModelChange, handleModelReasoningEffortChange, handleEffortChange, + handleServiceTierChange, handleSuggestionSelect, t ]) @@ -971,6 +1039,7 @@ export function HappyComposer(props: { contextWindow={contextWindow} model={model} modelReasoningEffort={modelReasoningEffort} + serviceTier={serviceTier} permissionMode={permissionMode} collaborationMode={collaborationMode} threadGoal={threadGoal} diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 3f974a8b..7032e7b6 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -10,6 +10,7 @@ import type { AgentState, CodexCollaborationMode, PermissionMode } from '@/types import type { ConversationStatus } from '@/realtime/types' import type { ThreadGoal } from '@/types/api' import { getContextBudgetTokens } from '@/chat/modelConfig' +import { isFastServiceTier } from './codexFastMode' import { useTranslation } from '@/lib/use-translation' // Vibing messages for thinking state @@ -154,6 +155,7 @@ export function StatusBar(props: { contextWindow?: number | null model?: string | null modelReasoningEffort?: string | null + serviceTier?: string | null permissionMode?: PermissionMode collaborationMode?: CodexCollaborationMode threadGoal?: ThreadGoal | null @@ -213,8 +215,12 @@ export function StatusBar(props: { const codexReasoningLabel = (props.agentFlavor === 'codex' || props.agentFlavor === 'opencode') ? formatCodexReasoningLabel(props.modelReasoningEffort) : null + // Prefer the explicit service tier (the real Fast-mode toggle) when set; + // fall back to the effort/model heuristic only when the tier is unknown. const codexFastMode = props.agentFlavor === 'codex' - ? isCodexFastMode(props.model, props.modelReasoningEffort) + ? (props.serviceTier != null + ? isFastServiceTier(props.serviceTier) + : isCodexFastMode(props.model, props.modelReasoningEffort)) : false const goalLabel = props.agentFlavor === 'codex' && props.threadGoal ? props.threadGoal.status === 'active' diff --git a/web/src/components/AssistantChat/codexFastMode.test.ts b/web/src/components/AssistantChat/codexFastMode.test.ts new file mode 100644 index 00000000..88baf15f --- /dev/null +++ b/web/src/components/AssistantChat/codexFastMode.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { codexModelAdvertisesFastTier, isFastServiceTier } from './codexFastMode' + +// Mirrors the real Codex catalog: the Fast tier's id is 'priority' and its +// display name is 'Fast', so the CLI captures both as lowercased tokens +// (['priority','fast']). Models without Fast advertise no such token. +const models = [ + { id: 'gpt-5.5', isDefault: true, serviceTiers: ['priority', 'fast'] }, + { id: 'gpt-5.4-mini', isDefault: false, serviceTiers: [] }, + { id: 'o3', isDefault: false } +] + +describe('codexModelAdvertisesFastTier', () => { + it('is true when the active model advertises a fast tier (real id=priority, name=Fast)', () => { + expect(codexModelAdvertisesFastTier('gpt-5.5', models)).toBe(true) + }) + + it('falls back to the catalog default model when session model is auto/null', () => { + // default model (gpt-5.5) advertises fast + expect(codexModelAdvertisesFastTier(null, models)).toBe(true) + expect(codexModelAdvertisesFastTier(undefined, models)).toBe(true) + expect(codexModelAdvertisesFastTier(' ', models)).toBe(true) + }) + + it('is false when the active model does not advertise a fast tier', () => { + expect(codexModelAdvertisesFastTier('gpt-5.4-mini', models)).toBe(false) + expect(codexModelAdvertisesFastTier('o3', models)).toBe(false) + }) + + it('is false when the model is unknown or the catalog is empty', () => { + expect(codexModelAdvertisesFastTier('gpt-9', models)).toBe(false) + expect(codexModelAdvertisesFastTier('gpt-5.5', [])).toBe(false) + }) + + it('matches fast tokens case-insensitively', () => { + expect(codexModelAdvertisesFastTier('m', [{ id: 'm', isDefault: true, serviceTiers: ['Fast'] }])).toBe(true) + }) +}) + +describe('isFastServiceTier', () => { + it('detects the fast tier regardless of casing/spacing', () => { + expect(isFastServiceTier('fast')).toBe(true) + expect(isFastServiceTier(' Fast ')).toBe(true) + }) + + it('treats null/standard as not fast', () => { + expect(isFastServiceTier(null)).toBe(false) + expect(isFastServiceTier(undefined)).toBe(false) + expect(isFastServiceTier('standard')).toBe(false) + }) +}) diff --git a/web/src/components/AssistantChat/codexFastMode.ts b/web/src/components/AssistantChat/codexFastMode.ts new file mode 100644 index 00000000..7d467d69 --- /dev/null +++ b/web/src/components/AssistantChat/codexFastMode.ts @@ -0,0 +1,51 @@ +// Codex Fast mode (service tier) availability is advertised per-model by the +// Codex app-server `model/list` catalog, which is resolved server-side from the +// user's account/auth/plan. An API-key session (no Fast credits) or a model +// without Fast support simply won't list a `fast` service tier. Gating the UI on +// this catalog signal — rather than a model-name heuristic — means the toggle +// only appears when toggling it will actually do something. + +type CodexModelCatalogEntry = { + id: string + isDefault: boolean + serviceTiers?: string[] +} + +function isFastTierId(tierId: string): boolean { + return /fast/i.test(tierId.trim()) +} + +/** + * Resolve the catalog entry for the session's active model. A null/empty + * session model means "auto" — the catalog's default model is active. + */ +function findActiveModel( + sessionModel: string | null | undefined, + models: ReadonlyArray +): T | undefined { + const normalized = sessionModel?.trim().toLowerCase() + if (normalized) { + return models.find((model) => model.id.trim().toLowerCase() === normalized) + } + return models.find((model) => model.isDefault) +} + +/** + * True when the session's active Codex model advertises a Fast service tier in + * the current auth/plan context. Returns false when the catalog is empty/not + * yet loaded so the toggle stays hidden until we have an authoritative answer. + */ +export function codexModelAdvertisesFastTier( + sessionModel: string | null | undefined, + models: ReadonlyArray +): boolean { + if (models.length === 0) { + return false + } + const active = findActiveModel(sessionModel, models) + return Boolean(active?.serviceTiers?.some(isFastTierId)) +} + +export function isFastServiceTier(serviceTier?: string | null): boolean { + return serviceTier?.trim().toLowerCase() === 'fast' +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 8aa2f6cc..351a017f 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -20,6 +20,7 @@ import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@ import { isQueuedForInvocation, mergeMessages } from '@/lib/messages' import { inactiveSessionCanResume } from '@/lib/sessionResume' import { HappyComposer, type ComposerSendError } from '@/components/AssistantChat/HappyComposer' +import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode' import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' import { resolvePendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' import { HappyThread } from '@/components/AssistantChat/HappyThread' @@ -559,7 +560,8 @@ function SessionChatInner(props: SessionChatProps) { setCollaborationMode, setModel, setModelReasoningEffort, - setEffort + setEffort, + setServiceTier } = useSessionActions( props.api, props.session.id, @@ -864,6 +866,17 @@ function SessionChatInner(props: SessionChatProps) { } }, [setEffort, props.onRefresh, haptic]) + const handleServiceTierChange = useCallback(async (serviceTier: string | null) => { + try { + await setServiceTier(serviceTier) + haptic.notification('success') + props.onRefresh() + } catch (e) { + haptic.notification('error') + console.error('Failed to set service tier:', e) + } + }, [setServiceTier, props.onRefresh, haptic]) + // Abort handler const handleAbort = useCallback(async () => { await abortSession() @@ -1162,6 +1175,16 @@ function SessionChatInner(props: SessionChatProps) { : undefined } onEffortChange={handleEffortChange} + serviceTier={agentFlavor === 'codex' ? props.session.serviceTier : undefined} + onServiceTierChange={ + agentFlavor === 'codex' + && props.session.active + && !controlledByUser + && !codexModelsState.error + && codexModelAdvertisesFastTier(props.session.model, codexModelsState.models) + ? handleServiceTierChange + : undefined + } onSwitchToRemote={handleSwitchToRemote} onTerminal={props.session.active && terminalSupported ? handleViewTerminal : undefined} terminalUnsupported={props.session.active && !terminalSupported} diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index fe96f37b..bdbb59f3 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -22,6 +22,7 @@ export function useSessionActions( setModel: (model: string | null) => Promise setModelReasoningEffort: (modelReasoningEffort: string | null) => Promise setEffort: (effort: string | null) => Promise + setServiceTier: (serviceTier: string | null) => Promise renameSession: (name: string) => Promise deleteSession: () => Promise isPending: boolean @@ -150,6 +151,22 @@ export function useSessionActions( onSuccess: () => void invalidateSession(), }) + const serviceTierMutation = useMutation({ + mutationFn: async (serviceTier: string | null) => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + if (agentFlavor !== 'codex') { + throw new Error('Fast mode is only supported for Codex sessions') + } + if (!codexCollaborationModeSupported) { + throw new Error('Fast mode is only supported for remote sessions') + } + await api.setServiceTier(sessionId, serviceTier) + }, + onSuccess: () => void invalidateSession(), + }) + const renameMutation = useMutation({ mutationFn: async (name: string) => { if (!api || !sessionId) { @@ -185,6 +202,7 @@ export function useSessionActions( setModel: modelMutation.mutateAsync, setModelReasoningEffort: modelReasoningEffortMutation.mutateAsync, setEffort: effortMutation.mutateAsync, + setServiceTier: serviceTierMutation.mutateAsync, renameSession: renameMutation.mutateAsync, deleteSession: deleteMutation.mutateAsync, isPending: abortMutation.isPending @@ -196,6 +214,7 @@ export function useSessionActions( || modelMutation.isPending || modelReasoningEffortMutation.isPending || effortMutation.isPending + || serviceTierMutation.isPending || renameMutation.isPending || deleteMutation.isPending, } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 5a0aba4e..d5f059c8 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -627,6 +627,9 @@ export default { 'misc.model': 'Model', 'misc.reasoningEffort': 'Reasoning Effort', 'misc.effort': 'Effort', + 'misc.fastMode': 'Fast Mode', + 'misc.fastModeStandard': 'Standard', + 'misc.fastModeFast': 'Fast', 'misc.variant': 'Variant', 'misc.loading': 'Loading…', 'misc.loadOlder': 'Load older', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index d3cecec5..5e63692e 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -631,6 +631,9 @@ export default { 'misc.model': '模型', 'misc.reasoningEffort': '推理强度', 'misc.effort': '思考强度', + 'misc.fastMode': '快速模式', + 'misc.fastModeStandard': '标准', + 'misc.fastModeFast': '快速', 'misc.variant': '变体', 'misc.loading': '加载中…', 'misc.loadOlder': '加载更早的', diff --git a/web/src/lib/sessionExport/markdown.test.ts b/web/src/lib/sessionExport/markdown.test.ts index a8627824..575565ed 100644 --- a/web/src/lib/sessionExport/markdown.test.ts +++ b/web/src/lib/sessionExport/markdown.test.ts @@ -28,6 +28,7 @@ function makeExport(messages: HapiSessionExport['messages']): HapiSessionExport model: null, modelReasoningEffort: null, effort: null, + serviceTier: null, permissionMode: 'default', collaborationMode: 'default' },