Skip to content

Commit 69c4ed4

Browse files
committed
refactor(provider): share error-body reading, adapter mechanics, protocol sets
Four provider-side twins collapse: the usage-limit parser and the gateway now share one error-body reader/walker (Codex exact-code match and xAI substring markers stay apart); seven adapters share JSON-rewrite and extra-header mechanics while per-adapter semantics stay local (the decorators bail to an unchanged request when nothing applies); a single Anthropic-protocol identity set + canonical segment replaces two sets + two canonicalizers; and one /models endpoint parser replaces the ollama/bounded-catalog pair. A retryFromBody hook lifts Codex resets_in_seconds onto retryAfterMs but only for Codex.
1 parent 72be1b3 commit 69c4ed4

18 files changed

Lines changed: 440 additions & 316 deletions

‎src/auth/codex/usage-limit-error.ts‎

Lines changed: 7 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
* other providers as Codex.
1414
*/
1515

16+
import {
17+
coerceBody,
18+
readErrorNode,
19+
resetSecondsFromNode,
20+
} from "../../provider/error-body.js";
21+
1622
export interface CodexUsageLimitError {
1723
readonly code: string;
1824
readonly message: string;
@@ -26,48 +32,6 @@ const USAGE_LIMIT_CODES = new Set([
2632
"usage_limit_exceeded",
2733
]);
2834

29-
function asRecord(value: unknown): Record<string, unknown> | undefined {
30-
if (typeof value !== "object" || value === null || Array.isArray(value))
31-
return undefined;
32-
return value as Record<string, unknown>;
33-
}
34-
35-
function tryParseJSON(text: string): unknown {
36-
try {
37-
return JSON.parse(text) as unknown;
38-
} catch {
39-
return undefined;
40-
}
41-
}
42-
43-
function coerceBody(raw: unknown): unknown {
44-
if (typeof raw === "string") {
45-
const trimmed = raw.trim();
46-
if (trimmed.length === 0) return undefined;
47-
return tryParseJSON(trimmed) ?? raw;
48-
}
49-
return raw;
50-
}
51-
52-
function readErrorNode(body: unknown): Record<string, unknown> | undefined {
53-
const root = asRecord(body);
54-
if (root === undefined) return undefined;
55-
56-
const detail = asRecord(root["detail"]);
57-
if (detail !== undefined) {
58-
const nested = asRecord(detail["error"]);
59-
if (nested !== undefined) return nested;
60-
// Some gateways put the fields directly under detail.
61-
if (typeof detail["code"] === "string") return detail;
62-
}
63-
64-
const top = asRecord(root["error"]);
65-
if (top !== undefined) return top;
66-
67-
if (typeof root["code"] === "string") return root;
68-
return undefined;
69-
}
70-
7135
/**
7236
* Nested Codex `detail.error.message` when the harness left it on `raw` while
7337
* classifying the HTTP statusText. Unlike `parseCodexUsageLimitError`, this
@@ -106,16 +70,7 @@ export function parseCodexUsageLimitError(
10670
? node["planType"]
10771
: undefined;
10872

109-
const resetsRaw =
110-
node["resets_in_seconds"] ??
111-
node["resetsInSeconds"] ??
112-
node["reset_after_seconds"];
113-
const resetsInSeconds =
114-
typeof resetsRaw === "number" &&
115-
Number.isFinite(resetsRaw) &&
116-
resetsRaw >= 0
117-
? Math.floor(resetsRaw)
118-
: undefined;
73+
const resetsInSeconds = resetSecondsFromNode(node);
11974

12075
return {
12176
code,

‎src/inference-gateway-error.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,39 @@ describe("normalizeInferenceErrorForRetry", () => {
308308
);
309309
});
310310

311+
test("Codex bare 429 with a body reset window retries on the body, not the header (CL-10664)", () => {
312+
const normalized = normalizeInferenceErrorForRetry({
313+
...BARE_429,
314+
providerId: "codex/acme-labs",
315+
raw: {
316+
error: {
317+
code: "rate_limit_exceeded",
318+
message: "Too Many Requests",
319+
resets_in_seconds: 7,
320+
},
321+
},
322+
});
323+
expect(normalized.category).toBe("retryable");
324+
expect(normalized.message).toBe("Rate limited");
325+
expect(normalized.retryAfterMs).toBe(7_000);
326+
});
327+
328+
test("non-Codex 429s keep the header retry-after; a body reset is not read (CL-10664)", () => {
329+
const normalized = normalizeInferenceErrorForRetry({
330+
...BARE_429,
331+
providerId: "xai/alice",
332+
raw: {
333+
error: {
334+
code: "rate_limit_exceeded",
335+
message: "Too Many Requests",
336+
resets_in_seconds: 7,
337+
},
338+
},
339+
});
340+
expect(normalized.category).toBe("retryable");
341+
expect(normalized.retryAfterMs).toBe(45_000);
342+
});
343+
311344
test.each([{}, { providerId: "openai" }])(
312345
"bare 429 without a known provider keeps intx's quota_exhausted (%j)",
313346
(context) => {

‎src/inference-gateway-error.ts‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import {
1919
xaiProfileFromProviderName,
2020
} from "./config/xai-providers.js";
2121
import { isXaiGrokLeafProvider } from "./subagent/provider-family.js";
22+
import {
23+
coerceBody,
24+
readErrorNode,
25+
resetSecondsFromNode,
26+
tryParseJSON,
27+
} from "./provider/error-body.js";
2228

2329
export interface InferenceErrorLike {
2430
category: string;
@@ -155,14 +161,6 @@ export function isGatewayOverloadInferenceError(
155161
return false;
156162
}
157163

158-
function tryParseJSON(text: string): unknown {
159-
try {
160-
return JSON.parse(text) as unknown;
161-
} catch {
162-
return undefined;
163-
}
164-
}
165-
166164
/**
167165
* Known OpenCode Go errors: provider context (id/flag/request URL) or Go
168166
* body markers; bare `provider_rate_limit_exceeded` alone is not enough —
@@ -352,25 +350,28 @@ export function isXaiShortRateLimitInferenceError(
352350
/**
353351
* Shared skeleton behind the twin rate-limit normalizers: known-provider
354352
* quota 429s without usage-limit bodies become retryable with scrubbed copy.
353+
* An optional body reader lifts a carrier's reset window onto `retryAfterMs`
354+
* when the wire omits a Retry-After header (body wins, matching the
355+
* usage-limit path).
355356
*/
356357
function normalizeProviderRateLimitError(
357358
error: InferenceErrorWithGoContext,
358359
isKnownProvider: (providerId: string | undefined) => boolean,
359360
isUsageLimit: (error: InferenceErrorLike) => boolean,
361+
retryFromBody?: (error: InferenceErrorLike) => number | undefined,
360362
): InferenceError {
361363
if (error.statusCode !== 429) return error;
362364
if (error.category !== "quota_exhausted") return error;
363365
if (!isKnownProvider(error.providerId)) return error;
364366
if (isUsageLimit(error)) return error;
365367

368+
const retryAfterMs = retryFromBody?.(error) ?? error.retryAfterMs;
366369
return {
367370
category: "retryable",
368371
message: RATE_LIMIT_USER_MESSAGE,
369372
statusCode: 429,
370373
...(error.raw !== undefined ? { raw: error.raw } : {}),
371-
...(error.retryAfterMs !== undefined
372-
? { retryAfterMs: error.retryAfterMs }
373-
: {}),
374+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
374375
};
375376
}
376377

@@ -430,6 +431,20 @@ export function isCodexShortRateLimitInferenceError(
430431
);
431432
}
432433

434+
/**
435+
* Codex sends its reset window in the 429 body, not a header; when a bare
436+
* rate-limit body carries one, retry on that window instead of the policy
437+
* default. Exact usage-limit codes never reach here (the skeleton vetoes).
438+
*/
439+
function codexRetryFromBody(error: InferenceErrorLike): number | undefined {
440+
const node = readErrorNode(coerceBody(error.raw));
441+
if (node === undefined) return undefined;
442+
const seconds = resetSecondsFromNode(node);
443+
if (seconds === undefined) return undefined;
444+
if (seconds <= 0) return 0;
445+
return seconds * 1000;
446+
}
447+
433448
/**
434449
* Bare 429 in a known-Codex context reclassifies as retryable; intx
435450
* defaults bare 429 to quota_exhausted; nested `usage_limit_reached` keeps
@@ -442,6 +457,7 @@ export function normalizeCodexRateLimitError(
442457
error,
443458
isKnownCodexProviderId,
444459
hasCodexUsageLimit,
460+
codexRetryFromBody,
445461
);
446462
}
447463

‎src/provider/adapter-decorators.ts‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Shared adapter decorator mechanics. Only the JSON-body / extra-header /
3+
* registry plumbing is shared; the per-adapter semantics stay in each
4+
* adapter's rewrite or header factory (codex parallel_tool_calls, DeepSeek
5+
* reasoning strip and V4 quirks, Bifrost virtual-key sentinel, anthropic
6+
* session header). Every decorator is a null overhead: when its hook bails,
7+
* the base request's body and headers pass through byte-identical.
8+
*/
9+
10+
import type {
11+
AdapterRegistry,
12+
BuiltRequest,
13+
ProviderAdapter,
14+
} from "@intx/inference";
15+
import type {
16+
ConversationTurn,
17+
InferenceOptions,
18+
LastCycleSource,
19+
} from "@intx/types/runtime";
20+
21+
export type DecoratorContext = {
22+
readonly messages: readonly ConversationTurn[];
23+
readonly model: string;
24+
readonly options: InferenceOptions;
25+
};
26+
27+
/** Parse the built body, apply the rewrite, and re-stringify only when the
28+
* rewrite reports a change. An optional gate keeps adapters that only
29+
* sometimes rewrite on their zero-parse fast path. */
30+
export function withJsonBodyRewrite(
31+
adapter: ProviderAdapter,
32+
rewrite: (
33+
body: Record<string, unknown>,
34+
context: DecoratorContext,
35+
) => boolean,
36+
gate?: (request: BuiltRequest, context: DecoratorContext) => boolean,
37+
): ProviderAdapter {
38+
return {
39+
...adapter,
40+
buildRequest: (messages, model, options) => {
41+
const built = adapter.buildRequest(messages, model, options);
42+
const context: DecoratorContext = { messages, model, options };
43+
if (gate !== undefined && !gate(built, context)) return built;
44+
let body: Record<string, unknown>;
45+
try {
46+
body = JSON.parse(built.body) as Record<string, unknown>;
47+
} catch {
48+
return built;
49+
}
50+
if (!rewrite(body, context)) return built;
51+
return { ...built, body: JSON.stringify(body) };
52+
},
53+
};
54+
}
55+
56+
/** Merge the factory's headers over the built request's own; the spread
57+
* never removes or rewrites a header the base adapter emitted. */
58+
export function withExtraHeaders(
59+
adapter: ProviderAdapter,
60+
headers: (context: DecoratorContext) => Record<string, string>,
61+
): ProviderAdapter {
62+
return {
63+
...adapter,
64+
buildRequest: (messages, model, options) => {
65+
const built = adapter.buildRequest(messages, model, options);
66+
const context: DecoratorContext = { messages, model, options };
67+
return {
68+
...built,
69+
headers: { ...built.headers, ...headers(context) },
70+
};
71+
},
72+
};
73+
}
74+
75+
/** Wrap an adapter registry so every resolved adapter passes through the
76+
* decorator; membership is delegated untouched. */
77+
export function withDecoratedAdapters(
78+
adapters: AdapterRegistry,
79+
decorate: (
80+
adapter: ProviderAdapter,
81+
source: LastCycleSource,
82+
) => ProviderAdapter,
83+
): AdapterRegistry {
84+
return {
85+
has: (provider) => adapters.has(provider),
86+
resolve(source, quirks) {
87+
return decorate(adapters.resolve(source, quirks), source);
88+
},
89+
};
90+
}
Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
11
import type {
22
AdapterRegistry,
3-
BuiltRequest,
43
ExtendedInferenceOptions,
54
ProviderAdapter,
65
} from "@intx/inference";
76
import {
8-
OPENCODE_GO_MESSAGES_PROVIDER,
9-
ZEN_MESSAGES_PROVIDER,
10-
} from "./anthropic-session-adapter.js";
11-
12-
const ANTHROPIC_MESSAGES_PROVIDERS: ReadonlySet<string> = new Set([
13-
"anthropic",
14-
ZEN_MESSAGES_PROVIDER,
15-
OPENCODE_GO_MESSAGES_PROVIDER,
16-
]);
7+
withDecoratedAdapters,
8+
withJsonBodyRewrite,
9+
} from "./adapter-decorators.js";
10+
import { ANTHROPIC_MESSAGES_PROTOCOLS } from "./provider-protocols.js";
1711

1812
type WireBlock = Record<string, unknown>;
1913

@@ -57,46 +51,35 @@ function placeBreakpoint(messages: WireMessage[], before: number): boolean {
5751
}
5852

5953
function moveBreakpointToPersistedTail(
60-
built: BuiltRequest,
54+
body: Record<string, unknown>,
6155
options: ExtendedInferenceOptions,
62-
): BuiltRequest {
56+
): boolean {
6357
const suffix = ephemeralSuffixLength(options);
64-
if (suffix === 0) return built;
65-
let body: Record<string, unknown>;
66-
try {
67-
body = JSON.parse(built.body) as Record<string, unknown>;
68-
} catch {
69-
return built;
70-
}
71-
if (!Array.isArray(body.messages)) return built;
58+
if (suffix === 0) return false;
59+
if (!Array.isArray(body.messages)) return false;
7260
const messages = body.messages as WireMessage[];
7361
const split = messages.length - suffix;
74-
if (split <= 0) return built;
62+
if (split <= 0) return false;
7563
stripBreakpoints(messages, split);
76-
if (!placeBreakpoint(messages, split)) return built;
77-
return { ...built, body: JSON.stringify(body) };
64+
return placeBreakpoint(messages, split);
7865
}
7966

67+
// Only Anthropic-protocol sources are decorated, so non-Anthropic requests
68+
// skip both the gate and the body parse (byte-identical to the base adapter).
8069
function withMovedBreakpoint(adapter: ProviderAdapter): ProviderAdapter {
81-
return {
82-
...adapter,
83-
buildRequest: (turns, model, options) =>
84-
moveBreakpointToPersistedTail(
85-
adapter.buildRequest(turns, model, options),
86-
options,
87-
),
88-
};
70+
return withJsonBodyRewrite(
71+
adapter,
72+
(body, { options }) => moveBreakpointToPersistedTail(body, options),
73+
(_request, { options }) => ephemeralSuffixLength(options) > 0,
74+
);
8975
}
9076

9177
export function withAnthropicCacheBreakpoint(
9278
adapters: AdapterRegistry,
9379
): AdapterRegistry {
94-
return {
95-
has: (provider) => adapters.has(provider),
96-
resolve(source, quirks) {
97-
const adapter = adapters.resolve(source, quirks);
98-
if (!ANTHROPIC_MESSAGES_PROVIDERS.has(source.provider)) return adapter;
99-
return withMovedBreakpoint(adapter);
100-
},
101-
};
80+
return withDecoratedAdapters(adapters, (adapter, source) =>
81+
ANTHROPIC_MESSAGES_PROTOCOLS.has(source.provider)
82+
? withMovedBreakpoint(adapter)
83+
: adapter,
84+
);
10285
}

0 commit comments

Comments
 (0)