diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2aeebdb2ccd8..dc1fa624bcbc 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,11 @@ import { assert, it } from "@effect/vitest"; +import type { ServerProviderModel } from "@t3tools/contracts"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyCodexConfigModelDefaults, + applyPreferredCodexDefaultModel, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -144,3 +149,203 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); + +it("uses an explicitly configured custom Codex model", () => { + const models = applyCodexConfigModelDefaults( + [ + { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", isCustom: false, capabilities: null }, + { slug: "custom-codex", name: "custom-codex", isCustom: true, capabilities: null }, + ], + { model: "custom-codex" }, + ); + + assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "custom-codex"); +}); + +it("uses the effective Codex config for model, reasoning, and service tier defaults", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + defaultReasoningEffort: "low", + defaultServiceTier: null, + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: false, + model: "gpt-test", + serviceTiers: [ + { + id: "priority", + name: "Fast", + description: "Lower latency responses.", + }, + ], + supportedReasoningEfforts: [ + { description: "Fast", reasoningEffort: "low" }, + { description: "Thorough", reasoningEffort: "high" }, + ], + }); + const models = applyCodexConfigModelDefaults( + [ + { + slug: "gpt-5.6-sol", + name: "GPT-5.6-Sol", + isCustom: false, + isDefault: true, + capabilities, + }, + { + slug: "gpt-test", + name: "GPT Test", + isCustom: false, + capabilities, + }, + ], + { + model: "gpt-test", + reasoningEffort: "high", + serviceTier: "priority", + }, + ); + + assert.deepStrictEqual( + models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), + [ + { slug: "gpt-5.6-sol", isDefault: undefined }, + { slug: "gpt-test", isDefault: true }, + ], + ); + assert.deepStrictEqual(models[1]?.capabilities?.optionDescriptors, [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }, + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard" }, + { + id: "priority", + label: "Fast", + description: "Lower latency responses.", + isDefault: true, + }, + ], + currentValue: "priority", + }, + ]); +}); + +it("applies supported Codex options when the configured model is unavailable", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + defaultReasoningEffort: "low", + defaultServiceTier: null, + description: "Test model", + displayName: "GPT-5.6 Sol", + hidden: false, + id: "gpt-5.6-sol", + isDefault: false, + model: "gpt-5.6-sol", + serviceTiers: [], + supportedReasoningEfforts: [ + { description: "Fast", reasoningEffort: "low" }, + { description: "Thorough", reasoningEffort: "high" }, + ], + }); + const models = applyCodexConfigModelDefaults( + [ + { + slug: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + isCustom: false, + capabilities, + }, + ], + { model: "gpt-unavailable", reasoningEffort: "high" }, + ); + + assert.equal(models[0]?.isDefault, true); + assert.equal(models[0]?.capabilities?.optionDescriptors?.[0]?.currentValue, "high"); +}); + +function tieredCodexModel(id: string) { + return mapCodexModelCapabilities({ + additionalSpeedTiers: [], + defaultReasoningEffort: "low", + defaultServiceTier: null, + description: "Test model", + displayName: id, + hidden: false, + id, + isDefault: false, + model: id, + serviceTiers: [{ id: "priority", name: "Fast", description: "Lower latency responses." }], + supportedReasoningEfforts: [ + { description: "Fast", reasoningEffort: "low" }, + { description: "Thorough", reasoningEffort: "high" }, + ], + }); +} + +function serviceTierDescriptor(model: ServerProviderModel | undefined) { + const descriptor = model?.capabilities?.optionDescriptors?.find( + (candidate) => candidate.id === "serviceTier", + ); + return descriptor?.type === "select" ? descriptor : undefined; +} + +const models = [ + { + slug: "gpt-6-luna", + name: "GPT-6 Luna", + isCustom: false, + capabilities: tieredCodexModel("gpt-6-luna"), + }, + { + slug: "gpt-6-astra", + name: "GPT-6 Astra", + isCustom: false, + capabilities: tieredCodexModel("gpt-6-astra"), + }, +]; + +it("applies the global Codex service tier to models other than the configured default", () => { + const result = applyCodexConfigModelDefaults(models, { + model: "gpt-6-luna", + reasoningEffort: "high", + serviceTier: "priority", + }); + + assert.equal(result.find((model) => model.isDefault)?.slug, "gpt-6-luna"); + const astra = result.find((model) => model.slug === "gpt-6-astra"); + assert.equal(serviceTierDescriptor(astra)?.currentValue, "priority"); + assert.deepStrictEqual( + serviceTierDescriptor(astra)?.options.find((option) => option.isDefault)?.id, + "priority", + ); + // Reasoning effort stays model-specific, so Astra keeps its catalog default. + assert.equal(astra?.capabilities?.optionDescriptors?.[0]?.currentValue, "low"); +}); + +it("leaves the service tier unknown on every model when Codex config cannot be read", () => { + const result = applyCodexConfigModelDefaults(models, null); + + for (const model of result) { + const descriptor = serviceTierDescriptor(model); + assert.equal(descriptor?.currentValue, undefined); + assert.equal( + descriptor?.options.some((option) => option.isDefault), + false, + ); + assert.equal(model.capabilities?.optionDescriptors?.[0]?.currentValue, "low"); + } +}); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index d65a09c6d4f9..049fb7ef122e 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -68,6 +68,12 @@ export interface CodexAppServerProviderSnapshot { readonly skills: ReadonlyArray; } +export interface CodexConfigModelDefaults { + readonly model?: string | null | undefined; + readonly reasoningEffort?: string | null | undefined; + readonly serviceTier?: string | null | undefined; +} + const REASONING_EFFORT_LABELS: Readonly> = { none: "None", minimal: "Minimal", @@ -233,8 +239,15 @@ export function applyPreferredCodexDefaultModel( if (!preferredSlug) { return models; } + return setDefaultModel(models, preferredSlug); +} + +function setDefaultModel( + models: ReadonlyArray, + defaultModel: string, +): ReadonlyArray { return models.map((model) => { - if (model.slug === preferredSlug) { + if (model.slug === defaultModel) { return model.isDefault ? model : { ...model, isDefault: true }; } if (!model.isDefault) { @@ -245,6 +258,105 @@ export function applyPreferredCodexDefaultModel( }); } +function applyConfiguredSelectDefault( + capabilities: ModelCapabilities | null, + optionId: string, + configuredValue: string | null | undefined, +): ModelCapabilities | null { + const value = configuredValue?.trim(); + if (!capabilities || !value) { + return capabilities; + } + + let matched = false; + const optionDescriptors = (capabilities.optionDescriptors ?? []).map((descriptor) => { + if ( + descriptor.type !== "select" || + descriptor.id !== optionId || + !descriptor.options.some((option) => option.id === value) + ) { + return descriptor; + } + + matched = true; + return { + ...descriptor, + currentValue: value, + options: descriptor.options.map((option) => { + const { isDefault: _isDefault, ...rest } = option; + return option.id === value ? { ...rest, isDefault: true } : rest; + }), + }; + }); + + return matched ? { ...capabilities, optionDescriptors } : capabilities; +} + +/** + * Strip a select's current value and default marker so clients resolve it to + * no selection. Used when the effective value cannot be read, where showing + * the catalog default would claim a tier Codex may not actually apply. + */ +function withUnknownSelectDefault( + capabilities: ModelCapabilities | null, + optionId: string, +): ModelCapabilities | null { + if (!capabilities) { + return capabilities; + } + + let matched = false; + const optionDescriptors = (capabilities.optionDescriptors ?? []).map((descriptor) => { + if (descriptor.type !== "select" || descriptor.id !== optionId) { + return descriptor; + } + matched = true; + const { currentValue: _currentValue, ...rest } = descriptor; + return { + ...rest, + options: descriptor.options.map(({ isDefault: _isDefault, ...option }) => option), + }; + }); + + return matched ? { ...capabilities, optionDescriptors } : capabilities; +} + +/** + * Use Codex's effective config for a new thread when it names an available + * model. Reasoning effort is model-specific, so it only lands on the default + * model; the service tier is global in Codex, so every model that supports the + * configured tier shows it. Missing config values retain T3's catalog-based + * fallbacks. A `null` config means Codex's configuration could not be read, + * which leaves the tier unknown rather than presenting catalog Standard for a + * tier Codex may silently override. + */ +export function applyCodexConfigModelDefaults( + models: ReadonlyArray, + config: CodexConfigModelDefaults | null, +): ReadonlyArray { + const configuredModel = config?.model?.trim(); + const configuredModelAvailable = + configuredModel !== undefined && + configuredModel.length > 0 && + models.some((model) => model.slug === configuredModel); + const modelsWithDefault = configuredModelAvailable + ? setDefaultModel(models, configuredModel) + : applyPreferredCodexDefaultModel(models); + const defaultModel = modelsWithDefault.find((model) => model.isDefault)?.slug; + + return modelsWithDefault.map((model) => { + const withReasoning = applyConfiguredSelectDefault( + model.capabilities, + "reasoningEffort", + model.slug === defaultModel ? config?.reasoningEffort : undefined, + ); + const capabilities = config + ? applyConfiguredSelectDefault(withReasoning, "serviceTier", config.serviceTier) + : withUnknownSelectDefault(withReasoning, "serviceTier"); + return capabilities === model.capabilities ? model : { ...model, capabilities }; + }); +} + /** * Codex has no static default capability set, so a bare custom slug borrows * the first built-in's descriptors; an entry with its own capabilities keeps @@ -330,6 +442,18 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( return models; }); +const readCodexConfigModelDefaults = Effect.fn("readCodexConfigModelDefaults")(function* ( + client: CodexClient.CodexAppServerClient["Service"], + cwd: string, +) { + const response = yield* client.request("config/read", { cwd, includeLayers: false }); + return { + model: response.config.model, + reasoningEffort: response.config.model_reasoning_effort, + serviceTier: response.config.service_tier, + }; +}); + export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { return { clientInfo: { @@ -423,12 +547,13 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models, rateLimits] = yield* Effect.all( + const [skillsResponse, models, configDefaults, rateLimits] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + readCodexConfigModelDefaults(client, input.cwd).pipe(Effect.orElseSucceed(() => null)), // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. client.request("account/rateLimits/read", undefined).pipe( @@ -456,8 +581,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun account: accountResponse, rateLimits, version, - models: applyPreferredCodexDefaultModel( + models: applyCodexConfigModelDefaults( appendCustomCodexModels(models, input.customModels ?? []), + configDefaults, ), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index dbe97cdd54e0..f21d07cb0bef 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -104,6 +104,22 @@ describe("buildTraitsTriggerDisplay", () => { }); }); + it("never claims Standard when the Codex service tier is unknown", () => { + // The server strips the current value and default marker when it cannot + // read Codex's effective tier. Nothing is shown instead of a guessed tier. + const unknownTier: ProviderOptionDescriptor = { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard" }, + { id: "priority", label: "Fast" }, + ], + }; + expect(display([EFFORT, unknownTier])).toEqual({ label: "High", showFastModeIcon: false }); + expect(display([unknownTier])).toEqual({ label: "", showFastModeIcon: false }); + }); + it("keeps non-fastMode booleans as text labels", () => { const thinking: Extract = { id: "thinking", diff --git a/docs/user/composer.md b/docs/user/composer.md index 4a8df5333664..dd0c95b8840c 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -43,6 +43,11 @@ project's configured model takes precedence; resetting that project setting returns to the remembered selection. Leaving reasoning level or service tier unset uses the provider's own configuration. +When no project or remembered selection applies, Codex uses its configured model +if it is available, including its supported reasoning default. Codex's configured +service tier applies to every model, so the composer shows it for whichever model +you pick. If T3 Code cannot read the Codex configuration, the service tier shows no +selection until you choose one. ## Quote an assistant response