mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
* 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
292 lines
10 KiB
TypeScript
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 }]
|
|
})
|
|
}
|