Skip to content
114 changes: 101 additions & 13 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,8 @@ describe("CodexAdapterV2 fork boundary", () => {
});

const DEFAULT_CODEX_SETTINGS = Schema.decodeSync(CodexSettings)({});
const CHATGPT_SHARING_LIMIT_MESSAGE =
"stream disconnected before completion: The ChatGPT user has reached their Subscription Sharing usage limit. Ask the user to try again after their usage limit resets or use an API key instead.";
const CODEX_TEST_MODEL_SELECTION = {
instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID,
model: "gpt-5.4",
Expand Down Expand Up @@ -1173,6 +1175,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
onRequest: (method: string, params: unknown) => Effect.Effect<void> = () => Effect.void,
readChildMetadata?: Parameters<typeof withCodexReplayChildMetadata>[2],
beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"],
onUsageLimits?: CodexAdapterV2.CodexAdapterV2Options["onUsageLimits"],
) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -1230,6 +1233,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
settings: DEFAULT_CODEX_SETTINGS,
environment: {},
clientFactory,
...(onUsageLimits ? { onUsageLimits } : {}),
digest: yield* Digest.Digest,
fileSystem,
idAllocator,
Expand Down Expand Up @@ -5426,14 +5430,58 @@ describe("CodexAdapterV2 post-settle continuation", () => {
code: "other",
details: "subscription_sharing_workplace_individual_usage_limit_exceeded",
notification: true,
usedBefore: 100,
expectedClass: "usage_limit",
expectedCode: "subscription_sharing_workplace_individual_usage_limit_exceeded",
expectedMessage: "workspace admin",
},
// regression: #25333 ChatGPT sharing limit prose classifies as a usage limit
{
name: "chatgpt-sharing-prose-retry",
code: "responseStreamDisconnected",
errorInfo: { responseStreamDisconnected: { httpStatusCode: null } },
message: CHATGPT_SHARING_LIMIT_MESSAGE,
notification: true,
willRetry: true,
usedBefore: 100,
expectedClass: "usage_limit",
expectedCode: "subscription_sharing_usage_limit_exceeded",
expectedMessage: "Usage settings",
},
{
name: "chatgpt-sharing-prose",
code: "responseStreamDisconnected",
errorInfo: { responseStreamDisconnected: { httpStatusCode: null } },
message: CHATGPT_SHARING_LIMIT_MESSAGE,
notification: false,
resetAfter: true,
expectedClass: "usage_limit",
expectedCode: "subscription_sharing_usage_limit_exceeded",
expectedMessage: "Usage settings",
},
{
name: "sharing-code-mention",
code: "responseStreamDisconnected",
errorInfo: { responseStreamDisconnected: { httpStatusCode: null } },
message: "Transport unavailable; this is not subscription_sharing_usage_limit_exceeded.",
notification: false,
expectedClass: "provider_error",
},
{
name: "sharing-code-mention-retry",
code: "responseStreamDisconnected",
errorInfo: { responseStreamDisconnected: { httpStatusCode: null } },
message: "Transport unavailable; this is not subscription_sharing_usage_limit_exceeded.",
notification: true,
willRetry: true,
expectedClass: "transport_error",
},
] as const)("classifies Codex terminal failures from $name evidence", (scenario) =>
Effect.scoped(
Effect.gen(function* () {
const nativeThreadId = `native-limit-${scenario.name}`;
const nativeTurnId = `turn-limit-${scenario.name}`;
const message = "Provider stopped this request.";
const message = "message" in scenario ? scenario.message : "Provider stopped this request.";
const errorInfo = "errorInfo" in scenario ? scenario.errorInfo : scenario.code;
const resetAt = "2033-05-19T07:20:00.000Z";
const snapshot = {
Expand All @@ -5453,6 +5501,26 @@ describe("CodexAdapterV2 post-settle continuation", () => {
scenario: `codex-limit-${scenario.name}`,
entries: [
...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }),
...("usedBefore" in scenario
? [
{
...snapshot,
frame: {
method: "account/rateLimits/updated",
params: {
rateLimits: {
limitId: "codex",
primary: {
usedPercent: scenario.usedBefore,
resetsAt: 2000100000,
windowDurationMins: 300,
},
},
},
},
},
]
: []),
...(scenario.name === "deferred-reset" ? [snapshot] : []),
...(scenario.name === "deferred-reset"
? [
Expand Down Expand Up @@ -5500,7 +5568,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
params: {
threadId: nativeThreadId,
turnId: nativeTurnId,
willRetry: scenario.name === "retry",
willRetry: scenario.name === "retry" || "willRetry" in scenario,
error: {
message,
codexErrorInfo: errorInfo,
Expand Down Expand Up @@ -5537,7 +5605,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
},
},
},
...(scenario.name === "late-reset" ? [snapshot] : []),
...(scenario.name === "late-reset" || "resetAfter" in scenario ? [snapshot] : []),
...(scenario.name === "deferred-reset"
? [
{
Expand Down Expand Up @@ -5575,12 +5643,19 @@ describe("CodexAdapterV2 post-settle continuation", () => {
],
});
const resetReceipt = yield* Deferred.make<void>();
const harness = yield* makeCodexReplayHarness(transcript, (event) =>
event.type === "turn_item.updated" &&
event.turnItem.type === "error" &&
event.turnItem.failure.resetAt === resetAt
? Deferred.succeed(resetReceipt, undefined)
: Effect.void,
const snapshotReceipt = yield* Deferred.make<void>();
const harness = yield* makeCodexReplayHarness(
transcript,
(event) =>
event.type === "turn_item.updated" &&
event.turnItem.type === "error" &&
event.turnItem.failure.resetAt === resetAt
? Deferred.succeed(resetReceipt, undefined)
: Effect.void,
undefined,
undefined,
undefined,
() => Deferred.succeed(snapshotReceipt, undefined),
);
yield* harness.runtime.startTurn(
makeCodexTestTurnInput({
Expand All @@ -5600,6 +5675,17 @@ describe("CodexAdapterV2 post-settle continuation", () => {
if (scenario.name === "deferred-reset") assert.equal(terminal.failure.resetAt, resetAt);
if (scenario.name === "matching-details")
assert.equal(terminal.failure.message, "Detailed provider allowance explanation.");
if ("resetAfter" in scenario) {
yield* Deferred.await(snapshotReceipt);
assert.isFalse(
harness.events.some(
(event) =>
event.type === "turn_item.updated" &&
event.turnItem.type === "error" &&
event.turnItem.failure.resetAt != null,
),
);
}
if (scenario.name === "late-reset") {
yield* Deferred.await(resetReceipt);
const item = harness.events.find(
Expand All @@ -5610,10 +5696,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
);
assert.isDefined(item);
}
if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1);
if (scenario.name === "chatgpt-workspace-limit") {
assert.equal(terminal.failure.code, scenario.details);
assert.include(terminal.failure.message, "workspace admin");
if (scenario.name === "retry" || "willRetry" in scenario)
assert.equal(terminal.retry?.attempt, 1);
if ("expectedCode" in scenario && "expectedMessage" in scenario) {
assert.equal(terminal.failure.code, scenario.expectedCode);
assert.include(terminal.failure.message, scenario.expectedMessage);
assert.equal(terminal.failure.resetAt, null);
}
}).pipe(
Effect.provide(
Expand Down
27 changes: 22 additions & 5 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1108,7 +1108,8 @@ function codexUsageLimitError(

/**
* ChatGPT sharing reports its own reason inside Codex's generic turn error, so
* the code it names replaces Codex's, and clients pick a recovery from it.
* the code it names replaces Codex's, and clients pick a recovery from it. Its
* allowance limits get the usage-limit recovery; other sharing failures keep their class.
*/
function withCodexManagedFailure(
failure: OrchestrationV2ProviderFailure,
Expand All @@ -1117,7 +1118,22 @@ function withCodexManagedFailure(
const managed = classifyCodexManagedError(nativeError);
return managed === undefined
? failure
: { ...failure, message: managed.message, code: managed.code };
: {
...failure,
message: managed.message,
code: managed.code,
...(managed.usageLimit ? { class: "usage_limit" } : {}),
};
}

function failureResetAt(
failure: OrchestrationV2ProviderFailure,
snapshot: CodexRateLimitSnapshot | undefined,
) {
// Sharing caps have no reset timestamp in the ordinary Codex allowance snapshot.
return classifyCodexManagedError(failure.code)?.usageLimit
? null
: codexUsageLimitResetAt(snapshot);
}

interface ActiveCodexTurnContext {
Expand Down Expand Up @@ -4434,8 +4450,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
const previous = yield* Ref.getAndUpdate(rateLimitSnapshot, (previous) =>
mergeCodexRateLimits(previous, payload.rateLimits),
);
const resetAt = codexUsageLimitResetAt(yield* Ref.get(rateLimitSnapshot));
const snapshot = yield* Ref.get(rateLimitSnapshot);
for (const item of (yield* Ref.get(limitedTurnItems)).values()) {
const resetAt = failureResetAt(item.failure, snapshot);
if (resetAt === null || item.failure.resetAt != null) continue;
const updated = {
...item,
Expand Down Expand Up @@ -5780,7 +5797,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
failure.class === "usage_limit"
? {
...failure,
resetAt: codexUsageLimitResetAt(yield* Ref.get(rateLimitSnapshot)),
resetAt: failureResetAt(failure, yield* Ref.get(rateLimitSnapshot)),
}
: failure,
...(input.providerRetry === undefined
Expand Down Expand Up @@ -5820,7 +5837,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
...event.failure,
resetAt:
event.failure.resetAt ??
codexUsageLimitResetAt(yield* Ref.get(rateLimitSnapshot)),
failureResetAt(event.failure, yield* Ref.get(rateLimitSnapshot)),
},
}
: event;
Expand Down
50 changes: 50 additions & 0 deletions apps/server/src/provider/CodexManagedErrors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ it("maps streamed failures to safe actionable messages and reconnect decisions",
message:
"ChatGPT could not validate this connection. Check the selected account and sharing permissions.",
code: "subscription_sharing_v2_invalid_user",
usageLimit: false,
},
);
assert.include(
Expand Down Expand Up @@ -58,3 +59,52 @@ it("preserves credentials for the current subscriber and permission error codes"
assert.strictEqual(classifyCodexManagedError({ error: { code } })?.code, code);
}
});

it("treats only ChatGPT sharing allowance limits as usage limits", () => {
assert.deepInclude(
classifyCodexManagedError(
"stream disconnected before completion: The ChatGPT user has reached their Subscription Sharing usage limit. Ask the user to try again after their usage limit resets or use an API key instead.",
),
{ code: "subscription_sharing_usage_limit_exceeded", usageLimit: true },
);
assert.isTrue(
classifyCodexManagedError("subscription_sharing_workplace_individual_usage_limit_exceeded")!
.usageLimit,
);
assert.isFalse(classifyCodexManagedError("subscription_sharing_usage_unavailable")!.usageLimit);
assert.isUndefined(
classifyCodexManagedError("stream disconnected before completion: error decoding response"),
);
assert.isUndefined(classifyCodexManagedError("You've hit your usage limit."));
});

it("does not infer sharing codes from incidental mentions in turn errors", () => {
const mention = "Transport unavailable; this is not subscription_sharing_usage_limit_exceeded.";
for (const value of [
mention,
{ message: mention },
{ message: "Transport unavailable", additionalDetails: mention },
{ code: "other", message: mention },
]) {
assert.isUndefined(classifyCodexManagedError(value));
}
assert.deepInclude(
classifyCodexManagedError({
error: { code: "subscription_sharing_usage_unavailable", message: mention },
}),
{ code: "subscription_sharing_usage_unavailable", usageLimit: false },
);
});

it("reads sharing codes from structured errors and exact turn details", () => {
const code = "subscription_sharing_usage_limit_exceeded";
const response = { error: { code } };
for (const value of [
response,
JSON.stringify(response),
{ message: "Provider stopped", additionalDetails: code },
{ message: "Provider stopped", additionalDetails: JSON.stringify(response) },
]) {
assert.deepInclude(classifyCodexManagedError(value), { code, usageLimit: true });
}
});
Loading
Loading