fix(codex): Fast mode (service tier) toggle + /fast command (closes #898) (#904)

* test: reproduce issue #898 (Codex fast mode service tier)

* fix(codex): add Fast mode (service tier) toggle and /fast command (closes #898)

* feat(codex+web): Fast mode UI toggle with full persistence

Wires the Codex Fast mode (service tier) end-to-end so it can be toggled
from the web composer and survives reload/handoff:

- shared: serviceTier on Session/SessionPatch, session-alive payload,
  resume target, and a SessionServiceTierRequest schema
- cli: AgentSessionBase carries serviceTier through keepAlive; runCodex
  syncs it to the session instance
- hub: service_tier column (schema v10 + migration), store setter,
  sessionCache + syncEngine plumbing, POST /sessions/:id/service-tier
- web: api.setServiceTier + mutation, a Fast/Standard toggle in the
  composer settings (gated to Codex GPT-5.5/5.4), and StatusBar now
  reflects the real tier instead of the effort heuristic

Refs #898

* fix(codex): preserve unset/persisted service tier on startup keepalive

Addresses HAPI Bot [Major] on PR #904: applyCurrentConfigToSession ran
setServiceTier(currentServiceTier ?? null) on wrapper-ready, collapsing the
untouched `undefined` state into explicit Standard. The immediate
setCollaborationMode keepalive then persisted serviceTier: null, silently
downgrading resumed Fast sessions and disabling account-default Fast.

- Seed currentServiceTier from the persisted session (sessionInfo.serviceTier),
  so a resumed Fast thread keeps running Fast.
- Only call setServiceTier when the tier is explicit (!== undefined), preserving
  the three-state omit semantics at the keepalive boundary.
- Add regression tests: persisted Fast is re-asserted; untouched omits the tier.

* feat(codex+web): gate Fast toggle on catalog-advertised service tier

The Fast toggle was gated on a model-name regex (gpt-5.5/5.4), which still
showed a no-op control to API-key users — Fast credits only apply with
ChatGPT login. Codex's model/list catalog advertises the service tiers
actually available for each model in the current auth/plan context, so gate
on that instead:

- cli: capture serviceTiers (ids) per model in ModelListItem + normalizeModel
- shared: CodexModelSummary.serviceTiers (flows through the existing
  getSessionCodexModels pass-through; no hub change needed)
- web: codexModelAdvertisesFastTier(sessionModel, models) replaces the regex;
  SessionChat gates the toggle on it (hidden while the catalog is
  loading/errored). The toggle now only appears when toggling it will
  actually take effect.

Refs #898

* fix(codex): make explicit Standard service tier sticky across resume

Addresses HAPI Bot [Major] (round 2): a single persisted null conflated
"untouched" with "explicit Standard". A user who turned Fast off persisted
null, but startup mapped null -> undefined (untouched) and omitted serviceTier,
so an account/thread-default Fast could silently return after restart/resume.

Introduce a distinct stored representation:
- 'fast' / 'standard' are explicit user choices; null/undefined = untouched.
- Translate 'standard' -> Codex app-server serviceTier: null ONLY when building
  thread/turn params (toAppServerServiceTier); untouched omits the field.
- /fast off now stores 'standard'; the web Standard option sends 'standard'.
- Tighten SessionServiceTierRequest to enum(['fast','standard']) so stray tier
  strings are never forwarded.

Tests: sticky-Standard-on-resume regression; turn/thread params translate
'standard'->null and omit on untouched; hub route applies fast/standard and
rejects unsupported values + local sessions.

Refs #898

* fix(codex): recognize real Fast tier (id 'priority', name 'Fast') in catalog gate

Live E2E against an authed Codex session revealed the model catalog advertises
the Fast tier with id 'priority' and display name 'Fast' (not id 'fast'), so the
/fast/i gate — which only saw tier ids — wrongly hid the toggle for valid
ChatGPT users on gpt-5.5/gpt-5.4. Capture both the tier id and name as
lowercased tokens so the existing name-based match recognizes 'Fast'. The sent
value stays 'fast' (the documented service_tier value / raw additionalSpeedTiers
request tier). Verified end-to-end: gpt-5.5/gpt-5.4 gate on, gpt-5.4-mini off.

Refs #898

* fix(codex): preserve service tier across session resume

Resuming a Codex session spawns a fresh session (serviceTier null) and merges
the old one in. Unlike model/effort/permissionMode, serviceTier was neither
threaded through the resume spawn nor preserved in mergeSessionData, so a
resumed Fast (or explicit Standard) session silently reverted to the account
default.

Thread serviceTier through the spawn path like its siblings:
- hub: resumeSession passes session.serviceTier to spawnSession; rpcGateway +
  syncEngine carry it in the spawn RPC payload; mergeSessionData preserves it
  old->new (safety net).
- cli: SpawnSessionOptions.serviceTier; apiMachine forwards it; buildCliArgs
  emits --service-tier for codex; the codex command parses it; runCodex seeds
  currentServiceTier from the spawn override first (opts.serviceTier ??
  sessionInfo.serviceTier), so a resumed thread immediately runs the right tier.

Verified end-to-end: set Fast -> kill process -> reopen -> resumed session (new
id) still runs Fast. Tests: buildCliArgs --service-tier (codex only), runCodex
spawn-override seed, mergeSessionData service-tier preservation.

Refs #898

* fix(codex): send advertised 'priority' tier id for Fast, not 'fast'

The model catalog advertises the Fast tier with request id 'priority' (display
name 'Fast'), and OpenAI docs confirm service_tier='fast' maps to the request
value 'priority'. The app-server serviceTier override is a raw request value
that does not validate unknown strings (a live probe accepted 'bogus-xyz'), so
sending 'fast' risks being silently ignored — no Fast applied.

Translate the stored 'fast' state to app-server 'priority' at the thread/turn
param boundary (toAppServerServiceTier); the stored/UI/command representation
stays 'fast'/'standard'. Verified live: a turn with serviceTier='priority' runs
and consumes the Fast-tier rate budget.

Addresses HAPI Bot [Major]. Refs #898

* fix(codex): validate --service-tier CLI value (fast|standard)

Addresses HAPI Bot [Minor]: the internal --service-tier spawn arg accepted any
non-empty string, unlike the web /service-tier enum, so a malformed value could
be seeded into currentServiceTier and persisted via keepalive. Parse it to
'fast'|'standard' and reject anything else, matching the web endpoint.

Refs #898
This commit is contained in:
SSU-WEI HUANG
2026-06-17 10:27:33 +08:00
committed by GitHub
parent 8526a9475e
commit c311afddca
50 changed files with 931 additions and 22 deletions
+14
View File
@@ -26,6 +26,7 @@ export type AgentSessionBaseOptions<Mode> = {
model?: SessionModel;
modelReasoningEffort?: SessionModelReasoningEffort;
effort?: SessionEffort;
serviceTier?: string | null;
collaborationMode?: SessionCollaborationMode;
};
@@ -50,6 +51,7 @@ export class AgentSessionBase<Mode> {
protected model?: SessionModel;
protected modelReasoningEffort?: SessionModelReasoningEffort;
protected effort?: SessionEffort;
protected serviceTier?: string | null;
protected collaborationMode?: SessionCollaborationMode;
constructor(opts: AgentSessionBaseOptions<Mode>) {
@@ -68,6 +70,7 @@ export class AgentSessionBase<Mode> {
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<Mode> {
model?: SessionModel
modelReasoningEffort?: SessionModelReasoningEffort
effort?: SessionEffort
serviceTier?: string | null
collaborationMode?: SessionCollaborationMode
} | undefined {
if (
@@ -144,6 +148,7 @@ export class AgentSessionBase<Mode> {
&& this.model === undefined
&& this.modelReasoningEffort === undefined
&& this.effort === undefined
&& this.serviceTier === undefined
&& this.collaborationMode === undefined
) {
return undefined;
@@ -153,6 +158,7 @@ export class AgentSessionBase<Mode> {
model: this.model,
modelReasoningEffort: this.modelReasoningEffort,
effort: this.effort,
serviceTier: this.serviceTier,
collaborationMode: this.collaborationMode
};
}
@@ -173,6 +179,14 @@ export class AgentSessionBase<Mode> {
return this.effort;
}
getServiceTier(): string | null | undefined {
return this.serviceTier;
}
setServiceTier(serviceTier: string | null): void {
this.serviceTier = serviceTier;
}
getCollaborationMode(): SessionCollaborationMode | undefined {
return this.collaborationMode;
}
+1
View File
@@ -74,6 +74,7 @@ function createSession(): Session {
model: null,
modelReasoningEffort: null,
effort: null,
serviceTier: null,
permissionMode: undefined,
collaborationMode: undefined
}
+1
View File
@@ -137,6 +137,7 @@ describe('API extra headers integration', () => {
model: null,
modelReasoningEffort: null,
effort: null,
serviceTier: null,
permissionMode: undefined,
collaborationMode: undefined
})
+2
View File
@@ -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
}
+2 -1
View File
@@ -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
+1
View File
@@ -573,6 +573,7 @@ export class ApiSessionClient extends EventEmitter {
model?: SessionModel
modelReasoningEffort?: string | null
effort?: string | null
serviceTier?: string | null
collaborationMode?: SessionCollaborationMode
}
): void {
+16
View File
@@ -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;
+5
View File
@@ -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 {
+63 -2
View File
@@ -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<Record<string, unknown>>,
loopArgs: [] as Array<Record<string, unknown>>,
sessionInfo: { serviceTier: null as string | null } as Record<string, unknown>,
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<string, unknown>) => {
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<typeof runCodex>[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<typeof runCodex>[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<typeof runCodex>[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<typeof runCodex>[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',
+63 -8
View File
@@ -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<void> = 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;
@@ -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',
+34
View File
@@ -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;
}
+39
View File
@@ -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',
+33
View File
@@ -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.'
+27
View File
@@ -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) => {
+17
View File
@@ -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)
}
+30 -1
View File
@@ -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<string>();
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)
};
}
+1
View File
@@ -12,6 +12,7 @@ export interface SpawnSessionOptions {
modelReasoningEffort?: string
yolo?: boolean
permissionMode?: string
serviceTier?: string
token?: string
sessionType?: 'simple' | 'worktree'
worktreeName?: string
+17
View File
@@ -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', {
+3
View File
@@ -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) {