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
+7
View File
@@ -518,6 +518,13 @@ export class ApiClient {
})
}
async setServiceTier(sessionId: string, serviceTier: string | null): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/service-tier`, {
method: 'POST',
body: JSON.stringify({ serviceTier })
})
}
async approvePermission(
sessionId: string,
requestId: string,
@@ -101,6 +101,10 @@ export function HappyComposer(props: {
onModelEffortChange?: (wireId: string | null) => void
onModelReasoningEffortChange?: (modelReasoningEffort: string | null) => void
onEffortChange?: (effort: string | null) => void
/** Codex Fast mode (service tier): current value ('fast' or null/standard). */
serviceTier?: string | null
/** When provided, a Fast-mode toggle renders (Codex GPT-5.5 / GPT-5.4 only). */
onServiceTierChange?: (serviceTier: string | null) => void
onSwitchToRemote?: () => void
onTerminal?: () => void
terminalUnsupported?: boolean
@@ -158,6 +162,8 @@ export function HappyComposer(props: {
onModelEffortChange,
onModelReasoningEffortChange,
onEffortChange,
serviceTier: rawServiceTier,
onServiceTierChange,
onSwitchToRemote,
onTerminal,
terminalUnsupported = false,
@@ -180,6 +186,7 @@ export function HappyComposer(props: {
const model = rawModel ?? null
const modelReasoningEffort = rawModelReasoningEffort ?? null
const effort = rawEffort ?? null
const serviceTier = rawServiceTier ?? null
const api = useAssistantApi()
const { composerEnterBehavior } = useComposerEnterBehavior()
@@ -615,6 +622,20 @@ export function HappyComposer(props: {
haptic('light')
}, [onEffortChange, controlsDisabled, haptic])
const handleServiceTierChange = useCallback((nextServiceTier: string | null) => {
if (!onServiceTierChange || controlsDisabled) return
onServiceTierChange(nextServiceTier)
setShowSettings(false)
haptic('light')
}, [onServiceTierChange, controlsDisabled, haptic])
// 'standard' (not null) is the explicit Fast-off choice so it persists
// distinctly from an untouched/account-default session.
const fastModeOptions: Array<{ value: string; label: string }> = useMemo(() => [
{ value: 'standard', label: t('misc.fastModeStandard') },
{ value: 'fast', label: t('misc.fastModeFast') }
], [t])
const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0)
const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0)
const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor) && modelOptions.length > 0)
@@ -625,6 +646,7 @@ export function HappyComposer(props: {
)
const showModelReasoningEffortSettings = Boolean(onModelReasoningEffortChange && codexReasoningEffortOptions.length > 0)
const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor))
const showFastModeSettings = Boolean(onServiceTierChange)
const showSettingsButton = Boolean(
showCollaborationSettings
|| showPermissionSettings
@@ -632,6 +654,7 @@ export function HappyComposer(props: {
|| showModelEffortSettings
|| showModelReasoningEffortSettings
|| showEffortSettings
|| showFastModeSettings
)
const showAbortButton = true
const voiceEnabled = Boolean(onVoiceToggle)
@@ -651,7 +674,7 @@ export function HappyComposer(props: {
}, [api])
const overlays = useMemo(() => {
if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings)) {
if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) {
return (
<div className="absolute bottom-[100%] mb-2 w-full">
<FloatingOverlay maxHeight={320}>
@@ -901,6 +924,47 @@ export function HappyComposer(props: {
))}
</div>
) : null}
{(showModelReasoningEffortSettings || showEffortSettings) && showFastModeSettings ? (
<div className="mx-3 h-px bg-[var(--app-divider)]" />
) : null}
{showFastModeSettings ? (
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
{t('misc.fastMode')}
</div>
{fastModeOptions.map((option) => (
<button
key={option.value ?? 'standard'}
type="button"
disabled={controlsDisabled}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
controlsDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-[var(--app-secondary-bg)]'
}`}
onClick={() => handleServiceTierChange(option.value)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
serviceTier === option.value
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{serviceTier === option.value && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={serviceTier === option.value ? 'text-[var(--app-link)]' : ''}>
{option.label}
</span>
</button>
))}
</div>
) : null}
</FloatingOverlay>
</div>
)
@@ -932,9 +996,11 @@ export function HappyComposer(props: {
selectedModelVariant,
showModelReasoningEffortSettings,
showEffortSettings,
showFastModeSettings,
modelOptions,
codexReasoningEffortOptions,
claudeEffortOptions,
fastModeOptions,
suggestions,
selectedIndex,
controlsDisabled,
@@ -943,6 +1009,7 @@ export function HappyComposer(props: {
model,
modelReasoningEffort,
effort,
serviceTier,
collaborationModeOptions,
permissionModeOptions,
handleCollaborationChange,
@@ -950,6 +1017,7 @@ export function HappyComposer(props: {
handleModelChange,
handleModelReasoningEffortChange,
handleEffortChange,
handleServiceTierChange,
handleSuggestionSelect,
t
])
@@ -971,6 +1039,7 @@ export function HappyComposer(props: {
contextWindow={contextWindow}
model={model}
modelReasoningEffort={modelReasoningEffort}
serviceTier={serviceTier}
permissionMode={permissionMode}
collaborationMode={collaborationMode}
threadGoal={threadGoal}
@@ -10,6 +10,7 @@ import type { AgentState, CodexCollaborationMode, PermissionMode } from '@/types
import type { ConversationStatus } from '@/realtime/types'
import type { ThreadGoal } from '@/types/api'
import { getContextBudgetTokens } from '@/chat/modelConfig'
import { isFastServiceTier } from './codexFastMode'
import { useTranslation } from '@/lib/use-translation'
// Vibing messages for thinking state
@@ -154,6 +155,7 @@ export function StatusBar(props: {
contextWindow?: number | null
model?: string | null
modelReasoningEffort?: string | null
serviceTier?: string | null
permissionMode?: PermissionMode
collaborationMode?: CodexCollaborationMode
threadGoal?: ThreadGoal | null
@@ -213,8 +215,12 @@ export function StatusBar(props: {
const codexReasoningLabel = (props.agentFlavor === 'codex' || props.agentFlavor === 'opencode')
? formatCodexReasoningLabel(props.modelReasoningEffort)
: null
// Prefer the explicit service tier (the real Fast-mode toggle) when set;
// fall back to the effort/model heuristic only when the tier is unknown.
const codexFastMode = props.agentFlavor === 'codex'
? isCodexFastMode(props.model, props.modelReasoningEffort)
? (props.serviceTier != null
? isFastServiceTier(props.serviceTier)
: isCodexFastMode(props.model, props.modelReasoningEffort))
: false
const goalLabel = props.agentFlavor === 'codex' && props.threadGoal
? props.threadGoal.status === 'active'
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { codexModelAdvertisesFastTier, isFastServiceTier } from './codexFastMode'
// Mirrors the real Codex catalog: the Fast tier's id is 'priority' and its
// display name is 'Fast', so the CLI captures both as lowercased tokens
// (['priority','fast']). Models without Fast advertise no such token.
const models = [
{ id: 'gpt-5.5', isDefault: true, serviceTiers: ['priority', 'fast'] },
{ id: 'gpt-5.4-mini', isDefault: false, serviceTiers: [] },
{ id: 'o3', isDefault: false }
]
describe('codexModelAdvertisesFastTier', () => {
it('is true when the active model advertises a fast tier (real id=priority, name=Fast)', () => {
expect(codexModelAdvertisesFastTier('gpt-5.5', models)).toBe(true)
})
it('falls back to the catalog default model when session model is auto/null', () => {
// default model (gpt-5.5) advertises fast
expect(codexModelAdvertisesFastTier(null, models)).toBe(true)
expect(codexModelAdvertisesFastTier(undefined, models)).toBe(true)
expect(codexModelAdvertisesFastTier(' ', models)).toBe(true)
})
it('is false when the active model does not advertise a fast tier', () => {
expect(codexModelAdvertisesFastTier('gpt-5.4-mini', models)).toBe(false)
expect(codexModelAdvertisesFastTier('o3', models)).toBe(false)
})
it('is false when the model is unknown or the catalog is empty', () => {
expect(codexModelAdvertisesFastTier('gpt-9', models)).toBe(false)
expect(codexModelAdvertisesFastTier('gpt-5.5', [])).toBe(false)
})
it('matches fast tokens case-insensitively', () => {
expect(codexModelAdvertisesFastTier('m', [{ id: 'm', isDefault: true, serviceTiers: ['Fast'] }])).toBe(true)
})
})
describe('isFastServiceTier', () => {
it('detects the fast tier regardless of casing/spacing', () => {
expect(isFastServiceTier('fast')).toBe(true)
expect(isFastServiceTier(' Fast ')).toBe(true)
})
it('treats null/standard as not fast', () => {
expect(isFastServiceTier(null)).toBe(false)
expect(isFastServiceTier(undefined)).toBe(false)
expect(isFastServiceTier('standard')).toBe(false)
})
})
@@ -0,0 +1,51 @@
// Codex Fast mode (service tier) availability is advertised per-model by the
// Codex app-server `model/list` catalog, which is resolved server-side from the
// user's account/auth/plan. An API-key session (no Fast credits) or a model
// without Fast support simply won't list a `fast` service tier. Gating the UI on
// this catalog signal — rather than a model-name heuristic — means the toggle
// only appears when toggling it will actually do something.
type CodexModelCatalogEntry = {
id: string
isDefault: boolean
serviceTiers?: string[]
}
function isFastTierId(tierId: string): boolean {
return /fast/i.test(tierId.trim())
}
/**
* Resolve the catalog entry for the session's active model. A null/empty
* session model means "auto" — the catalog's default model is active.
*/
function findActiveModel<T extends CodexModelCatalogEntry>(
sessionModel: string | null | undefined,
models: ReadonlyArray<T>
): T | undefined {
const normalized = sessionModel?.trim().toLowerCase()
if (normalized) {
return models.find((model) => model.id.trim().toLowerCase() === normalized)
}
return models.find((model) => model.isDefault)
}
/**
* True when the session's active Codex model advertises a Fast service tier in
* the current auth/plan context. Returns false when the catalog is empty/not
* yet loaded so the toggle stays hidden until we have an authoritative answer.
*/
export function codexModelAdvertisesFastTier(
sessionModel: string | null | undefined,
models: ReadonlyArray<CodexModelCatalogEntry>
): boolean {
if (models.length === 0) {
return false
}
const active = findActiveModel(sessionModel, models)
return Boolean(active?.serviceTiers?.some(isFastTierId))
}
export function isFastServiceTier(serviceTier?: string | null): boolean {
return serviceTier?.trim().toLowerCase() === 'fast'
}
+24 -1
View File
@@ -20,6 +20,7 @@ import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@
import { isQueuedForInvocation, mergeMessages } from '@/lib/messages'
import { inactiveSessionCanResume } from '@/lib/sessionResume'
import { HappyComposer, type ComposerSendError } from '@/components/AssistantChat/HappyComposer'
import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import { resolvePendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import { HappyThread } from '@/components/AssistantChat/HappyThread'
@@ -559,7 +560,8 @@ function SessionChatInner(props: SessionChatProps) {
setCollaborationMode,
setModel,
setModelReasoningEffort,
setEffort
setEffort,
setServiceTier
} = useSessionActions(
props.api,
props.session.id,
@@ -864,6 +866,17 @@ function SessionChatInner(props: SessionChatProps) {
}
}, [setEffort, props.onRefresh, haptic])
const handleServiceTierChange = useCallback(async (serviceTier: string | null) => {
try {
await setServiceTier(serviceTier)
haptic.notification('success')
props.onRefresh()
} catch (e) {
haptic.notification('error')
console.error('Failed to set service tier:', e)
}
}, [setServiceTier, props.onRefresh, haptic])
// Abort handler
const handleAbort = useCallback(async () => {
await abortSession()
@@ -1162,6 +1175,16 @@ function SessionChatInner(props: SessionChatProps) {
: undefined
}
onEffortChange={handleEffortChange}
serviceTier={agentFlavor === 'codex' ? props.session.serviceTier : undefined}
onServiceTierChange={
agentFlavor === 'codex'
&& props.session.active
&& !controlledByUser
&& !codexModelsState.error
&& codexModelAdvertisesFastTier(props.session.model, codexModelsState.models)
? handleServiceTierChange
: undefined
}
onSwitchToRemote={handleSwitchToRemote}
onTerminal={props.session.active && terminalSupported ? handleViewTerminal : undefined}
terminalUnsupported={props.session.active && !terminalSupported}
@@ -22,6 +22,7 @@ export function useSessionActions(
setModel: (model: string | null) => Promise<void>
setModelReasoningEffort: (modelReasoningEffort: string | null) => Promise<void>
setEffort: (effort: string | null) => Promise<void>
setServiceTier: (serviceTier: string | null) => Promise<void>
renameSession: (name: string) => Promise<void>
deleteSession: () => Promise<void>
isPending: boolean
@@ -150,6 +151,22 @@ export function useSessionActions(
onSuccess: () => void invalidateSession(),
})
const serviceTierMutation = useMutation({
mutationFn: async (serviceTier: string | null) => {
if (!api || !sessionId) {
throw new Error('Session unavailable')
}
if (agentFlavor !== 'codex') {
throw new Error('Fast mode is only supported for Codex sessions')
}
if (!codexCollaborationModeSupported) {
throw new Error('Fast mode is only supported for remote sessions')
}
await api.setServiceTier(sessionId, serviceTier)
},
onSuccess: () => void invalidateSession(),
})
const renameMutation = useMutation({
mutationFn: async (name: string) => {
if (!api || !sessionId) {
@@ -185,6 +202,7 @@ export function useSessionActions(
setModel: modelMutation.mutateAsync,
setModelReasoningEffort: modelReasoningEffortMutation.mutateAsync,
setEffort: effortMutation.mutateAsync,
setServiceTier: serviceTierMutation.mutateAsync,
renameSession: renameMutation.mutateAsync,
deleteSession: deleteMutation.mutateAsync,
isPending: abortMutation.isPending
@@ -196,6 +214,7 @@ export function useSessionActions(
|| modelMutation.isPending
|| modelReasoningEffortMutation.isPending
|| effortMutation.isPending
|| serviceTierMutation.isPending
|| renameMutation.isPending
|| deleteMutation.isPending,
}
+3
View File
@@ -627,6 +627,9 @@ export default {
'misc.model': 'Model',
'misc.reasoningEffort': 'Reasoning Effort',
'misc.effort': 'Effort',
'misc.fastMode': 'Fast Mode',
'misc.fastModeStandard': 'Standard',
'misc.fastModeFast': 'Fast',
'misc.variant': 'Variant',
'misc.loading': 'Loading…',
'misc.loadOlder': 'Load older',
+3
View File
@@ -631,6 +631,9 @@ export default {
'misc.model': '模型',
'misc.reasoningEffort': '推理强度',
'misc.effort': '思考强度',
'misc.fastMode': '快速模式',
'misc.fastModeStandard': '标准',
'misc.fastModeFast': '快速',
'misc.variant': '变体',
'misc.loading': '加载中…',
'misc.loadOlder': '加载更早的',
@@ -28,6 +28,7 @@ function makeExport(messages: HapiSessionExport['messages']): HapiSessionExport
model: null,
modelReasoningEffort: null,
effort: null,
serviceTier: null,
permissionMode: 'default',
collaborationMode: 'default'
},