Skip to content

Commit c311afd

Browse files
authored
fix(codex): Fast mode (service tier) toggle + /fast command (closes tiann#898) (tiann#904)
* test: reproduce issue tiann#898 (Codex fast mode service tier) * fix(codex): add Fast mode (service tier) toggle and /fast command (closes tiann#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 tiann#898 * fix(codex): preserve unset/persisted service tier on startup keepalive Addresses HAPI Bot [Major] on PR tiann#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 tiann#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 tiann#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 tiann#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 tiann#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 tiann#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 tiann#898
1 parent 8526a94 commit c311afd

50 files changed

Lines changed: 931 additions & 22 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cli/src/agent/sessionBase.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type AgentSessionBaseOptions<Mode> = {
2626
model?: SessionModel;
2727
modelReasoningEffort?: SessionModelReasoningEffort;
2828
effort?: SessionEffort;
29+
serviceTier?: string | null;
2930
collaborationMode?: SessionCollaborationMode;
3031
};
3132

@@ -50,6 +51,7 @@ export class AgentSessionBase<Mode> {
5051
protected model?: SessionModel;
5152
protected modelReasoningEffort?: SessionModelReasoningEffort;
5253
protected effort?: SessionEffort;
54+
protected serviceTier?: string | null;
5355
protected collaborationMode?: SessionCollaborationMode;
5456

5557
constructor(opts: AgentSessionBaseOptions<Mode>) {
@@ -68,6 +70,7 @@ export class AgentSessionBase<Mode> {
6870
this.model = opts.model;
6971
this.modelReasoningEffort = opts.modelReasoningEffort;
7072
this.effort = opts.effort;
73+
this.serviceTier = opts.serviceTier;
7174
this.collaborationMode = opts.collaborationMode;
7275

7376
this.queue.onBatchConsumed = (localIds) => this.client.emitMessagesConsumed(localIds);
@@ -137,13 +140,15 @@ export class AgentSessionBase<Mode> {
137140
model?: SessionModel
138141
modelReasoningEffort?: SessionModelReasoningEffort
139142
effort?: SessionEffort
143+
serviceTier?: string | null
140144
collaborationMode?: SessionCollaborationMode
141145
} | undefined {
142146
if (
143147
this.permissionMode === undefined
144148
&& this.model === undefined
145149
&& this.modelReasoningEffort === undefined
146150
&& this.effort === undefined
151+
&& this.serviceTier === undefined
147152
&& this.collaborationMode === undefined
148153
) {
149154
return undefined;
@@ -153,6 +158,7 @@ export class AgentSessionBase<Mode> {
153158
model: this.model,
154159
modelReasoningEffort: this.modelReasoningEffort,
155160
effort: this.effort,
161+
serviceTier: this.serviceTier,
156162
collaborationMode: this.collaborationMode
157163
};
158164
}
@@ -173,6 +179,14 @@ export class AgentSessionBase<Mode> {
173179
return this.effort;
174180
}
175181

182+
getServiceTier(): string | null | undefined {
183+
return this.serviceTier;
184+
}
185+
186+
setServiceTier(serviceTier: string | null): void {
187+
this.serviceTier = serviceTier;
188+
}
189+
176190
getCollaborationMode(): SessionCollaborationMode | undefined {
177191
return this.collaborationMode;
178192
}

cli/src/agent/sessionFactory.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ function createSession(): Session {
7474
model: null,
7575
modelReasoningEffort: null,
7676
effort: null,
77+
serviceTier: null,
7778
permissionMode: undefined,
7879
collaborationMode: undefined
7980
}

cli/src/api/api.extraHeaders.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ describe('API extra headers integration', () => {
137137
model: null,
138138
modelReasoningEffort: null,
139139
effort: null,
140+
serviceTier: null,
140141
permissionMode: undefined,
141142
collaborationMode: undefined
142143
})

cli/src/api/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export class ApiClient {
9898
model: raw.model,
9999
modelReasoningEffort: raw.modelReasoningEffort,
100100
effort: raw.effort,
101+
serviceTier: raw.serviceTier,
101102
permissionMode: raw.permissionMode,
102103
collaborationMode: raw.collaborationMode
103104
}
@@ -147,6 +148,7 @@ export class ApiClient {
147148
model: raw.model,
148149
modelReasoningEffort: raw.modelReasoningEffort,
149150
effort: raw.effort,
151+
serviceTier: raw.serviceTier,
150152
permissionMode: raw.permissionMode,
151153
collaborationMode: raw.collaborationMode
152154
}

cli/src/api/apiMachine.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ export class ApiMachineClient {
249249

250250
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
251251
this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => {
252-
const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName } = params || {}
252+
const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName } = params || {}
253253

254254
if (!directory) {
255255
throw new Error('Directory is required')
@@ -272,6 +272,7 @@ export class ApiMachineClient {
272272
modelReasoningEffort,
273273
yolo,
274274
permissionMode,
275+
serviceTier,
275276
token,
276277
sessionType,
277278
worktreeName

cli/src/api/apiSession.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,7 @@ export class ApiSessionClient extends EventEmitter {
573573
model?: SessionModel
574574
modelReasoningEffort?: string | null
575575
effort?: string | null
576+
serviceTier?: string | null
576577
collaborationMode?: SessionCollaborationMode
577578
}
578579
): void {

cli/src/codex/appServerTypes.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export interface ModelListItem {
3434
description?: string;
3535
}>;
3636
defaultReasoningEffort?: string | null;
37+
serviceTiers?: Array<{
38+
id?: string;
39+
name?: string;
40+
description?: string;
41+
}>;
42+
defaultServiceTier?: string | null;
3743
isDefault?: boolean;
3844
[key: string]: unknown;
3945
}
@@ -63,6 +69,11 @@ export interface CollaborationModeListResponse {
6369
export interface ThreadStartParams {
6470
model?: string;
6571
modelProvider?: string;
72+
/**
73+
* Service tier override (e.g. 'fast'). `null` selects the standard tier
74+
* explicitly; omit to inherit the account/thread default.
75+
*/
76+
serviceTier?: string | null;
6677
cwd?: string;
6778
approvalPolicy?: ApprovalPolicy;
6879
sandbox?: SandboxMode;
@@ -161,6 +172,11 @@ export interface TurnStartParams {
161172
approvalPolicy?: ApprovalPolicy;
162173
sandboxPolicy?: SandboxPolicy;
163174
model?: string;
175+
/**
176+
* Service tier override for this turn and subsequent turns (e.g. 'fast').
177+
* `null` selects the standard tier explicitly; omit to leave it unchanged.
178+
*/
179+
serviceTier?: string | null;
164180
effort?: ReasoningEffort;
165181
summary?: ReasoningSummary;
166182
personality?: string;

cli/src/codex/loop.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export interface EnhancedMode {
1616
model?: string;
1717
collaborationMode: CodexCollaborationMode;
1818
modelReasoningEffort?: ReasoningEffort;
19+
/**
20+
* Service tier override. `undefined` leaves it untouched (account default),
21+
* `'fast'` enables Fast mode, `null` selects the standard tier explicitly.
22+
*/
23+
serviceTier?: string | null;
1924
}
2025

2126
interface LoopOptions {

cli/src/codex/runCodex.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ const mockCodexSession = vi.hoisted(() => ({
55
setPermissionMode: vi.fn(),
66
setModel: vi.fn(),
77
setModelReasoningEffort: vi.fn(),
8+
setServiceTier: vi.fn(),
89
setCollaborationMode: vi.fn(),
910
stopKeepAlive: vi.fn()
1011
}))
1112

1213
const harness = vi.hoisted(() => ({
1314
bootstrapArgs: [] as Array<Record<string, unknown>>,
1415
loopArgs: [] as Array<Record<string, unknown>>,
16+
sessionInfo: { serviceTier: null as string | null } as Record<string, unknown>,
1517
session: {
1618
onUserMessage: vi.fn(),
1719
onCancelQueuedMessage: vi.fn(),
@@ -26,14 +28,16 @@ vi.mock('@/agent/sessionFactory', () => ({
2628
harness.bootstrapArgs.push(options)
2729
return {
2830
api: {},
29-
session: harness.session
31+
session: harness.session,
32+
sessionInfo: harness.sessionInfo
3033
}
3134
}),
3235
bootstrapExistingSession: vi.fn(async (options: Record<string, unknown>) => {
3336
harness.bootstrapArgs.push(options)
3437
return {
3538
api: {},
36-
session: harness.session
39+
session: harness.session,
40+
sessionInfo: harness.sessionInfo
3741
}
3842
})
3943
}))
@@ -103,12 +107,14 @@ describe('runCodex', () => {
103107
beforeEach(() => {
104108
harness.bootstrapArgs.length = 0
105109
harness.loopArgs.length = 0
110+
harness.sessionInfo = { serviceTier: null }
106111
harness.session.onUserMessage.mockReset()
107112
harness.session.onCancelQueuedMessage.mockReset()
108113
harness.session.rpcHandlerManager.registerHandler.mockReset()
109114
mockCodexSession.setPermissionMode.mockReset()
110115
mockCodexSession.setModel.mockReset()
111116
mockCodexSession.setModelReasoningEffort.mockReset()
117+
mockCodexSession.setServiceTier.mockReset()
112118
mockCodexSession.setCollaborationMode.mockReset()
113119
lifecycleMock.registerProcessHandlers.mockClear()
114120
lifecycleMock.cleanupAndExit.mockClear()
@@ -140,6 +146,61 @@ describe('runCodex', () => {
140146
expect(mockCodexSession.setCollaborationMode).toHaveBeenLastCalledWith('plan')
141147
})
142148

149+
it('preserves a persisted Fast service tier on startup', async () => {
150+
harness.sessionInfo = { serviceTier: 'fast' }
151+
152+
await runCodexImpl({
153+
existingSessionId: 'hapi-session-1',
154+
workingDirectory: '/tmp/project',
155+
resumeSessionId: 'codex-thread-1'
156+
} as Parameters<typeof runCodex>[0])
157+
158+
// The first keepalive sync must re-assert Fast, not collapse it.
159+
expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('fast')
160+
expect(mockCodexSession.setServiceTier).not.toHaveBeenCalledWith(null)
161+
})
162+
163+
it('keeps an explicit Standard service tier sticky on startup', async () => {
164+
harness.sessionInfo = { serviceTier: 'standard' }
165+
166+
await runCodexImpl({
167+
existingSessionId: 'hapi-session-1',
168+
workingDirectory: '/tmp/project',
169+
resumeSessionId: 'codex-thread-1'
170+
} as Parameters<typeof runCodex>[0])
171+
172+
// Explicit Standard must survive resume (not be dropped to untouched),
173+
// so later turns keep sending app-server serviceTier: null.
174+
expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('standard')
175+
})
176+
177+
it('prefers the spawn-time service tier override when resuming (hub passes Fast)', async () => {
178+
// On resume the hub spawns a fresh session (serviceTier null in the new
179+
// row) and passes the old tier via opts; the override must win so the
180+
// resumed thread immediately runs Fast.
181+
harness.sessionInfo = { serviceTier: null }
182+
183+
await runCodexImpl({
184+
workingDirectory: '/tmp/project',
185+
resumeSessionId: 'codex-thread-1',
186+
serviceTier: 'fast'
187+
} as Parameters<typeof runCodex>[0])
188+
189+
expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('fast')
190+
})
191+
192+
it('does not collapse an untouched service tier into explicit Standard on startup', async () => {
193+
harness.sessionInfo = { serviceTier: null }
194+
195+
await runCodexImpl({
196+
workingDirectory: '/tmp/project'
197+
} as Parameters<typeof runCodex>[0])
198+
199+
// Untouched (account-default) sessions must omit the tier entirely so
200+
// the keepalive never persists serviceTier: null over the default.
201+
expect(mockCodexSession.setServiceTier).not.toHaveBeenCalled()
202+
})
203+
143204
it('replays transcript history when attaching a new Hapi session to an existing Codex thread', async () => {
144205
await runCodexImpl({
145206
workingDirectory: '/tmp/project',

0 commit comments

Comments
 (0)