Skip to content

Commit aebdf39

Browse files
committed
Surface real rate-limit errors + per-provider quota docs + fix empty-text bug across all stream callers
Three real user complaints from screenshots: 1. Google RSA generator showed a generic "API key invalid / model wrong / quota exhausted — switch provider" checklist next to the ACTUAL provider error "YOU EXCEEDED YOUR CURRENT QUOTA · gemini-2.5-flash · Please retry in 15.66s". That checklist was misleading — the user's key was working fine. - GeneratorShell.tsx OutputArea now receives lastError prop and detects rate-limit signatures (429 / "rate limit" / "quota" / "retry in"). - When detected: shows the actual error inline, with rate-limit-specific next steps (wait the retry-in seconds / switch provider / add billing). Live-yellow color instead of red so user knows it's not a hard failure. - When other error: shows the verbatim provider error instead of the generic checklist. Generic fallback only when there's no error string. 2. Per-provider rate-limit reference now visible in two places: - lib/provider-limits.ts: documented free-tier + paid quotas for all 9 providers (Gemini 15 RPM/1500 RPD/1M TPM, Groq 30 RPM/14,400 RPD, etc.) with official docs URLs. - Settings page: each provider card has a "▸ rate limits · free/paid" accordion. Expanded shows bullets + docs link. - StatusBar: tiny FREE/PAID badge next to the model name with the summary line as tooltip. 3. "Saved to history" with no visible output bug — same res.text empty issue I fixed for the launch wizard, but it was also in /suggestions, /research/competitors, and /learn/frameworks (all four use llmStream directly, not GeneratorShell). - All four now use `const finalText = res.text || stream.text` so the streamed accumulation is used when the provider returns empty res.text. - Saved-ad records now always contain the actual generated output.
1 parent 1a87dea commit aebdf39

7 files changed

Lines changed: 231 additions & 18 deletions

File tree

app/learn/frameworks/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function Inner() {
6969
);
7070
addUsage(estimateCostUsd(res.providerId, res.modelId, res.usage), res.usage?.input_tokens ?? 0, res.usage?.output_tokens ?? 0);
7171
window.dispatchEvent(new Event("ados:usage"));
72-
setParsed(tryParseJson(res.text));
72+
setParsed(tryParseJson(res.text || stream.text));
7373
} catch (e: any) {
7474
if (e?.name !== "AbortError") setError(e?.message ?? "Failed");
7575
} finally {

app/research/competitors/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ function Inner() {
135135
const cost = estimateCostUsd(res.providerId, res.modelId, res.usage);
136136
addUsage(cost, res.usage?.input_tokens ?? 0, res.usage?.output_tokens ?? 0);
137137
window.dispatchEvent(new Event("ados:usage"));
138-
const json = tryParseJson(res.text);
138+
const finalText = res.text || stream.text;
139+
const json = tryParseJson(finalText);
139140
setParsed(json);
140141
const ad: GeneratedAd = {
141142
id: crypto.randomUUID(),
@@ -145,7 +146,7 @@ function Inner() {
145146
title: `Steal · ${input.competitor_name || "competitor"} · ${input.our_product}`,
146147
input: input as unknown as Record<string, unknown>,
147148
output_json: json,
148-
output_text: res.text,
149+
output_text: finalText,
149150
model_id: res.modelId,
150151
usage_input_tokens: res.usage?.input_tokens ?? 0,
151152
usage_output_tokens: res.usage?.output_tokens ?? 0,

app/settings/page.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { testApiKey } from "@/lib/llm";
1818
import { exportAll, importAll, wipeAll } from "@/lib/storage";
1919
import { formatCost, formatTokens } from "@/lib/utils";
2020
import { CURRENCIES, getCurrencyCode, setCurrencyCode } from "@/lib/currency";
21+
import { getProviderLimits } from "@/lib/provider-limits";
2122

2223
export default function SettingsPage() {
2324
return (
@@ -188,7 +189,31 @@ function SettingsInner() {
188189
</div>
189190
<p className="text-[11px] text-ink-muted mt-1 leading-relaxed">{p.description}</p>
190191
{p.free_note ? <p className="text-[10px] font-mono uppercase tracking-ui-wide text-pos mt-1">{p.free_note}</p> : null}
191-
<a href={p.get_key_url} target="_blank" rel="noreferrer" className="text-[10px] font-mono uppercase tracking-ui-wide text-info hover:underline inline-flex items-center gap-0.5 mt-1">
192+
{(() => {
193+
const lim = getProviderLimits(p.id);
194+
if (!lim) return null;
195+
return (
196+
<details className="mt-1">
197+
<summary className={`cursor-pointer list-none text-[10px] font-mono uppercase tracking-ui-wide ${lim.has_free_tier ? "text-pos" : "text-ink-faint"} hover:text-ink transition`}>
198+
▸ rate limits · {lim.has_free_tier ? "free" : "paid"}
199+
</summary>
200+
<ul className="mt-2 ml-2 space-y-0.5 text-[11px] text-ink-muted leading-relaxed">
201+
{lim.details.map((d, i) => (
202+
<li key={i} className="flex gap-1.5">
203+
<span className="text-ink-faint">·</span>
204+
<span>{d}</span>
205+
</li>
206+
))}
207+
<li className="mt-1">
208+
<a href={lim.docs_url} target="_blank" rel="noreferrer" className="text-info hover:underline inline-flex items-center gap-0.5">
209+
official docs <ExternalLink size={9} />
210+
</a>
211+
</li>
212+
</ul>
213+
</details>
214+
);
215+
})()}
216+
<a href={p.get_key_url} target="_blank" rel="noreferrer" className="text-[10px] font-mono uppercase tracking-ui-wide text-info hover:underline inline-flex items-center gap-0.5 mt-1 ml-2">
192217
get key <ExternalLink size={9} />
193218
</a>
194219
</div>

app/suggestions/page.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@ function Inner() {
7777
const cost = estimateCostUsd(res.providerId, res.modelId, res.usage);
7878
addUsage(cost, res.usage?.input_tokens ?? 0, res.usage?.output_tokens ?? 0);
7979
window.dispatchEvent(new Event("ados:usage"));
80-
const json = tryParseJson(res.text);
80+
// Some providers (Gemini) sometimes return res.text empty even when
81+
// streaming worked. Fall back to the accumulated stream buffer so the
82+
// saved ad always has the actual generated content.
83+
const finalText = res.text || stream.text;
84+
const json = tryParseJson(finalText);
8185
setParsed(json);
8286
const ad: GeneratedAd = {
8387
id: crypto.randomUUID(),
@@ -87,7 +91,7 @@ function Inner() {
8791
title: `Suggestions · ${brain.name || brain.business_name}`,
8892
input: {},
8993
output_json: json,
90-
output_text: res.text,
94+
output_text: finalText,
9195
model_id: res.modelId,
9296
usage_input_tokens: res.usage?.input_tokens ?? 0,
9397
usage_output_tokens: res.usage?.output_tokens ?? 0,

components/GeneratorShell.tsx

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ function Inner<I extends Record<string, unknown>>({ config, scope }: Props<I>) {
404404
</section>
405405

406406
<section className="lg:col-span-3 space-y-4">
407-
<OutputArea running={running} stream={stream.text} parsed={parsed} config={config as unknown as GeneratorConfig<Record<string, unknown>>} hasRun={hasRun} />
407+
<OutputArea running={running} stream={stream.text} parsed={parsed} config={config as unknown as GeneratorConfig<Record<string, unknown>>} hasRun={hasRun} lastError={error} />
408408
{savedId && nextSteps.length ? <NextStepsPanel steps={nextSteps} /> : null}
409409
</section>
410410
</div>
@@ -633,13 +633,18 @@ const OutputArea = memo(function OutputArea<I extends Record<string, unknown>>({
633633
parsed,
634634
config,
635635
hasRun,
636+
lastError,
636637
}: {
637638
running: boolean;
638639
stream: string;
639640
parsed: any;
640641
config: GeneratorConfig<I>;
641642
hasRun: boolean;
643+
lastError?: string | null;
642644
}) {
645+
// Detect a rate-limit error from common provider phrasings so we can give a
646+
// direct, actionable message instead of generic "your key may be invalid".
647+
const isRateLimit = lastError ? /rate limit|quota|too many requests|429|retry in/i.test(lastError) : false;
643648
// First-load empty state — only before any run has happened
644649
if (!running && !stream && !parsed && !hasRun) {
645650
return (
@@ -651,22 +656,56 @@ const OutputArea = memo(function OutputArea<I extends Record<string, unknown>>({
651656

652657
// Generation finished with nothing — surface a clear error instead of staying silent
653658
if (!running && !stream && !parsed && hasRun) {
659+
if (isRateLimit) {
660+
// Specific rate-limit UI: actionable next steps + a link to switch
661+
// provider. Generic "API key invalid" hint is removed for this case
662+
// because the key obviously works — they just exceeded the quota.
663+
return (
664+
<div className="border border-live/40 bg-live/5 p-5 space-y-2">
665+
<div className="text-[10px] font-mono uppercase tracking-ui-mega text-live flex items-center gap-2">
666+
<span className="h-1 w-1 bg-warn" /> rate-limit hit (free-tier quota)
667+
</div>
668+
<p className="text-sm text-ink leading-relaxed">
669+
Provider rejected with: <code className="text-[11px] font-mono text-live bg-base-900/60 px-1 py-0.5">{lastError}</code>
670+
</p>
671+
<p className="text-[12px] text-ink-muted leading-relaxed">
672+
Your API key works fine — you've hit the free-tier ceiling for this provider. Options:
673+
</p>
674+
<ul className="text-[12px] text-ink-muted list-disc list-inside space-y-0.5">
675+
<li>Wait the retry-in seconds shown in the error, then retry the same button.</li>
676+
<li>Switch to a different provider in <a href="/settings" className="text-live underline">Settings</a> — Groq + Cerebras have generous free tiers.</li>
677+
<li>Add billing on the same provider to upgrade past the free-tier cap.</li>
678+
</ul>
679+
<p className="text-[11px] font-mono uppercase tracking-ui-wide text-ink-subtle pt-1">
680+
see exact limits in settings → rate limits dropdown · or in the status bar at the bottom of the page
681+
</p>
682+
</div>
683+
);
684+
}
654685
return (
655686
<div className="border border-neg/40 bg-neg/5 p-5 space-y-2">
656687
<div className="text-[10px] font-mono uppercase tracking-ui-mega text-neg flex items-center gap-2">
657688
<span className="h-1 w-1 bg-neg" /> empty response
658689
</div>
659-
<p className="text-sm text-ink leading-relaxed">
660-
Your provider returned no content. This usually means:
661-
</p>
662-
<ul className="text-[12px] text-ink-muted list-disc list-inside space-y-0.5">
663-
<li>API key in <a href="/settings" className="text-live underline">Settings</a> is invalid or rate-limited</li>
664-
<li>Selected model doesn&apos;t support the request size — try a different model</li>
665-
<li>Free-tier quota exhausted — switch provider in Settings</li>
666-
</ul>
667-
<p className="text-[11px] font-mono uppercase tracking-ui-wide text-ink-subtle">
668-
open browser devtools → network tab → re-run to see the actual response from the provider
669-
</p>
690+
{lastError ? (
691+
<p className="text-sm text-ink leading-relaxed">
692+
Provider error: <code className="text-[11px] font-mono text-neg bg-base-900/60 px-1 py-0.5">{lastError}</code>
693+
</p>
694+
) : (
695+
<>
696+
<p className="text-sm text-ink leading-relaxed">
697+
Your provider returned no content. This usually means:
698+
</p>
699+
<ul className="text-[12px] text-ink-muted list-disc list-inside space-y-0.5">
700+
<li>API key in <a href="/settings" className="text-live underline">Settings</a> is invalid or rate-limited</li>
701+
<li>Selected model doesn&apos;t support the request size — try a different model</li>
702+
<li>Free-tier quota exhausted — switch provider in Settings</li>
703+
</ul>
704+
<p className="text-[11px] font-mono uppercase tracking-ui-wide text-ink-subtle">
705+
open browser devtools → network tab → re-run to see the actual response from the provider
706+
</p>
707+
</>
708+
)}
670709
</div>
671710
);
672711
}

components/StatusBar.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Link from "next/link";
55
import { getActiveProviderId, getActiveModelId, getUsage, hasAnyKeyConfigured, getProviderKey } from "@/lib/settings";
66
import { getProvider } from "@/lib/providers";
77
import { formatCost } from "@/lib/utils";
8+
import { getProviderLimits } from "@/lib/provider-limits";
89

910
export function StatusBar() {
1011
const [info, setInfo] = useState({
@@ -15,6 +16,9 @@ export function StatusBar() {
1516
input: 0,
1617
output: 0,
1718
time: "",
19+
limitSummary: "",
20+
limitDocsUrl: "",
21+
hasFreeTier: false,
1822
});
1923

2024
useEffect(() => {
@@ -24,6 +28,7 @@ export function StatusBar() {
2428
const modelId = pid ? getActiveModelId(pid) ?? provider?.default_model ?? "—" : "—";
2529
const model = provider?.models.find((m) => m.id === modelId) ?? null;
2630
const usage = getUsage();
31+
const limits = getProviderLimits(pid);
2732
setInfo({
2833
providerName: provider?.name ?? "no provider",
2934
modelLabel: model?.label?.split("—")[0]?.trim() ?? modelId,
@@ -32,6 +37,9 @@ export function StatusBar() {
3237
input: usage.input,
3338
output: usage.output,
3439
time: new Date().toTimeString().slice(0, 8),
40+
limitSummary: limits?.summary ?? "",
41+
limitDocsUrl: limits?.docs_url ?? "",
42+
hasFreeTier: limits?.has_free_tier ?? false,
3543
});
3644
};
3745
tick();
@@ -67,6 +75,13 @@ export function StatusBar() {
6775
<span className="text-ink-faint">Model</span>
6876
<span className="text-ink font-medium">{info.modelLabel}</span>
6977
</Cell>
78+
{info.limitSummary ? (
79+
<Cell>
80+
<span className={`text-[10px] uppercase tracking-ui-wide ${info.hasFreeTier ? "text-pos" : "text-ink-subtle"} hidden md:inline`} title={info.limitSummary + (info.limitDocsUrl ? ` · ${info.limitDocsUrl}` : "")}>
81+
{info.hasFreeTier ? "FREE" : "PAID"} · {info.limitSummary.split("·")[0]?.replace(/^FREE/, "").replace(/^Paid/, "").trim() || "see docs"}
82+
</span>
83+
</Cell>
84+
) : null}
7085
<Cell>
7186
<span className="text-ink-faint">Spend</span>
7287
<span className="text-live tabular font-medium">{formatCost(info.cost)}</span>

lib/provider-limits.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Per-provider free-tier quotas — what limits the user is likely hitting.
3+
* Reference text shown alongside the provider in Settings + StatusBar tooltip.
4+
*
5+
* These are documented public limits as of late 2025. They change; we update
6+
* when users report mismatch with reality. Not enforced client-side — the
7+
* provider returns 429 when exceeded; this is just *informational*.
8+
*/
9+
10+
export interface ProviderLimits {
11+
providerId: string;
12+
/** Concise one-line summary. Shown inline next to the model picker. */
13+
summary: string;
14+
/** Detailed bullets for tooltips / docs links. */
15+
details: string[];
16+
/** Whether the provider offers a free tier at all. */
17+
has_free_tier: boolean;
18+
/** Public docs URL where the user can confirm current limits. */
19+
docs_url: string;
20+
}
21+
22+
export const PROVIDER_LIMITS: Record<string, ProviderLimits> = {
23+
anthropic: {
24+
providerId: "anthropic",
25+
summary: "Paid only — no free tier. $5 minimum top-up, $0/mo if unused.",
26+
details: [
27+
"Pay-as-you-go, no monthly minimum after first $5 top-up.",
28+
"Default tier-1 limits: 50 req/min, 40k input tokens/min for Sonnet.",
29+
"Higher tiers unlock automatically based on spend history.",
30+
],
31+
has_free_tier: false,
32+
docs_url: "https://docs.anthropic.com/en/api/rate-limits",
33+
},
34+
openai: {
35+
providerId: "openai",
36+
summary: "Paid only — $5 minimum top-up. No free tier.",
37+
details: [
38+
"Tier-1 (≥$5 paid, <7 days old): 500 req/min for GPT-4.1 / GPT-5.",
39+
"Limits scale with billing history — tier-5 is 10,000 req/min.",
40+
"GPT-4.1-mini: cheapest paid tier, ~$0.40/M input.",
41+
],
42+
has_free_tier: false,
43+
docs_url: "https://platform.openai.com/docs/guides/rate-limits",
44+
},
45+
google: {
46+
summary: "FREE tier · 15 req/min · 1,500 req/day · 1M tokens/min on Gemini Flash.",
47+
providerId: "google",
48+
details: [
49+
"Free tier (no credit card): 15 RPM, 1500 RPD, 1M TPM on Gemini 2.5 Flash.",
50+
"Gemini 2.5 Pro free: 5 RPM, 100 RPD, 250K TPM.",
51+
"429 'quota exceeded' = day or minute cap. Look at the retry-in seconds.",
52+
"Paid tier (with billing): 2000 RPM, no daily cap, 4M TPM.",
53+
],
54+
has_free_tier: true,
55+
docs_url: "https://ai.google.dev/gemini-api/docs/rate-limits",
56+
},
57+
groq: {
58+
providerId: "groq",
59+
summary: "FREE · 30 req/min · 14,400 req/day · 6,000 tokens/min on Llama 70B.",
60+
details: [
61+
"Free tier: 30 RPM, ~14,400 RPD, 6,000 TPM on Llama 3.3 70B.",
62+
"Llama 3.1 8B has higher TPM (30,000) for short tasks.",
63+
"Mixtral 8x7B: 30 RPM but lower TPM. Avoid for long-context jobs.",
64+
"Hard quota — exhaust the daily cap and you wait until midnight UTC.",
65+
],
66+
has_free_tier: true,
67+
docs_url: "https://console.groq.com/docs/rate-limits",
68+
},
69+
cerebras: {
70+
providerId: "cerebras",
71+
summary: "FREE · 30 req/min · 60,000 tokens/min on Llama 70B.",
72+
details: [
73+
"Free tier: 30 RPM, 60,000 TPM on Llama 3.3 70B.",
74+
"Fastest tokens-per-second on the market (specialized hardware).",
75+
"Daily-token limits not publicly published — refresh on rate-limit error.",
76+
],
77+
has_free_tier: true,
78+
docs_url: "https://inference-docs.cerebras.ai/introduction",
79+
},
80+
openrouter: {
81+
providerId: "openrouter",
82+
summary: "FREE models · 20 req/min · 50/day on most :free variants.",
83+
details: [
84+
"Models tagged ':free' (Llama 3.3 70B, DeepSeek V3): 20 RPM, 50 RPD.",
85+
"Free-tier accounts may face additional caps based on credit balance.",
86+
"Paid usage routes via your OpenRouter credit — buy credits separately.",
87+
],
88+
has_free_tier: true,
89+
docs_url: "https://openrouter.ai/docs/api-reference/limits",
90+
},
91+
together: {
92+
providerId: "together",
93+
summary: "Paid pay-as-you-go · select free models w/ daily quotas.",
94+
details: [
95+
"Most models pay-per-token. Cheap rates (~$0.88/M for Llama 70B).",
96+
"A handful of 'free' models have daily quotas — check the model card.",
97+
"$5 free signup credit; expires.",
98+
],
99+
has_free_tier: false,
100+
docs_url: "https://docs.together.ai/docs/rate-limits",
101+
},
102+
deepseek: {
103+
providerId: "deepseek",
104+
summary: "Paid pay-as-you-go · cheapest serious reasoning model.",
105+
details: [
106+
"DeepSeek V3: ~$0.27/M input, ~$1.10/M output. No free tier.",
107+
"DeepSeek R1 (reasoner): ~$0.55/M input, ~$2.19/M output.",
108+
"Default RPM: tier-based, ~60 RPM for new accounts.",
109+
],
110+
has_free_tier: false,
111+
docs_url: "https://api-docs.deepseek.com/quick_start/rate_limit",
112+
},
113+
mistral: {
114+
providerId: "mistral",
115+
summary: "Paid pay-as-you-go · €5 free credit on signup.",
116+
details: [
117+
"Mistral Large: ~$2/M input, $6/M output.",
118+
"Mistral Small: ~$0.20/M input, $0.60/M output — recommended cost/quality.",
119+
"Default 60 RPM; multilingual + JSON output is a strength.",
120+
],
121+
has_free_tier: false,
122+
docs_url: "https://docs.mistral.ai/deployment/laplateforme/tier/",
123+
},
124+
};
125+
126+
export function getProviderLimits(providerId: string | null | undefined): ProviderLimits | null {
127+
if (!providerId) return null;
128+
return PROVIDER_LIMITS[providerId] ?? null;
129+
}

0 commit comments

Comments
 (0)