authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-11 05:30:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 05:30:19-07:00
loged0f7b908b2e7b3a22e6fd82a73794c2fe6ee06e
tree39a6a722d3518642087eaabf6481df9d382be2d3
parent18656a3402459d9d145bb3fa5767723531b27ac7
signature Signed by PGP key B5690EEEBB952194

fix(server): Sol Ultrafast stops vanishing from the Codex model picker (#18200)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

5 files changed, 212 insertions(+), 9 deletions(-)

apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+5-2
......@@ -14,7 +14,10 @@ import {
1414} from "../../provider/CodexTurnTokenUsage.ts";
1515import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot";
1616import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts";
17import { buildCodexInitializeParams } from "../../provider/CodexProvider.ts";
17import {
18 buildCodexInitializeParams,
19 codexAppServerEnvironment,
20} from "../../provider/CodexProvider.ts";
1821import {
1922 codexRateLimitsToUpdate,
2023 mergeCodexRateLimits,
......@@ -1511,7 +1514,7 @@ export const layerAppServerClientFactory: Layer.Layer<
15111514 Effect.gen(function* () {
15121515 const scope = yield* Scope.Scope;
15131516 const environment = {
1514 ...input.environment,
1517 ...codexAppServerEnvironment(input.environment),
15151518 ...(input.settings.homePath ? { CODEX_HOME: input.settings.homePath } : {}),
15161519 };
15171520 const command = yield* makeCodexAppServerSpawnCommand({
apps/server/src/provider/CodexProvider.catalog.test.ts created+111
......@@ -0,0 +1,111 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { CodexSettings } from "@t3tools/contracts";
4import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9
10import { checkCodexProviderStatus } from "./CodexProvider.ts";
11
12/**
13 * Stand-in for Codex 0.162's app-server catalog behavior:
14 * - `model/list` answers from `CODEX_HOME/models_cache.json` while it is fresh.
15 * - A startup refresh runs before `initialize`, so it uses the originator set
16 * at spawn (the override env var, else the plain CLI's `codex_cli_rs`).
17 * - The backend grants Ultrafast only to entitled accounts on app originators.
18 */
19const FAKE_CODEX_APP_SERVER = String.raw`#!/usr/bin/env node
20const fs = require("node:fs");
21const path = require("node:path");
22const readline = require("node:readline");
23const cachePath = path.join(process.env.CODEX_HOME, "models_cache.json");
24const originator = process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE ?? "codex_cli_rs";
25const ultrafast = process.env.FAKE_ULTRAFAST_ENTITLED === "1" && originator !== "codex_cli_rs";
26const model = (tiers) => ({
27 id: "gpt-6.1-sol", model: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", description: "",
28 hidden: false, isDefault: true, defaultReasoningEffort: "high",
29 supportedReasoningEfforts: [{ reasoningEffort: "high", description: "" }],
30 serviceTiers: tiers.map((id) => ({ id, name: id, description: "" })),
31});
32setTimeout(() => {
33 const tiers = ultrafast ? ["priority", "ultrafast"] : ["priority"];
34 fs.writeFileSync(cachePath, JSON.stringify({ fetched_at: new Date().toISOString(), models: [model(tiers)] }));
35}, 300);
36const write = (message) => process.stdout.write(JSON.stringify(message) + "\n");
37readline.createInterface({ input: process.stdin }).on("line", (line) => {
38 const { id, method } = JSON.parse(line);
39 if (id === undefined) return;
40 if (method === "initialize") {
41 write({ id, result: { userAgent: "codex/0.162.0", codexHome: process.env.CODEX_HOME, platformFamily: "unix", platformOs: "macos" } });
42 } else if (method === "account/read") {
43 write({ id, result: { account: { type: "chatgpt", email: "test@example.com", planType: "promax" }, requiresOpenaiAuth: false } });
44 } else if (method === "skills/list") {
45 write({ id, result: { data: [] } });
46 } else if (method === "model/list") {
47 write({ id, result: { data: JSON.parse(fs.readFileSync(cachePath, "utf8")).models, nextCursor: null } });
48 } else {
49 write({ id, error: { code: -32601, message: "unsupported" } });
50 }
51});
52`;
53
54const decodeCodexSettings = Schema.decodeSync(CodexSettings);
55
56const solServiceTiers = Effect.fn("solServiceTiers")(function* (ultrafastEntitled: boolean) {
57 const fs = yield* FileSystem.FileSystem;
58 const path = yield* Path.Path;
59 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-catalog-" });
60 const binaryPath = path.join(dir, "codex");
61 yield* fs.writeFileString(binaryPath, FAKE_CODEX_APP_SERVER);
62 yield* fs.chmod(binaryPath, 0o755);
63 // A fresh cache written by another client: the plain CLI's (no Ultrafast)
64 // when entitled, or a stale entitled catalog when the entitlement is gone.
65 yield* fs.writeFileString(
66 path.join(dir, "models_cache.json"),
67 JSON.stringify({
68 fetched_at: DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 1 })),
69 models: [
70 {
71 id: "gpt-6.1-sol",
72 model: "gpt-6.1-sol",
73 displayName: "GPT-6.1 Sol",
74 description: "",
75 hidden: false,
76 isDefault: true,
77 defaultReasoningEffort: "high",
78 supportedReasoningEfforts: [{ reasoningEffort: "high", description: "" }],
79 serviceTiers: (ultrafastEntitled ? ["priority"] : ["priority", "ultrafast"]).map(
80 (id) => ({ id, name: id, description: "" }),
81 ),
82 },
83 ],
84 }),
85 );
86
87 const { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: _ambient, ...environment } = process.env;
88 const provider = yield* checkCodexProviderStatus(
89 decodeCodexSettings({ binaryPath, homePath: dir }),
90 undefined,
91 { ...environment, FAKE_ULTRAFAST_ENTITLED: ultrafastEntitled ? "1" : "0" },
92 );
93 const serviceTier = provider.models
94 .find((model) => model.slug === "gpt-6.1-sol")
95 ?.capabilities?.optionDescriptors?.find((descriptor) => descriptor.id === "serviceTier");
96 return serviceTier?.type === "select" ? serviceTier.options.map((option) => option.id) : [];
97});
98
99it.layer(NodeServices.layer, { excludeTestServices: true })("Codex catalog discovery", (it) => {
100 it.effect("keeps Ultrafast when another Codex client wrote the shared catalog cache", () =>
101 Effect.gen(function* () {
102 assert.deepStrictEqual(yield* solServiceTiers(true), ["default", "priority", "ultrafast"]);
103 }).pipe(Effect.scoped),
104 );
105
106 it.effect("drops Ultrafast once the account is no longer entitled to it", () =>
107 Effect.gen(function* () {
108 assert.deepStrictEqual(yield* solServiceTiers(false), ["default", "priority"]);
109 }).pipe(Effect.scoped),
110 );
111});
apps/server/src/provider/CodexProvider.ts+85-6
......@@ -1,10 +1,13 @@
11import * as DateTime from "effect/DateTime";
22import * as Duration from "effect/Duration";
33import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
45import * as Layer from "effect/Layer";
56import * as Option from "effect/Option";
7import * as Path from "effect/Path";
68import * as Result from "effect/Result";
79import * as Schema from "effect/Schema";
10import * as Schedule from "effect/Schedule";
811import * as Scope from "effect/Scope";
912import * as Types from "effect/Types";
1013import * as ChildProcess from "effect/process/ChildProcess";
......@@ -66,6 +69,9 @@ type CodexRateLimitsProbe =
6669 | { readonly failure: string };
6770
6871const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
72const CODEX_CATALOG_REFRESH_TIMEOUT = "3 seconds" as const;
73const CODEX_CATALOG_REFRESH_POLL = "100 millis" as const;
74const CODEX_CLIENT_NAME = "T3 Code";
6975
7076const CODEX_PRESENTATION = {
7177 displayName: "Codex",
......@@ -347,11 +353,81 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (
347353 return models;
348354});
349355
356const decodeCodexModelsCacheStamp = Schema.decodeUnknownOption(
357 Schema.fromJsonString(Schema.Struct({ fetched_at: Schema.String })),
358);
359
360/** When Codex last wrote `models_cache.json`, in epoch millis; NaN when unreadable. */
361const readCodexModelsCacheFetchedAt = Effect.fn("readCodexModelsCacheFetchedAt")(function* (
362 codexHome: string,
363) {
364 const fileSystem = yield* FileSystem.FileSystem;
365 const path = yield* Path.Path;
366 const text = yield* fileSystem
367 .readFileString(path.join(codexHome, "models_cache.json"))
368 .pipe(Effect.option);
369 return Option.flatMap(text, decodeCodexModelsCacheStamp).pipe(
370 Option.flatMap((stamp) => DateTime.make(stamp.fetched_at)),
371 Option.match({ onNone: () => Number.NaN, onSome: DateTime.toEpochMillis }),
372 );
373});
374
375/**
376 * Lists the ChatGPT model catalog as fetched for T3 Code.
377 *
378 * `model/list` serves `CODEX_HOME/models_cache.json` while it is under five
379 * minutes old. Every Codex client on the machine shares that file, and the
380 * backend tailors the catalog to each client's originator: the plain CLI does
381 * not get Sol Ultrafast. So the first answer can be another client's catalog.
382 * App-server refreshes the catalog online at startup, so wait until a write
383 * newer than this process's spawn lands, then list again. If no refresh
384 * lands in time, keep the first answer. Another client writing in the same
385 * few hundred milliseconds can still win; the next probe corrects it.
386 */
387const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(function* (input: {
388 readonly client: CodexClient.CodexAppServerClient["Service"];
389 readonly codexHome: string;
390 readonly spawnedAtMs: number;
391}) {
392 const models = yield* requestAllCodexModels(input.client);
393 const refreshed = yield* readCodexModelsCacheFetchedAt(input.codexHome).pipe(
394 Effect.repeat({
395 schedule: Schedule.spaced(CODEX_CATALOG_REFRESH_POLL),
396 until: (fetchedAtMs) => fetchedAtMs >= input.spawnedAtMs,
397 }),
398 Effect.timeoutOption(CODEX_CATALOG_REFRESH_TIMEOUT),
399 );
400 if (Option.isNone(refreshed)) {
401 yield* Effect.logWarning(
402 "Codex did not refresh its model catalog in time; using the cached catalog.",
403 );
404 return models;
405 }
406 return yield* requestAllCodexModels(input.client);
407});
408
409/**
410 * Environment for every `codex app-server` T3 Code spawns.
411 *
412 * Codex sends its client name as the `originator` header, and the backend
413 * tailors the model catalog to it. App-server starts its catalog refresh
414 * before `initialize` names the client, so that first fetch goes out as the
415 * plain CLI, misses tiers like Sol Ultrafast, and overwrites the shared
416 * `models_cache.json`. Naming the originator at spawn closes that race.
417 * A value the user already set wins.
418 */
419export const codexAppServerEnvironment = (
420 environment: NodeJS.ProcessEnv | undefined,
421): NodeJS.ProcessEnv => ({
422 CODEX_INTERNAL_ORIGINATOR_OVERRIDE: CODEX_CLIENT_NAME,
423 ...environment,
424});
425
350426export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
351427 return {
352428 clientInfo: {
353 name: "T3 Code",
354 title: "T3 Code",
429 name: CODEX_CLIENT_NAME,
430 title: CODEX_CLIENT_NAME,
355431 version: packageJson.version,
356432 },
357433 capabilities: {
......@@ -382,7 +458,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu
382458 : undefined;
383459 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
384460 const environment = {
385 ...input.environment,
461 ...codexAppServerEnvironment(input.environment),
386462 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
387463 };
388464 const spawnCommand = yield* resolveSpawnCommand(
......@@ -427,6 +503,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
427503 readonly environment?: NodeJS.ProcessEnv;
428504 readonly skipNativeUsage?: boolean;
429505}) {
506 const spawnedAtMs = DateTime.toEpochMillis(yield* DateTime.now);
430507 const { client, initialize } = yield* withCodexAppServerClient(input);
431508
432509 // Extract the version string after the first '/' in userAgent, up to the next space or the end
......@@ -448,7 +525,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
448525 client.request("skills/list", {
449526 cwds: [input.cwd],
450527 }),
451 requestAllCodexModels(client),
528 accountResponse.account?.type === "chatgpt"
529 ? requestCodexChatGptModels({ client, codexHome: initialize.codexHome, spawnedAtMs })
530 : requestAllCodexModels(client),
452531 // Usage is an enrichment: a failure or a slow answer degrades to "no
453532 // usage this probe" rather than costing the account and models.
454533 input.skipNativeUsage
......@@ -589,14 +668,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
589668 }) => Effect.Effect<
590669 CodexAppServerProviderSnapshot,
591670 CodexErrors.CodexAppServerError,
592 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
671 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | Scope.Scope
593672 > = probeCodexAppServerProvider,
594673 environment?: NodeJS.ProcessEnv,
595674 managedAuth?: ServerProvider["auth"],
596675): Effect.fn.Return<
597676 ServerProviderDraft,
598677 ServerSettingsError,
599 ChildProcessSpawner.ChildProcessSpawner
678 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
600679> {
601680 const resolvedEnvironment = environment ?? process.env;
602681 const checkedAt = DateTime.formatIso(yield* DateTime.now);
apps/server/src/provider/Drivers/CodexDriver.ts+2
......@@ -235,6 +235,8 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
235235 ),
236236 ),
237237 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
238 Effect.provideService(FileSystem.FileSystem, fileSystem),
239 Effect.provideService(Path.Path, pathService),
238240 );
239241 const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig);
240242 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CodexSettings>>({
apps/server/src/provider/Drivers/CodexManagedProvider.ts+9-1
......@@ -1,5 +1,7 @@
11import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts";
22import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
35import * as Stream from "effect/Stream";
46import * as Option from "effect/Option";
57import { ChildProcessSpawner } from "effect/process";
......@@ -31,6 +33,8 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu
3133 const { instanceId, enabled, displayName, accentColor, config } = input;
3234 const http = yield* HttpClient.HttpClient;
3335 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
36 const fileSystem = yield* FileSystem.FileSystem;
37 const path = yield* Path.Path;
3438 const host = yield* ProviderHost.ProviderHost;
3539 const runtime = yield* makeCodexManagedRuntime({
3640 instanceId,
......@@ -190,7 +194,11 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu
190194 ),
191195 Effect.scoped,
192196 );
193 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
197 }).pipe(
198 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
199 Effect.provideService(FileSystem.FileSystem, fileSystem),
200 Effect.provideService(Path.Path, path),
201 );
194202 const snapshot = yield* makeManagedServerProvider({
195203 resolveMaintenance: () => Effect.succeed({ provider: DRIVER, packageName: null, update: null }),
196204 getSettings: host.settings.get,