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
153 changes: 119 additions & 34 deletions electron/providers/claude-sdk-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3018,6 +3018,123 @@ function mapClaudeStreamPlanEvent(args: {
return [];
}

/**
* Shape of `rate_limit_event.rate_limit_info`, mirroring `SDKRateLimitInfo`.
*
* `status`/`utilization` describe the *currently limiting* window only, and
* `rateLimitType` is what names it — including `"overage"`, the paid
* extra-usage credit budget, which resets on its own monthly period rather
* than with the 5-hour or weekly subscription windows. Reporting every one of
* these as a plain "rate limit" made an exhausted credit balance look like a
* subscription window that had failed to reset.
*
* `utilization` here is a 0..1 fraction (unlike the OAuth usage endpoint's
* 0..100 percentages, which must never be rescaled) and can legitimately
* exceed 1 when usage runs past a window's cap.
*/
interface ClaudeRateLimitInfo {
status?: string;
resetsAt?: number;
rateLimitType?: string;
utilization?: number;
overageStatus?: string;
overageResetsAt?: number;
overageDisabledReason?: string;
isUsingOverage?: boolean;
errorCode?: string;
}

const CLAUDE_RATE_LIMIT_WINDOW_LABELS: Record<string, string> = {
five_hour: "5-hour limit",
seven_day: "weekly limit",
seven_day_opus: "weekly Opus limit",
seven_day_sonnet: "weekly Sonnet limit",
seven_day_overage_included: "weekly limit",
};

/**
* Weekly and credit windows reset days away, so a bare `toLocaleTimeString()`
* reported "resets at 8:00:00 AM" with no hint of which day.
*/
function formatClaudeRateLimitReset(epochSeconds: number | undefined): string {
if (!epochSeconds) {
return "unknown";
}
const reset = new Date(epochSeconds * 1000);
return reset.toDateString() === new Date().toDateString()
? reset.toLocaleTimeString()
: reset.toLocaleString();
}

/**
* Extra usage is exhausted rather than merely inactive. `overageStatus` is
* deliberately not consulted: it tracks the overflow request outcome, while
* these two say the credit balance itself is gone.
*/
function isClaudeOutOfUsageCredits(info: ClaudeRateLimitInfo): boolean {
return (
info.overageDisabledReason === "out_of_credits" ||
info.errorCode === "credits_required"
);
}

function buildClaudeRateLimitEvents(
info: ClaudeRateLimitInfo | undefined,
): BridgeEvent[] {
if (!info) {
return [];
}
const isOverage = info.rateLimitType === "overage";
const windowLabel = info.rateLimitType
? CLAUDE_RATE_LIMIT_WINDOW_LABELS[info.rateLimitType]
: undefined;

if (info.status === "rejected") {
const resetsAt = isOverage
? (info.overageResetsAt ?? info.resetsAt)
: info.resetsAt;
const resetTime = formatClaudeRateLimitReset(resetsAt);
const headline = isOverage
? "Extra usage credits are exhausted"
: `Rate limit reached${windowLabel ? ` (${windowLabel})` : ""}`;
const creditSuffix =
!isOverage && isClaudeOutOfUsageCredits(info)
? " Extra usage credits are also exhausted."
: "";
return [
{
type: "error",
message: `${headline}. Resets at ${resetTime}.${creditSuffix}`,
recoverable: true,
},
];
}

if (info.status === "allowed_warning") {
const pct =
info.utilization != null
? ` (${Math.round(info.utilization * 100)}% used)`
: "";
const headline = isOverage
? "Approaching your extra usage credit limit"
: `Approaching ${windowLabel ?? "rate limit"}`;
let creditSuffix = "";
if (!isOverage && isClaudeOutOfUsageCredits(info)) {
creditSuffix = " Extra usage credits are exhausted.";
} else if (!isOverage && info.isUsingOverage) {
creditSuffix = " Extra usage credits are covering the overflow.";
}
return [
{
type: "system",
content: `${headline}${pct}. Consider pacing requests.${creditSuffix}`,
},
];
}

return [];
}

/**
* Minimal view of `SubagentProgressTracker` needed while mapping tool_use
* blocks, so the mapper stays a pure function of its inputs. Deliberately named
Expand Down Expand Up @@ -3501,41 +3618,9 @@ export function mapClaudeMessageToEvents(args: {
if (message.type === "rate_limit_event") {
const rlMsg = message as {
type: "rate_limit_event";
rate_limit_info?: {
status?: string;
resetsAt?: number;
utilization?: number;
api_error_status?: number | null;
};
rate_limit_info?: ClaudeRateLimitInfo;
};
const info = rlMsg.rate_limit_info;
if (info?.status === "rejected") {
const resetTime = info.resetsAt
? new Date(info.resetsAt * 1000).toLocaleTimeString()
: "unknown";
const statusSuffix =
info.api_error_status != null ? ` (HTTP ${info.api_error_status})` : "";
return [
{
type: "error",
message: `Rate limit reached. Resets at ${resetTime}.${statusSuffix}`,
recoverable: true,
},
];
}
if (info?.status === "allowed_warning") {
const pct =
info.utilization != null
? ` (${Math.round(info.utilization * 100)}% used)`
: "";
return [
{
type: "system",
content: `Approaching rate limit${pct}. Consider pacing requests.`,
},
];
}
return [];
return buildClaudeRateLimitEvents(rlMsg.rate_limit_info);
}

if (message.type === "tool_progress") {
Expand Down
154 changes: 154 additions & 0 deletions tests/claude-sdk-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,160 @@ describe("Claude MCP OAuth", () => {
});

describe("mapClaudeMessageToEvents", () => {
test("labels an exhausted extra-usage credit budget as credits, not a rate limit", () => {
// `rateLimitType: "overage"` is the paid credit budget, which keeps
// reporting 100% after the 5-hour and weekly windows have reset.
const events = mapClaudeMessageToEvents({
message: {
type: "rate_limit_event",
rate_limit_info: {
status: "allowed_warning",
rateLimitType: "overage",
utilization: 1,
resetsAt: 1_700_000_000,
},
uuid: "msg-rl-1",
session_id: "session-1",
} as never,
claudeDebugStream: false,
});
expect(events).toEqual([
{
type: "system",
content:
"Approaching your extra usage credit limit (100% used). Consider pacing requests.",
},
]);
});
test("names the limiting subscription window in rate limit warnings", () => {
const events = mapClaudeMessageToEvents({
message: {
type: "rate_limit_event",
rate_limit_info: {
status: "allowed_warning",
rateLimitType: "seven_day",
utilization: 0.85,
},
uuid: "msg-rl-2",
session_id: "session-1",
} as never,
claudeDebugStream: false,
});
expect(events).toEqual([
{
type: "system",
content:
"Approaching weekly limit (85% used). Consider pacing requests.",
},
]);
});
test("reports exhausted credits alongside a warning on a subscription window", () => {
const events = mapClaudeMessageToEvents({
message: {
type: "rate_limit_event",
rate_limit_info: {
status: "allowed_warning",
rateLimitType: "five_hour",
utilization: 0.92,
overageDisabledReason: "out_of_credits",
},
uuid: "msg-rl-3",
session_id: "session-1",
} as never,
claudeDebugStream: false,
});
expect(events).toEqual([
{
type: "system",
content:
"Approaching 5-hour limit (92% used). Consider pacing requests. Extra usage credits are exhausted.",
},
]);
});
test("notes when paid extra usage is already covering the overflow", () => {
const events = mapClaudeMessageToEvents({
message: {
type: "rate_limit_event",
rate_limit_info: {
status: "allowed_warning",
rateLimitType: "five_hour",
utilization: 1.2,
isUsingOverage: true,
},
uuid: "msg-rl-4",
session_id: "session-1",
} as never,
claudeDebugStream: false,
});
expect(events).toEqual([
{
type: "system",
content:
"Approaching 5-hour limit (120% used). Consider pacing requests. Extra usage credits are covering the overflow.",
},
]);
});
test("prefers the overage reset instant when the credit budget is what rejected the turn", () => {
const overageResetsAt = Math.floor(Date.now() / 1000) + 5 * 86_400;
const events = mapClaudeMessageToEvents({
message: {
type: "rate_limit_event",
rate_limit_info: {
status: "rejected",
rateLimitType: "overage",
resetsAt: Math.floor(Date.now() / 1000) + 600,
overageResetsAt,
overageDisabledReason: "out_of_credits",
},
uuid: "msg-rl-5",
session_id: "session-1",
} as never,
claudeDebugStream: false,
});
expect(events).toEqual([
{
type: "error",
message: `Extra usage credits are exhausted. Resets at ${new Date(
overageResetsAt * 1000,
).toLocaleString()}.`,
recoverable: true,
},
]);
});
test("keeps the generic rate limit wording when the window is unknown", () => {
const resetsAt = Math.floor(Date.now() / 1000) + 600;
const events = mapClaudeMessageToEvents({
message: {
type: "rate_limit_event",
rate_limit_info: { status: "rejected", resetsAt },
uuid: "msg-rl-6",
session_id: "session-1",
} as never,
claudeDebugStream: false,
});
expect(events).toEqual([
{
type: "error",
message: `Rate limit reached. Resets at ${new Date(
resetsAt * 1000,
).toLocaleTimeString()}.`,
recoverable: true,
},
]);
});
test("ignores rate limit events that only report the allowed state", () => {
expect(
mapClaudeMessageToEvents({
message: {
type: "rate_limit_event",
rate_limit_info: { status: "allowed", utilization: 0.1 },
uuid: "msg-rl-7",
session_id: "session-1",
} as never,
claudeDebugStream: false,
}),
).toEqual([]);
});
test("surfaces plugin installation outcomes from SDK system messages", () => {
const base = { type: "system", subtype: "plugin_install", uuid: "00000000-0000-0000-0000-000000000001", session_id: "session-1", name: "project-tools" } as const;
expect(mapClaudeMessageToEvents({ message: { ...base, status: "installed" }, claudeDebugStream: false })).toEqual([
Expand Down
Loading