Files
orca/src/shared/structured-agent-session-options.ts
T
Brennan Benson c8b8997762 feat(native-chat): offer Codex Ultrafast as a speed choice (#26962)
* feat(native-chat): offer Codex Ultrafast as a speed choice

Codex lists Ultrafast as a third service tier for eligible models and
accounts (GPT-6.1 Sol). Codex chats saved Fast as on/off, which cannot
hold a third tier. The Codex speed is now a Standard/Fast/Ultrafast
choice built from the tiers the model lists; the tier id is still
resolved from the catalog at each send. Older clients keep their Fast
toggle: the host still reports Fast on/off and accepts it as a speed.

* refactor(native-chat): save the Codex tier id instead of a speed name

The chat now keeps the tier id Codex lists (serviceTier: default,
priority, ultrafast) and sends it as-is on turn/start; Codex drops a
tier the model no longer lists with its own warning. This removes the
saved per-model tier map, the send-time lookup and the reconcile code.
The unused fastModeTierByModel catalog field is deleted. Older clients
still get Fast on/off, derived from the model's tier named Fast. Also
drops the pnpm-lock.yaml churn the first commit picked up.

* test(pi): drop the removed fastModeTierByModel catalog field

* fix(native-chat): review round 1 for Codex service tiers

- Accept an older client's fastMode write through the Codex adapter; it
  was refused before reaching the tier alias.
- Bump the saved model catalog schema so catalogs from before
  serviceTiers re-list instead of offering a Fast toggle with no tier.
- Keep only the reported tier on the record, not the Fast it implies.
- Drop a reported tier, not only a picked one, when the next model does
  not list it.
- Localize Standard inside the speed choice only.
- Return the options answer directly instead of a one-field wrapper.

* fix(native-chat): review round 2 for Codex service tiers

Keep a tier Codex reports for the thread (it accepts unlisted ones such
as flex); read the legacy Fast tier beside an empty serviceTiers list.

* fix(native-chat): review round 3 for Codex service tiers

A reported tier the old model listed and the new one does not resets to
Standard on a model switch, and the reported tier itself stays pickable.

* fix(native-chat): keep a re-picked reported Codex tier

The thread's reported tier is never reset by the unlisted-tier drop, so a
re-picked Flex survives the next read, a model switch, and the turn.

* refactor(native-chat): one rule for which Codex tier a model offers

Standard, a tier the model lists, or the thread's reported tier that no
model lists. The setter and the unlisted-tier reset share it, replacing
the separate reported-tier and model-switch guards.

* test(native-chat): drop the removed fastModeTierByModel field from main's new catalog test
2026-10-09 18:45:03 -07:00

292 lines
10 KiB
TypeScript

import type { AgentSessionOptionCatalog, CatalogModel } from './agent-session-option-catalog'
import { discoveredModel } from './structured-agent-session-discovered-model'
import {
buildNativeChatSessionOptionSnapshot,
resolveEffectiveNativeChatModelId,
withTrackedNativeChatModel
} from './native-chat-session-option-snapshot'
import {
applyNativeChatReportedSessionOptions,
clearTrackedSessionOption,
createNativeChatSessionOptionRecord,
setTrackedSessionOption,
type NativeChatSessionOptionRecord
} from './native-chat-session-option-state'
import { STRUCTURED_LAUNCH_SEED_OPTION_IDS } from './native-chat-session-option-defaults'
import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options'
import type {
AgentSessionModelCatalogResult,
AgentSessionOptionsResult
} from './agent-session-wire'
import {
decodeStructuredAgentSessionOptionValue,
encodeStructuredAgentSessionOptionValue
} from './structured-agent-session-option-codec'
export function structuredAgentSessionOptionCatalog(
seed: AgentSessionOptionCatalog,
result: AgentSessionOptionsResult
): AgentSessionOptionCatalog {
const models: CatalogModel[] = result.models.map((model) =>
discoveredModel(model, result.fastModeSupport?.supported === true)
)
if (result.current.model && !models.some((model) => model.id === result.current.model)) {
models.push({
id: result.current.model,
label: result.current.model,
options: seed.unknownModelOptions ?? []
})
}
return { ...seed, models, defaultModelIsCliDefault: true }
}
export type StructuredAgentSessionOptionState = {
catalog: AgentSessionOptionCatalog | null
/** What produced `catalog`; a weaker source never replaces a stronger one. `seed` is the
* built-in list before the host answered (shown as the quiet placeholder); `builtin` is the
* same list once the host said it has none, usable but naming nothing the host may replace. */
catalogSource: 'seed' | 'builtin' | 'host' | 'live' | null
record: NativeChatSessionOptionRecord
pendingId: string | null
}
/** With `seedCatalog`, the state holds the static seed from the first frame, but the picker shows
* the quiet placeholder until the host answers; every later source only upgrades it. */
export function createStructuredAgentSessionOptionState(
agent = 'codex',
seedCatalog?: AgentSessionOptionCatalog | null
): StructuredAgentSessionOptionState {
return {
catalog: seedCatalog ?? null,
catalogSource: seedCatalog ? 'seed' : null,
record: createNativeChatSessionOptionRecord(agent),
pendingId: null
}
}
/** The host answered with no list (none saved, a failed read, or an older host): the built-in
* list becomes usable, still naming nothing until a host or session list does. */
export function settleStructuredAgentSessionBuiltinCatalog(
state: StructuredAgentSessionOptionState
): StructuredAgentSessionOptionState {
return state.catalogSource === 'seed' ? { ...state, catalogSource: 'builtin' } : state
}
/**
* Applies the host's stored model catalog: models only, no current selection
* and no record writes, so nothing here reads as a committed value — the pick
* stays provisional until a live options result confirms it. A live catalog
* is never downgraded by this.
*/
export function applyStructuredAgentSessionModelCatalog(
state: StructuredAgentSessionOptionState,
seed: AgentSessionOptionCatalog,
catalog: AgentSessionModelCatalogResult,
options: { newLaunch: boolean }
): StructuredAgentSessionOptionState {
if (state.catalogSource === 'live') {
return state
}
const models =
catalog.origin === 'unknown'
? []
: catalog.models.map((model) =>
discoveredModel(model, catalog.fastModeSupport?.supported === true)
)
if (catalog.origin === 'unknown' || models.length === 0) {
return settleStructuredAgentSessionBuiltinCatalog(state)
}
// The host says whether its listed default is what this launch runs; an older host never says,
// so the client's own knowledge of the agent stands in. A reopened chat may keep its own model.
const namesDefault =
options.newLaunch &&
(catalog.listingNamesConfiguredModel ?? seed.hostListingNamesConfiguredModel === true)
return {
...state,
// `isDefault` came from a real listing, so a launch's CLI default is nameable —
// as a provisional `default`-source value, never a confirmed one.
catalog: {
...seed,
models,
...(namesDefault ? { defaultModelIsCliDefault: true } : {})
},
catalogSource: 'host'
}
}
export function applyStructuredAgentSessionOptions(
state: StructuredAgentSessionOptionState,
seed: AgentSessionOptionCatalog,
result: AgentSessionOptionsResult
): StructuredAgentSessionOptionState {
if (result.current.fastMode === undefined) {
clearTrackedSessionOption(state.record, result.current.model ?? null, 'fastMode')
}
if (result.current.serviceTier === undefined) {
clearTrackedSessionOption(state.record, result.current.model ?? null, 'serviceTier')
}
applyNativeChatReportedSessionOptions(
state.record,
{
...(result.current.model ? { model: result.current.model } : {}),
...(result.current.effort ? { effort: result.current.effort } : {}),
...(result.current.fastMode !== undefined ? { fastMode: result.current.fastMode } : {}),
...(result.current.serviceTier ? { serviceTier: result.current.serviceTier } : {})
},
result.current.confirmed ?? []
)
return {
...state,
catalog: structuredAgentSessionOptionCatalog(seed, result),
catalogSource: 'live'
}
}
/** Before the host answers, or an agent with no list reports, the quiet model pill: never
* pickable, so displaying it can never become a launch pick. */
const PROVIDER_DEFAULT_MODEL_PLACEHOLDER: SessionOptionDescriptor = {
id: 'model',
label: 'Model',
category: 'model',
kind: { type: 'select', choices: [] },
valueSource: 'unknown',
transport: 'agent-session',
settable: false,
disabledReason: 'available-after-session-start'
}
export function structuredAgentSessionOptionSnapshot(
state: StructuredAgentSessionOptionState
): SessionOptionDescriptor[] {
if (!state.catalog) {
return []
}
// Until the host answers, the built-in list would paint a label its list may replace.
if (state.catalogSource === 'seed') {
return [PROVIDER_DEFAULT_MODEL_PLACEHOLDER]
}
const snapshot = buildNativeChatSessionOptionSnapshot({
catalog: state.catalog,
// A seeded default can name a model the static seed does not list yet.
models: withTrackedNativeChatModel(state.catalog, state.catalog.models, state.record),
record: state.record,
mode: 'live',
modelLabel: 'Model',
liveTransport: 'agent-session'
})
return snapshot.length > 0 || state.catalogSource === 'live'
? snapshot
: [PROVIDER_DEFAULT_MODEL_PLACEHOLDER]
}
/** No launch holds a pick and no fence can carry one yet, so the picker only shows. */
export function lockedStructuredAgentSessionOptionSnapshot(
snapshot: readonly SessionOptionDescriptor[]
): SessionOptionDescriptor[] {
return snapshot.map((descriptor) => ({
...descriptor,
settable: false,
disabledReason: 'available-after-session-start'
}))
}
export function canSetStructuredAgentSessionOption(
state: StructuredAgentSessionOptionState,
id: string,
value: SessionOptionValue
): boolean {
const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id)
return Boolean(
state.catalog &&
state.pendingId === null &&
((typeof value === 'string' &&
descriptor?.kind.type === 'select' &&
descriptor.kind.choices.some((choice) => choice.value === value)) ||
(typeof value === 'boolean' && descriptor?.kind.type === 'boolean'))
)
}
export function commitStructuredAgentSessionOption(
state: StructuredAgentSessionOptionState,
id: string,
value: string
): StructuredAgentSessionOptionState {
if (!state.catalog) {
return state
}
const effectiveModel = resolveEffectiveNativeChatModelId(
state.catalog,
state.catalog.models,
state.record
)
const decoded = decodeStructuredAgentSessionOptionValue(id, value)
if (decoded === null) {
return { ...state, pendingId: null }
}
setTrackedSessionOption(state.record, id, decoded, 'dispatched', effectiveModel)
return { ...state, pendingId: null }
}
export function commitStructuredAgentSessionOptionValues(
state: StructuredAgentSessionOptionState,
values: Readonly<Record<string, string>>
): StructuredAgentSessionOptionState {
let next = state
for (const id of STRUCTURED_LAUNCH_SEED_OPTION_IDS) {
const value = values[id]
if (value !== undefined) {
next = commitStructuredAgentSessionOption(next, id, value)
}
}
return next
}
export type StructuredSessionOptionPick = {
modelId: string
optionId: string
value: SessionOptionValue
}
/**
* The picks a mutation must remember so the next launch starts where the user left off.
* Keyed off the same ids the launch seed reads back, so a pick this surface cannot
* re-seed is never written.
*
* Model and effort travel as a pair: a launch resolves a stored effort only under a
* stored model, so an effort-only pick adopts the model it was chosen against. Values
* come from what the provider committed, not what was requested — it reconciles an
* effort the newly selected model cannot run before reporting back.
*
* `state` may still be pre-commit: a changed model arrives in `committed`, and an
* unchanged one is already what the record tracks, so neither reading depends on the
* commit having landed.
*/
export function structuredAgentSessionOptionPicks(
state: StructuredAgentSessionOptionState,
committed: Readonly<Record<string, string>>
): StructuredSessionOptionPick[] {
if (!state.catalog) {
return []
}
const committedModel = committed.model
const modelId =
typeof committedModel === 'string' && committedModel.trim()
? committedModel
: resolveEffectiveNativeChatModelId(state.catalog, state.catalog.models, state.record)
if (!modelId) {
return []
}
return STRUCTURED_LAUNCH_SEED_OPTION_IDS.flatMap((optionId) => {
const value = committed[optionId]
if (value === undefined) {
return []
}
const decoded = decodeStructuredAgentSessionOptionValue(optionId, value)
return decoded === null ||
(typeof decoded === 'string' && !decoded.trim()) ||
encodeStructuredAgentSessionOptionValue(optionId, decoded) === null
? []
: [{ modelId, optionId, value: decoded }]
})
}