From 0520dabb2a9b1142c45fb8a18bc54370c026f71e Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:42:40 +0100 Subject: [PATCH 1/2] fix(codex): respect configured service tier defaults --- apps/server/src/codexModelOptions.test.ts | 10 +- apps/server/src/codexModelOptions.ts | 23 ++- .../src/provider/Layers/CodexAdapter.test.ts | 47 +++++ .../src/provider/Layers/CodexAdapter.ts | 6 +- .../src/provider/Layers/CodexProvider.test.ts | 188 +++++++++++++++++- .../src/provider/Layers/CodexProvider.ts | 64 ++++-- .../Layers/CodexSessionRuntime.test.ts | 71 +++++++ .../provider/Layers/CodexSessionRuntime.ts | 8 +- .../CodexTextGeneration.test.ts | 22 ++ .../chat/composerProviderState.test.tsx | 48 +++++ packages/shared/src/model.ts | 21 +- 11 files changed, 479 insertions(+), 29 deletions(-) diff --git a/apps/server/src/codexModelOptions.test.ts b/apps/server/src/codexModelOptions.test.ts index d035a60a46d..5284eda4e1b 100644 --- a/apps/server/src/codexModelOptions.test.ts +++ b/apps/server/src/codexModelOptions.test.ts @@ -18,5 +18,13 @@ it("keeps legacy persisted fast mode selections working", () => { { id: "fastMode", value: true }, ]); - assert.equal(getCodexServiceTierOptionValue(selection), "fast"); + assert.equal(getCodexServiceTierOptionValue(selection), "priority"); +}); + +it("keeps legacy persisted fast-off selections on explicit Standard routing", () => { + const selection = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "fastMode", value: false }, + ]); + + assert.equal(getCodexServiceTierOptionValue(selection), "default"); }); diff --git a/apps/server/src/codexModelOptions.ts b/apps/server/src/codexModelOptions.ts index 43b8a3c184f..c1501574021 100644 --- a/apps/server/src/codexModelOptions.ts +++ b/apps/server/src/codexModelOptions.ts @@ -4,11 +4,28 @@ import { getModelSelectionStringOptionValue, } from "@t3tools/shared/model"; +/** Explicit Standard routing sentinel used by Codex 0.145+ app-server and CLI config. */ +export const CODEX_STANDARD_SERVICE_TIER = "default"; +/** Canonical model-catalog/request id for Codex Fast routing. */ +export const CODEX_FAST_SERVICE_TIER = "priority"; + +export function normalizeCodexServiceTier(value: string | undefined): string | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return undefined; + // `fast` is the legacy config/T3 value; current model catalogs advertise `priority`. + return normalized === "fast" ? CODEX_FAST_SERVICE_TIER : normalized; +} + export function getCodexServiceTierOptionValue( modelSelection: ModelSelection | null | undefined, ): string | undefined { - return ( - getModelSelectionStringOptionValue(modelSelection, "serviceTier") ?? - (getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true ? "fast" : undefined) + const selected = normalizeCodexServiceTier( + getModelSelectionStringOptionValue(modelSelection, "serviceTier"), ); + if (selected) return selected; + + const legacyFastMode = getModelSelectionBooleanOptionValue(modelSelection, "fastMode"); + if (legacyFastMode === true) return CODEX_FAST_SERVICE_TIER; + if (legacyFastMode === false) return CODEX_STANDARD_SERVICE_TIER; + return undefined; } diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 456447054c1..15fc555539b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -344,6 +344,24 @@ validationLayer("CodexAdapterLive validation", (it) => { }), ); + it.effect("keeps explicit Standard routing when starting a session", () => + Effect.gen(function* () { + validationRuntimeFactory.factory.mockClear(); + const adapter = yield* CodexAdapter; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-standard"), + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "serviceTier", value: "default" }, + ]), + runtimeMode: "full-access", + }); + + NodeAssert.equal(validationRuntimeFactory.factory.mock.calls[0]?.[0].serviceTier, "default"); + }), + ); + it.effect("injects the canonical MCP server into Codex app-server sessions", () => { const runtimeFactory = makeRuntimeFactory(); const baseEnvironment = { KEEP_ME: "1" }; @@ -464,6 +482,35 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("sends a persisted Fast-to-Standard choice on later turns", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("sess-fast-to-standard"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "serviceTier", value: "priority" }, + ]), + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + runtime.sendTurnImpl.mockClear(); + + yield* adapter.sendTurn({ + threadId, + input: "continue on Standard", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "serviceTier", value: "default" }, + ]), + attachments: [], + }); + + NodeAssert.equal(runtime.sendTurnImpl.mock.calls[0]?.[0].serviceTier, "default"); + }), + ); + it.effect("passes configured launch args into the session runtime", () => { const runtimeFactory = makeRuntimeFactory(); const layer = Layer.effect( diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 6d2cae08618..0b31aa9b3c3 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1581,7 +1581,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } : {}), - ...(compatibility.serviceTier ? { serviceTier: compatibility.serviceTier } : {}), + ...(compatibility.serviceTier !== undefined + ? { serviceTier: compatibility.serviceTier } + : {}), ...(mcpSession ? makeCodexMcpRuntimeConfig(mcpSession, options?.environment ?? process.env) : {}), @@ -1942,7 +1944,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( effort: reasoningEffort as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, } : {}), - ...(serviceTier ? { serviceTier } : {}), + ...(serviceTier !== undefined ? { serviceTier } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), }) diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2aeebdb2ccd..bcdc1ba7bd8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,65 @@ import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as CodexClient from "effect-codex-app-server/client"; +import type * as CodexSchema from "effect-codex-app-server/schema"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { + buildProviderOptionSelectionsFromDescriptors, + createModelSelection, +} from "@t3tools/shared/model"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; +import { + applyPreferredCodexDefaultModel, + mapCodexModelCapabilities, + requestAllCodexModels, +} from "./CodexProvider.ts"; + +const priorityDefaultModel = { + additionalSpeedTiers: [], + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + defaultServiceTier: "priority", + serviceTiers: [ + { + id: "priority", + name: "Fast", + description: "1.5x speed, increased usage", + }, + { + id: "flex", + name: "Flex", + description: "Lower-cost asynchronous routing.", + }, + ], + supportedReasoningEfforts: [], +} satisfies CodexSchema.V2ModelListResponse__Model; + +function selectedServiceTier( + capabilities: ReturnType, +): string | undefined { + const descriptor = capabilities.optionDescriptors?.find( + (candidate) => candidate.id === "serviceTier", + ); + return descriptor?.type === "select" ? descriptor.currentValue : undefined; +} + +function dispatchedServiceTier( + capabilities: ReturnType, +): string | undefined { + return getCodexServiceTierOptionValue( + createModelSelection( + ProviderInstanceId.make("codex"), + priorityDefaultModel.model, + buildProviderOptionSelectionsFromDescriptors(capabilities.optionDescriptors ?? []), + ), + ); +} it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -101,8 +160,135 @@ it("uses standard routing when the catalog has no default service tier", () => { currentValue: "default", }, ]); + assert.equal(dispatchedServiceTier(capabilities), "default"); +}); + +it("uses Standard when effective config carries the durable Fast-default opt-out", () => { + const capabilities = mapCodexModelCapabilities(priorityDefaultModel, { + service_tier: null, + notice: { fast_default_opt_out: true }, + }); + + assert.equal(selectedServiceTier(capabilities), "default"); + assert.equal(dispatchedServiceTier(capabilities), "default"); + const descriptor = capabilities.optionDescriptors?.find( + (candidate) => candidate.id === "serviceTier", + ); + assert.equal(descriptor?.type, "select"); + if (descriptor?.type === "select") { + assert.equal( + descriptor.options.some((option) => option.id === "priority"), + true, + ); + } + assert.equal( + selectedServiceTier( + mapCodexModelCapabilities(priorityDefaultModel, { + service_tier: "fast", + notice: { fast_default_opt_out: true }, + }), + ), + "priority", + ); +}); + +it("uses Standard when effective config explicitly selects the default tier", () => { + const capabilities = mapCodexModelCapabilities(priorityDefaultModel, { + service_tier: "default", + }); + + assert.equal(selectedServiceTier(capabilities), "default"); + assert.equal(dispatchedServiceTier(capabilities), "default"); }); +it("adopts the catalog-managed Fast default only when config has no explicit choice", () => { + const capabilities = mapCodexModelCapabilities(priorityDefaultModel, { + service_tier: null, + notice: null, + }); + + assert.equal(selectedServiceTier(capabilities), "priority"); + assert.equal(dispatchedServiceTier(capabilities), "priority"); +}); + +it("honors explicit Fast config in legacy and canonical forms", () => { + for (const configured of ["fast", "priority"] as const) { + const capabilities = mapCodexModelCapabilities(priorityDefaultModel, { + service_tier: configured, + }); + assert.equal(selectedServiceTier(capabilities), "priority"); + assert.equal(dispatchedServiceTier(capabilities), "priority"); + } +}); + +it("honors explicit Flex config ahead of the catalog-managed Fast default", () => { + const capabilities = mapCodexModelCapabilities(priorityDefaultModel, { + service_tier: "flex", + }); + + assert.equal(selectedServiceTier(capabilities), "flex"); + assert.equal(dispatchedServiceTier(capabilities), "flex"); +}); + +it("uses explicit Standard when a configured tier is unsupported by the model", () => { + const capabilities = mapCodexModelCapabilities( + { + ...priorityDefaultModel, + serviceTiers: priorityDefaultModel.serviceTiers.filter((tier) => tier.id === "priority"), + }, + { service_tier: "flex" }, + ); + + assert.equal(selectedServiceTier(capabilities), "default"); + assert.equal(dispatchedServiceTier(capabilities), "default"); +}); + +it.effect( + "reads Windows and WSL catalogs through each provider client's own effective config", + () => + Effect.gen(function* () { + const makeClient = ( + config: CodexSchema.V2ConfigReadResponse__Config, + expectedCwd: string, + ): CodexClient.CodexAppServerClient["Service"] => { + const client = { + request: (method: string, params: unknown) => { + if (method === "config/read") { + assert.deepStrictEqual(params, { cwd: expectedCwd }); + return Effect.succeed({ config, origins: {} }); + } + if (method === "model/list") { + return Effect.succeed({ data: [priorityDefaultModel], nextCursor: null }); + } + return Effect.die(new Error(`Unexpected request: ${method}`)); + }, + }; + return client as unknown as CodexClient.CodexAppServerClient["Service"]; + }; + + const windowsModels = yield* requestAllCodexModels( + makeClient( + { service_tier: null, notice: { fast_default_opt_out: true } }, + "C:\\work\\project", + ), + "C:\\work\\project", + ); + const wslModels = yield* requestAllCodexModels( + makeClient({ service_tier: null, notice: null }, "/home/work/project"), + "/home/work/project", + ); + + assert.equal( + selectedServiceTier(windowsModels[0]?.capabilities ?? { optionDescriptors: [] }), + "default", + ); + assert.equal( + selectedServiceTier(wslModels[0]?.capabilities ?? { optionDescriptors: [] }), + "priority", + ); + }), +); + it("marks the most preferred available model as default", () => { const models = applyPreferredCodexDefaultModel([ { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1ed9c750c18..bc741688072 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -33,6 +33,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; +import { CODEX_STANDARD_SERVICE_TIER, normalizeCodexServiceTier } from "../../codexModelOptions.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); @@ -61,8 +62,6 @@ const REASONING_EFFORT_LABELS: Readonly> = { ultra: "Ultra", }; -const DEFAULT_SERVICE_TIER_ID = "default"; - function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } @@ -109,6 +108,7 @@ function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, + config?: CodexSchema.V2ConfigReadResponse__Config, ): ModelCapabilities { const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => reasoningEffort === model.defaultReasoningEffort @@ -123,7 +123,7 @@ export function mapCodexModelCapabilities( }, ); const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; - const serviceTiers = + const rawServiceTiers = model.serviceTiers && model.serviceTiers.length > 0 ? model.serviceTiers : (model.additionalSpeedTiers ?? []).map((id) => ({ @@ -131,12 +131,29 @@ export function mapCodexModelCapabilities( name: id === "fast" ? "Fast" : id, description: "", })); + const serviceTiers = rawServiceTiers.reduce>( + (tiers, tier) => { + const id = normalizeCodexServiceTier(tier.id) ?? tier.id; + if (tiers.some((candidate) => candidate.id === id)) return tiers; + tiers.push({ ...tier, id }); + return tiers; + }, + [], + ); + const configuredServiceTier = resolveConfiguredCodexServiceTier(config); + const normalizedCatalogDefault = normalizeCodexServiceTier(model.defaultServiceTier ?? undefined); const catalogDefaultServiceTier = serviceTiers.some( - (tier) => tier.id === model.defaultServiceTier, + (tier) => tier.id === normalizedCatalogDefault, ) - ? model.defaultServiceTier + ? normalizedCatalogDefault : null; - const defaultServiceTier = catalogDefaultServiceTier ?? DEFAULT_SERVICE_TIER_ID; + const defaultServiceTier = + configuredServiceTier === undefined + ? (catalogDefaultServiceTier ?? CODEX_STANDARD_SERVICE_TIER) + : configuredServiceTier === CODEX_STANDARD_SERVICE_TIER || + !serviceTiers.some((tier) => tier.id === configuredServiceTier) + ? CODEX_STANDARD_SERVICE_TIER + : configuredServiceTier; const optionDescriptors: ProviderOptionDescriptor[] = []; if (reasoningOptions.length > 0) { @@ -155,9 +172,9 @@ export function mapCodexModelCapabilities( type: "select", options: [ { - id: DEFAULT_SERVICE_TIER_ID, + id: CODEX_STANDARD_SERVICE_TIER, label: "Standard", - ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), + ...(defaultServiceTier === CODEX_STANDARD_SERVICE_TIER ? { isDefault: true } : {}), }, ...serviceTiers.map((tier) => ({ id: tier.id, @@ -175,6 +192,26 @@ export function mapCodexModelCapabilities( }); } +function resolveConfiguredCodexServiceTier( + config: CodexSchema.V2ConfigReadResponse__Config | undefined, +): string | undefined { + if (!config) return undefined; + const configured = normalizeCodexServiceTier( + typeof config.service_tier === "string" ? config.service_tier : undefined, + ); + // Codex treats the durable opt-out notice as the fallback when no explicit tier is configured. + if (configured) return configured; + const notice = config.notice; + const optedOut = + notice !== null && + typeof notice === "object" && + "fast_default_opt_out" in notice && + notice.fast_default_opt_out === true; + // The durable opt-out is stronger than the catalog-managed default. + if (optedOut) return CODEX_STANDARD_SERVICE_TIER; + return undefined; +} + const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => { // Capitalize 'gpt' to 'GPT-' and capitalize any letter following a dash return model.displayName @@ -184,13 +221,14 @@ const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => function parseCodexModelListResponse( response: CodexSchema.V2ModelListResponse, + config: CodexSchema.V2ConfigReadResponse__Config, ): ReadonlyArray { return response.data.map((model) => ({ slug: model.model, name: toDisplayName(model), isCustom: false, ...(model.isDefault ? { isDefault: true } : {}), - capabilities: mapCodexModelCapabilities(model), + capabilities: mapCodexModelCapabilities(model, config), })); } @@ -282,18 +320,20 @@ function parseCodexSkillsListResponse( }); } -const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( +export const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( client: CodexClient.CodexAppServerClient["Service"], + cwd: string, ) { const models: ServerProviderModel[] = []; let cursor: string | null | undefined = undefined; + const configResponse = yield* client.request("config/read", { cwd }); do { const response: CodexSchema.V2ModelListResponse = yield* client.request( "model/list", cursor ? { cursor } : {}, ); - models.push(...parseCodexModelListResponse(response)); + models.push(...parseCodexModelListResponse(response, configResponse.config)); cursor = response.nextCursor; } while (cursor); @@ -394,7 +434,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun client.request("skills/list", { cwds: [input.cwd], }), - requestAllCodexModels(client), + requestAllCodexModels(client, input.cwd), ], { concurrency: "unbounded" }, ); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index ac86f1d6968..73b3e878bcb 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -216,6 +216,28 @@ describe("buildTurnStartParams", () => { ], }); }); + + it.effect("keeps explicit Standard routing on turn/start, including null compatibility", () => + Effect.gen(function* () { + const current = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Continue", + serviceTier: "default", + }); + const legacyClear = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Continue", + serviceTier: null, + }); + + NodeAssert.equal(current.serviceTier, "default"); + NodeAssert.equal(legacyClear.serviceTier, null); + NodeAssert.equal(Object.hasOwn(current, "serviceTier"), true); + NodeAssert.equal(Object.hasOwn(legacyClear, "serviceTier"), true); + }), + ); }); describe("buildCodexDeveloperInstructions", () => { @@ -365,6 +387,55 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { + it.effect("sends explicit Standard routing on both thread/start and thread/resume", () => + Effect.gen(function* () { + const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; + const client = { + request: ( + method: M, + payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + calls.push({ method, payload }); + return Effect.succeed( + makeThreadOpenResponse( + `${method}-result`, + ) as CodexRpc.ClientRequestResponsesByMethod[M], + ); + }, + }; + + yield* openCodexThread({ + client, + threadId: ThreadId.make("thread-standard-start"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.4", + serviceTier: "default", + resumeThreadId: undefined, + }); + yield* openCodexThread({ + client, + threadId: ThreadId.make("thread-standard-resume"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.4", + serviceTier: "default", + resumeThreadId: "provider-thread-existing", + }); + + NodeAssert.deepStrictEqual( + calls.map(({ method, payload }) => ({ + method, + serviceTier: (payload as { readonly serviceTier?: string | null }).serviceTier, + })), + [ + { method: "thread/start", serviceTier: "default" }, + { method: "thread/resume", serviceTier: "default" }, + ], + ); + }), + ); + it.effect("falls back to thread/start when resume fails recoverably", () => Effect.gen(function* () { const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 7504f43236c..048110dfc4c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -88,7 +88,7 @@ export type CodexTurnStartParamsWithCollaborationMode = typeof CodexTurnStartParamsWithCollaborationMode.Type; export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; -type CodexServiceTier = NonNullable; +type CodexServiceTier = Exclude; type CodexThreadItem = | EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number] | EffectCodexSchema.V2ThreadRollbackResponse["thread"]["turns"][number]["items"][number]; @@ -298,7 +298,7 @@ function buildThreadStartParams(input: { approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, ...(input.model ? { model: input.model } : {}), - ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), }; } @@ -385,7 +385,7 @@ export function buildTurnStartParams(input: { approvalPolicy: config.approvalPolicy, sandboxPolicy: runtimeModeToTurnSandboxPolicy(input.runtimeMode), ...(input.model ? { model: input.model } : {}), - ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(collaborationMode ? { collaborationMode } : {}), }).pipe( @@ -1285,7 +1285,7 @@ export const makeCodexSessionRuntime = ( ...(input.input ? { prompt: input.input } : {}), ...(input.attachments ? { attachments: input.attachments } : {}), ...(normalizedModel ? { model: normalizedModel } : {}), - ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 657118fff51..6b90d438415 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -260,6 +260,28 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); + it.effect("passes explicit Standard routing to helper Codex invocations", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + requireServiceTier: "default", + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "serviceTier", value: "default" }, + ]), + }), + ), + ); + it.effect("passes exec-safe launch args into codex exec", () => withFakeCodexEnv( { diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 067e71ef1bf..7b34b1c7bea 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -126,6 +126,54 @@ describe("getComposerProviderState", () => { ); }); + it("keeps a saved Standard draft choice ahead of a catalog-managed Fast default", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("serviceTier", [ + { id: "default", label: "Standard" }, + { id: "priority", label: "Fast", isDefault: true }, + ]), + ]), + modelOptions: selections(["serviceTier", "default"]), + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["serviceTier", "default"])); + }); + + it("normalizes a legacy saved Fast choice against current service-tier descriptors", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("serviceTier", [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + ]), + ]), + modelOptions: selections(["fastMode", true]), + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["serviceTier", "priority"])); + }); + + it("normalizes a legacy string-valued Fast tier against current descriptors", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("serviceTier", [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + ]), + ]), + modelOptions: selections(["serviceTier", "fast"]), + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["serviceTier", "priority"])); + }); + it("drops selections for descriptors the model does not declare", () => { const state = getComposerProviderState({ provider: PROVIDER, diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index b351379e55a..cbe3012c015 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -151,12 +151,21 @@ export function getProviderOptionDescriptors(input: { const { caps, selections } = input; const baseDescriptors = (caps.optionDescriptors ?? []).map(cloneDescriptor); - return baseDescriptors.map((descriptor) => - withDescriptorCurrentValue( - descriptor, - getRawSelectionValueById(selections, descriptor.id) ?? descriptor.currentValue, - ), - ); + return baseDescriptors.map((descriptor) => { + let selectedValue = getRawSelectionValueById(selections, descriptor.id); + // Codex used a boolean `fastMode` option before app-server exposed service tiers. + // Preserve its string and boolean legacy states against the current descriptor ids. + if (descriptor.id === "serviceTier") { + if (typeof selectedValue === "string" && selectedValue.trim().toLowerCase() === "fast") { + selectedValue = "priority"; + } else if (selectedValue === undefined) { + const legacyFastMode = getRawSelectionValueById(selections, "fastMode"); + if (legacyFastMode === true) selectedValue = "priority"; + if (legacyFastMode === false) selectedValue = "default"; + } + } + return withDescriptorCurrentValue(descriptor, selectedValue ?? descriptor.currentValue); + }); } export function getProviderOptionCurrentValue( From 1b7df626c4545ba4f273891d494a350a22bfab0e Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:49:00 +0100 Subject: [PATCH 2/2] test(mobile): expect normalized Fast service tier --- apps/mobile/src/lib/modelOptions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 9a71640b45a..52c34d5d425 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -47,6 +47,6 @@ describe("mobile model options", () => { }); expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); - expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]); + expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]); }); });