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' },