authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-11 20:25:59-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 20:25:59-03:00
loga92161a05040c3fcaaa479f1b60d2d45cd368dd4
tree35c96cf72500d309d7aceb63ae49aa8331c2e758
parentcd64ad384e40988f57e4caa5a3f74f25ca5ef450
signature Signed by PGP key B5690EEEBB952194

fix(codex): preserve qualified model ids in selection and generation (#9921)


8 files changed, 128 insertions(+), 14 deletions(-)

apps/server/src/provider/Drivers/CodexDriver.ts+5-1
...@@ -186,7 +186,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -186,7 +186,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
186 environment: processEnv,186 environment: processEnv,
187 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),187 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
188 });188 });
189 const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv);
190189
191 // Build a managed snapshot whose settings never change — mutations come190 // Build a managed snapshot whose settings never change — mutations come
192 // in as instance rebuilds from the registry rather than in-place191 // in as instance rebuilds from the registry rather than in-place
...@@ -242,6 +241,11 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -242,6 +241,11 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
242 }),241 }),
243 ),242 ),
244 );243 );
244 const textGeneration = yield* makeCodexTextGeneration(
245 effectiveConfig,
246 processEnv,
247 snapshot.getSnapshot.pipe(Effect.map((value) => value.models)),
248 );
245 const snapshotForCwd = (cwd: string) =>249 const snapshotForCwd = (cwd: string) =>
246 !effectiveConfig.enabled250 !effectiveConfig.enabled
247 ? snapshot.getSnapshot251 ? snapshot.getSnapshot
apps/server/src/provider/Layers/CodexProvider.test.ts+17
...@@ -127,6 +127,23 @@ it("prefers sol over terra when both are available", () => {...@@ -127,6 +127,23 @@ it("prefers sol over terra when both are available", () => {
127 assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol");127 assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol");
128});128});
129129
130it("ranks qualified Codex models while preserving their wire ids", () => {
131 const models = applyPreferredCodexDefaultModel([
132 {
133 slug: "openai.gpt-5.6-luna",
134 name: "Luna",
135 isCustom: false,
136 isDefault: true,
137 capabilities: null,
138 },
139 { slug: "openai.gpt-5.6-sol", name: "Sol", isCustom: false, capabilities: null },
140 ]);
141 assert.deepStrictEqual(
142 models.filter((model) => model.isDefault).map((model) => model.slug),
143 ["openai.gpt-5.6-sol"],
144 );
145});
146
130it("keeps Codex's own default when no preferred model is available", () => {147it("keeps Codex's own default when no preferred model is available", () => {
131 const models = applyPreferredCodexDefaultModel([148 const models = applyPreferredCodexDefaultModel([
132 { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null },149 { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null },
apps/server/src/provider/Layers/CodexProvider.ts+8-4
...@@ -25,7 +25,11 @@ import type {...@@ -25,7 +25,11 @@ import type {
25} from "@t3tools/contracts";25} from "@t3tools/contracts";
26import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";26import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";
2727
28import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model";28import {
29 codexModelFamily,
30 createModelCapabilities,
31 readCustomModelEntries,
32} from "@t3tools/shared/model";
29import { resolveSpawnCommand } from "@t3tools/shared/shell";33import { resolveSpawnCommand } from "@t3tools/shared/shell";
30import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";34import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
31import {35import {
...@@ -232,9 +236,9 @@ function parseCodexModelListResponse(...@@ -232,9 +236,9 @@ function parseCodexModelListResponse(
232export function applyPreferredCodexDefaultModel(236export function applyPreferredCodexDefaultModel(
233 models: ReadonlyArray<ServerProviderModel>,237 models: ReadonlyArray<ServerProviderModel>,
234): ReadonlyArray<ServerProviderModel> {238): ReadonlyArray<ServerProviderModel> {
235 const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.find((slug) =>239 const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) =>
236 models.some((model) => model.slug === slug && !model.isCustom),240 models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug),
237 );241 )[0]?.slug;
238 if (!preferredSlug) {242 if (!preferredSlug) {
239 return models;243 return models;
240 }244 }
apps/server/src/provider/ModelManifest.test.ts+29
...@@ -38,6 +38,20 @@ const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel =>...@@ -38,6 +38,20 @@ const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel =>
38});38});
3939
40describe("classifyModels", () => {40describe("classifyModels", () => {
41 it("classifies qualified Codex families without changing their wire ids", () => {
42 const manifest: ModelManifestData = { version: 1, currentModels: { codex: ["gpt-test"] } };
43 const models = [
44 model({ slug: "openai.gpt-test", isLegacy: true }),
45 model({ slug: "openai.gpt-old" }),
46 ];
47 assert.deepStrictEqual(
48 classifyModels(models, manifest, CODEX).map((entry) => [entry.slug, entry.isLegacy ?? false]),
49 [
50 ["openai.gpt-test", false],
51 ["openai.gpt-old", true],
52 ],
53 );
54 });
41 it("flags non-current models, clears stale flags, and skips custom models", () => {55 it("flags non-current models, clears stale flags, and skips custom models", () => {
42 const manifest: ModelManifestData = {56 const manifest: ModelManifestData = {
43 version: 1,57 version: 1,
...@@ -64,6 +78,21 @@ describe("classifyModels", () => {...@@ -64,6 +78,21 @@ describe("classifyModels", () => {
64});78});
6579
66describe("applyManifestDefault", () => {80describe("applyManifestDefault", () => {
81 it("resolves the manifest default to the qualified live model", () => {
82 const manifest: ModelManifestData = {
83 version: 1,
84 currentModels: {},
85 providers: { codex: { models: [], profiles: {}, defaults: { chat: "gpt-test" } } },
86 };
87 const models = [
88 model({ slug: "openai.gpt-old", isDefault: true }),
89 model({ slug: "openai.gpt-test" }),
90 ];
91 assert.strictEqual(
92 applyManifestDefault(models, manifest, CODEX).find((entry) => entry.isDefault)?.slug,
93 "openai.gpt-test",
94 );
95 });
67 it("moves the default flag and its aliases to the manifest's chat default", () => {96 it("moves the default flag and its aliases to the manifest's chat default", () => {
68 const driver = ProviderDriverKind.make("antigravity");97 const driver = ProviderDriverKind.make("antigravity");
69 const manifest: ModelManifestData = {98 const manifest: ModelManifestData = {
apps/server/src/provider/ModelManifest.ts+18-6
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 type ProviderDriverKind,19 type ProviderDriverKind,
20 type ServerProviderModel,20 type ServerProviderModel,
21} from "@t3tools/contracts";21} from "@t3tools/contracts";
22import { codexModelFamily } from "@t3tools/shared/model";
22import * as Clock from "effect/Clock";23import * as Clock from "effect/Clock";
23import * as Context from "effect/Context";24import * as Context from "effect/Context";
24import * as Effect from "effect/Effect";25import * as Effect from "effect/Effect";
...@@ -212,13 +213,15 @@ function isLegacyModel(...@@ -212,13 +213,15 @@ function isLegacyModel(
212 driverKind: ProviderDriverKind,213 driverKind: ProviderDriverKind,
213 slug: string,214 slug: string,
214): boolean {215): boolean {
215 const catalogModel = manifest.providers?.[driverKind]?.models.find(216 const family = driverKind === "codex" ? codexModelFamily(slug) : slug;
216 (model) => model.slug === slug,217 const catalog = manifest.providers?.[driverKind]?.models;
217 );218 const catalogModel =
219 catalog?.find((model) => model.slug === slug) ??
220 catalog?.find((model) => model.slug === family);
218 if (catalogModel) return catalogModel.status === "legacy";221 if (catalogModel) return catalogModel.status === "legacy";
219 const currentModels = manifest.currentModels[driverKind];222 const currentModels = manifest.currentModels[driverKind];
220 if (!currentModels) return false;223 if (!currentModels) return false;
221 return !currentModels.includes(slug);224 return !currentModels.includes(slug) && !currentModels.includes(family);
222}225}
223226
224/**227/**
...@@ -260,8 +263,17 @@ export function applyManifestDefault(...@@ -260,8 +263,17 @@ export function applyManifestDefault(
260 manifest: ModelManifestData,263 manifest: ModelManifestData,
261 driverKind: ProviderDriverKind,264 driverKind: ProviderDriverKind,
262): ReadonlyArray<ServerProviderModel> {265): ReadonlyArray<ServerProviderModel> {
263 const slug = manifestDefaultModel(manifest, driverKind);266 const requestedSlug = manifestDefaultModel(manifest, driverKind);
264 if (slug === undefined || !models.some((model) => model.slug === slug)) return models;267 if (requestedSlug === undefined) return models;
268 const slug =
269 models.find((model) => model.slug === requestedSlug)?.slug ??
270 (driverKind === "codex"
271 ? models.find(
272 (model) =>
273 !model.isCustom && codexModelFamily(model.slug) === codexModelFamily(requestedSlug),
274 )?.slug
275 : undefined);
276 if (slug === undefined) return models;
265 const previous = models.find((model) => model.isDefault && model.slug !== slug);277 const previous = models.find((model) => model.isDefault && model.slug !== slug);
266 if (!previous) return models;278 if (!previous) return models;
267 const movedAliases = previous.aliases ?? [];279 const movedAliases = previous.aliases ?? [];
apps/server/src/textGeneration/CodexTextGeneration.test.ts+34-1
...@@ -134,6 +134,7 @@ function withFakeCodexEnv<A, E, R>(...@@ -134,6 +134,7 @@ function withFakeCodexEnv<A, E, R>(
134 input: FakeCodexInput & {134 input: FakeCodexInput & {
135 launchArgs?: string;135 launchArgs?: string;
136 environment?: NodeJS.ProcessEnv;136 environment?: NodeJS.ProcessEnv;
137 models?: ReadonlyArray<string>;
137 },138 },
138 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,139 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
139) {140) {
...@@ -142,12 +143,44 @@ function withFakeCodexEnv<A, E, R>(...@@ -142,12 +143,44 @@ function withFakeCodexEnv<A, E, R>(
142 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });143 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });
143 const codexPath = yield* makeFakeCodexBinary(tempDir, input);144 const codexPath = yield* makeFakeCodexBinary(tempDir, input);
144 const config = decodeCodexSettings({ binaryPath: codexPath, launchArgs: input.launchArgs });145 const config = decodeCodexSettings({ binaryPath: codexPath, launchArgs: input.launchArgs });
145 const textGeneration = yield* makeCodexTextGeneration(config, input.environment);146 const textGeneration = yield* makeCodexTextGeneration(
147 config,
148 input.environment,
149 Effect.succeed(
150 (input.models ?? []).map((slug) => ({
151 slug,
152 name: slug,
153 isCustom: false,
154 capabilities: null,
155 })),
156 ),
157 );
146 return yield* effectFn(textGeneration);158 return yield* effectFn(textGeneration);
147 }).pipe(Effect.scoped);159 }).pipe(Effect.scoped);
148}160}
149161
150it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {162it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
163 for (const selectedModel of ["gpt-5.6-luna", "openai.gpt-5.6-luna"]) {
164 it.effect(`dispatches the qualified live model for ${selectedModel}`, () =>
165 withFakeCodexEnv(
166 {
167 output: JSON.stringify({ title: "Bedrock title" }),
168 models: ["openai.gpt-5.6-luna"],
169 requireArg: "--model openai.gpt-5.6-luna",
170 forbidArg: "--model gpt-5.6-luna",
171 },
172 (textGeneration) =>
173 Effect.gen(function* () {
174 const result = yield* textGeneration.generateThreadTitle({
175 cwd: process.cwd(),
176 message: "Describe this change",
177 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), selectedModel),
178 });
179 expect(result.title).toBe("Bedrock title");
180 }),
181 ),
182 );
183 }
151 it.effect("generates and sanitizes commit messages without branch by default", () =>184 it.effect("generates and sanitizes commit messages without branch by default", () =>
152 withFakeCodexEnv(185 withFakeCodexEnv(
153 {186 {
apps/server/src/textGeneration/CodexTextGeneration.ts+12-2
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 type CodexSettings,11 type CodexSettings,
12 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,12 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
13 type ModelSelection,13 type ModelSelection,
14 type ServerProviderModel,
14 TextGenerationError,15 TextGenerationError,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
16import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";17import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
...@@ -34,7 +35,7 @@ import {...@@ -34,7 +35,7 @@ import {
34 sanitizeThreadTitle,35 sanitizeThreadTitle,
35 toJsonSchemaObject,36 toJsonSchemaObject,
36} from "./TextGenerationUtils.ts";37} from "./TextGenerationUtils.ts";
37import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";38import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model";
38import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts";39import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts";
3940
40const CODEX_TIMEOUT_MS = 180_000;41const CODEX_TIMEOUT_MS = 180_000;
...@@ -46,6 +47,7 @@ const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknow...@@ -46,6 +47,7 @@ const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknow
46export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* (47export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* (
47 codexConfig: CodexSettings,48 codexConfig: CodexSettings,
48 environment?: NodeJS.ProcessEnv,49 environment?: NodeJS.ProcessEnv,
50 getModels: Effect.Effect<ReadonlyArray<ServerProviderModel>> = Effect.succeed([]),
49) {51) {
50 const fileSystem = yield* FileSystem.FileSystem;52 const fileSystem = yield* FileSystem.FileSystem;
51 const path = yield* Path.Path;53 const path = yield* Path.Path;
...@@ -178,6 +180,14 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -178,6 +180,14 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
178 const outputPath = yield* writeTempFile(operation, "codex-output", "");180 const outputPath = yield* writeTempFile(operation, "codex-output", "");
179181
180 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {182 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {
183 const models = yield* getModels;
184 const requestedModel = modelSelection.model;
185 const model =
186 models.find((candidate) => candidate.slug === requestedModel)?.slug ??
187 models.find(
188 (candidate) => !candidate.isCustom && codexModelFamily(candidate.slug) === requestedModel,
189 )?.slug ??
190 requestedModel;
181 const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment);191 const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment);
182 const reasoningEffort =192 const reasoningEffort =
183 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??193 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
...@@ -193,7 +203,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -193,7 +203,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
193 "-s",203 "-s",
194 "read-only",204 "read-only",
195 "--model",205 "--model",
196 modelSelection.model,206 model,
197 "--config",207 "--config",
198 `model_reasoning_effort="${reasoningEffort}"`,208 `model_reasoning_effort="${reasoningEffort}"`,
199 ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []),209 ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []),
packages/shared/src/model.ts+5
...@@ -225,6 +225,11 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole...@@ -225,6 +225,11 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole
225 return typeof text === "string" && /\bultrathink\b/i.test(text);225 return typeof text === "string" && /\bultrathink\b/i.test(text);
226}226}
227227
228/** Compare Codex model families without changing provider-owned dispatch identifiers. */
229export function codexModelFamily(slug: string): string {
230 return slug.startsWith("openai.gpt-") ? slug.slice("openai.".length) : slug;
231}
232
228export function normalizeModelSlug(233export function normalizeModelSlug(
229 model: string | null | undefined,234 model: string | null | undefined,
230 provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND,235 provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND,