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
24 changes: 24 additions & 0 deletions .changeset/quota-error-envelope-3491.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 167 additions & 0 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' });
});
});
});
});
79 changes: 72 additions & 7 deletions packages/plugin-chatbot/src/tool-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,14 +169,59 @@ const AI_QUOTA_CODES = new Set<string>([
'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".
*
* 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 ?? '');
Expand All@@ -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<string, unknown>)
: 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,
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
24 changes: 24 additions & 0 deletions .changeset/quota-error-envelope-3491.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 167 additions & 0 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' });
});
});
});
});
79 changes: 72 additions & 7 deletions packages/plugin-chatbot/src/tool-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,14 +169,59 @@ const AI_QUOTA_CODES = new Set<string>([
'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".
*
* 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 ?? '');
Expand All@@ -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<string, unknown>)
: 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,
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
24 changes: 24 additions & 0 deletions .changeset/quota-error-envelope-3491.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 167 additions & 0 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' });
});
});
});
});
79 changes: 72 additions & 7 deletions packages/plugin-chatbot/src/tool-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,14 +169,59 @@ const AI_QUOTA_CODES = new Set<string>([
'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".
*
* 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 ?? '');
Expand All@@ -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<string, unknown>)
: 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,
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
24 changes: 24 additions & 0 deletions .changeset/quota-error-envelope-3491.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 167 additions & 0 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' });
});
});
});
});
79 changes: 72 additions & 7 deletions packages/plugin-chatbot/src/tool-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,14 +169,59 @@ const AI_QUOTA_CODES = new Set<string>([
'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".
*
* 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 ?? '');
Expand All@@ -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<string, unknown>)
: 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,
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
24 changes: 24 additions & 0 deletions .changeset/quota-error-envelope-3491.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 167 additions & 0 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' });
});
});
});
});
79 changes: 72 additions & 7 deletions packages/plugin-chatbot/src/tool-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,14 +169,59 @@ const AI_QUOTA_CODES = new Set<string>([
'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".
*
* 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 ?? '');
Expand All@@ -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<string, unknown>)
: 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,
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
24 changes: 24 additions & 0 deletions .changeset/quota-error-envelope-3491.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 167 additions & 0 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' });
});
});
});
});
79 changes: 72 additions & 7 deletions packages/plugin-chatbot/src/tool-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,14 +169,59 @@ const AI_QUOTA_CODES = new Set<string>([
'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".
*
* 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 ?? '');
Expand All@@ -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<string, unknown>)
: 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,
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
24 changes: 24 additions & 0 deletions .changeset/quota-error-envelope-3491.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 167 additions & 0 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' });
});
});
});
});
79 changes: 72 additions & 7 deletions packages/plugin-chatbot/src/tool-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,14 +169,59 @@ const AI_QUOTA_CODES = new Set<string>([
'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".
*
* 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 ?? '');
Expand All@@ -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<string, unknown>)
: 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,
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
24 changes: 24 additions & 0 deletions .changeset/quota-error-envelope-3491.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 167 additions & 0 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' });
});
});
});
});
79 changes: 72 additions & 7 deletions packages/plugin-chatbot/src/tool-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -169,14 +169,59 @@ const AI_QUOTA_CODES = new Set<string>([
'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".
*
* 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 ?? '');
Expand All@@ -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<string, unknown>)
: 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,
};
Expand Down
Loading