Skip to content

Commit 6f94de4

Browse files
Merge pull request #551 from corbitsdev/cl-6935-stop-labeling-short-xai-rate-limits-as-quota-exhaustion
Stop labeling short xAI rate limits as quota exhaustion
2 parents 63ec5c1 + 5f203b5 commit 6f94de4

12 files changed

Lines changed: 387 additions & 11 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1717

1818
### Agent
1919

20+
- **xAI short HTTP 429s are rate limits, not quota exhaustion.** Bare 429s from
21+
known xAI/Grok providers remapped to retryable so moderate Retry-After no
22+
longer aborts as a long-window quota. Clear usage/quota body markers still
23+
abort. Transcript shows "Rate limited — retrying…" instead of "Quota exhausted".
24+
2025
- **`apply_patch` can update files again.** Its Update operation read the target
2126
through the line-numbered `read_file` view and then tried to match the patch's
2227
raw context lines against it, so every context-bearing update failed with

src/agent/director.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
ReactorAction,
99
ToolDefinition,
1010
ConversationTurn,
11+
RetryPolicy,
1112
} from "@intx/types/runtime";
1213
import { type SessionMetadata, type TaskBoundary } from "../session/compactor.js";
1314
import type { WorkflowCoordinator } from "../workflows/coordinator.js";
@@ -33,8 +34,6 @@ import { isOperatorOriginated } from "./message-provenance.js";
3334
import { classifyBriefSalvage, isHardBlockSalvage } from "../subagent/brief-dispatch.js";
3435
import { PRIMARY_SALVAGE_NUDGE } from "./look-tour.js";
3536

36-
const RETRY_POLICY = createCorbitsRetryPolicy();
37-
3837
// Fired when turnsSinceUserMessage reaches TURNS_SINCE_USER_MESSAGE_BACKSTOP.
3938
// A nudge, not a pause — the operator explicitly wants long autonomous runs
4039
// to keep going, so silence alone (with no detected cycle) is not
@@ -356,12 +355,22 @@ export interface ChatDirectorOptions {
356355
onTasksChange: (tasks: Task[]) => void;
357356
requestContinuation?: (() => void) | undefined;
358357
provider?: { providerName: string; model?: string } | undefined;
358+
/**
359+
* Live catalog provider id for retry stamping. Resolved on each retry
360+
* decision so mid-session `/model` switches remapping without rebuilding
361+
* the agent. When set, preferred over static `provider.providerName`.
362+
*/
363+
getProviderId?: (() => string | undefined) | undefined;
364+
/** Explicit retry policy; when set, skips the default Corbits policy. */
365+
retryPolicy?: RetryPolicy | undefined;
359366
}
360367

361368
// The constructor takes the resolved ModelFamilyPolicy rather than the raw
362369
// `provider` input the factory function accepts and resolves on its behalf.
363370
type ChatDirectorImplOptions = Omit<ChatDirectorOptions, "provider"> & {
364371
modelFamilyPolicy?: ModelFamilyPolicy | undefined;
372+
/** Provider-stamped retry policy (xAI short 429 remapping needs providerId). */
373+
retryPolicy?: RetryPolicy | undefined;
365374
};
366375

367376
class ChatDirectorImpl extends DefaultDirector {
@@ -390,6 +399,7 @@ class ChatDirectorImpl extends DefaultDirector {
390399
private startedAt = Date.now();
391400
private readonly compaction: CompactionGovernor;
392401
private readonly modelFamilyPolicy: ModelFamilyPolicy;
402+
private readonly retryPolicy: RetryPolicy;
393403
// Consecutive assistant turns that contain tool calls and no text. Reset on
394404
// any turn with text and on every fresh user message — a weak model that
395405
// spins in place on one thread of tool calls still converges to the
@@ -476,6 +486,7 @@ class ChatDirectorImpl extends DefaultDirector {
476486
);
477487
this.modelFamilyPolicy =
478488
options.modelFamilyPolicy ?? resolveModelFamilyPolicy({ providerName: "" });
489+
this.retryPolicy = options.retryPolicy ?? createCorbitsRetryPolicy();
479490
}
480491

481492
setWorkflowCoordinator(coordinator: WorkflowCoordinator | undefined): void {
@@ -618,7 +629,7 @@ class ChatDirectorImpl extends DefaultDirector {
618629
const options = {
619630
...action.options,
620631
tools,
621-
retryPolicy: action.options?.retryPolicy ?? RETRY_POLICY,
632+
retryPolicy: action.options?.retryPolicy ?? this.retryPolicy,
622633
};
623634
if (this.inactivityTimeoutMs !== undefined)
624635
options.inactivityTimeoutMs = this.inactivityTimeoutMs;
@@ -1064,12 +1075,24 @@ export function createChatDirector(
10641075
toolDefinitions: ToolDefinition[],
10651076
options: ChatDirectorOptions,
10661077
): ChatDirector {
1067-
const { provider, ...rest } = options;
1078+
const { provider, getProviderId, retryPolicy, ...rest } = options;
10681079
return new ChatDirectorImpl(systemPrompt, toolDefinitions, {
10691080
...rest,
10701081
// `provider` is raw {providerName, model} input; the constructor wants
10711082
// the resolved ModelFamilyPolicy, not the input it was resolved from.
10721083
modelFamilyPolicy: provider !== undefined ? resolveModelFamilyPolicy(provider) : undefined,
1084+
// Stamp provider id onto retry errors so known-xAI short 429s remap.
1085+
// Prefer an explicit policy, then a live getter (mid-session `/model`),
1086+
// then the bootstrap providerName.
1087+
retryPolicy:
1088+
retryPolicy ??
1089+
createCorbitsRetryPolicy(
1090+
getProviderId !== undefined
1091+
? { providerId: getProviderId }
1092+
: provider !== undefined
1093+
? { providerId: provider.providerName }
1094+
: undefined,
1095+
),
10731096
});
10741097
}
10751098

src/agent/retry-policy.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,96 @@ describe("createCorbitsRetryPolicy", () => {
5555
});
5656
expect(decision).toEqual({ kind: "abort" });
5757
});
58+
59+
test("stamped xAI bare 429 retries as retryable, not long-quota abort", async () => {
60+
const policy = createCorbitsRetryPolicy({ providerId: "xai/thegreataxios" });
61+
const decision = await policy({
62+
attempt: 1,
63+
elapsedMs: 0,
64+
error: {
65+
category: "quota_exhausted",
66+
message: "Too Many Requests",
67+
statusCode: 429,
68+
retryAfterMs: 45_000,
69+
raw: { error: { message: "Too Many Requests" } },
70+
},
71+
});
72+
// Remapped to retryable → default backoff, not abort on moderate Retry-After.
73+
expect(decision).toEqual({ kind: "retry", delayMs: 500 });
74+
});
75+
76+
test("stamped xAI usage/quota body still aborts on long retryAfterMs", async () => {
77+
const policy = createCorbitsRetryPolicy({ providerId: "xai/thegreataxios" });
78+
const decision = await policy({
79+
attempt: 1,
80+
elapsedMs: 0,
81+
error: {
82+
category: "quota_exhausted",
83+
message: "You exceeded your current quota",
84+
statusCode: 429,
85+
retryAfterMs: 86_400_000,
86+
raw: {
87+
error: {
88+
message: "You exceeded your current quota",
89+
code: "insufficient_quota",
90+
},
91+
},
92+
},
93+
});
94+
expect(decision).toEqual({ kind: "abort" });
95+
});
96+
97+
test("unknown provider bare 429 with moderate Retry-After still aborts as quota", async () => {
98+
const policy = createCorbitsRetryPolicy({ providerId: "openai" });
99+
const decision = await policy({
100+
attempt: 1,
101+
elapsedMs: 0,
102+
error: {
103+
category: "quota_exhausted",
104+
message: "Too Many Requests",
105+
statusCode: 429,
106+
retryAfterMs: 45_000,
107+
raw: { error: { message: "Too Many Requests" } },
108+
},
109+
});
110+
expect(decision).toEqual({ kind: "abort" });
111+
});
112+
113+
test("live providerId getter: non-xAI → xAI starts remapping bare 429", async () => {
114+
let current: string | undefined = "openai";
115+
const policy = createCorbitsRetryPolicy({ providerId: () => current });
116+
const bare429 = {
117+
attempt: 1,
118+
elapsedMs: 0,
119+
error: {
120+
category: "quota_exhausted" as const,
121+
message: "Too Many Requests",
122+
statusCode: 429,
123+
retryAfterMs: 45_000,
124+
raw: { error: { message: "Too Many Requests" } },
125+
},
126+
};
127+
expect(await policy(bare429)).toEqual({ kind: "abort" });
128+
current = "xai/thegreataxios";
129+
expect(await policy(bare429)).toEqual({ kind: "retry", delayMs: 500 });
130+
});
131+
132+
test("live providerId getter: xAI → non-xAI stops remapping bare 429", async () => {
133+
let current: string | undefined = "xai/thegreataxios";
134+
const policy = createCorbitsRetryPolicy({ providerId: () => current });
135+
const bare429 = {
136+
attempt: 1,
137+
elapsedMs: 0,
138+
error: {
139+
category: "quota_exhausted" as const,
140+
message: "Too Many Requests",
141+
statusCode: 429,
142+
retryAfterMs: 45_000,
143+
raw: { error: { message: "Too Many Requests" } },
144+
},
145+
};
146+
expect(await policy(bare429)).toEqual({ kind: "retry", delayMs: 500 });
147+
current = "openai";
148+
expect(await policy(bare429)).toEqual({ kind: "abort" });
149+
});
58150
});

src/agent/retry-policy.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { createDefaultRetryPolicy } from "@intx/inference";
22
import type { RetryDecision, RetryPolicy, RetrySituation } from "@intx/types/runtime";
3-
import { normalizeInferenceErrorForRetry } from "../inference-gateway-error.js";
3+
import {
4+
normalizeInferenceErrorForRetry,
5+
type InferenceErrorWithGoContext,
6+
} from "../inference-gateway-error.js";
47

58
// Providers that enforce long-window quotas (e.g. monthly limits) set
69
// Retry-After to days or weeks. The default policy trusts that value and
@@ -9,10 +12,32 @@ import { normalizeInferenceErrorForRetry } from "../inference-gateway-error.js";
912
// so the user can switch providers or decide when to retry manually.
1013
const MAX_BLIND_WAIT_MS = 30_000;
1114

12-
export function createCorbitsRetryPolicy(): RetryPolicy {
15+
export interface CorbitsRetryPolicyOptions {
16+
/**
17+
* Catalog provider id (e.g. xai/thegreataxios) stamped onto errors before
18+
* normalize. Pass a getter when the live provider can change mid-session
19+
* (e.g. `/model`); it is resolved on each retry decision.
20+
*/
21+
providerId?: string | (() => string | undefined);
22+
}
23+
24+
/**
25+
* Corbits retry policy. When `providerId` is set, merges it onto the error
26+
* before `normalizeInferenceErrorForRetry` so known-provider remappers (xAI
27+
* short 429 → retryable, Go, Codex) can gate on context the harness does not
28+
* attach to InferenceError today.
29+
*/
30+
export function createCorbitsRetryPolicy(options?: CorbitsRetryPolicyOptions): RetryPolicy {
1331
const defaultPolicy = createDefaultRetryPolicy();
1432
return (situation: RetrySituation): RetryDecision | Promise<RetryDecision> => {
15-
const error = normalizeInferenceErrorForRetry(situation.error);
33+
const raw = options?.providerId;
34+
const stampedProviderId = typeof raw === "function" ? raw() : raw;
35+
const incoming = situation.error as InferenceErrorWithGoContext;
36+
const withProvider: InferenceErrorWithGoContext =
37+
stampedProviderId !== undefined && incoming.providerId === undefined
38+
? { ...incoming, providerId: stampedProviderId }
39+
: incoming;
40+
const error = normalizeInferenceErrorForRetry(withProvider);
1641
if (
1742
error.category === "quota_exhausted" &&
1843
error.retryAfterMs !== undefined &&

src/inference-error-message.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,32 @@ describe("inferenceErrorMessage", () => {
3737
expect(line).not.toContain("Codex");
3838
expect(line).toBe("Quota exhausted — usage limit reached.");
3939
});
40+
41+
test("known-xAI short 429 shows rate-limit line, not Quota exhausted", () => {
42+
const line = inferenceErrorMessage({
43+
category: "quota_exhausted",
44+
message: "Too Many Requests",
45+
statusCode: 429,
46+
providerId: "xai/thegreataxios",
47+
raw: { error: { message: "Too Many Requests" } },
48+
});
49+
expect(line.toLowerCase()).toMatch(/rate limit/);
50+
expect(line).not.toContain("Quota exhausted");
51+
});
52+
53+
test("known-xAI quota body still shows Quota exhausted", () => {
54+
const line = inferenceErrorMessage({
55+
category: "quota_exhausted",
56+
message: "You exceeded your current quota",
57+
statusCode: 429,
58+
providerId: "xai/thegreataxios",
59+
raw: {
60+
error: {
61+
message: "You exceeded your current quota",
62+
code: "insufficient_quota",
63+
},
64+
},
65+
});
66+
expect(line).toBe("Quota exhausted — usage limit reached.");
67+
});
4068
});

src/inference-error-message.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { codexProfileFromProviderName, isCodexProviderName } from "./config/code
1414
import {
1515
gatewayOverloadUserMessage,
1616
isGatewayOverloadInferenceError,
17+
isXaiShortRateLimitInferenceError,
18+
XAI_RATE_LIMIT_USER_MESSAGE,
1719
type InferenceErrorLike,
1820
} from "./inference-gateway-error.js";
1921

@@ -90,6 +92,9 @@ function codexUsageLimitLine(error: InferenceErrorLike): string | undefined {
9092
/** One line describing the failure, falling back to the provider's own message. */
9193
export function inferenceErrorMessage(error: InferenceErrorLike): string {
9294
if (isGatewayOverloadInferenceError(error)) return gatewayOverloadUserMessage(error);
95+
// Dual-path: harness may still emit intx's quota_exhausted for a known-xAI
96+
// short 429; FRIENDLY_BY_CATEGORY would otherwise say "Quota exhausted".
97+
if (isXaiShortRateLimitInferenceError(error)) return XAI_RATE_LIMIT_USER_MESSAGE;
9398

9499
const category = classifyInferenceErrorCategory(error);
95100
if (category === "quota_exhausted") {

src/inference-gateway-error.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,4 +266,56 @@ describe("normalizeInferenceErrorForRetry", () => {
266266
const normalized = normalizeInferenceErrorForRetry(error);
267267
expect(normalized).toBe(error);
268268
});
269+
270+
test("known-xAI bare 429 reclassifies as retryable", () => {
271+
const bare = {
272+
category: "quota_exhausted" as const,
273+
message: "Too Many Requests",
274+
statusCode: 429,
275+
retryAfterMs: 45_000,
276+
raw: { error: { message: "Too Many Requests" } },
277+
};
278+
279+
// Without xAI context, leave intx's classification alone.
280+
expect(normalizeInferenceErrorForRetry(bare)).toEqual(bare);
281+
282+
const viaProviderId = normalizeInferenceErrorForRetry({
283+
...bare,
284+
providerId: "xai/thegreataxios",
285+
});
286+
expect(viaProviderId.category).toBe("retryable");
287+
expect(viaProviderId.retryAfterMs).toBe(45_000);
288+
expect(viaProviderId.message.toLowerCase()).toMatch(/rate limit/);
289+
});
290+
291+
test("known-xAI 429 with usage/quota body stays quota_exhausted", () => {
292+
const normalized = normalizeInferenceErrorForRetry({
293+
category: "quota_exhausted",
294+
message: "Too Many Requests",
295+
statusCode: 429,
296+
providerId: "xai/thegreataxios",
297+
retryAfterMs: 86_400_000,
298+
raw: {
299+
error: {
300+
message: "You exceeded your current quota, please check your plan and billing details.",
301+
type: "insufficient_quota",
302+
code: "insufficient_quota",
303+
},
304+
},
305+
});
306+
expect(normalized.category).toBe("quota_exhausted");
307+
expect(normalized.retryAfterMs).toBe(86_400_000);
308+
});
309+
310+
test("unknown provider bare 429 stays quota_exhausted", () => {
311+
const err = {
312+
category: "quota_exhausted" as const,
313+
message: "Too Many Requests",
314+
statusCode: 429,
315+
providerId: "openai",
316+
retryAfterMs: 5_000,
317+
raw: { error: { message: "Too Many Requests" } },
318+
};
319+
expect(normalizeInferenceErrorForRetry(err)).toEqual(err);
320+
});
269321
});

0 commit comments

Comments
 (0)