From 18ca874d64ab16c8855ee2fed688c7dc1f04393a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 10:36:31 -0700 Subject: [PATCH] Stop labeling short xAI rate limits as quota exhaustion Bare HTTP 429s from known xAI/Grok providers remapped to retryable rate limits so moderate Retry-After no longer aborts as a long-window quota. The TUI stamps the live provider onto transcript formatting and the retry policy follows /model switches via a providerId getter. --- CHANGELOG.md | 5 ++ src/agent/director.ts | 31 ++++++++-- src/agent/retry-policy.test.ts | 92 +++++++++++++++++++++++++++++ src/agent/retry-policy.ts | 31 +++++++++- src/inference-error-message.test.ts | 28 +++++++++ src/inference-error-message.ts | 5 ++ src/inference-gateway-error.test.ts | 52 ++++++++++++++++ src/inference-gateway-error.ts | 74 ++++++++++++++++++++++- src/tui/runner.ts | 15 +++++ src/tui/runtime-bridge.ts | 14 +++++ src/tui/stream-event-map.test.ts | 33 +++++++++++ src/tui/stream-event-map.ts | 18 +++++- 12 files changed, 387 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f676aed65..dfb5261d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Agent +- **xAI short HTTP 429s are rate limits, not quota exhaustion.** Bare 429s from + known xAI/Grok providers remapped to retryable so moderate Retry-After no + longer aborts as a long-window quota. Clear usage/quota body markers still + abort. Transcript shows "Rate limited — retrying…" instead of "Quota exhausted". + - **Compaction keeps scored work, not retry loops.** Errored tool results are no longer auto-pinned; identical errors collapse to one representative. Anchors are scored (writes, successful task completions, plan updates) and pair diff --git a/src/agent/director.ts b/src/agent/director.ts index 9653ad5a5..5429971af 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -8,6 +8,7 @@ import type { ReactorAction, ToolDefinition, ConversationTurn, + RetryPolicy, } from "@intx/types/runtime"; import { type SessionMetadata, type TaskBoundary } from "../session/compactor.js"; import type { WorkflowCoordinator } from "../workflows/coordinator.js"; @@ -33,8 +34,6 @@ import { isOperatorOriginated } from "./message-provenance.js"; import { classifyBriefSalvage, isHardBlockSalvage } from "../subagent/brief-dispatch.js"; import { PRIMARY_SALVAGE_NUDGE } from "./look-tour.js"; -const RETRY_POLICY = createCorbitsRetryPolicy(); - // Fired when turnsSinceUserMessage reaches TURNS_SINCE_USER_MESSAGE_BACKSTOP. // A nudge, not a pause — the operator explicitly wants long autonomous runs // to keep going, so silence alone (with no detected cycle) is not @@ -356,12 +355,22 @@ export interface ChatDirectorOptions { onTasksChange: (tasks: Task[]) => void; requestContinuation?: (() => void) | undefined; provider?: { providerName: string; model?: string } | undefined; + /** + * Live catalog provider id for retry stamping. Resolved on each retry + * decision so mid-session `/model` switches remapping without rebuilding + * the agent. When set, preferred over static `provider.providerName`. + */ + getProviderId?: (() => string | undefined) | undefined; + /** Explicit retry policy; when set, skips the default Corbits policy. */ + retryPolicy?: RetryPolicy | undefined; } // The constructor takes the resolved ModelFamilyPolicy rather than the raw // `provider` input the factory function accepts and resolves on its behalf. type ChatDirectorImplOptions = Omit & { modelFamilyPolicy?: ModelFamilyPolicy | undefined; + /** Provider-stamped retry policy (xAI short 429 remapping needs providerId). */ + retryPolicy?: RetryPolicy | undefined; }; class ChatDirectorImpl extends DefaultDirector { @@ -390,6 +399,7 @@ class ChatDirectorImpl extends DefaultDirector { private startedAt = Date.now(); private readonly compaction: CompactionGovernor; private readonly modelFamilyPolicy: ModelFamilyPolicy; + private readonly retryPolicy: RetryPolicy; // Consecutive assistant turns that contain tool calls and no text. Reset on // any turn with text and on every fresh user message — a weak model that // spins in place on one thread of tool calls still converges to the @@ -476,6 +486,7 @@ class ChatDirectorImpl extends DefaultDirector { ); this.modelFamilyPolicy = options.modelFamilyPolicy ?? resolveModelFamilyPolicy({ providerName: "" }); + this.retryPolicy = options.retryPolicy ?? createCorbitsRetryPolicy(); } setWorkflowCoordinator(coordinator: WorkflowCoordinator | undefined): void { @@ -618,7 +629,7 @@ class ChatDirectorImpl extends DefaultDirector { const options = { ...action.options, tools, - retryPolicy: action.options?.retryPolicy ?? RETRY_POLICY, + retryPolicy: action.options?.retryPolicy ?? this.retryPolicy, }; if (this.inactivityTimeoutMs !== undefined) options.inactivityTimeoutMs = this.inactivityTimeoutMs; @@ -1057,12 +1068,24 @@ export function createChatDirector( toolDefinitions: ToolDefinition[], options: ChatDirectorOptions, ): ChatDirector { - const { provider, ...rest } = options; + const { provider, getProviderId, retryPolicy, ...rest } = options; return new ChatDirectorImpl(systemPrompt, toolDefinitions, { ...rest, // `provider` is raw {providerName, model} input; the constructor wants // the resolved ModelFamilyPolicy, not the input it was resolved from. modelFamilyPolicy: provider !== undefined ? resolveModelFamilyPolicy(provider) : undefined, + // Stamp provider id onto retry errors so known-xAI short 429s remap. + // Prefer an explicit policy, then a live getter (mid-session `/model`), + // then the bootstrap providerName. + retryPolicy: + retryPolicy ?? + createCorbitsRetryPolicy( + getProviderId !== undefined + ? { providerId: getProviderId } + : provider !== undefined + ? { providerId: provider.providerName } + : undefined, + ), }); } diff --git a/src/agent/retry-policy.test.ts b/src/agent/retry-policy.test.ts index badc29f35..c9a4391cc 100644 --- a/src/agent/retry-policy.test.ts +++ b/src/agent/retry-policy.test.ts @@ -55,4 +55,96 @@ describe("createCorbitsRetryPolicy", () => { }); expect(decision).toEqual({ kind: "abort" }); }); + + test("stamped xAI bare 429 retries as retryable, not long-quota abort", async () => { + const policy = createCorbitsRetryPolicy({ providerId: "xai/thegreataxios" }); + const decision = await policy({ + attempt: 1, + elapsedMs: 0, + error: { + category: "quota_exhausted", + message: "Too Many Requests", + statusCode: 429, + retryAfterMs: 45_000, + raw: { error: { message: "Too Many Requests" } }, + }, + }); + // Remapped to retryable → default backoff, not abort on moderate Retry-After. + expect(decision).toEqual({ kind: "retry", delayMs: 500 }); + }); + + test("stamped xAI usage/quota body still aborts on long retryAfterMs", async () => { + const policy = createCorbitsRetryPolicy({ providerId: "xai/thegreataxios" }); + const decision = await policy({ + attempt: 1, + elapsedMs: 0, + error: { + category: "quota_exhausted", + message: "You exceeded your current quota", + statusCode: 429, + retryAfterMs: 86_400_000, + raw: { + error: { + message: "You exceeded your current quota", + code: "insufficient_quota", + }, + }, + }, + }); + expect(decision).toEqual({ kind: "abort" }); + }); + + test("unknown provider bare 429 with moderate Retry-After still aborts as quota", async () => { + const policy = createCorbitsRetryPolicy({ providerId: "openai" }); + const decision = await policy({ + attempt: 1, + elapsedMs: 0, + error: { + category: "quota_exhausted", + message: "Too Many Requests", + statusCode: 429, + retryAfterMs: 45_000, + raw: { error: { message: "Too Many Requests" } }, + }, + }); + expect(decision).toEqual({ kind: "abort" }); + }); + + test("live providerId getter: non-xAI → xAI starts remapping bare 429", async () => { + let current: string | undefined = "openai"; + const policy = createCorbitsRetryPolicy({ providerId: () => current }); + const bare429 = { + attempt: 1, + elapsedMs: 0, + error: { + category: "quota_exhausted" as const, + message: "Too Many Requests", + statusCode: 429, + retryAfterMs: 45_000, + raw: { error: { message: "Too Many Requests" } }, + }, + }; + expect(await policy(bare429)).toEqual({ kind: "abort" }); + current = "xai/thegreataxios"; + expect(await policy(bare429)).toEqual({ kind: "retry", delayMs: 500 }); + }); + + test("live providerId getter: xAI → non-xAI stops remapping bare 429", async () => { + let current: string | undefined = "xai/thegreataxios"; + const policy = createCorbitsRetryPolicy({ providerId: () => current }); + const bare429 = { + attempt: 1, + elapsedMs: 0, + error: { + category: "quota_exhausted" as const, + message: "Too Many Requests", + statusCode: 429, + retryAfterMs: 45_000, + raw: { error: { message: "Too Many Requests" } }, + }, + }; + expect(await policy(bare429)).toEqual({ kind: "retry", delayMs: 500 }); + current = "openai"; + expect(await policy(bare429)).toEqual({ kind: "abort" }); + }); }); diff --git a/src/agent/retry-policy.ts b/src/agent/retry-policy.ts index ecfd67ae5..ff1dbccea 100644 --- a/src/agent/retry-policy.ts +++ b/src/agent/retry-policy.ts @@ -1,6 +1,9 @@ import { createDefaultRetryPolicy } from "@intx/inference"; import type { RetryDecision, RetryPolicy, RetrySituation } from "@intx/types/runtime"; -import { normalizeInferenceErrorForRetry } from "../inference-gateway-error.js"; +import { + normalizeInferenceErrorForRetry, + type InferenceErrorWithGoContext, +} from "../inference-gateway-error.js"; // Providers that enforce long-window quotas (e.g. monthly limits) set // Retry-After to days or weeks. The default policy trusts that value and @@ -9,10 +12,32 @@ import { normalizeInferenceErrorForRetry } from "../inference-gateway-error.js"; // so the user can switch providers or decide when to retry manually. const MAX_BLIND_WAIT_MS = 30_000; -export function createCorbitsRetryPolicy(): RetryPolicy { +export interface CorbitsRetryPolicyOptions { + /** + * Catalog provider id (e.g. xai/thegreataxios) stamped onto errors before + * normalize. Pass a getter when the live provider can change mid-session + * (e.g. `/model`); it is resolved on each retry decision. + */ + providerId?: string | (() => string | undefined); +} + +/** + * Corbits retry policy. When `providerId` is set, merges it onto the error + * before `normalizeInferenceErrorForRetry` so known-provider remappers (xAI + * short 429 → retryable, Go, Codex) can gate on context the harness does not + * attach to InferenceError today. + */ +export function createCorbitsRetryPolicy(options?: CorbitsRetryPolicyOptions): RetryPolicy { const defaultPolicy = createDefaultRetryPolicy(); return (situation: RetrySituation): RetryDecision | Promise => { - const error = normalizeInferenceErrorForRetry(situation.error); + const raw = options?.providerId; + const stampedProviderId = typeof raw === "function" ? raw() : raw; + const incoming = situation.error as InferenceErrorWithGoContext; + const withProvider: InferenceErrorWithGoContext = + stampedProviderId !== undefined && incoming.providerId === undefined + ? { ...incoming, providerId: stampedProviderId } + : incoming; + const error = normalizeInferenceErrorForRetry(withProvider); if ( error.category === "quota_exhausted" && error.retryAfterMs !== undefined && diff --git a/src/inference-error-message.test.ts b/src/inference-error-message.test.ts index ea55f9d23..f59377f32 100644 --- a/src/inference-error-message.test.ts +++ b/src/inference-error-message.test.ts @@ -37,4 +37,32 @@ describe("inferenceErrorMessage", () => { expect(line).not.toContain("Codex"); expect(line).toBe("Quota exhausted — usage limit reached."); }); + + test("known-xAI short 429 shows rate-limit line, not Quota exhausted", () => { + const line = inferenceErrorMessage({ + category: "quota_exhausted", + message: "Too Many Requests", + statusCode: 429, + providerId: "xai/thegreataxios", + raw: { error: { message: "Too Many Requests" } }, + }); + expect(line.toLowerCase()).toMatch(/rate limit/); + expect(line).not.toContain("Quota exhausted"); + }); + + test("known-xAI quota body still shows Quota exhausted", () => { + const line = inferenceErrorMessage({ + category: "quota_exhausted", + message: "You exceeded your current quota", + statusCode: 429, + providerId: "xai/thegreataxios", + raw: { + error: { + message: "You exceeded your current quota", + code: "insufficient_quota", + }, + }, + }); + expect(line).toBe("Quota exhausted — usage limit reached."); + }); }); diff --git a/src/inference-error-message.ts b/src/inference-error-message.ts index 4a007b49f..2ccbc8dd6 100644 --- a/src/inference-error-message.ts +++ b/src/inference-error-message.ts @@ -14,6 +14,8 @@ import { codexProfileFromProviderName, isCodexProviderName } from "./config/code import { gatewayOverloadUserMessage, isGatewayOverloadInferenceError, + isXaiShortRateLimitInferenceError, + XAI_RATE_LIMIT_USER_MESSAGE, type InferenceErrorLike, } from "./inference-gateway-error.js"; @@ -90,6 +92,9 @@ function codexUsageLimitLine(error: InferenceErrorLike): string | undefined { /** One line describing the failure, falling back to the provider's own message. */ export function inferenceErrorMessage(error: InferenceErrorLike): string { if (isGatewayOverloadInferenceError(error)) return gatewayOverloadUserMessage(error); + // Dual-path: harness may still emit intx's quota_exhausted for a known-xAI + // short 429; FRIENDLY_BY_CATEGORY would otherwise say "Quota exhausted". + if (isXaiShortRateLimitInferenceError(error)) return XAI_RATE_LIMIT_USER_MESSAGE; const category = classifyInferenceErrorCategory(error); if (category === "quota_exhausted") { diff --git a/src/inference-gateway-error.test.ts b/src/inference-gateway-error.test.ts index 62bc484f1..3734e807d 100644 --- a/src/inference-gateway-error.test.ts +++ b/src/inference-gateway-error.test.ts @@ -266,4 +266,56 @@ describe("normalizeInferenceErrorForRetry", () => { const normalized = normalizeInferenceErrorForRetry(error); expect(normalized).toBe(error); }); + + test("known-xAI bare 429 reclassifies as retryable", () => { + const bare = { + category: "quota_exhausted" as const, + message: "Too Many Requests", + statusCode: 429, + retryAfterMs: 45_000, + raw: { error: { message: "Too Many Requests" } }, + }; + + // Without xAI context, leave intx's classification alone. + expect(normalizeInferenceErrorForRetry(bare)).toEqual(bare); + + const viaProviderId = normalizeInferenceErrorForRetry({ + ...bare, + providerId: "xai/thegreataxios", + }); + expect(viaProviderId.category).toBe("retryable"); + expect(viaProviderId.retryAfterMs).toBe(45_000); + expect(viaProviderId.message.toLowerCase()).toMatch(/rate limit/); + }); + + test("known-xAI 429 with usage/quota body stays quota_exhausted", () => { + const normalized = normalizeInferenceErrorForRetry({ + category: "quota_exhausted", + message: "Too Many Requests", + statusCode: 429, + providerId: "xai/thegreataxios", + retryAfterMs: 86_400_000, + raw: { + error: { + message: "You exceeded your current quota, please check your plan and billing details.", + type: "insufficient_quota", + code: "insufficient_quota", + }, + }, + }); + expect(normalized.category).toBe("quota_exhausted"); + expect(normalized.retryAfterMs).toBe(86_400_000); + }); + + test("unknown provider bare 429 stays quota_exhausted", () => { + const err = { + category: "quota_exhausted" as const, + message: "Too Many Requests", + statusCode: 429, + providerId: "openai", + retryAfterMs: 5_000, + raw: { error: { message: "Too Many Requests" } }, + }; + expect(normalizeInferenceErrorForRetry(err)).toEqual(err); + }); }); diff --git a/src/inference-gateway-error.ts b/src/inference-gateway-error.ts index 6dee374e4..1333b15b5 100644 --- a/src/inference-gateway-error.ts +++ b/src/inference-gateway-error.ts @@ -10,6 +10,8 @@ import { parseCodexUsageLimitError, } from "./auth/codex/usage-limit-error.js"; import { codexProfileFromProviderName, isCodexProviderName } from "./config/codex-providers.js"; +import { isXaiProviderName } from "./config/xai-providers.js"; +import { isXaiGrokLeafProvider } from "./subagent/provider-family.js"; export interface InferenceErrorLike { category: string; @@ -47,6 +49,20 @@ const GATEWAY_OVERLOAD_TEXT_MARKERS = [ /** User-visible line while the harness retries a transient gateway overload. */ export const GATEWAY_OVERLOAD_USER_MESSAGE = "Inference gateway overloaded — retrying…"; +/** User-visible line while the harness retries a short known-xAI HTTP 429. */ +export const XAI_RATE_LIMIT_USER_MESSAGE = "Rate limited — retrying…"; + +/** Body markers that mean a real usage/quota window, not a short rate limit. */ +const XAI_QUOTA_BODY_MARKERS = [ + "insufficient_quota", + "usage limit", + "usage_limit", + "quota exceeded", + "quota exhausted", + "exceeded your current quota", + "billing details", +] as const; + function stringFromRaw(raw: unknown): string { if (typeof raw === "string") return raw; if (raw instanceof Error) return raw.message; @@ -186,6 +202,57 @@ export function normalizeOpenCodeGoInferenceError( }; } +function isKnownXaiProviderId(providerId: string | undefined): boolean { + if (providerId === undefined || providerId.length === 0) return false; + if (isXaiProviderName(providerId)) return true; + return isXaiGrokLeafProvider({ providerName: providerId }); +} + +function textHasXaiQuotaMarkers(...parts: string[]): boolean { + const combined = parts.join("\n").toLowerCase(); + return XAI_QUOTA_BODY_MARKERS.some((marker) => combined.includes(marker)); +} + +/** + * True when a known-xAI HTTP 429 looks like a short rate limit rather than a + * usage/quota window. Used by both retry normalization and transcript copy — + * FRIENDLY_BY_CATEGORY would otherwise paint every quota_exhausted 429 as + * "Quota exhausted" even when the policy remaps it to retryable. + * + * Discrimination is body markers for quota, not Retry-After length. + */ +export function isXaiShortRateLimitInferenceError(error: InferenceErrorLike): boolean { + if (!isKnownXaiProviderId(error.providerId)) return false; + if (error.statusCode !== 429) return false; + if (error.category !== "quota_exhausted" && error.category !== "retryable") return false; + if (textHasXaiQuotaMarkers(error.message ?? "", stringFromRaw(error.raw))) return false; + return true; +} + +/** + * intx defaults bare 429 → quota_exhausted. For known-xAI / Grok contexts a + * bare 429 (or rate-limit body without usage/quota markers) reclassifies as + * retryable so moderate Retry-After values are not treated as long-window + * quota exhaustion by the Corbits blind-wait abort. + * + * Clear usage/quota body markers keep quota_exhausted. Unknown providers are + * never remapped. + */ +export function normalizeXaiRateLimitError(error: InferenceErrorWithGoContext): InferenceError { + if (error.statusCode !== 429) return error; + if (error.category !== "quota_exhausted") return error; + if (!isKnownXaiProviderId(error.providerId)) return error; + if (textHasXaiQuotaMarkers(error.message ?? "", stringFromRaw(error.raw))) return error; + + return { + category: "retryable", + message: XAI_RATE_LIMIT_USER_MESSAGE, + statusCode: 429, + ...(error.raw !== undefined ? { raw: error.raw } : {}), + ...(error.retryAfterMs !== undefined ? { retryAfterMs: error.retryAfterMs } : {}), + }; +} + /** * Lift Codex `usage_limit_reached` bodies onto quota_exhausted with a reset ETA * and profile-switch hint. The harness leaves nested `detail.error` on `raw` @@ -230,8 +297,8 @@ function normalizeCodexUsageLimitError(error: InferenceErrorWithGoContext): Infe /** * Reclassify gateway overload errors so the default retry policy treats them as * transient instead of aborting on protocol_mismatch. Also normalizes OpenCode - * Go quota/rate-limit shapes (including HTTP 400 mis-status) and Codex usage - * limits (nested detail.error with resets_in_seconds). + * Go quota/rate-limit shapes (including HTTP 400 mis-status), known-xAI short + * 429s, and Codex usage limits (nested detail.error with resets_in_seconds). */ export function normalizeInferenceErrorForRetry( error: InferenceErrorWithGoContext, @@ -239,6 +306,9 @@ export function normalizeInferenceErrorForRetry( const goNormalized = normalizeOpenCodeGoInferenceError(error); if (goNormalized !== error) return goNormalized; + const xaiNormalized = normalizeXaiRateLimitError(error); + if (xaiNormalized !== error) return xaiNormalized; + const codexNormalized = normalizeCodexUsageLimitError(error); if (codexNormalized !== error) return codexNormalized; diff --git a/src/tui/runner.ts b/src/tui/runner.ts index c0d8f6334..64414778f 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -1372,6 +1372,9 @@ export async function runTUI(initialConfig: Config): Promise { enqueueAgentDeliver(() => currentAgent.deliver(buildCompactionContinuationMessage())); }, provider: { providerName: config.providerName, model: config.model }, + // Live id so mid-session `/model` updates xAI bare-429 remapping + // without rebuilding the agent (aligned with transcript stamp). + getProviderId: () => config.providerName, }, ); directorHolder.instance = d; @@ -1704,6 +1707,10 @@ export async function runTUI(initialConfig: Config): Promise { // Stable handle handed to the App so the underlying agent can be swapped out // from under it without a remount; method calls always target the live agent. + // Host mounts later; stampProvider.fn is wired once the bridge exists. + const stampProvider: { fn: ((id: string | undefined) => void) | undefined } = { + fn: undefined, + }; const agentProxy: Agent = { send: async (content, opts) => { await sessionOps.awaitTail(); @@ -1739,6 +1746,7 @@ export async function runTUI(initialConfig: Config): Promise { liveSources = [source]; liveDefaultSource = source.id; setAgentSourceUnlessClosed(currentAgent, source); + stampProvider.fn?.(source.id); void persistRunSnapshot("running"); }, setSources: (sources, defaultSource) => { @@ -1754,6 +1762,7 @@ export async function runTUI(initialConfig: Config): Promise { activeXaiSource = xaiProfile !== undefined ? { profile: xaiProfile, source: head } : undefined; liveSource = head; + stampProvider.fn?.(head.id); } void persistRunSnapshot("running"); }, @@ -2253,6 +2262,7 @@ export async function runTUI(initialConfig: Config): Promise { const provider = id.slice(0, sep); const model = id.slice(sep + 1); config = { ...config, providerName: provider, model }; + host.bridge.setInferenceProviderId(provider); const bundle = buildSessionSources(); agentProxy.setSources(bundle.sources, bundle.defaultSource); @@ -2488,6 +2498,11 @@ export async function runTUI(initialConfig: Config): Promise { }; setActiveDisposeHost(disposeHost); + // Harness inference.error events omit providerId; stamp the live catalog id + // onto the stream map so transcript copy can identify known-xAI short 429s. + stampProvider.fn = (id) => host.bridge.setInferenceProviderId(id); + stampProvider.fn(config.providerName); + setMentionSuggestionSource(host.shell, (prefix) => listPathSuggestions(prefix, config.cwd)); // The fleet reports itself. Store changes drive it, so a lane finishing or diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 315ea2474..d56f64b5a 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -191,6 +191,12 @@ export interface SessionBridge { * or similar) on whatever cadence it already polls at. */ syncAgentProgress: (sessions: readonly TaskProgressSession[]) => void; + /** + * Stamp the live catalog provider id onto the stream map context so + * `inference.error` transcript lines can identify known-xAI short 429s + * when the harness event omits `providerId`. + */ + setInferenceProviderId: (id: string | undefined) => void; } const NOOP_PORT: SessionPort = { @@ -1231,6 +1237,14 @@ export function attachSessionBridge( bag.agentSessions = sessions; syncAgentProgress(shell, bag, sessions, now()); }, + setInferenceProviderId: (id) => { + if (bag.disposed) return; + if (id === undefined) { + delete bag.mapCtx.providerId; + } else { + bag.mapCtx.providerId = id; + } + }, dispose: () => { bag.disposed = true; applyCadence(null); diff --git a/src/tui/stream-event-map.test.ts b/src/tui/stream-event-map.test.ts index f79055a62..e4e4a5995 100644 --- a/src/tui/stream-event-map.test.ts +++ b/src/tui/stream-event-map.test.ts @@ -349,6 +349,39 @@ describe("inference.error text", () => { expect(line).toContain("/model"); }); + test("ctx.providerId xAI + bare quota_exhausted 429 shows rate-limit copy", () => { + const ctx = createStreamMapContext({ providerId: "xai/thegreataxios" }); + const [event] = mapProductionEvent( + { + type: "inference.error", + data: { + error: { + category: "quota_exhausted", + message: "Too Many Requests", + statusCode: 429, + raw: { error: { message: "Too Many Requests" } }, + }, + }, + }, + ctx, + ); + expect(event?.type).toBe("error"); + if (event?.type !== "error") return; + expect(event.message.toLowerCase()).toMatch(/rate limit/); + expect(event.message).not.toContain("Quota exhausted"); + }); + + test("bare quota_exhausted 429 without ctx/provider still shows Quota exhausted", () => { + expect( + message({ + category: "quota_exhausted", + message: "Too Many Requests", + statusCode: 429, + raw: { error: { message: "Too Many Requests" } }, + }), + ).toBe("Quota exhausted — usage limit reached."); + }); + test("an unclassified failure keeps the provider's own words", () => { expect(message({ message: "socket hang up" })).toBe("socket hang up"); expect(message({ category: "wat", message: "socket hang up" })).toBe("socket hang up"); diff --git a/src/tui/stream-event-map.ts b/src/tui/stream-event-map.ts index f81973ac9..f8e1173ef 100644 --- a/src/tui/stream-event-map.ts +++ b/src/tui/stream-event-map.ts @@ -107,9 +107,15 @@ export interface StreamMapContext { * consumes it, or expires it. */ errorRollbackArmed: boolean; + /** + * Live catalog provider id (e.g. `xai/thegreataxios`). Harness + * `inference.error` events omit providerId; the session stamps this so + * transcript formatting can reuse known-provider remappers. + */ + providerId?: string; } -export function createStreamMapContext(): StreamMapContext { +export function createStreamMapContext(opts?: { providerId?: string }): StreamMapContext { return { callIdToName: new Map(), callIdToArgs: new Map(), @@ -119,6 +125,7 @@ export function createStreamMapContext(): StreamMapContext { attemptArmed: false, attemptCallIds: new Set(), errorRollbackArmed: false, + ...(opts?.providerId !== undefined ? { providerId: opts.providerId } : {}), }; } @@ -452,6 +459,12 @@ function mapEvent( : "inference error"; // A classified failure gets the line written for it; anything unclassified // keeps the provider's own words rather than a generic stand-in. + const providerId = + typeof err?.providerId === "string" + ? err.providerId + : typeof ctx?.providerId === "string" + ? ctx.providerId + : undefined; const message = typeof err?.category === "string" ? inferenceErrorMessage({ @@ -459,7 +472,8 @@ function mapEvent( message: rawMessage, ...(typeof err.statusCode === "number" ? { statusCode: err.statusCode } : {}), ...(err.raw !== undefined ? { raw: err.raw } : {}), - ...(typeof err.providerId === "string" ? { providerId: err.providerId } : {}), + ...(providerId !== undefined ? { providerId } : {}), + ...(typeof err.retryAfterMs === "number" ? { retryAfterMs: err.retryAfterMs } : {}), }) : rawMessage; // Hand the armed boundary to the next event rather than disarming: the