diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 071fb20674a8bd7bccde486316b1525fd361bdd7..22dd047f5c694b588100a3da69576d7a6dfd242f 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -186,7 +186,6 @@ export const CodexDriver: ProviderDriver = { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); - const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); // Build a managed snapshot whose settings never change — mutations come // in as instance rebuilds from the registry rather than in-place @@ -242,6 +241,11 @@ export const CodexDriver: ProviderDriver = { }), ), ); + const textGeneration = yield* makeCodexTextGeneration( + effectiveConfig, + processEnv, + snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), + ); const snapshotForCwd = (cwd: string) => !effectiveConfig.enabled ? snapshot.getSnapshot diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2aeebdb2ccd867514c5d683bb46df3834954d29c..0c7a40d9bd9ef84a4b076b137da5507ddb553e15 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -127,6 +127,23 @@ it("prefers sol over terra when both are available", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); }); +it("ranks qualified Codex models while preserving their wire ids", () => { + const models = applyPreferredCodexDefaultModel([ + { + slug: "openai.gpt-5.6-luna", + name: "Luna", + isCustom: false, + isDefault: true, + capabilities: null, + }, + { slug: "openai.gpt-5.6-sol", name: "Sol", isCustom: false, capabilities: null }, + ]); + assert.deepStrictEqual( + models.filter((model) => model.isDefault).map((model) => model.slug), + ["openai.gpt-5.6-sol"], + ); +}); + it("keeps Codex's own default when no preferred model is available", () => { const models = applyPreferredCodexDefaultModel([ { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1971913f1f98c8777e1176e04eafec3c5e159937..31d02f42c5f4aa9926d482d08b24fb76d1f38760 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -25,7 +25,11 @@ import type { } from "@t3tools/contracts"; import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; -import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model"; +import { + codexModelFamily, + createModelCapabilities, + readCustomModelEntries, +} from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; import { @@ -232,9 +236,9 @@ function parseCodexModelListResponse( export function applyPreferredCodexDefaultModel( models: ReadonlyArray, ): ReadonlyArray { - const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.find((slug) => - models.some((model) => model.slug === slug && !model.isCustom), - ); + const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) => + models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug), + )[0]?.slug; if (!preferredSlug) { return models; } diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index bb592a0a6fc8c5aecc70ebc6c47ad36c11535cc5..0a2ca28feb7324f6da152d9daf172150f0b3b195 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -38,6 +38,20 @@ const model = (overrides: Partial): ServerProviderModel => }); describe("classifyModels", () => { + it("classifies qualified Codex families without changing their wire ids", () => { + const manifest: ModelManifestData = { version: 1, currentModels: { codex: ["gpt-test"] } }; + const models = [ + model({ slug: "openai.gpt-test", isLegacy: true }), + model({ slug: "openai.gpt-old" }), + ]; + assert.deepStrictEqual( + classifyModels(models, manifest, CODEX).map((entry) => [entry.slug, entry.isLegacy ?? false]), + [ + ["openai.gpt-test", false], + ["openai.gpt-old", true], + ], + ); + }); it("flags non-current models, clears stale flags, and skips custom models", () => { const manifest: ModelManifestData = { version: 1, @@ -64,6 +78,21 @@ describe("classifyModels", () => { }); describe("applyManifestDefault", () => { + it("resolves the manifest default to the qualified live model", () => { + const manifest: ModelManifestData = { + version: 1, + currentModels: {}, + providers: { codex: { models: [], profiles: {}, defaults: { chat: "gpt-test" } } }, + }; + const models = [ + model({ slug: "openai.gpt-old", isDefault: true }), + model({ slug: "openai.gpt-test" }), + ]; + assert.strictEqual( + applyManifestDefault(models, manifest, CODEX).find((entry) => entry.isDefault)?.slug, + "openai.gpt-test", + ); + }); it("moves the default flag and its aliases to the manifest's chat default", () => { const driver = ProviderDriverKind.make("antigravity"); const manifest: ModelManifestData = { diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 15000630b35948bad0a784f03ec4f15809446f4a..f5ad15dd85865176a757671aac08b451d61644f3 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -19,6 +19,7 @@ import { type ProviderDriverKind, type ServerProviderModel, } from "@t3tools/contracts"; +import { codexModelFamily } from "@t3tools/shared/model"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -212,13 +213,15 @@ function isLegacyModel( driverKind: ProviderDriverKind, slug: string, ): boolean { - const catalogModel = manifest.providers?.[driverKind]?.models.find( - (model) => model.slug === slug, - ); + const family = driverKind === "codex" ? codexModelFamily(slug) : slug; + const catalog = manifest.providers?.[driverKind]?.models; + const catalogModel = + catalog?.find((model) => model.slug === slug) ?? + catalog?.find((model) => model.slug === family); if (catalogModel) return catalogModel.status === "legacy"; const currentModels = manifest.currentModels[driverKind]; if (!currentModels) return false; - return !currentModels.includes(slug); + return !currentModels.includes(slug) && !currentModels.includes(family); } /** @@ -260,8 +263,17 @@ export function applyManifestDefault( manifest: ModelManifestData, driverKind: ProviderDriverKind, ): ReadonlyArray { - const slug = manifestDefaultModel(manifest, driverKind); - if (slug === undefined || !models.some((model) => model.slug === slug)) return models; + const requestedSlug = manifestDefaultModel(manifest, driverKind); + if (requestedSlug === undefined) return models; + const slug = + models.find((model) => model.slug === requestedSlug)?.slug ?? + (driverKind === "codex" + ? models.find( + (model) => + !model.isCustom && codexModelFamily(model.slug) === codexModelFamily(requestedSlug), + )?.slug + : undefined); + if (slug === undefined) return models; const previous = models.find((model) => model.isDefault && model.slug !== slug); if (!previous) return models; const movedAliases = previous.aliases ?? []; diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 0129136d5e8d77e9a300df1deec09461a975b4d9..12a327d3445294184e487a6fc7f4d5278aab8127 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -134,6 +134,7 @@ function withFakeCodexEnv( input: FakeCodexInput & { launchArgs?: string; environment?: NodeJS.ProcessEnv; + models?: ReadonlyArray; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { @@ -142,12 +143,44 @@ function withFakeCodexEnv( const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); const codexPath = yield* makeFakeCodexBinary(tempDir, input); const config = decodeCodexSettings({ binaryPath: codexPath, launchArgs: input.launchArgs }); - const textGeneration = yield* makeCodexTextGeneration(config, input.environment); + const textGeneration = yield* makeCodexTextGeneration( + config, + input.environment, + Effect.succeed( + (input.models ?? []).map((slug) => ({ + slug, + name: slug, + isCustom: false, + capabilities: null, + })), + ), + ); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { + for (const selectedModel of ["gpt-5.6-luna", "openai.gpt-5.6-luna"]) { + it.effect(`dispatches the qualified live model for ${selectedModel}`, () => + withFakeCodexEnv( + { + output: JSON.stringify({ title: "Bedrock title" }), + models: ["openai.gpt-5.6-luna"], + requireArg: "--model openai.gpt-5.6-luna", + forbidArg: "--model gpt-5.6-luna", + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Describe this change", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), selectedModel), + }); + expect(result.title).toBe("Bedrock title"); + }), + ), + ); + } it.effect("generates and sanitizes commit messages without branch by default", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0b870ac1d679d01baeac1418f6e7371baf8dfe58..10c16fc9cee554c18b9a597ec8ab36b210cf5897 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -11,6 +11,7 @@ import { type CodexSettings, DEFAULT_TEXT_GENERATION_REASONING_EFFORT, type ModelSelection, + type ServerProviderModel, TextGenerationError, } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; @@ -34,7 +35,7 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "./TextGenerationUtils.ts"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; const CODEX_TIMEOUT_MS = 180_000; @@ -46,6 +47,7 @@ const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknow export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( codexConfig: CodexSettings, environment?: NodeJS.ProcessEnv, + getModels: Effect.Effect> = Effect.succeed([]), ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -178,6 +180,14 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const outputPath = yield* writeTempFile(operation, "codex-output", ""); const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { + const models = yield* getModels; + const requestedModel = modelSelection.model; + const model = + models.find((candidate) => candidate.slug === requestedModel)?.slug ?? + models.find( + (candidate) => !candidate.isCustom && codexModelFamily(candidate.slug) === requestedModel, + )?.slug ?? + requestedModel; const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment); const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? @@ -193,7 +203,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func "-s", "read-only", "--model", - modelSelection.model, + model, "--config", `model_reasoning_effort="${reasoningEffort}"`, ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []), diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index d6cb26f25be3f0292b27d4c4d94857ae5f7839ec..b3c278a1ac36584acb0464f95fc1b306c080ad55 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -225,6 +225,11 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole return typeof text === "string" && /\bultrathink\b/i.test(text); } +/** Compare Codex model families without changing provider-owned dispatch identifiers. */ +export function codexModelFamily(slug: string): string { + return slug.startsWith("openai.gpt-") ? slug.slice("openai.".length) : slug; +} + export function normalizeModelSlug( model: string | null | undefined, provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND,