diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 38e64af02e1213c33e2da2c987e7af319b8ddbe7..d8ef1461ef73c440c82fad0e81e8703cd98d6542 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -14,7 +14,10 @@ import { } from "../../provider/CodexTurnTokenUsage.ts"; import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot"; import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts"; -import { buildCodexInitializeParams } from "../../provider/CodexProvider.ts"; +import { + buildCodexInitializeParams, + codexAppServerEnvironment, +} from "../../provider/CodexProvider.ts"; import { codexRateLimitsToUpdate, mergeCodexRateLimits, @@ -1511,7 +1514,7 @@ export const layerAppServerClientFactory: Layer.Layer< Effect.gen(function* () { const scope = yield* Scope.Scope; const environment = { - ...input.environment, + ...codexAppServerEnvironment(input.environment), ...(input.settings.homePath ? { CODEX_HOME: input.settings.homePath } : {}), }; const command = yield* makeCodexAppServerSpawnCommand({ diff --git a/apps/server/src/provider/CodexProvider.catalog.test.ts b/apps/server/src/provider/CodexProvider.catalog.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d862ac8bc8f473ab5f89c317356e14a32c219a7e --- /dev/null +++ b/apps/server/src/provider/CodexProvider.catalog.test.ts @@ -0,0 +1,111 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { CodexSettings } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { checkCodexProviderStatus } from "./CodexProvider.ts"; + +/** + * Stand-in for Codex 0.162's app-server catalog behavior: + * - `model/list` answers from `CODEX_HOME/models_cache.json` while it is fresh. + * - A startup refresh runs before `initialize`, so it uses the originator set + * at spawn (the override env var, else the plain CLI's `codex_cli_rs`). + * - The backend grants Ultrafast only to entitled accounts on app originators. + */ +const FAKE_CODEX_APP_SERVER = String.raw`#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const readline = require("node:readline"); +const cachePath = path.join(process.env.CODEX_HOME, "models_cache.json"); +const originator = process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE ?? "codex_cli_rs"; +const ultrafast = process.env.FAKE_ULTRAFAST_ENTITLED === "1" && originator !== "codex_cli_rs"; +const model = (tiers) => ({ + id: "gpt-6.1-sol", model: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", description: "", + hidden: false, isDefault: true, defaultReasoningEffort: "high", + supportedReasoningEfforts: [{ reasoningEffort: "high", description: "" }], + serviceTiers: tiers.map((id) => ({ id, name: id, description: "" })), +}); +setTimeout(() => { + const tiers = ultrafast ? ["priority", "ultrafast"] : ["priority"]; + fs.writeFileSync(cachePath, JSON.stringify({ fetched_at: new Date().toISOString(), models: [model(tiers)] })); +}, 300); +const write = (message) => process.stdout.write(JSON.stringify(message) + "\n"); +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const { id, method } = JSON.parse(line); + if (id === undefined) return; + if (method === "initialize") { + write({ id, result: { userAgent: "codex/0.162.0", codexHome: process.env.CODEX_HOME, platformFamily: "unix", platformOs: "macos" } }); + } else if (method === "account/read") { + write({ id, result: { account: { type: "chatgpt", email: "test@example.com", planType: "promax" }, requiresOpenaiAuth: false } }); + } else if (method === "skills/list") { + write({ id, result: { data: [] } }); + } else if (method === "model/list") { + write({ id, result: { data: JSON.parse(fs.readFileSync(cachePath, "utf8")).models, nextCursor: null } }); + } else { + write({ id, error: { code: -32601, message: "unsupported" } }); + } +}); +`; + +const decodeCodexSettings = Schema.decodeSync(CodexSettings); + +const solServiceTiers = Effect.fn("solServiceTiers")(function* (ultrafastEntitled: boolean) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-catalog-" }); + const binaryPath = path.join(dir, "codex"); + yield* fs.writeFileString(binaryPath, FAKE_CODEX_APP_SERVER); + yield* fs.chmod(binaryPath, 0o755); + // A fresh cache written by another client: the plain CLI's (no Ultrafast) + // when entitled, or a stale entitled catalog when the entitlement is gone. + yield* fs.writeFileString( + path.join(dir, "models_cache.json"), + JSON.stringify({ + fetched_at: DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 1 })), + models: [ + { + id: "gpt-6.1-sol", + model: "gpt-6.1-sol", + displayName: "GPT-6.1 Sol", + description: "", + hidden: false, + isDefault: true, + defaultReasoningEffort: "high", + supportedReasoningEfforts: [{ reasoningEffort: "high", description: "" }], + serviceTiers: (ultrafastEntitled ? ["priority"] : ["priority", "ultrafast"]).map( + (id) => ({ id, name: id, description: "" }), + ), + }, + ], + }), + ); + + const { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: _ambient, ...environment } = process.env; + const provider = yield* checkCodexProviderStatus( + decodeCodexSettings({ binaryPath, homePath: dir }), + undefined, + { ...environment, FAKE_ULTRAFAST_ENTITLED: ultrafastEntitled ? "1" : "0" }, + ); + const serviceTier = provider.models + .find((model) => model.slug === "gpt-6.1-sol") + ?.capabilities?.optionDescriptors?.find((descriptor) => descriptor.id === "serviceTier"); + return serviceTier?.type === "select" ? serviceTier.options.map((option) => option.id) : []; +}); + +it.layer(NodeServices.layer, { excludeTestServices: true })("Codex catalog discovery", (it) => { + it.effect("keeps Ultrafast when another Codex client wrote the shared catalog cache", () => + Effect.gen(function* () { + assert.deepStrictEqual(yield* solServiceTiers(true), ["default", "priority", "ultrafast"]); + }).pipe(Effect.scoped), + ); + + it.effect("drops Ultrafast once the account is no longer entitled to it", () => + Effect.gen(function* () { + assert.deepStrictEqual(yield* solServiceTiers(false), ["default", "priority"]); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 5109bb840f561919ea9d443f42545a0341a975d1..9ed238bf5d2ceb9981dcdc970612708b4d6f96bd 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -1,10 +1,13 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as Types from "effect/Types"; import * as ChildProcess from "effect/process/ChildProcess"; @@ -66,6 +69,9 @@ type CodexRateLimitsProbe = | { readonly failure: string }; const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; +const CODEX_CATALOG_REFRESH_TIMEOUT = "3 seconds" as const; +const CODEX_CATALOG_REFRESH_POLL = "100 millis" as const; +const CODEX_CLIENT_NAME = "T3 Code"; const CODEX_PRESENTATION = { displayName: "Codex", @@ -347,11 +353,81 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( return models; }); +const decodeCodexModelsCacheStamp = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ fetched_at: Schema.String })), +); + +/** When Codex last wrote `models_cache.json`, in epoch millis; NaN when unreadable. */ +const readCodexModelsCacheFetchedAt = Effect.fn("readCodexModelsCacheFetchedAt")(function* ( + codexHome: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const text = yield* fileSystem + .readFileString(path.join(codexHome, "models_cache.json")) + .pipe(Effect.option); + return Option.flatMap(text, decodeCodexModelsCacheStamp).pipe( + Option.flatMap((stamp) => DateTime.make(stamp.fetched_at)), + Option.match({ onNone: () => Number.NaN, onSome: DateTime.toEpochMillis }), + ); +}); + +/** + * Lists the ChatGPT model catalog as fetched for T3 Code. + * + * `model/list` serves `CODEX_HOME/models_cache.json` while it is under five + * minutes old. Every Codex client on the machine shares that file, and the + * backend tailors the catalog to each client's originator: the plain CLI does + * not get Sol Ultrafast. So the first answer can be another client's catalog. + * App-server refreshes the catalog online at startup, so wait until a write + * newer than this process's spawn lands, then list again. If no refresh + * lands in time, keep the first answer. Another client writing in the same + * few hundred milliseconds can still win; the next probe corrects it. + */ +const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(function* (input: { + readonly client: CodexClient.CodexAppServerClient["Service"]; + readonly codexHome: string; + readonly spawnedAtMs: number; +}) { + const models = yield* requestAllCodexModels(input.client); + const refreshed = yield* readCodexModelsCacheFetchedAt(input.codexHome).pipe( + Effect.repeat({ + schedule: Schedule.spaced(CODEX_CATALOG_REFRESH_POLL), + until: (fetchedAtMs) => fetchedAtMs >= input.spawnedAtMs, + }), + Effect.timeoutOption(CODEX_CATALOG_REFRESH_TIMEOUT), + ); + if (Option.isNone(refreshed)) { + yield* Effect.logWarning( + "Codex did not refresh its model catalog in time; using the cached catalog.", + ); + return models; + } + return yield* requestAllCodexModels(input.client); +}); + +/** + * Environment for every `codex app-server` T3 Code spawns. + * + * Codex sends its client name as the `originator` header, and the backend + * tailors the model catalog to it. App-server starts its catalog refresh + * before `initialize` names the client, so that first fetch goes out as the + * plain CLI, misses tiers like Sol Ultrafast, and overwrites the shared + * `models_cache.json`. Naming the originator at spawn closes that race. + * A value the user already set wins. + */ +export const codexAppServerEnvironment = ( + environment: NodeJS.ProcessEnv | undefined, +): NodeJS.ProcessEnv => ({ + CODEX_INTERNAL_ORIGINATOR_OVERRIDE: CODEX_CLIENT_NAME, + ...environment, +}); + export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { return { clientInfo: { - name: "T3 Code", - title: "T3 Code", + name: CODEX_CLIENT_NAME, + title: CODEX_CLIENT_NAME, version: packageJson.version, }, capabilities: { @@ -382,7 +458,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu : undefined; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const environment = { - ...input.environment, + ...codexAppServerEnvironment(input.environment), ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; const spawnCommand = yield* resolveSpawnCommand( @@ -427,6 +503,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly environment?: NodeJS.ProcessEnv; readonly skipNativeUsage?: boolean; }) { + const spawnedAtMs = DateTime.toEpochMillis(yield* DateTime.now); const { client, initialize } = yield* withCodexAppServerClient(input); // 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 client.request("skills/list", { cwds: [input.cwd], }), - requestAllCodexModels(client), + accountResponse.account?.type === "chatgpt" + ? requestCodexChatGptModels({ client, codexHome: initialize.codexHome, spawnedAtMs }) + : requestAllCodexModels(client), // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. input.skipNativeUsage @@ -589,14 +668,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }) => Effect.Effect< CodexAppServerProviderSnapshot, CodexErrors.CodexAppServerError, - ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | Scope.Scope > = probeCodexAppServerProvider, environment?: NodeJS.ProcessEnv, managedAuth?: ServerProvider["auth"], ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, - ChildProcessSpawner.ChildProcessSpawner + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 8aa546d103e128c7f8cf839f1defbe76bf39f522..09d5413f39d5936f214ce608fd0405ce29bb5989 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -235,6 +235,8 @@ export const CodexDriver: ProviderDriver>({ diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index d7b20bf2e00707a110ee5a29268006da685de332..374f177fd9cae827c2816c6f60d2f5c20e935c74 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -1,5 +1,7 @@ import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/process"; @@ -31,6 +33,8 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu const { instanceId, enabled, displayName, accentColor, config } = input; const http = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const host = yield* ProviderHost.ProviderHost; const runtime = yield* makeCodexManagedRuntime({ instanceId, @@ -190,7 +194,11 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu ), Effect.scoped, ); - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const snapshot = yield* makeManagedServerProvider({ resolveMaintenance: () => Effect.succeed({ provider: DRIVER, packageName: null, update: null }), getSettings: host.settings.get,