Skip to content

Token-Aware Admission

Token-aware admission is an optional local quota smoother for provider request and token budgets. It controls when an attempt may start; it does not change provider concurrency, transport capacity, or retry policy.

Four controls, four jobs

Control Bounds Typical owner
Worker concurrency Framework tasks doing useful work concurrency / max_workers
Provider concurrency Calls holding a provider-capacity slot max_provider_concurrency or the strategy/model
Requests per minute (RPM) Physical attempts admitted per quota scope max_requests_per_minute
Tokens per minute (TPM) Estimated then reconciled token load per quota scope max_tokens_per_minute plus an estimator

Equal request counts are not equal load. Ten classification requests may fit inside one long generation's token budget. Raising workers or connection-pool size cannot overcome RPM or TPM; it only creates more tasks waiting at the quota gate.

The live-attempt order is:

coordinated cooldown → token estimation → atomic RPM+TPM reservation
→ provider-capacity wait → final cooldown check → provider start

No provider-capacity slot is held during estimation or quota waiting. WorkItemResult.quota_wait_seconds is therefore separate from admission_wait_seconds, which measures provider-capacity wait.

Configure RPM and TPM

from async_batch_llm import CharacterTokenEstimator, ProcessorConfig

config = ProcessorConfig(
    concurrency=32,
    max_requests_per_minute=500,
    max_tokens_per_minute=200_000,
    token_estimator=CharacterTokenEstimator(
        characters_per_token=4.0,
        expected_output_tokens=400,
    ),
)

TPM is opt-in. When max_tokens_per_minute is configured, every live provider attempt needs a TokenEstimate. Supply token_estimator on the config, on CallableStrategy, or through a strategy's estimate_tokens() hook. A missing estimator fails before provider work with TokenEstimatorRequired. An individual estimate larger than the configured per-minute token limit fails immediately with TokenEstimateExceedsLimit; it cannot become admissible by waiting.

Configuration failures

These failures describe the run's configuration, not the item. Each is non-retryable and happens before any provider call:

Exception error_category
TokenEstimatorRequired ErrorCategory.TOKEN_ESTIMATOR_REQUIRED
TokenEstimationError (estimator raised or returned an invalid estimate) ErrorCategory.TOKEN_ESTIMATION_ERROR
TokenEstimateExceedsLimit ErrorCategory.TOKEN_ESTIMATE_EXCEEDS_LIMIT
QuotaScopeError (a strategy's quota_scope could not be resolved) ErrorCategory.QUOTA_SCOPE_ERROR

Since v0.27 they are never replayed from artifacts, even under ResumePolicy.REUSE_ALL. The first three are checkpointed with replay_eligible=False; quota_scope_error fails before an artifact key exists and is not checkpointed. Fix the configuration and resume, and those items execute again. Because a missing estimator or a broken quota scope usually affects every item, TOKEN_ESTIMATOR_REQUIRED and QUOTA_SCOPE_ERROR are good abort_on_error_categories candidates. See ErrorCategory for every category.

CharacterTokenEstimator uses a character-ratio heuristic and a fixed expected output allowance. It is approximate and is never enabled automatically. Prefer a provider tokenizer and a workload-specific output estimate when accuracy matters.

Reservation and reconciliation

Each physical attempt atomically reserves one RPM unit and its estimated input plus output tokens from one FIFO gate. It never reserves RPM and then waits separately for TPM.

After provider start:

  • Known usage below the estimate refunds the difference.
  • Known usage above the estimate records debt. Availability may go negative; later attempts wait until refill pays the debt.
  • Explicit known zero refunds the full token reservation but still consumes the RPM unit because a provider attempt started.
  • Unknown usage retains the estimate conservatively. This is distinct from known zero.
  • Recoverable failed-attempt usage is reconciled before the retry. Every physical retry then receives a fresh estimate and reservation.

Cancellation before provider start refunds both the RPM unit and token reservation. Cancellation after provider start follows the same known/unknown usage rules as any other started attempt. An item or batch deadline may expire while waiting; the waiting attempt makes no provider call and leaves no live reservation. A deadline reached during the provider-capacity wait records timing.timeout_category="admission_timeout" and keeps the elapsed wait in admission_wait_seconds; a deadline during the RPM/TPM quota wait does not set admission_timeout. See guardrails.

Dry-run, compatible artifact replay, and middleware-filtered items bypass live quota admission. They emit no quota-admission events and mutate no RPM/TPM state.

Reported usage and canonical totals

Usage knowledge is independent of the compatibility dictionary returned by exception extraction. Missing usage may expose zero-filled counters, but those placeholders do not authorize a token refund.

Provider usage Overall usage known? Reported total
Absent, None, empty, or unrelated fields only No None
cached_input_tokens only No None
total_tokens: 0 Yes 0
input_tokens: 7, output_tokens: 3 Yes 10
Input 7, output 3, optional exception total None Yes 10
input_tokens: 7, output absent Yes 7
Recognized counter explicitly None or invalid, without a valid total No None
Input 7, output 3, explicit total 12 Yes 12

A valid explicit total wins over the component sum. In exception usage, an absent total or total_tokens=None permits derivation from input/output counters when at least one is present and both present counters are valid. A non-None invalid total remains unknown. An absent component counts as zero; an invalid component does not. Successful strategy mappings still reject total_tokens=None under their strict validation contract. The derived total is written into the normalized total_tokens field used by results, events, statistics, and new checkpoints. Caller-owned mappings and historical artifacts are not modified. Cached tokens are telemetry, not an additional charge or an automatic deduction from the total. An optional cached_input_tokens=None allows fallback to cache_read_tokens or prompt_tokens_details.cached_tokens; a valid explicit zero remains zero.

Successful strategy mappings require non-negative integers: None, booleans, negative, fractional, and non-finite values fail validation. Exception extraction is best effort and treats invalid counters as unknown rather than known zero. It retains compatibility with integral numeric values and integer strings in provider exception usage.

Exception usage supports mappings, attribute objects, modern/legacy provider field names, and synchronous .usage() accessors. A valid framework _failed_token_usage report takes precedence, followed by cause-result usage and direct exception usage. Empty or invalid framework stamps do not hide a valid lower-priority report. Async accessors are not started or awaited; ordinary accessor failures are best effort, while cancellation and process-control exceptions propagate.

The executor observes each failed physical attempt before recovery hooks or guardrail error replacement. A synchronous processor _extract_token_usage override can supply a valid positive total (including a derived one); calling super() reuses the observation without calling the provider accessor again. Zero-filled legacy overrides cannot turn unknown into known zero or erase a known positive total. Report explicit unbilled usage on the provider exception when a zero refund is intended.

If strategy.on_error adds or changes a valid _failed_token_usage stamp, the executor uses that later report for the failed attempt's result accounting, including when the hook raises or is interrupted by a deadline or batch abort. It does not call the provider accessor again or revise the completed quota reconciliation. A later report replaces that attempt's earlier result usage; it is not added as another attempt. Empty/invalid stamps do not erase previously observed usage. Explicit known zero is a valid later result report.

For example, if a failed attempt reserved 20 tokens and its usage was unknown at reconciliation, admission retains 20. If on_error then reports 10, the item records 10 tokens while its quota timing still shows unknown usage. This preserves usage supplied during recovery without delaying reservation finalization behind hooks that may block, fail, or be cancelled.

Quota timing and QUOTA_RECONCILED describe individual physical attempts. An item that fails with 10 reported tokens and then succeeds with 15 reports 25 item tokens, while its reconciliations report 10 and 15 separately. ITEM_COMPLETED reports the final attempt's tokens. Batch live counters add newly executed usage; replay keeps the historical item usage but adds zero live consumption. Reservation estimates for unknown attempts never become invented provider-reported tokens.

Quota scopes

quota_scope identifies the account or upstream budget shared by strategies. Object identity defines sharing. By default it follows concurrency_scope, so existing strategies preserve their established ownership. Override it when one account quota spans multiple models or when one shared client serves independent accounts.

Quota scope and concurrency scope answer different questions:

  • quota_scope: which calls spend the same RPM/TPM budget and share cooldown?
  • concurrency_scope: which calls contend for the same client/provider capacity?

Two models sharing one account quota:

from async_batch_llm import ArtifactIdentity, CallableStrategy, TokenEstimate

shared_quota = object()


async def call_model_a(prompt, *, attempt, timeout, state):
    ...  # call model A and return a CallOutcome


async def call_model_b(prompt, *, attempt, timeout, state):
    ...  # call model B and return a CallOutcome


def estimate_a(prompt, *, strategy, attempt, state):
    return TokenEstimate(input_tokens=count_model_a(prompt), output_tokens=300)


def estimate_b(prompt, *, strategy, attempt, state):
    return TokenEstimate(input_tokens=count_model_b(prompt), output_tokens=600)


strategy_a = CallableStrategy(
    call_model_a,
    quota_scope=shared_quota,
    token_estimator=estimate_a,
    identity=ArtifactIdentity(
        provider="example",
        model="model-a",
        prompt_version="v1",
        parser_version="v1",
        application_version="v1",
    ),
)
strategy_b = CallableStrategy(
    call_model_b,
    quota_scope=shared_quota,
    token_estimator=estimate_b,
    identity=ArtifactIdentity(
        provider="example",
        model="model-b",
        prompt_version="v1",
        parser_version="v1",
        application_version="v1",
    ),
)

For independent account budgets, use distinct identities:

account_a_quota = object()
account_b_quota = object()

strategy_a = CallableStrategy(
    call_model_a,
    quota_scope=account_a_quota,
    token_estimator=estimate_a,
    identity=identity_a,
)
strategy_b = CallableStrategy(
    call_model_b,
    quota_scope=account_b_quota,
    token_estimator=estimate_b,
    identity=identity_b,
)

Do not derive scope identities from credentials or place arbitrary scope representations in logs or metrics. Events expose only run-local ordinal scope IDs.

Retry-aware estimators

The estimator receives the effective middleware-replaced strategy and prompt, the logical attempt number, and the item's RetryState. This allows validation recovery or model escalation to change the estimate:

def estimate(prompt, *, strategy, attempt, state):
    escalated = state is not None and state.get("model") == "large"
    output_allowance = 1_200 if escalated else 300
    return TokenEstimate(
        input_tokens=provider_tokenizer(prompt),
        output_tokens=output_allowance,
    )

Synchronous estimators run off the event loop. Asynchronous estimators are awaited directly. Estimator failures are redacted framework errors and are not sent through strategy or middleware recovery hooks.

Visibility boundaries

Token accounting covers attempts visible to ABL, including recoverable failed-attempt usage. Retries hidden inside an upstream gateway require gateway-reported usage to be visible. If a gateway returns only aggregate usage, ABL cannot reconstruct its internal attempts or exact reservation deltas.

Providers implement quotas differently: fixed windows, rolling windows, weighted model budgets, cached-token rules, and account-level policies all exist. ABL's gate is a local continuously refilled smoother. It reduces bursts but does not claim to reproduce a provider's enforcement exactly.

TPM admission also does not schedule active GPU sequences, KV-cache occupancy, or instantaneous decode-token capacity. Use a serving system or gateway that owns those resources when that is the real constraint.

FIFO trade-off

Admission is FIFO within each quota scope. This prevents later small requests from repeatedly bypassing an earlier large request, but a large head item can delay small items behind it. Split unrelated workloads into intentional quota scopes only when they truly have independent upstream budgets; otherwise the head-of-line behavior reflects a real shared limit.

Observe and troubleshoot

Attempt timing reports estimate components, reserved and reported tokens, reconciliation delta, quota wait, and run-local scope ordinal. Processor stats and MetricsObserver aggregate bounded quota wait percentiles, reservations, reported tokens, refunds, debt, known-zero/unknown attempts, estimator failures, and scope count. QUOTA_ADMITTED and QUOTA_RECONCILED events expose attempt-level evidence without using item or scope IDs as metric labels.

If one estimate exceeds max_tokens_per_minute, raise the limit to the real account budget, reduce expected output, or route that workload to its real independent quota scope. Waiting can't fix it, and quota_burst_seconds doesn't affect this check. If timeouts or 429s

make admission look conservative, inspect unknown_usage_attempts: without a reliable provider total, retaining the reservation is intentional.

See Choosing Your Limits for sizing order and Troubleshooting for symptom-based guidance.

Exception identity and attempt ownership

The executor keeps classification, timing, and cumulative usage in private per-attempt and per-item records. It does not add or update _abl_error_info, _abl_work_item_timing, _abl_admission_wait_seconds, or cumulative _failed_token_usage on provider exceptions. A reused exception is classified against the current strategy on every physical attempt. call() and call-pool raising APIs still raise the original exception instance.

Provider or custom strategy _failed_token_usage stamps remain supported, including read-only mapping views of exception attributes. Such stamps describe provider usage; item-wide totals and timing belong to WorkItemResult. Framework guard replacements carry usage knowledge internally rather than creating zero-filled stamps. An explicit valid zero supplied by a hook remains known zero. A custom hook that writes an ordinary four-key zero dictionary is asserting known zero: the framework cannot distinguish it from a copied unknown-usage compatibility dictionary once that provenance has been discarded.

Burst size and admission races

ProcessorConfig.quota_burst_seconds=None preserves a full-minute bucket. A positive finite value b sets fixed capacity C=max(1, limit*b/60) in each configured dimension, with refill at limit/60 per second. RPM starts with C units. TPM also starts full; a head estimate larger than C can start when the bucket is full and leaves debt that must refill before later requests. Capacity never grows permanently. An estimate larger than the per-minute TPM limit still fails before provider work.

Without refunds, at most C+limit RPM units are admitted in any rolling minute. For TPM reservations the bound is max(C, E_max)+limit, where E_max is the largest admitted estimate in that interval. This exception matters for large requests: the simpler limit*(1+b/60) bound applies only when neither the one-unit floor nor oversized estimates apply. Reconciliation can refund tokens and allow more reservations; these bounds are not guarantees about actual tokens reported by a provider. Provider windows, SDK retries and other processes remain outside this local limiter.

A cooldown may begin while an attempt waits for quota or capacity. The final synchronous start check preempts that attempt, releases capacity, refunds both quota dimensions exactly once, then re-enters admission at the FIFO tail. Preemption consumes no attempt or rate-limit retry budget. Quota, cooldown and capacity waits accumulate on the same attempt timing. Admission/finalization events can repeat for the same physical try; they describe reservations, not provider calls. Cancellation, item/batch deadlines and aborts during re-entry retain the same refund and cleanup rules.

Each provider start snapshots its scope's cooldown generation. A late 429 from an older generation still consumes the item's rate-limit retry budget but does not start another cooldown or escalate its consecutive counter. An exhausted item reports failure without waiting for the shared cooldown it just triggered; that coordinator-owned cooldown continues to pause its peers until completion or host shutdown.

If the rate-limit strategy raises or returns a non-finite or negative duration, ABL logs a warning and peers wait for at least the configured cooldown_seconds (and a longer server hint, subject to its configured cap). Bare ExponentialBackoffStrategy() now uses the same 300-second initial cooldown and 1.5 multiplier as RateLimitConfig. Both retry backoffs are overflow-safe.

Scope registry entries remain strongly owned until ordered host cleanup. Weak entries are deferred: dropping a scope must not orphan its quota wake or cooldown task or bypass a failed cleanup retry.