fix(codex): Fast mode (service tier) toggle + /fast command (closes #898)#904
fix(codex): Fast mode (service tier) toggle + /fast command (closes #898)#904swear01 wants to merge 10 commits into
Conversation
There was a problem hiding this comment.
Findings
- No findings.
Summary
- Review mode: initial
- No issues found in the latest diff. Residual risk: I did not execute the test suite, and the new service tier behavior still depends on the installed Codex app-server accepting
serviceTierforthread/startandturn/startat runtime.
Testing
- Not run (automation)
HAPI Bot
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
There was a problem hiding this comment.
Findings
- [Major] Keepalive clears the persisted/default service tier on Codex startup —
applyCurrentConfigToSession()runs when the Codex wrapper becomes ready, andsetServiceTier(currentServiceTier ?? null)turns the untouchedundefinedstate into explicit Standard.setCollaborationMode()then immediately pushes a keepalive, so the hub persistsserviceTier: nullbefore the user does anything. A resumed Fast session is silently downgraded, and account-default Fast is also disabled for new untouched sessions. Evidencecli/src/codex/runCodex.ts:109
Suggested fix:const { api, session, sessionInfo } = bootstrap let currentServiceTier: string | null | undefined = sessionInfo.serviceTier === 'fast' ? 'fast' : undefined const applyCurrentConfigToSession = (options?: { syncModel?: boolean }) => { const sessionInstance = sessionWrapperRef.current if (!sessionInstance) return // Preserve the third state: undefined means "omit/leave account default". if (currentServiceTier !== undefined) { sessionInstance.setServiceTier(currentServiceTier) } }
Summary
- Review mode: follow-up after new commits
- Found one Major issue in the new service-tier plumbing: startup keepalive collapses “unset” into explicit Standard and overwrites Fast/default state.
Testing
- Not run (automation). Add a Codex resume/startup test that seeds
serviceTier: 'fast'and asserts the first config sync/keepalive preservesfast, plus an untouched session test that asserts no service tier is emitted.
HAPI Bot
| sessionInstance.setModel(currentModel ?? null); | ||
| } | ||
| sessionInstance.setModelReasoningEffort(currentModelReasoningEffort ?? null); | ||
| sessionInstance.setServiceTier(currentServiceTier ?? null); |
There was a problem hiding this comment.
[MAJOR] currentServiceTier starts as undefined, but this added line converts it to null every time the Codex wrapper becomes ready. Because setCollaborationMode() immediately pushes a keepalive, the hub persists serviceTier: null before any user action, so resuming a persisted Fast session silently downgrades it and untouched sessions lose the account-default tier.
Suggested fix:
const { api, session, sessionInfo } = bootstrap
let currentServiceTier: string | null | undefined =
sessionInfo.serviceTier === 'fast' ? 'fast' : undefined
const applyCurrentConfigToSession = (options?: { syncModel?: boolean }) => {
const sessionInstance = sessionWrapperRef.current
if (!sessionInstance) return
// Preserve the third state: undefined means "omit/leave account default".
if (currentServiceTier !== undefined) {
sessionInstance.setServiceTier(currentServiceTier)
}
}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.
There was a problem hiding this comment.
Findings
- [Major] Service tier persistence still collapses explicit Standard into “unset” — the follow-up keeps persisted Fast, but
sessionInfo.serviceTier ?? undefinedstill maps storednulltoundefined. Since the new/service-tierpath stores explicit Standard asnull, a user who turns Fast off loses that explicit Standard choice after restart/resume; later Codex starts/turns omitserviceTierinstead of sending app-servernull, so account/default Fast can come back. The nullable free-form schema also leaves unsupported tier strings accepted and forwarded. Evidencecli/src/codex/runCodex.ts:90,shared/src/apiTypes.ts:145,hub/src/web/routes/sessions.ts:579.
Suggested fix:Use the stored// shared: reserve null for untouched; persist explicit Standard distinctly. const ServiceTierRequestValueSchema = z.preprocess( (value) => value === null ? 'standard' : typeof value === 'string' ? value.trim().toLowerCase() : value, z.enum(['fast', 'standard']) ) export const SessionServiceTierRequestSchema = z.object({ serviceTier: ServiceTierRequestValueSchema }) type StoredServiceTier = 'fast' | 'standard' | null type CodexServiceTier = 'fast' | null | undefined const toCodexServiceTier = (tier: StoredServiceTier | undefined): CodexServiceTier => tier === 'standard' ? null : tier === 'fast' ? 'fast' : undefined
standardvalue when persisting/returning applied config, and translate to Codex app-servernullonly when buildingthread/startorturn/startparams.
Summary
- Review mode: follow-up after new commits
- The prior Fast-resume regression is partially fixed, but the persisted representation still cannot distinguish untouched from explicit Standard. That makes Fast-off non-sticky after restart/resume and leaves invalid service-tier strings accepted.
Testing
- Not run (automation). Add regression coverage for: toggle Standard → persist distinct
standard→ restart → first Codex turn sends app-serverserviceTier: null; andPOST /service-tierrejects an unsupported value.
HAPI Bot
| // explicitly. Seed from the persisted session so a resumed Fast thread | ||
| // keeps running Fast; a persisted standard/null is treated as untouched so | ||
| // we never collapse "unset" into an explicit Standard the user didn't pick. | ||
| let currentServiceTier: string | null | undefined = sessionInfo.serviceTier ?? undefined; |
There was a problem hiding this comment.
[MAJOR] This preserves persisted Fast, but it still treats persisted null as “unset”. The new /service-tier path also uses null for explicit Standard, so after restart/resume Fast-off is no longer sticky: HAPI omits serviceTier instead of sending Codex app-server null, and account/default Fast can come back. Store explicit Standard as a distinct value such as standard, reserve null for untouched, and translate standard to app-server null only when building thread/start / turn/start params.
Suggested fix:
type StoredServiceTier = 'fast' | 'standard' | null
type CodexServiceTier = 'fast' | null | undefined
const toCodexServiceTier = (tier: StoredServiceTier | undefined): CodexServiceTier =>
tier === 'standard' ? null : tier === 'fast' ? 'fast' : undefinedThe 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
There was a problem hiding this comment.
Findings
- [Major] Explicit Standard service tier is still persisted as “unset” — the UI sends
nullfor Standard, the API schema accepts/forwards that samenull, and Codex startup converts persistednulltoundefined. That means a user who turns Fast off only sends app-serverserviceTier: nullwhile the process is alive; after restart/resume HAPI omitsserviceTier, so an account/thread default Fast tier can come back. The same loose schema also accepts arbitrary non-empty strings and forwards them as tier ids. Evidenceweb/src/components/AssistantChat/HappyComposer.tsx:632,shared/src/apiTypes.ts:145,hub/src/web/routes/sessions.ts:579,cli/src/codex/runCodex.ts:90.
Suggested fix:Store/returnconst ServiceTierRequestValueSchema = z.preprocess( (value) => value === null ? "standard" : typeof value === "string" ? value.trim().toLowerCase() : value, z.enum(["fast", "standard"]) ) type StoredServiceTier = "fast" | "standard" | null type CodexServiceTier = "fast" | null | undefined const toCodexServiceTier = (tier: StoredServiceTier | undefined): CodexServiceTier => tier === "standard" ? null : tier === "fast" ? "fast" : undefined
standardfor an explicit Fast-off choice, reserve storednullfor untouched, and translatestandardto app-servernullonly when buildingthread/start/turn/startparams.
Summary
- Review mode: follow-up after new commits
- The follow-up improves UI gating by reading the Codex model catalog, but the earlier Standard-vs-unset persistence issue remains unresolved. This still makes Fast-off non-sticky across restart/resume.
Testing
- Not run (automation). Add regression coverage for: Fast on -> Fast off -> persisted
standard-> restart/resume -> first Codex turn includes app-serverserviceTier: null; andPOST /service-tierrejects unsupported strings.
HAPI Bot
| // explicitly. Seed from the persisted session so a resumed Fast thread | ||
| // keeps running Fast; a persisted standard/null is treated as untouched so | ||
| // we never collapse "unset" into an explicit Standard the user didn't pick. | ||
| let currentServiceTier: string | null | undefined = sessionInfo.serviceTier ?? undefined; |
There was a problem hiding this comment.
[MAJOR] This still treats persisted null as “unset”, while the new Standard option also sends/stores null. After the user disables Fast, HAPI sends app-server serviceTier: null only until the Codex process restarts; on resume this line converts the stored value to undefined, so later thread/start / turn/start params omit the tier and an account/thread Fast default can reappear. Store explicit Standard as a distinct value such as standard, reserve null for untouched, and translate standard to app-server null only at the Codex app-server boundary.
Suggested fix:
type StoredServiceTier = "fast" | "standard" | null
type CodexServiceTier = "fast" | null | undefined
const toCodexServiceTier = (tier: StoredServiceTier | undefined): CodexServiceTier =>
tier === "standard" ? null : tier === "fast" ? "fast" : undefinedAddresses 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
There was a problem hiding this comment.
Findings
- No issues found in the latest diff with confidence >= 80%.
Summary
- Review mode: follow-up after new commits
- The previous Standard-vs-untouched service tier issue appears resolved: the web/API path now sends
standard, the hub rejects unsupported tier strings, persistence keeps the sentinel distinct fromnull, and the Codex app-server boundary translatesstandardtoserviceTier: nullonly when building thread/turn params. - Residual risk: I did not run the full suites in this automation pass, so this is a static review only.
Testing
- Not run (automation)
HAPI Bot
…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
There was a problem hiding this comment.
Findings
- No issues found in the latest diff with confidence >= 80%.
Summary
- Review mode: follow-up after new commits
- Re-reviewed the full PR diff, including the follow-up service-tier catalog/token changes and the Codex Fast mode path across web, hub persistence/RPC, shared schemas, CLI keepalive, slash commands, and Codex app-server request builders.
- Residual risk: this was a static review pass; I did not run the test suites.
Testing
- Not run (automation)
HAPI Bot
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
There was a problem hiding this comment.
Findings
- [Major] Fast mode sends the wrong app-server tier value — the new catalog normalization says the Codex Fast tier's catalog id/request value is
priority, but the request builder translates HAPI's storedfaststate intoserviceTier: 'fast'. That means/fastand the UI toggle can persist as Fast whilethread/start/turn/startdo not request the advertised app-server tier. Evidencecli/src/codex/utils/appServerConfig.ts:60.
Suggested fix:function toAppServerServiceTier(stored: string | null | undefined): string | null | undefined { if (stored === 'fast') { return 'priority'; } if (stored === 'standard') { return null; } return undefined; }
Summary
- Review mode: follow-up after new commits
- Re-reviewed the full PR diff and the follow-up resume propagation path. Found one app-server wire-value mismatch in the Fast-mode request translation.
Testing
- Not run (automation)
HAPI Bot
| */ | ||
| function toAppServerServiceTier(stored: string | null | undefined): string | null | undefined { | ||
| if (stored === 'fast') { | ||
| return 'fast'; |
There was a problem hiding this comment.
[MAJOR] Fast mode is persisted internally as fast, but this boundary is where that sentinel becomes the Codex app-server serviceTier value. The new catalog handling documents/tests the real Fast tier as id/request value priority, while this line sends fast, so /fast and the UI toggle can show/persist Fast without requesting the advertised tier on thread/start / turn/start.
Suggested fix:
function toAppServerServiceTier(stored: string | null | undefined): string | null | undefined {
if (stored === 'fast') {
return 'priority';
}
if (stored === 'standard') {
return null;
}
return undefined;
}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
There was a problem hiding this comment.
Findings
- [Minor] Validate CLI service-tier values before starting Codex — the web endpoint only accepts
fast/standard, but the new--service-tierCLI path accepts any non-empty string.runCodexthen seedscurrentServiceTierfrom that option and the keepalive path can persist the invalid value, while app-server param building silently omits unknown tiers. Evidencecli/src/commands/codex.ts:80.
Suggested fix: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') } // ... } else if (arg === '--service-tier') { const tier = commandArgs[++i] if (!tier) { throw new Error('Missing --service-tier value') } options.serviceTier = parseServiceTier(tier) }
Summary
- Review mode: follow-up after new commits
- Re-reviewed the full PR diff. The prior
fastvs app-serverpriorityfinding appears addressed; one remaining ingress can still store unsupported service-tier values.
Testing
- Not run (automation; local runner does not have
buninstalled)
HAPI Bot
| throw new Error('Missing --model-reasoning-effort value') | ||
| } | ||
| options.modelReasoningEffort = parseReasoningEffort(effort) | ||
| } else if (arg === '--service-tier') { |
There was a problem hiding this comment.
[MINOR] The web endpoint validates serviceTier to only fast/standard, but this new CLI flag accepts any non-empty string. Since runCodex seeds currentServiceTier directly from opts.serviceTier, the keepalive path can persist an unsupported value like turbo, while toAppServerServiceTier later omits it from app-server requests. Validate this ingress before assigning it.
Suggested fix:
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')
}
// ...
} else if (arg === '--service-tier') {
const tier = commandArgs[++i]
if (!tier) {
throw new Error('Missing --service-tier value')
}
options.serviceTier = parseServiceTier(tier)
}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
There was a problem hiding this comment.
Findings
- No issues found.
Summary
- Review mode: follow-up after new commits
- Re-reviewed the full current PR diff. The previous CLI
--service-tiervalidation finding is addressed in the latest head. Residual risk: I could not run local typecheck/tests in this environment.
Testing
- Not run (automation;
bunis not installed in this runner)
HAPI Bot
Closes #898 (Codex portion)
Adds Codex Fast mode (the service-tier speed switch — ~1.5× faster on GPT-5.5 / GPT-5.4 for higher credit cost), both as a
/fastcommand and as a composer UI toggle, wired end-to-end with persistence.What's included
CLI (Codex)
serviceTieronTurnStartParams/ThreadStartParams; passed throughbuildTurnStartParams(three-state: omit = untouched,null= explicit Standard,"fast"= Fast)./fast on|off|statusintercepted inresolveCodexSlashCommand.runCodexthreads it into everyEnhancedModeand accepts it viaSetSessionConfig.AgentSessionBasecarries it through thesession-alivekeepalive so the hub can persist it.Hub (persistence)
service_tiercolumn (schema v10 + idempotent migration), store setter,sessionCache+syncEngineplumbing, change-detection/broadcast.POST /sessions/:id/service-tier(Codex + remote-only), forwardingSetSessionConfig.Shared (protocol)
serviceTieronSession/SessionPatch, thesession-alivepayload, the resume target, and aSessionServiceTierRequestschema.Web (UI)
api.setServiceTier+useSessionActionsmutation (Codex + remote gating).codexModelSupportsFastMode).StatusBarnow reflects the real tier instead of the effort heuristic.misc.fastMode*i18n keys (en + zh-CN).Behavior notes / upstream gotchas honored
Option<Option<String>>): Fast off sends explicitnull, never an empty string — avoids the openai/codex#15853 turn-context-clears-tier pitfall.model.Out of scope (follow-up)
/fast— a different (Opus output-acceleration) mechanism; the non-interactive switch is still unconfirmed and will be scoped separately.Test plan
appServerConfig,slashCommands), then implementationcodexFastMode.test.tstsc --noEmitclean across cli / web / hub (CI gate)