Skip to content

Commit 935818e

Browse files
shivamhwpPR Batch Testerclaudemaria-rcks
authored
fix(codex): sharing usage limits offer the existing recovery options (#25341)
Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: maria-rcks <maria@kuuro.net>
1 parent 3f905ca commit 935818e

8 files changed

Lines changed: 290 additions & 58 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts‎

Lines changed: 101 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,8 @@ describe("CodexAdapterV2 fork boundary", () => {
480480
});
481481

482482
const DEFAULT_CODEX_SETTINGS = Schema.decodeSync(CodexSettings)({});
483+
const CHATGPT_SHARING_LIMIT_MESSAGE =
484+
"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.";
483485
const CODEX_TEST_MODEL_SELECTION = {
484486
instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID,
485487
model: "gpt-5.4",
@@ -1235,6 +1237,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
12351237
onRequest: (method: string, params: unknown) => Effect.Effect<void> = () => Effect.void,
12361238
readChildMetadata?: Parameters<typeof withCodexReplayChildMetadata>[2],
12371239
beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"],
1240+
onUsageLimits?: CodexAdapterV2.CodexAdapterV2Options["onUsageLimits"],
12381241
) =>
12391242
Effect.gen(function* () {
12401243
const fileSystem = yield* FileSystem.FileSystem;
@@ -1292,6 +1295,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
12921295
settings: DEFAULT_CODEX_SETTINGS,
12931296
environment: {},
12941297
clientFactory,
1298+
...(onUsageLimits ? { onUsageLimits } : {}),
12951299
digest: yield* Digest.Digest,
12961300
fileSystem,
12971301
idAllocator,
@@ -5612,14 +5616,58 @@ describe("CodexAdapterV2 post-settle continuation", () => {
56125616
code: "other",
56135617
details: "subscription_sharing_workplace_individual_usage_limit_exceeded",
56145618
notification: true,
5619+
usedBefore: 100,
5620+
expectedClass: "usage_limit",
5621+
expectedCode: "subscription_sharing_workplace_individual_usage_limit_exceeded",
5622+
expectedMessage: "workspace admin",
5623+
},
5624+
// regression: #25333 ChatGPT sharing limit prose classifies as a usage limit
5625+
{
5626+
name: "chatgpt-sharing-prose-retry",
5627+
code: "responseStreamDisconnected",
5628+
errorInfo: { responseStreamDisconnected: { httpStatusCode: null } },
5629+
message: CHATGPT_SHARING_LIMIT_MESSAGE,
5630+
notification: true,
5631+
willRetry: true,
5632+
usedBefore: 100,
5633+
expectedClass: "usage_limit",
5634+
expectedCode: "subscription_sharing_usage_limit_exceeded",
5635+
expectedMessage: "Usage settings",
5636+
},
5637+
{
5638+
name: "chatgpt-sharing-prose",
5639+
code: "responseStreamDisconnected",
5640+
errorInfo: { responseStreamDisconnected: { httpStatusCode: null } },
5641+
message: CHATGPT_SHARING_LIMIT_MESSAGE,
5642+
notification: false,
5643+
resetAfter: true,
5644+
expectedClass: "usage_limit",
5645+
expectedCode: "subscription_sharing_usage_limit_exceeded",
5646+
expectedMessage: "Usage settings",
5647+
},
5648+
{
5649+
name: "sharing-code-mention",
5650+
code: "responseStreamDisconnected",
5651+
errorInfo: { responseStreamDisconnected: { httpStatusCode: null } },
5652+
message: "Transport unavailable; this is not subscription_sharing_usage_limit_exceeded.",
5653+
notification: false,
56155654
expectedClass: "provider_error",
56165655
},
5656+
{
5657+
name: "sharing-code-mention-retry",
5658+
code: "responseStreamDisconnected",
5659+
errorInfo: { responseStreamDisconnected: { httpStatusCode: null } },
5660+
message: "Transport unavailable; this is not subscription_sharing_usage_limit_exceeded.",
5661+
notification: true,
5662+
willRetry: true,
5663+
expectedClass: "transport_error",
5664+
},
56175665
] as const)("classifies Codex terminal failures from $name evidence", (scenario) =>
56185666
Effect.scoped(
56195667
Effect.gen(function* () {
56205668
const nativeThreadId = `native-limit-${scenario.name}`;
56215669
const nativeTurnId = `turn-limit-${scenario.name}`;
5622-
const message = "Provider stopped this request.";
5670+
const message = "message" in scenario ? scenario.message : "Provider stopped this request.";
56235671
const errorInfo = "errorInfo" in scenario ? scenario.errorInfo : scenario.code;
56245672
const resetAt = "2033-05-19T07:20:00.000Z";
56255673
const snapshot = {
@@ -5639,6 +5687,26 @@ describe("CodexAdapterV2 post-settle continuation", () => {
56395687
scenario: `codex-limit-${scenario.name}`,
56405688
entries: [
56415689
...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }),
5690+
...("usedBefore" in scenario
5691+
? [
5692+
{
5693+
...snapshot,
5694+
frame: {
5695+
method: "account/rateLimits/updated",
5696+
params: {
5697+
rateLimits: {
5698+
limitId: "codex",
5699+
primary: {
5700+
usedPercent: scenario.usedBefore,
5701+
resetsAt: 2000100000,
5702+
windowDurationMins: 300,
5703+
},
5704+
},
5705+
},
5706+
},
5707+
},
5708+
]
5709+
: []),
56425710
...(scenario.name === "deferred-reset" ? [snapshot] : []),
56435711
...(scenario.name === "deferred-reset"
56445712
? [
@@ -5686,7 +5754,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
56865754
params: {
56875755
threadId: nativeThreadId,
56885756
turnId: nativeTurnId,
5689-
willRetry: scenario.name === "retry",
5757+
willRetry: scenario.name === "retry" || "willRetry" in scenario,
56905758
error: {
56915759
message,
56925760
codexErrorInfo: errorInfo,
@@ -5723,7 +5791,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
57235791
},
57245792
},
57255793
},
5726-
...(scenario.name === "late-reset" ? [snapshot] : []),
5794+
...(scenario.name === "late-reset" || "resetAfter" in scenario ? [snapshot] : []),
57275795
...(scenario.name === "deferred-reset"
57285796
? [
57295797
{
@@ -5761,12 +5829,19 @@ describe("CodexAdapterV2 post-settle continuation", () => {
57615829
],
57625830
});
57635831
const resetReceipt = yield* Deferred.make<void>();
5764-
const harness = yield* makeCodexReplayHarness(transcript, (event) =>
5765-
event.type === "turn_item.updated" &&
5766-
event.turnItem.type === "error" &&
5767-
event.turnItem.failure.resetAt === resetAt
5768-
? Deferred.succeed(resetReceipt, undefined)
5769-
: Effect.void,
5832+
const snapshotReceipt = yield* Deferred.make<void>();
5833+
const harness = yield* makeCodexReplayHarness(
5834+
transcript,
5835+
(event) =>
5836+
event.type === "turn_item.updated" &&
5837+
event.turnItem.type === "error" &&
5838+
event.turnItem.failure.resetAt === resetAt
5839+
? Deferred.succeed(resetReceipt, undefined)
5840+
: Effect.void,
5841+
undefined,
5842+
undefined,
5843+
undefined,
5844+
() => Deferred.succeed(snapshotReceipt, undefined),
57705845
);
57715846
yield* harness.runtime.startTurn(
57725847
makeCodexTestTurnInput({
@@ -5786,6 +5861,17 @@ describe("CodexAdapterV2 post-settle continuation", () => {
57865861
if (scenario.name === "deferred-reset") assert.equal(terminal.failure.resetAt, resetAt);
57875862
if (scenario.name === "matching-details")
57885863
assert.equal(terminal.failure.message, "Detailed provider allowance explanation.");
5864+
if ("resetAfter" in scenario) {
5865+
yield* Deferred.await(snapshotReceipt);
5866+
assert.isFalse(
5867+
harness.events.some(
5868+
(event) =>
5869+
event.type === "turn_item.updated" &&
5870+
event.turnItem.type === "error" &&
5871+
event.turnItem.failure.resetAt != null,
5872+
),
5873+
);
5874+
}
57895875
if (scenario.name === "late-reset") {
57905876
yield* Deferred.await(resetReceipt);
57915877
const item = harness.events.find(
@@ -5796,10 +5882,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
57965882
);
57975883
assert.isDefined(item);
57985884
}
5799-
if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1);
5800-
if (scenario.name === "chatgpt-workspace-limit") {
5801-
assert.equal(terminal.failure.code, scenario.details);
5802-
assert.include(terminal.failure.message, "workspace admin");
5885+
if (scenario.name === "retry" || "willRetry" in scenario)
5886+
assert.equal(terminal.retry?.attempt, 1);
5887+
if ("expectedCode" in scenario && "expectedMessage" in scenario) {
5888+
assert.equal(terminal.failure.code, scenario.expectedCode);
5889+
assert.include(terminal.failure.message, scenario.expectedMessage);
5890+
assert.equal(terminal.failure.resetAt, null);
58035891
}
58045892
}).pipe(
58055893
Effect.provide(

‎apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,7 +1148,8 @@ function codexUsageLimitError(
11481148

11491149
/**
11501150
* ChatGPT sharing reports its own reason inside Codex's generic turn error, so
1151-
* the code it names replaces Codex's, and clients pick a recovery from it.
1151+
* the code it names replaces Codex's, and clients pick a recovery from it. Its
1152+
* allowance limits get the usage-limit recovery; other sharing failures keep their class.
11521153
*/
11531154
function withCodexManagedFailure(
11541155
failure: OrchestrationV2ProviderFailure,
@@ -1157,7 +1158,22 @@ function withCodexManagedFailure(
11571158
const managed = classifyCodexManagedError(nativeError);
11581159
return managed === undefined
11591160
? failure
1160-
: { ...failure, message: managed.message, code: managed.code };
1161+
: {
1162+
...failure,
1163+
message: managed.message,
1164+
code: managed.code,
1165+
...(managed.usageLimit ? { class: "usage_limit" } : {}),
1166+
};
1167+
}
1168+
1169+
function failureResetAt(
1170+
failure: OrchestrationV2ProviderFailure,
1171+
snapshot: CodexRateLimitSnapshot | undefined,
1172+
) {
1173+
// Sharing caps have no reset timestamp in the ordinary Codex allowance snapshot.
1174+
return classifyCodexManagedError(failure.code)?.usageLimit
1175+
? null
1176+
: codexUsageLimitResetAt(snapshot);
11611177
}
11621178

11631179
interface ActiveCodexTurnContext {
@@ -4480,8 +4496,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
44804496
const previous = yield* Ref.getAndUpdate(rateLimitSnapshot, (previous) =>
44814497
mergeCodexRateLimits(previous, payload.rateLimits),
44824498
);
4483-
const resetAt = codexUsageLimitResetAt(yield* Ref.get(rateLimitSnapshot));
4499+
const snapshot = yield* Ref.get(rateLimitSnapshot);
44844500
for (const item of (yield* Ref.get(limitedTurnItems)).values()) {
4501+
const resetAt = failureResetAt(item.failure, snapshot);
44854502
if (resetAt === null || item.failure.resetAt != null) continue;
44864503
const updated = {
44874504
...item,
@@ -5826,7 +5843,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
58265843
failure.class === "usage_limit"
58275844
? {
58285845
...failure,
5829-
resetAt: codexUsageLimitResetAt(yield* Ref.get(rateLimitSnapshot)),
5846+
resetAt: failureResetAt(failure, yield* Ref.get(rateLimitSnapshot)),
58305847
}
58315848
: failure,
58325849
...(input.providerRetry === undefined
@@ -5866,7 +5883,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
58665883
...event.failure,
58675884
resetAt:
58685885
event.failure.resetAt ??
5869-
codexUsageLimitResetAt(yield* Ref.get(rateLimitSnapshot)),
5886+
failureResetAt(event.failure, yield* Ref.get(rateLimitSnapshot)),
58705887
},
58715888
}
58725889
: event;

‎apps/server/src/provider/CodexManagedErrors.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ it("maps streamed failures to safe actionable messages and reconnect decisions",
1212
message:
1313
"ChatGPT could not validate this connection. Check the selected account and sharing permissions.",
1414
code: "subscription_sharing_v2_invalid_user",
15+
usageLimit: false,
1516
},
1617
);
1718
assert.include(
@@ -58,3 +59,52 @@ it("preserves credentials for the current subscriber and permission error codes"
5859
assert.strictEqual(classifyCodexManagedError({ error: { code } })?.code, code);
5960
}
6061
});
62+
63+
it("treats only ChatGPT sharing allowance limits as usage limits", () => {
64+
assert.deepInclude(
65+
classifyCodexManagedError(
66+
"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.",
67+
),
68+
{ code: "subscription_sharing_usage_limit_exceeded", usageLimit: true },
69+
);
70+
assert.isTrue(
71+
classifyCodexManagedError("subscription_sharing_workplace_individual_usage_limit_exceeded")!
72+
.usageLimit,
73+
);
74+
assert.isFalse(classifyCodexManagedError("subscription_sharing_usage_unavailable")!.usageLimit);
75+
assert.isUndefined(
76+
classifyCodexManagedError("stream disconnected before completion: error decoding response"),
77+
);
78+
assert.isUndefined(classifyCodexManagedError("You've hit your usage limit."));
79+
});
80+
81+
it("does not infer sharing codes from incidental mentions in turn errors", () => {
82+
const mention = "Transport unavailable; this is not subscription_sharing_usage_limit_exceeded.";
83+
for (const value of [
84+
mention,
85+
{ message: mention },
86+
{ message: "Transport unavailable", additionalDetails: mention },
87+
{ code: "other", message: mention },
88+
]) {
89+
assert.isUndefined(classifyCodexManagedError(value));
90+
}
91+
assert.deepInclude(
92+
classifyCodexManagedError({
93+
error: { code: "subscription_sharing_usage_unavailable", message: mention },
94+
}),
95+
{ code: "subscription_sharing_usage_unavailable", usageLimit: false },
96+
);
97+
});
98+
99+
it("reads sharing codes from structured errors and exact turn details", () => {
100+
const code = "subscription_sharing_usage_limit_exceeded";
101+
const response = { error: { code } };
102+
for (const value of [
103+
response,
104+
JSON.stringify(response),
105+
{ message: "Provider stopped", additionalDetails: code },
106+
{ message: "Provider stopped", additionalDetails: JSON.stringify(response) },
107+
]) {
108+
assert.deepInclude(classifyCodexManagedError(value), { code, usageLimit: true });
109+
}
110+
});

0 commit comments

Comments
 (0)