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
2 changes: 1 addition & 1 deletion i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@
"GLM 5.3, frontier open model with reasoning",
"3,000M token allowance per billing period",
"400M tokens per rolling 4h window: the limit a coding agent reaches first",
"1M context · 5 concurrent requests",
"1M context · 10 concurrent requests",
"Everything in nan_member",
"Access switches on a few minutes after payment"
],
Expand Down
2 changes: 1 addition & 1 deletion i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@
"GLM 5.3, modelo abierto frontier con razonamiento",
"Asignación de 3.000M de tokens por periodo de facturación",
"400M de tokens por ventana deslizante de 4h: el límite que un agente de código alcanza primero",
"Contexto de 1M · 5 peticiones en paralelo",
"Contexto de 1M · 10 peticiones en paralelo",
"Todo lo incluido en nan_member",
"El acceso se activa pocos minutos después del pago"
],
Expand Down
36 changes: 31 additions & 5 deletions src/components/docs/RateLimits.astro
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
---
import { env } from 'cloudflare:workers';
import {
concurrencyValue,
formatTokens,
getRateLimitsConfig,
perKeyOuterCapValue,
premiumConcurrency,
windowedModelBody,
windowedModelHeadline,
rateLimitsLabels,
} from '../../lib/rateLimits';

const { perKey, tokensPerMinuteByModel, requestsPerMinuteByModel, windowedModels } =
getRateLimitsConfig(env);
const config = getRateLimitsConfig(env);
const { perKey, tokensPerMinuteByModel, requestsPerMinuteByModel, windowedModels, concurrencyByModel } =
config;

// This card is embedded from both the English and the Spanish guides, and MDX
// content cannot pass props down from the layout, so the locale is read off the
Expand All @@ -29,10 +33,32 @@ const T = rateLimitsLabels(lang);
<dd class="text-neutral-200 text-right">{perKey.requestsPerMinute} rpm</dd>
</div>
<div class="flex items-baseline justify-between gap-4 font-mono text-xs">
<dt class="text-neutral-500 uppercase tracking-wider">{T.maxParallel}</dt>
<dd class="text-neutral-200 text-right">{perKey.maxParallel} {T.concurrent}</dd>
<dt class="text-neutral-500 uppercase tracking-wider">{T.perKeyConcurrency}</dt>
<dd class="text-neutral-200 text-right">{T.concurrencyPointer}</dd>
</div>
<div class="flex items-baseline justify-between gap-4 font-mono text-xs">
<dt class="text-neutral-500 uppercase tracking-wider">{T.perKeyAcrossModels}</dt>
<dd class="text-neutral-200 text-right">{perKeyOuterCapValue(perKey, lang)}</dd>
</div>
</dl>
</div>

<div class="rounded-xl border border-neutral-800/60 bg-[#0a0a0a] p-6 mb-6">
<p class="font-mono text-[10px] text-violet-400 uppercase tracking-widest mb-4">
{T.concurrencyPerModel}
</p>
<p class="font-mono text-[11px] leading-relaxed text-neutral-400 mb-4">{T.concurrencyNote}</p>
<dl class="grid gap-3 sm:grid-cols-2">
{
concurrencyByModel.map((c) => (
<div class="flex items-baseline justify-between gap-4 font-mono text-xs">
<dt class="text-neutral-500 uppercase tracking-wider">{c.model}</dt>
<dd class="text-neutral-200 text-right">{concurrencyValue(c, lang)}</dd>
</div>
))
}
</dl>
<p class="mt-4 font-mono text-[11px] leading-relaxed text-neutral-400">{T.concurrencyExempt}</p>
</div>

{
Expand Down Expand Up @@ -62,7 +88,7 @@ const T = rateLimitsLabels(lang);
</div>
<div class="flex items-baseline justify-between gap-4 font-mono text-xs">
<dt class="text-neutral-500 uppercase tracking-wider">{T.concurrentRequests}</dt>
<dd class="text-neutral-200 text-right">{m.maxParallel}</dd>
<dd class="text-neutral-200 text-right">{premiumConcurrency(config, m.model, m.maxParallel)}</dd>
</div>
</dl>
<p class="mt-4 font-mono text-[11px] leading-relaxed text-neutral-400">
Expand Down
16 changes: 14 additions & 2 deletions src/lib/__fixtures__/ratelimits.expected.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
**rate limits per API key**

- Requests / min: 60 rpm
- Max parallel: 5 concurrent
- Concurrent requests: per model — see the per-model limits below

**concurrent requests per model**

- glm5.3: 7 (base plan) · 10 (premium plan)
- glm5.3-flash: 7 (base plan) · 10 (premium plan)
- deepseek-v4-flash: 7 (base plan) · 10 (premium plan)
- qwen3.8-flash: 7 (base plan) · 10 (premium plan)
- mimo-v2.5: 5
- qwen3.6: 5
- gemma4: 5

Audio, embedding and rerank endpoints have no concurrency limit.

**glm5.3 · premium tier limits**

- Rolling 4h window: 400M tokens
- Allowance / billing period: 3,000M tokens
- Context window: 1M tokens
- Concurrent requests: 5
- Concurrent requests: 10

400M tokens per rolling 4 hours is the limit a heavy coding-agent run reaches first, well before the allowance. Once you hit it, glm5.3 requests are rejected until the window slides forward: it is a rolling window, not a daily reset. The allowance counter goes back to zero when your billing period starts, and if you upgrade part-way into a period that first allowance is prorated to the share of the period you paid for.

Expand Down
49 changes: 44 additions & 5 deletions src/lib/mdxToText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,13 @@ describe('mdxToText rate limits', () => {

it('serves the values from the injected config, not hardcoded ones', async () => {
const out = await mdxToText(input, {
perKey: { requestsPerMinute: 120, maxParallel: 8 },
perKey: { requestsPerMinute: 120, maxParallel: 8, tierMaxParallel: { inference: 7, premium: 10 } },
tokensPerMinuteByModel: [{ model: 'foo', label: '2M tpm' }],
requestsPerMinuteByModel: [{ model: 'bar', label: '500 rpm' }],
concurrencyByModel: [
{ model: 'foo', maxParallel: 5, tierMaxParallel: { inference: 7, premium: 10 } },
{ model: 'plain', maxParallel: 5 },
],
windowedModels: [
{
model: 'baz',
Expand All @@ -104,35 +108,70 @@ describe('mdxToText rate limits', () => {
],
});
expect(out).toContain('- Requests / min: 120 rpm');
expect(out).toContain('- Max parallel: 8 concurrent');
// maxParallel is the legacy outer cap: no surface renders it, the
// per-key row points at the per-model table instead.
expect(out).not.toContain('- Max parallel');
expect(out).toContain('- Concurrent requests: per model — see the per-model limits below');
expect(out).toContain('- foo: 2M tpm');
expect(out).toContain('- bar: 500 rpm');
expect(out).toContain('**concurrent requests per model**');
expect(out).toContain('- foo: 7 (base plan) · 10 (premium plan)');
expect(out).toContain('- plain: 5');
// The card names the exempt endpoints under the per-model table; the
// plain-text renderer says the same thing.
expect(out).toContain('Audio, embedding and rerank endpoints have no concurrency limit.');
expect(out).toContain('**baz · premium tier limits**');
expect(out).toContain('- Rolling 6h window: 7M tokens');
expect(out).toContain('- Allowance / billing period: 9M tokens');
expect(out).toContain('- Context window: 128K tokens');
// baz has no row in concurrencyByModel, so the card falls back to the
// windowed entry's own flat default.
expect(out).toContain('- Concurrent requests: 2');
});

it('defaults to the same numbers <RateLimits /> renders', async () => {
const out = await mdxToText(input);
expect(out).toContain('- Requests / min: 60 rpm');
expect(out).toContain('- Max parallel: 5 concurrent');
// The per-key block no longer publishes a flat concurrency number (it
// said 5, which the per-model tiers made false): it points at the
// per-model table below instead.
expect(out).not.toContain('- Max parallel');
expect(out).not.toContain('5 concurrent');
expect(out).toContain('- Concurrent requests: per model — see the per-model limits below');
// The per-model concurrency card, with the tier numbers of the four
// frontier models and the flat 5 of the rest.
expect(out).toContain('**concurrent requests per model**');
expect(out).toContain('- glm5.3: 7 (base plan) · 10 (premium plan)');
expect(out).toContain('- glm5.3-flash: 7 (base plan) · 10 (premium plan)');
expect(out).toContain('- deepseek-v4-flash: 7 (base plan) · 10 (premium plan)');
expect(out).toContain('- qwen3.8-flash: 7 (base plan) · 10 (premium plan)');
expect(out).toContain('- mimo-v2.5: 5');
expect(out).toContain('- qwen3.6: 5');
expect(out).toContain('- gemma4: 5');
// glm5.3 is gated by the window, not by a per-minute rate, and the docs
// had no row for it at all while the model was already being served.
// had no row for it at all while the model was already being served. Its
// concurrency row resolves the premium tier's number.
expect(out).toContain('- Rolling 4h window: 400M tokens');
expect(out).toContain('- Allowance / billing period: 3,000M tokens');
expect(out).toContain('- Concurrent requests: 10');
// The exempt-endpoints note travels with the per-model table on every
// surface, so a consumer never reads the list as exhaustive.
expect(out).toContain('Audio, embedding and rerank endpoints have no concurrency limit.');
});

it('omits the per-model blocks when they are empty', async () => {
const out = await mdxToText(input, {
perKey: { requestsPerMinute: 60, maxParallel: 5 },
perKey: { requestsPerMinute: 60, maxParallel: 5, tierMaxParallel: { inference: 7, premium: 10 } },
tokensPerMinuteByModel: [],
requestsPerMinuteByModel: [],
concurrencyByModel: [],
windowedModels: [],
});
expect(out).not.toContain('tokens / min per model');
expect(out).not.toContain('requests / min per model');
expect(out).not.toContain('concurrent requests per model');
// The note is tied to the table's presence, not a standalone block.
expect(out).not.toContain('Audio, embedding and rerank endpoints have no concurrency limit.');
expect(out).not.toContain('premium tier limits');
});
});
Expand Down
26 changes: 22 additions & 4 deletions src/lib/mdxToText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import remarkGfm from 'remark-gfm';
import remarkMdx from 'remark-mdx';
import remarkStringify from 'remark-stringify';
import {
concurrencyValue,
DEFAULT_RATE_LIMITS,
formatTokens,
premiumConcurrency,
rateLimitsLabels,
windowedModelNote,
type RateLimitsConfig,
} from './rateLimits';
Expand Down Expand Up @@ -416,21 +419,36 @@ function brandIntroToMd(node: MdxNode): string {
}

function rateLimitsToMd(config: RateLimitsConfig): string {
// The canonical text is English-only, so the labels resolve to the English
// table: the per-key block and the card must not be able to drift apart.
const L = rateLimitsLabels('en');
const lines = [
'**rate limits per API key**',
`**${L.perKey}**`,
'',
`- Requests / min: ${config.perKey.requestsPerMinute} rpm`,
`- Max parallel: ${config.perKey.maxParallel} concurrent`,
`- ${L.requestsPerMin}: ${config.perKey.requestsPerMinute} rpm`,
// Concurrency is enforced per model, so the per-key block points at the
// per-model table below instead of a flat number the tiers made false.
`- ${L.perKeyConcurrency}: ${L.concurrencyPointer}`,
'',
];
if (config.concurrencyByModel.length) {
lines.push('**concurrent requests per model**', '');
for (const c of config.concurrencyByModel) lines.push(`- ${c.model}: ${concurrencyValue(c)}`);
// The card names the endpoints the list above does not cover; the bot
// consumer gets the same sentence, or the list reads as exhaustive. The
// blank line first keeps the note out of the last list item.
lines.push('', L.concurrencyExempt, '');
}
for (const m of config.windowedModels) {
lines.push(
`**${m.model} · premium tier limits**`,
'',
`- Rolling ${m.windowHours}h window: ${formatTokens(m.windowTokens)} tokens`,
`- Allowance / billing period: ${formatTokens(m.periodCapTokens)} tokens`,
`- Context window: ${formatTokens(m.contextTokens)} tokens`,
`- Concurrent requests: ${m.maxParallel}`,
// The premium card addresses premium members, so it resolves the
// premium tier's number rather than the flat default.
`- Concurrent requests: ${premiumConcurrency(config, m.model, m.maxParallel)}`,
'',
windowedModelNote(m),
'',
Expand Down
48 changes: 43 additions & 5 deletions src/lib/openapiSpec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,20 +197,23 @@ describe('openapi.json: rate limits come from the single source of truth', () =>
expect(description).toContain(
`| Requests per minute | ${DEFAULT_RATE_LIMITS.perKey.requestsPerMinute} |`,
);
expect(description).toContain(
`| Concurrent requests | ${DEFAULT_RATE_LIMITS.perKey.maxParallel} |`,
);
// Concurrency is enforced per model, so the row points at the per-model
// table below instead of a flat per-key number.
expect(description).toContain('| Concurrent requests | per model — see the per-model limits below |');
});

/** An env override has to reach /docs/api, not only /docs/models. */
it('follows an env override of the per-key limits', () => {
it('follows an env override of the per-key rate, and leaks no legacy parallel cap', () => {
const overridden = getRateLimitsConfig({ RATE_LIMIT_RPM: '250', RATE_LIMIT_PARALLEL: '9' });
const description = resolveSpec(overridden).info.description;
expect(description).toContain('| Requests per minute | 250 |');
expect(description).toContain('| Concurrent requests | 9 |');
expect(description).not.toContain(
`| Requests per minute | ${DEFAULT_RATE_LIMITS.perKey.requestsPerMinute} |`,
);
// RATE_LIMIT_PARALLEL is the legacy outer cap: it still parses (env
// overrides must not crash) but no surface publishes it any more.
expect(description).not.toContain('| Concurrent requests | 9 |');
expect(description).toContain('| Concurrent requests | per model — see the per-model limits below |');
});

it('publishes every model that carries a per-minute limit', () => {
Expand All @@ -231,6 +234,41 @@ describe('openapi.json: rate limits come from the single source of truth', () =>
expect(description).toContain(`${formatTokens(m.contextTokens)} tokens`);
}
});

it('publishes the per-model concurrency with the tier numbers', () => {
const description = resolveSpec(DEFAULT_RATE_LIMITS).info.description;
expect(description).toContain('Concurrency is enforced per model');
// Grouped the way the per-minute rows are, so the Rate limits section
// grows no `| `glm5.3` |` row of its own that could shadow the Model
// catalog's row for the same model.
expect(description).toContain(
'| `glm5.3`, `glm5.3-flash`, `deepseek-v4-flash`, `qwen3.8-flash` | 7 (base plan) · 10 (premium plan) |',
);
expect(description).toContain('| `mimo-v2.5`, `qwen3.6`, `gemma4` | 5 |');
});

it('names the endpoints the per-model concurrency table does not cover', () => {
const description = resolveSpec(DEFAULT_RATE_LIMITS).info.description;
expect(description).toContain('Audio, embedding and rerank endpoints have no concurrency limit.');
});

it('gives a model whose numbers differ its own row, not its neighbour\'s', () => {
const config = {
...DEFAULT_RATE_LIMITS,
concurrencyByModel: [
...DEFAULT_RATE_LIMITS.concurrencyByModel,
{ model: 'frontier-next', maxParallel: 5, tierMaxParallel: { inference: 8, premium: 12 } },
],
};
const description = resolveSpec(config).info.description;
expect(description).toContain('| `frontier-next` | 8 (base plan) · 12 (premium plan) |');
});

it('resolves the premium concurrency in the windowed note', () => {
const description = resolveSpec(DEFAULT_RATE_LIMITS).info.description;
// glm5.3 is premium-only, so the note states the premium tier's number.
expect(description).toMatch(/Context window: 1M tokens, 10 concurrent requests\./);
});
});

/**
Expand Down
Loading
Loading