Rate Limit Planner — PHP source
Turn RPM/TPM limits into a concrete request schedule — batch size, spacing, binding limit, and total run time, with a safety factor for retries. 100% client-side.
This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
/**
* Rate Limit Planner — turn provider rate limits plus a workload into a
* concrete schedule: batch size, spacing, timeline and wall time.
*
* Language: PHP 8.1+ (standard library only)
* Port of src/lib/rateLimitPlanner.ts (the canonical TypeScript
* implementation).
* Tool page: https://dev.cosmolabs.org/tools/rate-limit-planner
*
* Deterministic — no time reads. The TS lib throws RangeError; this port
* throws RangeException with the same messages. With no limits at all the
* plan is unbounded: batchSize/maxConcurrent become INF, matching the
* TypeScript original's Infinity.
*/
declare(strict_types=1);
const WINDOW_MS = 60000;
const DEFAULT_SAFETY = 0.8;
/**
* Plan a schedule under the given limits.
*
* $limits keys (both optional): 'rpm' / 'tpm' — requests / tokens per
* minute; absent = not limited. $workload: ['requests' => int,
* 'avgTokensPerRequest' => float]. $opts['safetyFactor'] must be in
* (0, 1] (default 0.8). Throws RangeException on negative workload
* numbers or an out-of-range safety factor.
*/
function planRateLimit(array $limits, array $workload, array $opts = []): array
{
$sf = $opts['safetyFactor'] ?? DEFAULT_SAFETY;
$warnings = [];
$requests = $workload['requests'];
$avg = $workload['avgTokensPerRequest'];
if ($requests < 0 || $avg < 0) {
throw new RangeException('requests and avgTokensPerRequest must be >= 0');
}
if ($sf <= 0 || $sf > 1) {
throw new RangeException('safetyFactor must be in (0, 1]');
}
$rpmEff = isset($limits['rpm']) ? $limits['rpm'] * $sf : null;
$tpmEff = isset($limits['tpm']) ? $limits['tpm'] * $sf : null;
// Impossible: one request alone exceeds the token budget.
if ($tpmEff !== null && $avg > $tpmEff && $requests > 0) {
return [
'batchSize' => 0,
'intervalMs' => 0,
'maxConcurrent' => 0,
'boundedBy' => 'tpm',
'timeline' => [],
'totalMs' => INF,
'warnings' => [
sprintf(
'A single request averages %s tokens but the effective token limit is %s/min — no schedule can run this. Shrink requests or raise the tier.',
rateLimitPlannerFmtNum($avg),
rateLimitPlannerFmtNum(floor($tpmEff))
),
],
];
}
$byRpm = $rpmEff ?? INF;
$byTokens = ($tpmEff === null || $avg == 0) ? INF : $tpmEff / $avg;
if (!is_finite($byRpm) && !is_finite($byTokens)) {
$warnings[] = 'No limits set — the plan assumes an unbounded endpoint. Add RPM or TPM for a real schedule.';
}
$steady = max(1, floor(min($byRpm, $byTokens)));
if (!is_finite($byRpm) && !is_finite($byTokens)) {
$boundedBy = 'none';
} elseif (floor($byRpm) == floor($byTokens)) {
$boundedBy = 'both';
} else {
$boundedBy = $byRpm < $byTokens ? 'rpm' : 'tpm';
}
// Even pacing inside the window: batchSize requests spread over 60s.
$intervalMs = round(WINDOW_MS / $steady);
// With even spacing and a per-request latency near intervalMs, one
// request is in flight at a time; concurrency >1 only helps
// sub-interval latencies, so the safe published floor is 1 — batch
// bursts raise it to batchSize.
$maxConcurrent = $steady == 1 ? 1 : min($steady, ceil($steady / 4));
$timeline = [];
$remaining = $requests;
$batch = 0;
while ($remaining > 0 && $batch < 10) {
$take = min($steady, $remaining);
$timeline[] = [
'batch' => $batch + 1,
'atMs' => $batch * WINDOW_MS,
'requests' => $take,
'tokens' => $take * $avg,
];
$remaining -= $take;
$batch++;
}
$windowsNeeded = $requests > 0 ? (int) ceil($requests / $steady) : 0;
$lastWindowRequests = $windowsNeeded > 0 ? $requests - ($windowsNeeded - 1) * $steady : 0;
$totalMs = $windowsNeeded > 0 ? ($windowsNeeded - 1) * WINDOW_MS + $intervalMs * $lastWindowRequests : 0;
if ($rpmEff !== null && $requests > 0 && $steady > $byRpm) {
$warnings[] = 'Rounded up to at least one request per window — even a single request per minute keeps the schedule honest.';
}
return [
'batchSize' => $steady,
'intervalMs' => $intervalMs,
'maxConcurrent' => $maxConcurrent,
'boundedBy' => $boundedBy,
'timeline' => $timeline,
'totalMs' => $totalMs,
'warnings' => $warnings,
];
}
/** Human summary line for the plan (used by the island + docs). */
function describePlan(array $plan): string
{
if ($plan['batchSize'] === 0) {
return 'No viable schedule.';
}
if ($plan['boundedBy'] === 'none') {
return sprintf(
'%s+ requests per window — endpoint treated as unbounded.',
rateLimitPlannerPlainNum($plan['batchSize'])
);
}
$limiter = $plan['boundedBy'] === 'both'
? 'both limits bind together'
: sprintf('the %s limit binds first', strtoupper($plan['boundedBy']));
return sprintf(
'%s requests per 60s window (one every %sms) — %s.',
rateLimitPlannerPlainNum($plan['batchSize']),
rateLimitPlannerPlainNum($plan['intervalMs']),
$limiter
);
}
/** Plain interpolation like TS `${v}` (no separators, no trailing .0). */
function rateLimitPlannerPlainNum(float $v): string
{
if (is_infinite($v)) {
return $v > 0 ? 'Infinity' : '-Infinity';
}
if (floor($v) == $v) {
return (string) (int) $v;
}
return (string) $v;
}
/** Format like TS `toLocaleString('en-US')`: thousands separators. */
function rateLimitPlannerFmtNum(float $v): string
{
if (is_infinite($v)) {
return $v > 0 ? 'Infinity' : '-Infinity';
}
$a = abs($v);
$ip = (int) floor($a);
$digits = (string) $ip;
$len = strlen($digits);
$out = '';
for ($i = 0; $i < $len; $i++) {
if ($i > 0 && ($len - $i) % 3 === 0) {
$out .= ',';
}
$out .= $digits[$i];
}
$frac = $a - $ip;
if ($frac > 0.0) {
$out .= rtrim(rtrim(number_format($frac, 6, '.', ''), '0'), '.');
}
return $v < 0 ? '-' . $out : $out;
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →