diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index e20edf4c5e723..b85577d157817 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -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", @@ -1173,6 +1175,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, readChildMetadata?: Parameters[2], beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], + onUsageLimits?: CodexAdapterV2.CodexAdapterV2Options["onUsageLimits"], ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1230,6 +1233,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { settings: DEFAULT_CODEX_SETTINGS, environment: {}, clientFactory, + ...(onUsageLimits ? { onUsageLimits } : {}), digest: yield* Digest.Digest, fileSystem, idAllocator, @@ -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 = { @@ -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" ? [ @@ -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, @@ -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" ? [ { @@ -5575,12 +5643,19 @@ 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, + undefined, + () => Deferred.succeed(snapshotReceipt, undefined), ); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ @@ -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( @@ -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( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 277232830676d..514c9b0e20cd5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -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, @@ -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 { @@ -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, @@ -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 @@ -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; diff --git a/apps/server/src/provider/CodexManagedErrors.test.ts b/apps/server/src/provider/CodexManagedErrors.test.ts index 0436011620a1f..753fad49d0d0a 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,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 }); + } +}); diff --git a/apps/server/src/provider/CodexManagedErrors.ts b/apps/server/src/provider/CodexManagedErrors.ts index 0dd42c37f6397..4cad5709d2eb0 100644 --- a/apps/server/src/provider/CodexManagedErrors.ts +++ b/apps/server/src/provider/CodexManagedErrors.ts @@ -49,33 +49,68 @@ const failures = { "ChatGPT could not authorize this connection. Check the client and sharing permissions.", }, }; -export function classifyCodexManagedError(value: unknown) { - let text: string; - try { - text = typeof value === "string" ? value : JSON.stringify(value); - } catch { - 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.", - }; +const usageLimitCodes = new Set([ + "subscription_sharing_usage_limit_exceeded", + "subscription_sharing_workplace_individual_usage_limit_exceeded", +]); + +function unwrapError(value: unknown): unknown { + if (typeof value === "string") { + try { + value = JSON.parse(value); + } catch { + return value; } - return { ...failure, code }; } - 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 = + sharingCode(error) ?? + messages.map(sharingCode).find((code) => code !== undefined) ?? + // 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(" "); + 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 failure; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6fd54db6bd52a..1b52fa4d012f8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2541,6 +2541,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: