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> = {
186186 environment: processEnv,
187187 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
188188 });
189 const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv);
190189
191190 // Build a managed snapshot whose settings never change — mutations come
192191 // in as instance rebuilds from the registry rather than in-place
......@@ -242,6 +241,11 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
242241 }),
243242 ),
244243 );
244 const textGeneration = yield* makeCodexTextGeneration(
245 effectiveConfig,
246 processEnv,
247 snapshot.getSnapshot.pipe(Effect.map((value) => value.models)),
248 );
245249 const snapshotForCwd = (cwd: string) =>
246250 !effectiveConfig.enabled
247251 ? snapshot.getSnapshot
apps/server/src/provider/Layers/CodexProvider.test.ts+17
......@@ -127,6 +127,23 @@ it("prefers sol over terra when both are available", () => {
127127 assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol");
128128});
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
130147it("keeps Codex's own default when no preferred model is available", () => {
131148 const models = applyPreferredCodexDefaultModel([
132149 { 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 {
2525} from "@t3tools/contracts";
2626import { 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";
2933import { resolveSpawnCommand } from "@t3tools/shared/shell";
3034import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
3135import {
......@@ -232,9 +236,9 @@ function parseCodexModelListResponse(
232236export function applyPreferredCodexDefaultModel(
233237 models: ReadonlyArray<ServerProviderModel>,
234238): ReadonlyArray<ServerProviderModel> {
235 const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.find((slug) =>
236 models.some((model) => model.slug === slug && !model.isCustom),
237 );
239 const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) =>
240 models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug),
241 )[0]?.slug;
238242 if (!preferredSlug) {
239243 return models;
240244 }
apps/server/src/provider/ModelManifest.test.ts+29
......@@ -38,6 +38,20 @@ const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel =>
3838});
3939
4040describe("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 });
4155 it("flags non-current models, clears stale flags, and skips custom models", () => {
4256 const manifest: ModelManifestData = {
4357 version: 1,
......@@ -64,6 +78,21 @@ describe("classifyModels", () => {
6478});
6579
6680describe("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 });
6796 it("moves the default flag and its aliases to the manifest's chat default", () => {
6897 const driver = ProviderDriverKind.make("antigravity");
6998 const manifest: ModelManifestData = {
apps/server/src/provider/ModelManifest.ts+18-6
......@@ -19,6 +19,7 @@ import {
1919 type ProviderDriverKind,
2020 type ServerProviderModel,
2121} from "@t3tools/contracts";
22import { codexModelFamily } from "@t3tools/shared/model";
2223import * as Clock from "effect/Clock";
2324import * as Context from "effect/Context";
2425import * as Effect from "effect/Effect";
......@@ -212,13 +213,15 @@ function isLegacyModel(
212213 driverKind: ProviderDriverKind,
213214 slug: string,
214215): boolean {
215 const catalogModel = manifest.providers?.[driverKind]?.models.find(
216 (model) => model.slug === slug,
217 );
216 const family = driverKind === "codex" ? codexModelFamily(slug) : slug;
217 const catalog = manifest.providers?.[driverKind]?.models;
218 const catalogModel =
219 catalog?.find((model) => model.slug === slug) ??
220 catalog?.find((model) => model.slug === family);
218221 if (catalogModel) return catalogModel.status === "legacy";
219222 const currentModels = manifest.currentModels[driverKind];
220223 if (!currentModels) return false;
221 return !currentModels.includes(slug);
224 return !currentModels.includes(slug) && !currentModels.includes(family);
222225}
223226
224227/**
......@@ -260,8 +263,17 @@ export function applyManifestDefault(
260263 manifest: ModelManifestData,
261264 driverKind: ProviderDriverKind,
262265): ReadonlyArray<ServerProviderModel> {
263 const slug = manifestDefaultModel(manifest, driverKind);
264 if (slug === undefined || !models.some((model) => model.slug === slug)) return models;
266 const requestedSlug = manifestDefaultModel(manifest, driverKind);
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;
265277 const previous = models.find((model) => model.isDefault && model.slug !== slug);
266278 if (!previous) return models;
267279 const movedAliases = previous.aliases ?? [];
apps/server/src/textGeneration/CodexTextGeneration.test.ts+34-1
......@@ -134,6 +134,7 @@ function withFakeCodexEnv<A, E, R>(
134134 input: FakeCodexInput & {
135135 launchArgs?: string;
136136 environment?: NodeJS.ProcessEnv;
137 models?: ReadonlyArray<string>;
137138 },
138139 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
139140) {
......@@ -142,12 +143,44 @@ function withFakeCodexEnv<A, E, R>(
142143 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });
143144 const codexPath = yield* makeFakeCodexBinary(tempDir, input);
144145 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 );
146158 return yield* effectFn(textGeneration);
147159 }).pipe(Effect.scoped);
148160}
149161
150162it.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 }
151184 it.effect("generates and sanitizes commit messages without branch by default", () =>
152185 withFakeCodexEnv(
153186 {
apps/server/src/textGeneration/CodexTextGeneration.ts+12-2
......@@ -11,6 +11,7 @@ import {
1111 type CodexSettings,
1212 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
1313 type ModelSelection,
14 type ServerProviderModel,
1415 TextGenerationError,
1516} from "@t3tools/contracts";
1617import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
......@@ -34,7 +35,7 @@ import {
3435 sanitizeThreadTitle,
3536 toJsonSchemaObject,
3637} from "./TextGenerationUtils.ts";
37import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
38import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model";
3839import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts";
3940
4041const CODEX_TIMEOUT_MS = 180_000;
......@@ -46,6 +47,7 @@ const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknow
4647export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* (
4748 codexConfig: CodexSettings,
4849 environment?: NodeJS.ProcessEnv,
50 getModels: Effect.Effect<ReadonlyArray<ServerProviderModel>> = Effect.succeed([]),
4951) {
5052 const fileSystem = yield* FileSystem.FileSystem;
5153 const path = yield* Path.Path;
......@@ -178,6 +180,14 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
178180 const outputPath = yield* writeTempFile(operation, "codex-output", "");
179181
180182 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;
181191 const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment);
182192 const reasoningEffort =
183193 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
......@@ -193,7 +203,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
193203 "-s",
194204 "read-only",
195205 "--model",
196 modelSelection.model,
206 model,
197207 "--config",
198208 `model_reasoning_effort="${reasoningEffort}"`,
199209 ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []),
packages/shared/src/model.ts+5
......@@ -225,6 +225,11 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole
225225 return typeof text === "string" && /\bultrathink\b/i.test(text);
226226}
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
228233export function normalizeModelSlug(
229234 model: string | null | undefined,
230235 provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND,