Skip to content

Commit 6d4cc09

Browse files
os-zhuangclaude
andauthored
feat(chatbot): surface AI quota refusals as upgrade/top-up CTA (P1d) (#1777)
Closes the loop opened by the cloud-side pricing rework: when the token guardrail refuses an AI request with a 429 (free design quota, free data-Q&A trial, or paid allowance exhausted), the chat UI now shows a friendly upgrade / top-up CTA instead of a red "Response failed" transport error. The ai-sdk chat transport throws a plain Error whose `message` is the 429 body text (no status code preserved), so the only signal is the JSON body. - tool-display.ts: parseAiQuotaError() — strip retry/format prefixes, locate the JSON, match the three guardrail codes; null otherwise. + unit tests. - ChatbotEnhanced.tsx: ErrorBanner renders an amber upgrade card with a CTA ("Upgrade plan" / "Buy a credit pack" for topUp; zh/en by locale) on a quota refusal; generic errors unchanged. New onUpgrade prop threaded through; FloatingChatbot forwards it automatically via {...chatbotProps}. - marketplaceApi.ts: cloudPricingDeepLink() — deep-link to the cloud upgrade entry (envs list), mirroring cloudInstallDeepLink; centralized for re-pointing. - AiChatPage.tsx + ConsoleFloatingChatbot.tsx: wire onUpgrade -> open cloud. Pairs with cloud PR objectstack-ai/cloud#371 (P0+P1a+P1b). Tests: 5 parseAiQuotaError cases pass; changed files typecheck clean. Full browser E2E is the T8 follow-up. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e236764 commit 6d4cc09

6 files changed

Lines changed: 166 additions & 1 deletion

File tree

packages/app-shell/src/console/ai/AiChatPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
import { AppHeader } from '../../layout/AppHeader';
5252
import { fetchPendingDraftCount } from '../../preview/draftStatus';
5353
import { getRuntimeConfig } from '../../runtime-config';
54+
import { cloudPricingDeepLink } from '../marketplace/marketplaceApi';
5455
import { useNavigationContext } from '../../context/NavigationContext';
5556
import {
5657
sanitizeChatMessagesForCache,
@@ -906,6 +907,7 @@ function ChatPane({
906907
>
907908
<ChatbotEnhanced
908909
className="min-h-0 flex-1 bg-background md:max-w-5xl"
910+
onUpgrade={() => window.open(cloudPricingDeepLink(), '_blank', 'noopener,noreferrer')}
909911
surface="plain"
910912
maxHeight="100%"
911913
headerSlot={headerSlot}

packages/app-shell/src/console/marketplace/marketplaceApi.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,19 @@ export function cloudInstallDeepLink(packageId: string): string {
354354
return `${base}/apps/cloud-control/sys_package/${encodeURIComponent(packageId)}`;
355355
}
356356

357+
/**
358+
* Deep-link to the cloud upgrade entry point: the environments list, where each
359+
* environment's "Upgrade Plan" action opens Stripe checkout. Surfaced from the
360+
* tenant SPA when an AI quota refusal (429) offers an upgrade / top-up CTA.
361+
* Centralized so the target can be re-pointed (dedicated pricing or credit-pack
362+
* page) as the cloud billing UI evolves. Same `cloud-control` app slug as
363+
* cloudInstallDeepLink above.
364+
*/
365+
export function cloudPricingDeepLink(): string {
366+
const base = getCloudBase() || 'https://cloud.objectos.app';
367+
return `${base}/apps/cloud-control/sys_environment`;
368+
}
369+
357370
/**
358371
* Look up whether a package is already installed in the given environment.
359372
* Returns the installed version string when an `enabled=true` row exists,

packages/app-shell/src/layout/ConsoleFloatingChatbot.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
import { useAssistant, requestAssistantReview, emitCanvasInvalidate, type AssistantEditorContext } from '../assistant/assistantBus';
4646
import { fetchPendingDraftCount } from '../preview/draftStatus';
4747
import { getRuntimeConfig } from '../runtime-config';
48+
import { cloudPricingDeepLink } from '../console/marketplace/marketplaceApi';
4849

4950
/**
5051
* Display names for the built-in platform agents. The backend ships English
@@ -537,6 +538,7 @@ function ChatbotInner({
537538
return (
538539
<>
539540
<FloatingChatbot
541+
onUpgrade={() => window.open(cloudPricingDeepLink(), '_blank', 'noopener,noreferrer')}
540542
floatingConfig={{
541543
position: 'bottom-right',
542544
defaultOpen,

packages/plugin-chatbot/src/ChatbotEnhanced.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, E
2424
import type { ChatStatus } from 'ai';
2525
import {
2626
humanizeToolName,
27+
parseAiQuotaError,
2728
summarizeChatError,
2829
unwrapToolResult,
2930
} from './tool-display';
@@ -297,6 +298,12 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
297298
onStop?: () => void;
298299
/** Reload / retry the last assistant message */
299300
onReload?: () => void;
301+
/**
302+
* Called when the user clicks the upgrade / top-up CTA shown for an AI quota
303+
* refusal (429 from the cloud token guardrail). Hosts typically open the cloud
304+
* billing/pricing page. When omitted, no CTA button is shown.
305+
*/
306+
onUpgrade?: () => void;
300307
disabled?: boolean;
301308
/** Whether the assistant is currently generating a response */
302309
isLoading?: boolean;
@@ -746,6 +753,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
746753
onClear,
747754
onStop,
748755
onReload,
756+
onUpgrade,
749757
disabled = false,
750758
isLoading = false,
751759
error,
@@ -1566,7 +1574,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
15661574
</Conversation>
15671575

15681576
{error ? (
1569-
<ErrorBanner error={error} onReload={onReload} />
1577+
<ErrorBanner error={error} onReload={onReload} onUpgrade={onUpgrade} />
15701578
) : null}
15711579

15721580
<div
@@ -2101,12 +2109,51 @@ function getToolSummaryStatus(
21012109
function ErrorBanner({
21022110
error,
21032111
onReload,
2112+
onUpgrade,
21042113
}: {
21052114
error: Error;
21062115
onReload?: () => void;
2116+
onUpgrade?: () => void;
21072117
}) {
2118+
const quota = React.useMemo(() => parseAiQuotaError(error), [error]);
21082119
const { summary, details } = React.useMemo(() => summarizeChatError(error), [error]);
21092120
const [expanded, setExpanded] = React.useState(false);
2121+
2122+
// AI quota refusal (429 from the cloud token guardrail) -> friendly upgrade /
2123+
// top-up CTA instead of a red "Response failed" banner.
2124+
if (quota) {
2125+
const isZh =
2126+
typeof navigator !== 'undefined' && !!navigator.language?.toLowerCase().startsWith('zh');
2127+
const text =
2128+
(isZh ? quota.message : quota.messageEn ?? quota.message) ||
2129+
(isZh ? 'AI \u989d\u5ea6\u5df2\u7528\u5b8c\u3002' : 'You have reached your AI quota.');
2130+
const cta = quota.topUp
2131+
? (isZh ? '\u8d2d\u4e70\u989d\u5ea6\u5305' : 'Buy a credit pack')
2132+
: (isZh ? '\u5347\u7ea7\u65b9\u6848' : 'Upgrade plan');
2133+
const title = isZh ? '\u9700\u8981\u5347\u7ea7' : 'Upgrade needed';
2134+
return (
2135+
<div className="border-t bg-background px-3 py-2 text-sm" role="alert">
2136+
<div className="rounded-md border border-amber-300/40 bg-amber-50/60 px-3 py-2 text-foreground dark:bg-amber-950/20">
2137+
<div className="flex items-start gap-2">
2138+
<div className="min-w-0 flex-1">
2139+
<div className="font-medium leading-snug text-foreground">{title}</div>
2140+
<div className="mt-0.5 break-words leading-snug text-muted-foreground">{text}</div>
2141+
</div>
2142+
{onUpgrade ? (
2143+
<button
2144+
type="button"
2145+
onClick={onUpgrade}
2146+
className="inline-flex h-7 shrink-0 items-center rounded-md border border-amber-400/50 bg-background px-2 text-xs font-medium text-amber-700 hover:bg-amber-100/60 dark:text-amber-300"
2147+
>
2148+
{cta}
2149+
</button>
2150+
) : null}
2151+
</div>
2152+
</div>
2153+
</div>
2154+
);
2155+
}
2156+
21102157
return (
21112158
<div
21122159
className="border-t bg-background px-3 py-2 text-sm"
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { parseAiQuotaError } from './tool-display';
5+
6+
describe('parseAiQuotaError', () => {
7+
const body = (extra: Record<string, unknown>) =>
8+
JSON.stringify({ message: 'quota', messageEn: 'Quota used up', ...extra });
9+
10+
it('recognizes the free design quota refusal', () => {
11+
const r = parseAiQuotaError(new Error(body({ error: 'ai_design_quota_exhausted', upgrade: true })));
12+
expect(r).toMatchObject({ code: 'ai_design_quota_exhausted', upgrade: true, topUp: false });
13+
});
14+
15+
it('recognizes the free data-chat trial refusal', () => {
16+
const r = parseAiQuotaError(new Error(body({ error: 'ai_data_chat_trial_exhausted', upgrade: true })));
17+
expect(r?.code).toBe('ai_data_chat_trial_exhausted');
18+
});
19+
20+
it('recognizes the paid allowance refusal with topUp', () => {
21+
const r = parseAiQuotaError(new Error(body({ error: 'ai_allowance_exhausted', upgrade: false, topUp: true })));
22+
expect(r).toMatchObject({ code: 'ai_allowance_exhausted', upgrade: false, topUp: true });
23+
expect(r?.messageEn).toBe('Quota used up');
24+
});
25+
26+
it('strips the ai-sdk "Failed after N attempts" retry prefix', () => {
27+
const r = parseAiQuotaError(
28+
new Error(`Failed after 2 attempts. Last error: ${body({ error: 'ai_allowance_exhausted' })}`),
29+
);
30+
expect(r?.code).toBe('ai_allowance_exhausted');
31+
});
32+
33+
it('returns null for unrelated errors', () => {
34+
expect(parseAiQuotaError(new Error('network timeout'))).toBeNull();
35+
expect(parseAiQuotaError(new Error(JSON.stringify({ error: 'something_else' })))).toBeNull();
36+
expect(parseAiQuotaError(undefined)).toBeNull();
37+
expect(parseAiQuotaError('')).toBeNull();
38+
});
39+
});

packages/plugin-chatbot/src/tool-display.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,65 @@ export function summarizeChatError(err: unknown): {
144144
details: stripped.length > sentence.length ? stripped : undefined,
145145
};
146146
}
147+
148+
/** AI quota refusal codes emitted by the cloud token guardrail (HTTP 429). */
149+
export type AiQuotaCode =
150+
| 'ai_design_quota_exhausted'
151+
| 'ai_data_chat_trial_exhausted'
152+
| 'ai_allowance_exhausted';
153+
154+
export interface AiQuotaError {
155+
code: AiQuotaCode;
156+
/** Localized (zh) message from the backend. */
157+
message: string;
158+
/** English message from the backend. */
159+
messageEn?: string;
160+
/** Free tier -> upgrade to a paid plan. */
161+
upgrade: boolean;
162+
/** Paid tier -> buy a credit top-up pack. */
163+
topUp: boolean;
164+
}
165+
166+
const AI_QUOTA_CODES = new Set<string>([
167+
'ai_design_quota_exhausted',
168+
'ai_data_chat_trial_exhausted',
169+
'ai_allowance_exhausted',
170+
]);
171+
172+
/**
173+
* Recognize the cloud AI token guardrail's 429 quota refusals so the chat UI can
174+
* show a friendly upgrade / top-up CTA instead of a generic "response failed".
175+
*
176+
* The ai-sdk chat transport throws a plain Error whose `message` is the response
177+
* body text (no HTTP status is preserved), so the only signal is the JSON body:
178+
* strip the same retry/format prefixes summarizeChatError handles, locate the
179+
* JSON object, and match its `error` code. Returns null for anything else.
180+
*/
181+
export function parseAiQuotaError(err: unknown): AiQuotaError | null {
182+
const raw = err instanceof Error ? err.message : String(err ?? '');
183+
if (!raw) return null;
184+
const stripped = raw
185+
.replace(/^Failed after \d+ attempts?\.\s*Last error:\s*/i, '')
186+
.replace(/^Invalid error response format:\s*/i, '')
187+
.trim();
188+
const start = stripped.indexOf('{');
189+
const end = stripped.lastIndexOf('}');
190+
if (start < 0 || end <= start) return null;
191+
let body: any;
192+
try {
193+
body = JSON.parse(stripped.slice(start, end + 1));
194+
} catch {
195+
return null;
196+
}
197+
if (!body || typeof body.error !== 'string' || !AI_QUOTA_CODES.has(body.error)) {
198+
return null;
199+
}
200+
return {
201+
code: body.error as AiQuotaCode,
202+
message: typeof body.message === 'string' ? body.message : '',
203+
messageEn: typeof body.messageEn === 'string' ? body.messageEn : undefined,
204+
upgrade: body.upgrade === true,
205+
topUp: body.topUp === true,
206+
};
207+
}
208+

0 commit comments

Comments
 (0)