Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
111 changes: 111 additions & 0 deletions apps/server/src/provider/CodexProvider.catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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),
);
});
91 changes: 85 additions & 6 deletions apps/server/src/provider/CodexProvider.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
),
),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, pathService),
);
const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig);
const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CodexSettings>>({
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/provider/Drivers/CodexManagedProvider.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading