From 2b2ec58d66c5b433d12c950e8e6ce51139921cae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 16:56:43 +0000 Subject: [PATCH] fix(plugin-chatbot): parseAiQuotaError reads the nested declared error envelope (#3491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two cloud AI 429 producers fill the same `error` key in opposite ways (the token guardrail puts the code there, service-ai puts the message there and the code in a `code` sibling), while ADR-0112 declares a third shape both converge on: `{ success: false, error: { code, message } }`. The consumer has to learn the declared shape first, or the producers' convergence silently turns every quota refusal back into a generic "Response failed" banner (cloud#944; same consumer-first sequencing as objectui#2992). Code lookup is a total order — declared envelope, flat guardrail code, `code` sibling — so a transitional producer double-emitting the new envelope next to the legacy top-level keys has one defined outcome. Only the code's location widens: the recognized code set is unchanged and any unrecognized shape still degrades to null. Companion fields keep their top-level read; their position in the declared envelope is not presumed (cloud#1168 fixes the real shape). Tests: three dialects x hit/miss, unknown-shape degradation, companion-field backward compatibility, and the parse-priority order. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .changeset/quota-error-envelope-3491.md | 24 +++ .../plugin-chatbot/src/tool-display.test.ts | 167 ++++++++++++++++++ packages/plugin-chatbot/src/tool-display.ts | 79 ++++++++- 3 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 .changeset/quota-error-envelope-3491.md diff --git a/.changeset/quota-error-envelope-3491.md b/.changeset/quota-error-envelope-3491.md new file mode 100644 index 0000000000..a77ff447c3 --- /dev/null +++ b/.changeset/quota-error-envelope-3491.md @@ -0,0 +1,24 @@ +--- +'@object-ui/plugin-chatbot': patch +--- + +`parseAiQuotaError` now reads the AI quota refusal code from all three shapes the +cloud 429 producers use, instead of only the flat `error`-holds-the-code dialect. + +The two live producers fill the same `error` key in opposite ways — the token +guardrail puts the **code** there, `service-ai` puts the **message** there and the +code in a `code` sibling — while ADR-0112 declares a third shape both are +converging on: `{ success: false, error: { code, message } }`. The consumer had to +learn the declared shape **first**, or the producers' convergence would silently +turn every quota refusal back into a generic "Response failed" banner (the same +consumer-first sequencing as objectui#2992). + +- Code lookup order is a total order — declared envelope, then the flat guardrail + code, then the `code` sibling — so a transitional producer that double-emits the + new envelope alongside the legacy top-level keys has one defined outcome. +- Only the code's **location** widens. The recognized code set is unchanged, and + any unrecognized shape still degrades to today's behavior (`null`), so no + non-quota error is newly captured by the quota CTA. +- Companion fields (`upgrade`, `topUp`, `messageEn`) keep their established + top-level read; their position inside the declared envelope is deliberately not + presumed, and is aligned once the producer PR fixes the real shape. diff --git a/packages/plugin-chatbot/src/tool-display.test.ts b/packages/plugin-chatbot/src/tool-display.test.ts index 8ea6a1732d..e3d56f5bf4 100644 --- a/packages/plugin-chatbot/src/tool-display.test.ts +++ b/packages/plugin-chatbot/src/tool-display.test.ts @@ -81,4 +81,171 @@ describe('parseAiQuotaError', () => { expect(parseAiQuotaError(undefined)).toBeNull(); expect(parseAiQuotaError('')).toBeNull(); }); + + // The three-dialect matrix (objectui#3491 / cloud#944). The two live producers + // fill `error` in opposite ways and ADR-0112 declares a third shape they are + // converging on (cloud#1168); every one of them must be readable HERE before + // any producer moves, and every one must miss on a non-quota code. + describe('dialect matrix', () => { + const err = (payload: unknown) => new Error(JSON.stringify(payload)); + const CODES = [ + 'ai_design_quota_exhausted', + 'ai_data_chat_trial_exhausted', + 'ai_allowance_exhausted', + ] as const; + + describe('flat guardrail dialect — `error` holds the code', () => { + it.each(CODES)('hits on %s', (code) => { + expect(parseAiQuotaError(err({ error: code, message: 'zh', upgrade: true }))).toMatchObject({ + code, + message: 'zh', + upgrade: true, + }); + }); + + it('misses on a code outside the recognized set', () => { + expect(parseAiQuotaError(err({ error: 'ai_quota_exhausted', message: 'zh' }))).toBeNull(); + }); + }); + + describe('service-ai dialect — code in the `code` sibling key', () => { + it.each(CODES)('hits on %s', (code) => { + expect( + parseAiQuotaError(err({ error: '', code, resetAt: '2026-08-09T00:00:00Z' })), + ).toMatchObject({ code, message: '' }); + }); + + it('reads the prose this dialect puts in `error` as the message', () => { + // `error` carries the message here, so it must surface as the message — + // and, in the flat dialect above, must NOT (it is the code there). + const prose = 'AI allowance exhausted, retry after the reset.'; + expect( + parseAiQuotaError(err({ error: prose, code: 'ai_allowance_exhausted' })), + ).toMatchObject({ code: 'ai_allowance_exhausted', message: prose }); + }); + + it('misses on the code service-ai emits today, which is not in the set', () => { + // Documents a real remaining gap rather than asserting it away: the + // shape is now readable, the vocabulary is cloud#1168's to align. + expect( + parseAiQuotaError(err({ error: '', code: 'ai_quota_exhausted', resetAt: 'x' })), + ).toBeNull(); + }); + }); + + describe('declared envelope (ADR-0112) — code nested under `error`', () => { + it.each(CODES)('hits on %s', (code) => { + expect( + parseAiQuotaError(err({ success: false, error: { code, message: 'zh' } })), + ).toMatchObject({ code, message: 'zh' }); + }); + + it('misses on a declared non-quota code', () => { + expect( + parseAiQuotaError( + err({ success: false, error: { code: 'QUOTA_EXCEEDED', message: 'zh' } }), + ), + ).toBeNull(); + }); + + it('misses when the nested error carries no code at all', () => { + expect(parseAiQuotaError(err({ success: false, error: { message: 'zh' } }))).toBeNull(); + }); + }); + + it('degrades to today’s behavior (null) for unknown shapes', () => { + expect(parseAiQuotaError(err({ success: false, error: null }))).toBeNull(); + expect(parseAiQuotaError(err({ success: false, error: [] }))).toBeNull(); + expect(parseAiQuotaError(err({ error: { code: 42 } }))).toBeNull(); + expect(parseAiQuotaError(err({ code: 42 }))).toBeNull(); + expect(parseAiQuotaError(new Error('{ not json }'))).toBeNull(); + }); + + it('still locates a quota body embedded in surrounding text', () => { + // Unchanged pre-existing behavior, pinned because the dialect widening + // reads more keys off whatever this substring extraction returns: the body + // is sliced from the first `{` to the last `}`, so a wrapper (prose, or a + // JSON array of one error) does not hide it. + expect( + parseAiQuotaError(new Error('POST /api/chat 429: {"error":"ai_allowance_exhausted"}')), + ).toMatchObject({ code: 'ai_allowance_exhausted' }); + expect( + parseAiQuotaError(err([{ success: false, error: { code: 'ai_allowance_exhausted' } }])), + ).toMatchObject({ code: 'ai_allowance_exhausted' }); + }); + + describe('companion fields stay backward compatible', () => { + it('reads top-level upgrade / topUp / messageEn alongside a nested code', () => { + // The realistic transitional producer: declared envelope emitted next to + // the legacy top-level keys old clients still read. + expect( + parseAiQuotaError( + err({ + success: false, + error: { code: 'ai_allowance_exhausted', message: 'zh' }, + messageEn: 'Allowance used up', + upgrade: false, + topUp: true, + }), + ), + ).toMatchObject({ + code: 'ai_allowance_exhausted', + message: 'zh', + messageEn: 'Allowance used up', + upgrade: false, + topUp: true, + }); + }); + + it('defaults the CTA flags to false when a payload carries none', () => { + // A nested-only payload gets no CTA flags: their position in the declared + // envelope is deliberately not presumed (cloud#1168 aligns it). + expect( + parseAiQuotaError(err({ success: false, error: { code: 'ai_allowance_exhausted' } })), + ).toEqual({ + code: 'ai_allowance_exhausted', + message: '', + messageEn: undefined, + upgrade: false, + topUp: false, + }); + }); + }); + + describe('parse priority is a total order', () => { + it('prefers the declared envelope over the legacy limbs', () => { + expect( + parseAiQuotaError( + err({ + success: false, + error: { code: 'ai_design_quota_exhausted', message: 'nested' }, + code: 'ai_allowance_exhausted', + message: 'flat', + }), + ), + ).toMatchObject({ code: 'ai_design_quota_exhausted', message: 'nested' }); + }); + + it('prefers the flat code over the sibling code key', () => { + expect( + parseAiQuotaError( + err({ error: 'ai_design_quota_exhausted', code: 'ai_allowance_exhausted' }), + ), + ).toMatchObject({ code: 'ai_design_quota_exhausted' }); + }); + + it('falls through to a legacy limb when the nested code is unrecognized', () => { + expect( + parseAiQuotaError( + err({ + success: false, + error: { code: 'QUOTA_EXCEEDED', message: 'nested' }, + code: 'ai_allowance_exhausted', + message: 'flat', + }), + ), + ).toMatchObject({ code: 'ai_allowance_exhausted', message: 'nested' }); + }); + }); + }); }); diff --git a/packages/plugin-chatbot/src/tool-display.ts b/packages/plugin-chatbot/src/tool-display.ts index 58d8761954..4e0e36dfeb 100644 --- a/packages/plugin-chatbot/src/tool-display.ts +++ b/packages/plugin-chatbot/src/tool-display.ts @@ -169,6 +169,22 @@ const AI_QUOTA_CODES = new Set([ 'ai_allowance_exhausted', ]); +/** The value as a recognized quota code, or undefined for anything else. */ +function asAiQuotaCode(value: unknown): AiQuotaCode | undefined { + return typeof value === 'string' && AI_QUOTA_CODES.has(value) + ? (value as AiQuotaCode) + : undefined; +} + +/** + * The value as non-empty text, or undefined — so an empty string falls through + * to the next candidate source instead of winning as the message (the + * `{ error: '', code: … }` dialect emits exactly that). + */ +function asText(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + /** * Recognize the cloud AI token guardrail's 429 quota refusals so the chat UI can * show a friendly upgrade / top-up CTA instead of a generic "response failed". @@ -176,7 +192,36 @@ const AI_QUOTA_CODES = new Set([ * The ai-sdk chat transport throws a plain Error whose `message` is the response * body text (no HTTP status is preserved), so the only signal is the JSON body: * strip the same retry/format prefixes summarizeChatError handles, locate the - * JSON object, and match its `error` code. Returns null for anything else. + * JSON object, and find the code in it. Returns null for anything else. + * + * ## Three dialects, one code set + * + * The two cloud 429 producers fill the same `error` key in OPPOSITE ways, and + * ADR-0112 declares a third shape they are converging on (objectui#3491, + * cloud#944). All three are read here so the consumer is ready BEFORE the + * producers move (cloud#1168) — the alternative is a silent misfit at the + * moment they converge: + * + * | dialect | shape | + * |------------------------------|------------------------------------------------| + * | declared envelope (ADR-0112) | `{ success: false, error: { code, message } }` | + * | flat guardrail | `{ error: CODE, message, upgrade, topUp }` | + * | service-ai sibling key | `{ error: PROSE, code: CODE }` | + * + * Only the code's LOCATION widens; the recognized code set is unchanged, so an + * unknown shape still degrades to today's behavior (null). Note that a + * spec-conformant `error.code` must be an `ErrorCode` ledger member + * (SCREAMING_SNAKE) and none of these three codes is registered today, so the + * nested branch matches the legacy vocabulary only — aligning the vocabulary is + * cloud#1168's call and needs a follow-up here, not a guess now. + * + * ## Parse priority (a total order, deliberately) + * + * `declared envelope > flat guardrail > sibling code key`. A payload that + * satisfies two dialects at once — a transitional producer double-emitting the + * new envelope alongside the legacy top-level keys is the realistic case — must + * have ONE defined outcome, so the most-declared position wins, and the legacy + * limbs remain reachable when the nested code is absent or unrecognized. */ export function parseAiQuotaError(err: unknown): AiQuotaError | null { const raw = err instanceof Error ? err.message : String(err ?? ''); @@ -194,13 +239,33 @@ export function parseAiQuotaError(err: unknown): AiQuotaError | null { } catch { return null; } - if (!body || typeof body.error !== 'string' || !AI_QUOTA_CODES.has(body.error)) { - return null; - } + if (!body || typeof body !== 'object') return null; + + // The declared envelope's nested error object, when `error` carries one. + const nested = + body.error && typeof body.error === 'object' + ? (body.error as Record) + : undefined; + + const flatCode = asAiQuotaCode(body.error); + const code = asAiQuotaCode(nested?.code) ?? flatCode ?? asAiQuotaCode(body.code); + if (!code) return null; + return { - code: body.error as AiQuotaCode, - message: typeof body.message === 'string' ? body.message : '', - messageEn: typeof body.messageEn === 'string' ? body.messageEn : undefined, + code, + message: + asText(nested?.message) ?? + asText(body.message) ?? + // The service-ai dialect puts prose in `error`. Read it as text only when + // `error` is not itself the code slot, or the flat dialect would surface + // its own code ('ai_allowance_exhausted') to the user as the message. + (flatCode ? undefined : asText(body.error)) ?? + '', + // Companion fields keep their established top-level read. Their position in + // the declared envelope is NOT presumed here (`error.details.upgrade`? a + // sibling of `error.code`?) — cloud#1168 decides the real shape, and + // inventing a nested key now would fossilize a contract nobody agreed to. + messageEn: asText(body.messageEn), upgrade: body.upgrade === true, topUp: body.topUp === true, };