Skip to content

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 →