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 {...@@ -14,7 +14,10 @@ import {
14} from "../../provider/CodexTurnTokenUsage.ts";14} from "../../provider/CodexTurnTokenUsage.ts";
15import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot";15import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot";
16import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts";16import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts";
17import { buildCodexInitializeParams } from "../../provider/CodexProvider.ts";17import {
18 buildCodexInitializeParams,
19 codexAppServerEnvironment,
20} from "../../provider/CodexProvider.ts";
18import {21import {
19 codexRateLimitsToUpdate,22 codexRateLimitsToUpdate,
20 mergeCodexRateLimits,23 mergeCodexRateLimits,
...@@ -1511,7 +1514,7 @@ export const layerAppServerClientFactory: Layer.Layer<...@@ -1511,7 +1514,7 @@ export const layerAppServerClientFactory: Layer.Layer<
1511 Effect.gen(function* () {1514 Effect.gen(function* () {
1512 const scope = yield* Scope.Scope;1515 const scope = yield* Scope.Scope;
1513 const environment = {1516 const environment = {
1514 ...input.environment,1517 ...codexAppServerEnvironment(input.environment),
1515 ...(input.settings.homePath ? { CODEX_HOME: input.settings.homePath } : {}),1518 ...(input.settings.homePath ? { CODEX_HOME: input.settings.homePath } : {}),
1516 };1519 };
1517 const command = yield* makeCodexAppServerSpawnCommand({1520 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 @@...@@ -1,10 +1,13 @@
1import * as DateTime from "effect/DateTime";1import * as DateTime from "effect/DateTime";
2import * as Duration from "effect/Duration";2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
6import * as Result from "effect/Result";8import * as Result from "effect/Result";
7import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
10import * as Schedule from "effect/Schedule";
8import * as Scope from "effect/Scope";11import * as Scope from "effect/Scope";
9import * as Types from "effect/Types";12import * as Types from "effect/Types";
10import * as ChildProcess from "effect/process/ChildProcess";13import * as ChildProcess from "effect/process/ChildProcess";
...@@ -66,6 +69,9 @@ type CodexRateLimitsProbe =...@@ -66,6 +69,9 @@ type CodexRateLimitsProbe =
66 | { readonly failure: string };69 | { readonly failure: string };
6770
68const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;71const 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
70const CODEX_PRESENTATION = {76const CODEX_PRESENTATION = {
71 displayName: "Codex",77 displayName: "Codex",
...@@ -347,11 +353,81 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (...@@ -347,11 +353,81 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (
347 return models;353 return models;
348});354});
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
350export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {426export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
351 return {427 return {
352 clientInfo: {428 clientInfo: {
353 name: "T3 Code",429 name: CODEX_CLIENT_NAME,
354 title: "T3 Code",430 title: CODEX_CLIENT_NAME,
355 version: packageJson.version,431 version: packageJson.version,
356 },432 },
357 capabilities: {433 capabilities: {
...@@ -382,7 +458,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu...@@ -382,7 +458,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu
382 : undefined;458 : undefined;
383 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;459 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
384 const environment = {460 const environment = {
385 ...input.environment,461 ...codexAppServerEnvironment(input.environment),
386 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),462 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
387 };463 };
388 const spawnCommand = yield* resolveSpawnCommand(464 const spawnCommand = yield* resolveSpawnCommand(
...@@ -427,6 +503,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun...@@ -427,6 +503,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
427 readonly environment?: NodeJS.ProcessEnv;503 readonly environment?: NodeJS.ProcessEnv;
428 readonly skipNativeUsage?: boolean;504 readonly skipNativeUsage?: boolean;
429}) {505}) {
506 const spawnedAtMs = DateTime.toEpochMillis(yield* DateTime.now);
430 const { client, initialize } = yield* withCodexAppServerClient(input);507 const { client, initialize } = yield* withCodexAppServerClient(input);
431508
432 // Extract the version string after the first '/' in userAgent, up to the next space or the end509 // 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...@@ -448,7 +525,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
448 client.request("skills/list", {525 client.request("skills/list", {
449 cwds: [input.cwd],526 cwds: [input.cwd],
450 }),527 }),
451 requestAllCodexModels(client),528 accountResponse.account?.type === "chatgpt"
529 ? requestCodexChatGptModels({ client, codexHome: initialize.codexHome, spawnedAtMs })
530 : requestAllCodexModels(client),
452 // Usage is an enrichment: a failure or a slow answer degrades to "no531 // Usage is an enrichment: a failure or a slow answer degrades to "no
453 // usage this probe" rather than costing the account and models.532 // usage this probe" rather than costing the account and models.
454 input.skipNativeUsage533 input.skipNativeUsage
...@@ -589,14 +668,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -589,14 +668,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
589 }) => Effect.Effect<668 }) => Effect.Effect<
590 CodexAppServerProviderSnapshot,669 CodexAppServerProviderSnapshot,
591 CodexErrors.CodexAppServerError,670 CodexErrors.CodexAppServerError,
592 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope671 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | Scope.Scope
593 > = probeCodexAppServerProvider,672 > = probeCodexAppServerProvider,
594 environment?: NodeJS.ProcessEnv,673 environment?: NodeJS.ProcessEnv,
595 managedAuth?: ServerProvider["auth"],674 managedAuth?: ServerProvider["auth"],
596): Effect.fn.Return<675): Effect.fn.Return<
597 ServerProviderDraft,676 ServerProviderDraft,
598 ServerSettingsError,677 ServerSettingsError,
599 ChildProcessSpawner.ChildProcessSpawner678 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
600> {679> {
601 const resolvedEnvironment = environment ?? process.env;680 const resolvedEnvironment = environment ?? process.env;
602 const checkedAt = DateTime.formatIso(yield* DateTime.now);681 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...@@ -235,6 +235,8 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
235 ),235 ),
236 ),236 ),
237 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),237 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
238 Effect.provideService(FileSystem.FileSystem, fileSystem),
239 Effect.provideService(Path.Path, pathService),
238 );240 );
239 const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig);241 const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig);
240 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CodexSettings>>({242 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CodexSettings>>({
apps/server/src/provider/Drivers/CodexManagedProvider.ts+9-1
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts";1import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
3import * as Stream from "effect/Stream";5import * as Stream from "effect/Stream";
4import * as Option from "effect/Option";6import * as Option from "effect/Option";
5import { ChildProcessSpawner } from "effect/process";7import { ChildProcessSpawner } from "effect/process";
...@@ -31,6 +33,8 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu...@@ -31,6 +33,8 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu
31 const { instanceId, enabled, displayName, accentColor, config } = input;33 const { instanceId, enabled, displayName, accentColor, config } = input;
32 const http = yield* HttpClient.HttpClient;34 const http = yield* HttpClient.HttpClient;
33 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;35 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
36 const fileSystem = yield* FileSystem.FileSystem;
37 const path = yield* Path.Path;
34 const host = yield* ProviderHost.ProviderHost;38 const host = yield* ProviderHost.ProviderHost;
35 const runtime = yield* makeCodexManagedRuntime({39 const runtime = yield* makeCodexManagedRuntime({
36 instanceId,40 instanceId,
...@@ -190,7 +194,11 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu...@@ -190,7 +194,11 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu
190 ),194 ),
191 Effect.scoped,195 Effect.scoped,
192 );196 );
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 );
194 const snapshot = yield* makeManagedServerProvider({202 const snapshot = yield* makeManagedServerProvider({
195 resolveMaintenance: () => Effect.succeed({ provider: DRIVER, packageName: null, update: null }),203 resolveMaintenance: () => Effect.succeed({ provider: DRIVER, packageName: null, update: null }),
196 getSettings: host.settings.get,204 getSettings: host.settings.get,