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
2 changes: 1 addition & 1 deletion apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
return { client, sharedHomePath, outcome };
}).pipe(Effect.timeout(CODEX_RESET_CREDIT_TIMEOUT));
const reading = yield* client
.request("account/rateLimits/read", undefined)
.request("account/rateLimits/read", null)
.pipe(Effect.timeout("5 seconds"), Effect.result);
if (reading._tag === "Failure")
return {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2628,6 +2628,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
method: "item/tool/requestUserInput",
requestId: ApprovalRequestId.make("req-user-input-1"),
payload: {
isBlocking: true,
itemId: "item-user-input-1",
threadId: "thread-1",
turnId: "turn-1",
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/provider/Layers/CodexProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import {
applyPreferredCodexDefaultModel,
codexAccountAuthLabel,
codexVersionFloorMessage,
mapCodexModelCapabilities,
readCodexRateLimitsShared,
} from "./CodexProvider.ts";
Expand Down Expand Up @@ -101,6 +102,11 @@ it("uses standard routing when the catalog has no default service tier", () => {
name: "Fast",
description: "1.5x speed, increased usage",
},
{
id: "ultrafast",
name: "Ultrafast",
description: "The fastest available responses for latency-sensitive work.",
},
],
supportedReasoningEfforts: [],
});
Expand All @@ -117,6 +123,11 @@ it("uses standard routing when the catalog has no default service tier", () => {
label: "Fast",
description: "1.5x speed, increased usage",
},
{
id: "ultrafast",
label: "Ultrafast",
description: "Even faster, more expensive",
},
],
currentValue: "default",
},
Expand Down Expand Up @@ -182,6 +193,17 @@ it("ignores custom models that shadow a preferred slug", () => {
assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4");
});

it("warns only when the Codex CLI predates the regenerated protocol", () => {
assert.strictEqual(codexVersionFloorMessage("0.156.0"), undefined);
assert.strictEqual(codexVersionFloorMessage("0.159.2"), undefined);
assert.strictEqual(codexVersionFloorMessage(undefined), undefined);
assert.strictEqual(codexVersionFloorMessage("not-a-version"), undefined);
assert.strictEqual(
codexVersionFloorMessage("0.155.4"),
"Codex CLI v0.155.4 is older than Pylon supports. Update Codex to v0.156.0 or newer; threads can fail to start or roll back until then.",
);
});

it("labels every account plan Codex can report", () => {
// `planType` is an open string, so exhaustiveness no longer pins these
// mappings — this table is what does. Reordering a plan into the wrong group,
Expand All @@ -193,6 +215,7 @@ it("labels every account plan Codex can report", () => {
["plus", "ChatGPT Plus Subscription"],
["pro", "ChatGPT Pro 20x Subscription"],
["prolite", "ChatGPT Pro 5x Subscription"],
["promax", "ChatGPT Pro Max Subscription"],
["team", "ChatGPT Team Subscription"],
["self_serve_business_prolite", "ChatGPT Business Subscription"],
["self_serve_business_usage_based", "ChatGPT Business Subscription"],
Expand Down
51 changes: 42 additions & 9 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
createModelCapabilities,
readCustomModelEntries,
} from "@t3tools/shared/model";
import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
import { resolveSpawnCommand } from "@t3tools/shared/shell";
import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
import {
Expand Down Expand Up @@ -98,6 +99,28 @@ const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = {

const DEFAULT_SERVICE_TIER_ID = "default";

/** Shorter copy for tiers whose catalog description wraps in the traits menu. */
const SERVICE_TIER_DESCRIPTIONS: Readonly<Record<string, string>> = {
ultrafast: "Even faster, more expensive",
};

// The generated app-server bindings track Codex 0.159 and require fields that
// first shipped in 0.156 (`Thread.projectId`, `isBlocking` on user-input
// requests), so an older CLI fails to decode partway through a session.
const MINIMUM_CODEX_CLI_VERSION = "0.156.0";

export function codexVersionFloorMessage(version: string | null | undefined): string | undefined {
// An unparseable version is left alone rather than guessed at.
if (
!version ||
parseSemver(version) === null ||
compareSemverVersions(version, MINIMUM_CODEX_CLI_VERSION) >= 0
) {
return undefined;
}
return `Codex CLI v${version} is older than Pylon supports. Update Codex to v${MINIMUM_CODEX_CLI_VERSION} or newer; threads can fail to start or roll back until then.`;
}

function reasoningEffortLabel(reasoningEffort: string): string {
return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort;
}
Expand All @@ -123,6 +146,8 @@ export function codexPlanLabel(planType: string | null | undefined): string {
return "ChatGPT Pro 20x Subscription";
case "prolite":
return "ChatGPT Pro 5x Subscription";
case "promax":
return "ChatGPT Pro Max Subscription";
case "team":
return "ChatGPT Team Subscription";
case "self_serve_business_prolite":
Expand Down Expand Up @@ -206,12 +231,15 @@ export function mapCodexModelCapabilities(
label: "Standard",
...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}),
},
...serviceTiers.map((tier) => ({
id: tier.id,
label: tier.name,
...(tier.description ? { description: tier.description } : {}),
...(defaultServiceTier === tier.id ? { isDefault: true } : {}),
})),
...serviceTiers.map((tier) => {
const description = SERVICE_TIER_DESCRIPTIONS[tier.id] ?? tier.description;
return {
id: tier.id,
label: tier.name,
...(description ? { description } : {}),
...(defaultServiceTier === tier.id ? { isDefault: true } : {}),
};
}),
],
currentValue: defaultServiceTier,
});
Expand Down Expand Up @@ -552,7 +580,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
sharedHomePath,
accountIdentity,
read: client
.request("account/rateLimits/read", undefined)
.request("account/rateLimits/read", null)
.pipe(
Effect.option,
Effect.timeoutOption(CODEX_RATE_LIMITS_PROBE_TIMEOUT),
Expand Down Expand Up @@ -803,6 +831,10 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
const usageLimits = snapshot.rateLimits
? usageLimitsFromCodexRateLimits(snapshot.rateLimits, checkedAt)
: snapshot.sharedUsageLimits;
const versionFloorMessage = codexVersionFloorMessage(snapshot.version);
const probeMessage = [accountStatus.message, versionFloorMessage]
.filter((message) => message !== undefined)
.join(" ");

return buildServerProvider({
presentation: CODEX_PRESENTATION,
Expand All @@ -821,10 +853,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
probe: {
installed: true,
version: snapshot.version ?? null,
status: accountStatus.status,
status:
versionFloorMessage && accountStatus.status === "ready" ? "warning" : accountStatus.status,
auth: accountStatus.auth,
...(usageLimits ? { usageLimits } : {}),
...(accountStatus.message ? { message: accountStatus.message } : {}),
...(probeMessage ? { message: probeMessage } : {}),
},
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ for (const threadId of threads.keys()) persistNative(threadId, [nativeHeader(thr
const metadata = (threadId) => ({
id: threadId, cwd, path: nativePath(threadId), ephemeral: false,
status: { type: config.unloadedSnapshots && !loaded.has(threadId) ? "notLoaded" : "idle" }, turns: threads.get(threadId) ?? [],
cliVersion: "test", createdAt: 1, updatedAt: 1, modelProvider: "openai", preview: "",
cliVersion: "test", createdAt: 1, updatedAt: 1, modelProvider: "openai", preview: "", projectId: null,
sessionId: threadId, source: "appServer"
});
const opened = (threadId) => ({
Expand Down Expand Up @@ -187,6 +187,7 @@ const approvalRequests = [
params: {
turnId: "native-turn",
itemId: "native-input",
isBlocking: true,
questions: [{ id: "question", header: "Choice", question: "Choose" }],
},
},
Expand Down
30 changes: 17 additions & 13 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,25 +99,28 @@ describe("Codex thread history", () => {
);
}

it.effect("keeps the count-based rollback API for older threads", () =>
it.effect("surfaces Codex rejecting a revert of a legacy thread", () =>
Effect.gen(function* () {
const rejection = CodexErrors.CodexAppServerRequestError.invalidRequest(
"thread/revert only supports paginated threads",
);
const client: Parameters<typeof rollbackCodexThread>[0] = {
raw: { request: () => Effect.succeed({ thread: {} }) },
request: <M extends CodexRpc.ClientRequestMethod>(
method: M,
params: CodexRpc.ClientRequestParamsByMethod[M],
) => {
NodeAssert.equal(method, "thread/rollback");
NodeAssert.deepEqual(params, { threadId: "legacy-thread", numTurns: 2 });
raw: {
request: (method) => {
if (method === "thread/read") return Effect.succeed({ thread: {} });
if (method === "thread/revert") return Effect.fail(rejection);
return Effect.die(`Unexpected raw request: ${method}`);
},
},
request: <M extends CodexRpc.ClientRequestMethod>(method: M) => {
NodeAssert.equal(method, "thread/read");
return Effect.succeed({
thread: { id: "legacy-thread", turns: [] },
thread: { id: "legacy-thread", turns: [{ id: "turn-1", items: [] }] },
} as unknown as CodexRpc.ClientRequestResponsesByMethod[M]);
},
};
NodeAssert.deepEqual(yield* rollbackCodexThread(client, "legacy-thread", 2), {
threadId: "legacy-thread",
turns: [],
});
const error = yield* Effect.flip(rollbackCodexThread(client, "legacy-thread", 1));
NodeAssert.strictEqual(error, rejection);
}),
);
});
Expand Down Expand Up @@ -807,6 +810,7 @@ function makeThreadStartedNotification(
id: threadId,
modelProvider: "openai",
preview: "",
projectId: null,
sessionId: threadId,
source,
status: { type: "idle" as const },
Expand Down
16 changes: 6 additions & 10 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ const McpElicitationFormField = Schema.Struct({
type: Schema.optionalKey(NullableMcpElicitationString),
title: Schema.optionalKey(NullableMcpElicitationString),
description: Schema.optionalKey(NullableMcpElicitationString),
default: Schema.optionalKey(Schema.Unknown),
default: Schema.optionalKey(Schema.Json),
enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
oneOf: Schema.optionalKey(
Expand Down Expand Up @@ -186,8 +186,7 @@ export type CodexTurnStartParamsWithCollaborationMode =
export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type;
type CodexServiceTier = NonNullable<EffectCodexSchema.V2ThreadStartParams["serviceTier"]>;
type CodexThreadItem =
| EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number]
| EffectCodexSchema.V2ThreadRollbackResponse["thread"]["turns"][number]["items"][number];
EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number];

export interface CodexSessionRuntimeOptions {
readonly threadId: ThreadId;
Expand Down Expand Up @@ -486,7 +485,7 @@ export function toMcpElicitationResponse(
? "always"
: undefined;
const form = mcpElicitationFormFields(payload);
const content: Record<string, unknown> = {};
const content: Record<string, Schema.Json> = {};

for (const [key, field] of Object.entries(form?.properties ?? {})) {
const options = mcpElicitationFieldOptions(field);
Expand Down Expand Up @@ -1332,7 +1331,7 @@ function updateSession(
}

function parseThreadSnapshot(
response: EffectCodexSchema.V2ThreadReadResponse | EffectCodexSchema.V2ThreadRollbackResponse,
response: EffectCodexSchema.V2ThreadReadResponse,
): CodexThreadSnapshot {
return {
threadId: response.thread.id,
Expand Down Expand Up @@ -1420,11 +1419,8 @@ export const rollbackCodexThread = Effect.fn("rollbackCodexThread")(function* (
threadId: string,
numTurns: number,
): Effect.fn.Return<CodexThreadSnapshot, CodexErrors.CodexAppServerError> {
if ((yield* readCodexHistoryMode(client, threadId)) !== "paginated") {
return parseThreadSnapshot(yield* client.request("thread/rollback", { threadId, numTurns }));
}
// Paginated threads replace history at a turn boundary instead of supporting
// the legacy count-based rollback endpoint.
// Codex replaces history at a turn boundary. It rejects threads that still
// use legacy history, which have no rollback API since Codex 0.156.
const snapshot = yield* readCodexThread(client, threadId);
const retainedCount = Math.max(0, snapshot.turns.length - numTurns);
const firstRemoved = snapshot.turns[retainedCount];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
[codex!.snapshot.refresh, claude!.snapshot.refresh],
{ concurrency: "unbounded" },
);
expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" });
expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.156.0" });
expect(claudeSnapshot).toMatchObject({
status: "ready",
installed: true,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/primeAgentBackends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export const readPrimeAgentCodexWindows = Effect.fn("readPrimeAgentCodexWindows"
accessToken: signIn.accessToken,
chatgptAccountId: signIn.accountId,
});
return yield* client.request("account/rateLimits/read", undefined);
return yield* client.request("account/rateLimits/read", null);
}).pipe(
Effect.scoped,
Effect.timeoutOption(CODEX_CAPACITY_READ_TIMEOUT_MS),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ rl.on("line", (line) => {
write({
id,
result: {
userAgent: "t3-collab-mock/0.0.0",
userAgent: "t3-collab-mock/0.156.0",
codexHome: "/tmp",
platformFamily: "unix",
platformOs: "linux",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"forkedFromId": null,
"parentThreadId": null,
"preview": "",
"projectId": null,
"ephemeral": false,
"historyMode": "legacy",
"modelProvider": "openai",
Expand Down Expand Up @@ -389,6 +390,7 @@
"forkedFromId": null,
"parentThreadId": null,
"preview": "",
"projectId": null,
"ephemeral": false,
"historyMode": "legacy",
"modelProvider": "openai",
Expand Down
4 changes: 4 additions & 0 deletions docs/user/providers-codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ For one account, use the default Codex provider with your normal Codex login.
[Provider setup](./install.md#providers) covers installation, **Settings → Providers**, and custom
binaries or environment variables.

Pylon needs Codex CLI 0.156.0 or newer. An older CLI shows a warning on the provider, and its threads
can fail to start or roll back until you update Codex. A thread that Codex still stores in its
pre-0.156 history format cannot be rolled back; continue it or start a new one.

## Use multiple accounts

A shared Codex home with a shadow home lets work and personal accounts continue the same threads.
Expand Down
Loading
Loading