mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* 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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ function createSession(): Session {
|
||||
model: null,
|
||||
modelReasoningEffort: null,
|
||||
effort: null,
|
||||
serviceTier: null,
|
||||
permissionMode: undefined,
|
||||
collaborationMode: undefined
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('API extra headers integration', () => {
|
||||
model: null,
|
||||
modelReasoningEffort: null,
|
||||
effort: null,
|
||||
serviceTier: null,
|
||||
permissionMode: undefined,
|
||||
collaborationMode: undefined
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -573,6 +573,7 @@ export class ApiSessionClient extends EventEmitter {
|
||||
model?: SessionModel
|
||||
modelReasoningEffort?: string | null
|
||||
effort?: string | null
|
||||
serviceTier?: string | null
|
||||
collaborationMode?: SessionCollaborationMode
|
||||
}
|
||||
): void {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface SpawnSessionOptions {
|
||||
modelReasoningEffort?: string
|
||||
yolo?: boolean
|
||||
permissionMode?: string
|
||||
serviceTier?: string
|
||||
token?: string
|
||||
sessionType?: 'simple' | 'worktree'
|
||||
worktreeName?: string
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user