From d00d365c08f06dc5e6c37ac521fa4ff74de3bb07 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Mon, 26 Oct 2026 05:38:11 +0000 Subject: [PATCH 1/5] fix(server): ChatGPT sharing usage limits get usage-limit recovery Codex can report a ChatGPT sharing limit as prose only ("The ChatGPT user has reached their Subscription Sharing usage limit"), which the managed error classifier did not recognize. Even with a sharing code, the adapter replaced the message and code but kept provider_error, so the Limited state, reset time and resume-at-reset never applied. The classifier now maps that exact prose to the personal sharing limit code and flags the personal and Business sharing limits as usage limits. The Codex adapter gives those the usage_limit class; other sharing failures keep theirs. Reset times still come only from exhausted Codex windows. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 72 ++++++++++++++++--- .../Adapters/CodexAdapterV2.ts | 10 ++- .../src/provider/CodexManagedErrors.test.ts | 19 +++++ .../server/src/provider/CodexManagedErrors.ts | 50 +++++++------ 4 files changed, 120 insertions(+), 31 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 69fca8c0825b8..85e6cf55aef78 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -471,6 +471,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", @@ -4556,14 +4558,41 @@ describe("CodexAdapterV2 post-settle continuation", () => { code: "other", details: "subscription_sharing_workplace_individual_usage_limit_exceeded", notification: true, - expectedClass: "provider_error", + 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: 0, + 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", }, ] 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 = { @@ -4583,6 +4612,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" ? [ @@ -4630,7 +4679,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, @@ -4667,7 +4716,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, }, - ...(scenario.name === "late-reset" ? [snapshot] : []), + ...(scenario.name === "late-reset" || "resetAfter" in scenario ? [snapshot] : []), ...(scenario.name === "deferred-reset" ? [ { @@ -4730,7 +4779,7 @@ 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 (scenario.name === "late-reset") { + if (scenario.name === "late-reset" || "resetAfter" in scenario) { yield* Deferred.await(resetReceipt); const item = harness.events.find( (event) => @@ -4740,10 +4789,15 @@ 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, + "usedBefore" in scenario && scenario.usedBefore >= 100 ? resetAt : null, + ); } }).pipe( Effect.provide( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 58cd4a0c0c17b..9ecc1dba6f370 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1092,7 +1092,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, @@ -1101,7 +1102,12 @@ 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" } : {}), + }; } interface ActiveCodexTurnContext { diff --git a/apps/server/src/provider/CodexManagedErrors.test.ts b/apps/server/src/provider/CodexManagedErrors.test.ts index 0436011620a1f..697185c4bbbff 100644 --- a/apps/server/src/provider/CodexManagedErrors.test.ts +++ b/apps/server/src/provider/CodexManagedErrors.test.ts @@ -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( @@ -58,3 +59,21 @@ 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.")); +}); diff --git a/apps/server/src/provider/CodexManagedErrors.ts b/apps/server/src/provider/CodexManagedErrors.ts index 0dd42c37f6397..a482642efda60 100644 --- a/apps/server/src/provider/CodexManagedErrors.ts +++ b/apps/server/src/provider/CodexManagedErrors.ts @@ -49,6 +49,17 @@ const failures = { "ChatGPT could not authorize this connection. Check the client and sharing permissions.", }, }; +const usageLimitCodes = new Set([ + "subscription_sharing_usage_limit_exceeded", + "subscription_sharing_workplace_individual_usage_limit_exceeded", +]); +// Codex can stream only ChatGPT's prose for a sharing failure, without its code. +const proseCodes = [ + [ + "has reached their Subscription Sharing usage limit", + "subscription_sharing_usage_limit_exceeded", + ], +] as const; export function classifyCodexManagedError(value: unknown) { let text: string; try { @@ -57,25 +68,24 @@ export function classifyCodexManagedError(value: unknown) { return undefined; } if (typeof text !== "string") return undefined; - for (const [code, failure] of Object.entries(failures)) { - if (!text.includes(code)) continue; - if (code === "subscription_sharing_unsupported_capability") { - if (text.includes("tool 'namespace'")) - return { - ...failure, - code, - message: - "Codex sent a tool namespace that ChatGPT sharing does not support. Use another provider for this request.", - }; - if (text.includes("additional_tools")) - return { - ...failure, - code, - message: - "Codex sent an input item that ChatGPT sharing does not support. Use another provider for this request.", - }; - } - return { ...failure, code }; + const code = + (Object.keys(failures) as Array).find((known) => text.includes(known)) ?? + proseCodes.find(([prose]) => text.includes(prose))?.[1]; + if (code === undefined) return undefined; + const failure = { ...failures[code], code, usageLimit: usageLimitCodes.has(code) }; + if (code === "subscription_sharing_unsupported_capability") { + if (text.includes("tool 'namespace'")) + return { + ...failure, + message: + "Codex sent a tool namespace that ChatGPT sharing does not support. Use another provider for this request.", + }; + if (text.includes("additional_tools")) + return { + ...failure, + message: + "Codex sent an input item that ChatGPT sharing does not support. Use another provider for this request.", + }; } - return undefined; + return failure; } From caab0d69d925e9b9ae9bbda7a47f60811af38fae Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Mon, 26 Oct 2026 05:39:11 +0000 Subject: [PATCH 2/5] fix(web): ChatGPT limit banner stays up when the failure is a usage limit ChatView hides the thread error banner for usage-limit failures in favor of the recovery banner. Now that ChatGPT sharing limits are usage limits, that would drop the ChatGPT banner and its "Manage usage" and "Copy request for admin" actions. Keep it for those two codes; mobile already shows both. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d6d3e09536070..2898a72a594ba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -42,6 +42,10 @@ import { RightPanelSurfaceContent } from "./chat/RightPanelSurfaceContent"; import { usePendingThreadRequests } from "./chat/usePendingThreadRequests"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; +import { + CHATGPT_USAGE_LIMIT_CODE, + CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, +} from "@t3tools/shared/usageLimits"; import { latestExecutedRun, latestRootProviderFailure, @@ -967,9 +971,12 @@ export default function ChatView(props: ChatViewProps) { ); const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; const activeThreadShell = serverThread; + // ChatGPT sharing limits keep their own banner; OpenAI's partner guidelines specify it. const timelineThreadError = serverRuntime?.status === "failed" && serverRuntime.lastErrorClass === "usage_limit" && + serverRuntime.lastErrorCode !== CHATGPT_USAGE_LIMIT_CODE && + serverRuntime.lastErrorCode !== CHATGPT_WORKSPACE_USAGE_LIMIT_CODE && activeThreadShell?.latestRun && visibleThreadError === serverRuntime.lastError ? null From 823f582e54b4d95583495dca92c10a8300ec3130 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Mon, 26 Oct 2026 07:11:58 +0000 Subject: [PATCH 3/5] fix(codex): match sharing error codes only in explicit values --- .../Adapters/CodexAdapterV2.test.ts | 17 +++++++ .../src/provider/CodexManagedErrors.test.ts | 31 +++++++++++++ .../server/src/provider/CodexManagedErrors.ts | 45 +++++++++++++++---- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 85e6cf55aef78..fc5997da3a2e2 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -4587,6 +4587,23 @@ describe("CodexAdapterV2 post-settle continuation", () => { 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* () { diff --git a/apps/server/src/provider/CodexManagedErrors.test.ts b/apps/server/src/provider/CodexManagedErrors.test.ts index 697185c4bbbff..753fad49d0d0a 100644 --- a/apps/server/src/provider/CodexManagedErrors.test.ts +++ b/apps/server/src/provider/CodexManagedErrors.test.ts @@ -77,3 +77,34 @@ it("treats only ChatGPT sharing allowance limits as usage limits", () => { ); 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 }); + } +}); diff --git a/apps/server/src/provider/CodexManagedErrors.ts b/apps/server/src/provider/CodexManagedErrors.ts index a482642efda60..83ba1ec9462f4 100644 --- a/apps/server/src/provider/CodexManagedErrors.ts +++ b/apps/server/src/provider/CodexManagedErrors.ts @@ -60,19 +60,46 @@ const proseCodes = [ "subscription_sharing_usage_limit_exceeded", ], ] as const; -export function classifyCodexManagedError(value: unknown) { - let text: string; - try { - text = typeof value === "string" ? value : JSON.stringify(value); - } catch { - return undefined; + +function unwrapError(value: unknown): unknown { + if (typeof value === "string") { + try { + value = JSON.parse(value); + } catch { + return value; + } } - if (typeof text !== "string") return undefined; + return typeof value === "object" && value !== null && "error" in value ? value.error : value; +} + +function isManagedCode(value: unknown): value is keyof typeof failures { + return typeof value === "string" && Object.hasOwn(failures, value); +} + +function sharingCode(value: unknown) { + const error = unwrapError(value); + const code = typeof error === "object" && error !== null && "code" in error ? error.code : error; + return isManagedCode(code) ? code : undefined; +} + +export function classifyCodexManagedError(value: unknown) { + const error = unwrapError(value); + const messages = + typeof error === "string" + ? [error] + : typeof error === "object" && error !== null + ? [ + "message" in error ? error.message : null, + "additionalDetails" in error ? error.additionalDetails : null, + ].filter((message): message is string => typeof message === "string") + : []; const code = - (Object.keys(failures) as Array).find((known) => text.includes(known)) ?? - proseCodes.find(([prose]) => text.includes(prose))?.[1]; + sharingCode(error) ?? + messages.map(sharingCode).find((code) => code !== undefined) ?? + proseCodes.find(([prose]) => messages.some((message) => message.includes(prose)))?.[1]; if (code === undefined) return undefined; const failure = { ...failures[code], code, usageLimit: usageLimitCodes.has(code) }; + const text = messages.join(" "); if (code === "subscription_sharing_unsupported_capability") { if (text.includes("tool 'namespace'")) return { From 929ec38997c24343c0f88a097038790fddcb7a0b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 02:25:49 +0000 Subject: [PATCH 4/5] fix(codex): keep sharing cap resets independent of Codex windows --- .../Adapters/CodexAdapterV2.test.ts | 40 +++++++++++++------ .../Adapters/CodexAdapterV2.ts | 17 ++++++-- .../server/src/provider/CodexManagedErrors.ts | 14 +++---- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index fc5997da3a2e2..4491598b828c1 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1079,6 +1079,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { Effect.void, onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, readChildMetadata?: Parameters[2], + onUsageLimits?: CodexAdapterV2.CodexAdapterV2Options["onUsageLimits"], ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1125,6 +1126,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { settings: DEFAULT_CODEX_SETTINGS, environment: {}, clientFactory, + ...(onUsageLimits ? { onUsageLimits } : {}), crypto: yield* Crypto.Crypto, fileSystem, idAllocator, @@ -4571,7 +4573,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { message: CHATGPT_SHARING_LIMIT_MESSAGE, notification: true, willRetry: true, - usedBefore: 0, + usedBefore: 100, expectedClass: "usage_limit", expectedCode: "subscription_sharing_usage_limit_exceeded", expectedMessage: "Usage settings", @@ -4771,12 +4773,18 @@ describe("CodexAdapterV2 post-settle continuation", () => { ], }); const resetReceipt = yield* Deferred.make(); - 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(); + 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, + () => Deferred.succeed(snapshotReceipt, undefined), ); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ @@ -4796,7 +4804,18 @@ 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 (scenario.name === "late-reset" || "resetAfter" in scenario) { + 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( (event) => @@ -4811,10 +4830,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { 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, - "usedBefore" in scenario && scenario.usedBefore >= 100 ? resetAt : null, - ); + assert.equal(terminal.failure.resetAt, null); } }).pipe( Effect.provide( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 9ecc1dba6f370..73c5443ffe84a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1110,6 +1110,16 @@ function withCodexManagedFailure( }; } +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 { capacityRetryAttempt?: number; capacityRetryPending?: Deferred.Deferred; @@ -4317,8 +4327,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, @@ -5613,7 +5624,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 @@ -5650,7 +5661,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; diff --git a/apps/server/src/provider/CodexManagedErrors.ts b/apps/server/src/provider/CodexManagedErrors.ts index 83ba1ec9462f4..4cad5709d2eb0 100644 --- a/apps/server/src/provider/CodexManagedErrors.ts +++ b/apps/server/src/provider/CodexManagedErrors.ts @@ -53,13 +53,6 @@ const usageLimitCodes = new Set([ "subscription_sharing_usage_limit_exceeded", "subscription_sharing_workplace_individual_usage_limit_exceeded", ]); -// Codex can stream only ChatGPT's prose for a sharing failure, without its code. -const proseCodes = [ - [ - "has reached their Subscription Sharing usage limit", - "subscription_sharing_usage_limit_exceeded", - ], -] as const; function unwrapError(value: unknown): unknown { if (typeof value === "string") { @@ -96,7 +89,12 @@ export function classifyCodexManagedError(value: unknown) { const code = sharingCode(error) ?? messages.map(sharingCode).find((code) => code !== undefined) ?? - proseCodes.find(([prose]) => messages.some((message) => message.includes(prose)))?.[1]; + // Codex can stream ChatGPT's sharing-limit prose without its code. + (messages.some((message) => + message.includes("has reached their Subscription Sharing usage limit"), + ) + ? "subscription_sharing_usage_limit_exceeded" + : undefined); if (code === undefined) return undefined; const failure = { ...failures[code], code, usageLimit: usageLimitCodes.has(code) }; const text = messages.join(" "); From e512ca0e23b11806968a400318eadaa80f814708 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 02:35:26 +0000 Subject: [PATCH 5/5] fix(web): ChatGPT sharing limits show in the composer recovery card The sharing limit showed a floating ChatGPT banner on top of the usage-limit timeline line and recovery card. The recovery card now carries the ChatGPT title, icon and Manage usage or admin-request action, and the floating banner is suppressed like other usage limits. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 8 +-- .../src/components/chat/ThreadErrorBanner.tsx | 14 ++--- .../chat/UsageLimitRecoveryBanner.test.tsx | 1 + .../chat/UsageLimitRecoveryBanner.tsx | 52 ++++++++++++++++--- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2898a72a594ba..175d8c4541274 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -42,10 +42,6 @@ import { RightPanelSurfaceContent } from "./chat/RightPanelSurfaceContent"; import { usePendingThreadRequests } from "./chat/usePendingThreadRequests"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; -import { - CHATGPT_USAGE_LIMIT_CODE, - CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, -} from "@t3tools/shared/usageLimits"; import { latestExecutedRun, latestRootProviderFailure, @@ -971,12 +967,9 @@ export default function ChatView(props: ChatViewProps) { ); const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; const activeThreadShell = serverThread; - // ChatGPT sharing limits keep their own banner; OpenAI's partner guidelines specify it. const timelineThreadError = serverRuntime?.status === "failed" && serverRuntime.lastErrorClass === "usage_limit" && - serverRuntime.lastErrorCode !== CHATGPT_USAGE_LIMIT_CODE && - serverRuntime.lastErrorCode !== CHATGPT_WORKSPACE_USAGE_LIMIT_CODE && activeThreadShell?.latestRun && visibleThreadError === serverRuntime.lastError ? null @@ -2507,6 +2500,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadShell?.latestRun ? usageLimitRecoveryBannerItem({ runId: activeThreadShell.latestRun.runId, + errorCode: serverRuntime.lastErrorCode ?? null, resetAt: serverRuntime.usageLimitResetAt ?? null, stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt, recovery: activeThreadShell.limitRecovery ?? null, diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 0bc3b95b71b4e..7c8f4b7fee78c 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -14,7 +14,7 @@ import { OpenAI } from "../Icons"; import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; import { ExpandableText } from "../settings/ExpandableText"; -function copyAdminRequest() { +export function copyAdminRequest() { void writeTextToClipboard(CHATGPT_WORKSPACE_ADMIN_REQUEST, "admin request").then( () => toastManager.add({ @@ -96,11 +96,13 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ {chatGptLimit ? (

ChatGPT usage limit reached

-

- {workspaceLimit - ? "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits." - : "Review your usage settings in ChatGPT."} -

+ {workspaceLimit ? ( +

+ { + "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits." + } +

+ ) : null}
) : ( diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx index 0938e7a3c6889..a4b534d66d1eb 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx @@ -9,6 +9,7 @@ const stoppedAt = new Date(2026, 9, 20, 12, 0); function bannerDescription(resetAt: Date, timestampFormat: TimestampFormat) { return usageLimitRecoveryBannerItem({ runId: RunId.make("run-1"), + errorCode: null, resetAt: resetAt.toISOString(), stoppedAt: stoppedAt.toISOString(), snoozedUntil: null, diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index c99781738793d..aabe7734a3329 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -5,16 +5,24 @@ import { } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { deadlineStore } from "@t3tools/client-runtime/clock"; +import { + CHATGPT_USAGE_LIMIT_CODE, + CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, +} from "@t3tools/shared/usageLimits"; import { GaugeIcon } from "lucide-react"; import { useState } from "react"; import { useDeadlinePassed } from "../../hooks/useDeadline"; +import { OpenAI } from "../Icons"; +import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; import { Button } from "../ui/button"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { toastManager } from "../ui/toast"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; +import { copyAdminRequest } from "./ThreadErrorBanner"; type RecoveryProps = { runId: RunId; + errorCode: string | null; resetAt: string | null; stoppedAt: string; snoozedUntil: string | null; @@ -24,17 +32,47 @@ type RecoveryProps = { }; export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBannerStackItem { - const { runId, resetAt, stoppedAt, timestampFormat } = props; + const { runId, errorCode, resetAt, stoppedAt, timestampFormat } = props; const canSchedule = resetAt !== null && Date.parse(resetAt) > Date.parse(stoppedAt); + const workspaceLimit = errorCode === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE; + const chatGptLimit = workspaceLimit || errorCode === CHATGPT_USAGE_LIMIT_CODE; + const recoveryActions = canSchedule ? ( + + ) : null; + const resetDescription = resetAt + ? `Resets ${formatUpcomingTimestamp(resetAt, timestampFormat)}` + : "Reset time unavailable; retry manually"; + if (!chatGptLimit) { + return { + id: `usage-limit-recovery:${runId}`, + variant: "warning", + icon: , + title: "Usage limit reached", + description: resetDescription, + actions: recoveryActions, + }; + } + // OpenAI's partner guidelines specify this ChatGPT notice and its actions. return { id: `usage-limit-recovery:${runId}`, variant: "warning", - icon: , - title: "Usage limit reached", - description: resetAt - ? `Resets ${formatUpcomingTimestamp(resetAt, timestampFormat)}` - : "Reset time unavailable; retry manually", - actions: canSchedule ? : null, + icon: