Skip to content

Quotas and cost v0.3

Compute is the largest cost risk for a public modelling platform. Examples are an accidental 500-task backtest, a leaked token, or a person who mines on free fits. From v0.3, the platform does these steps for each run:

  1. It estimates the cost of the run before the run starts.
  2. It compares the run with quotas at three levels.
  3. It schedules the run fairly with the work of all other users.
  4. It meters the run into a cost ledger when the run ends.

The contracts are in mapi_spec.quotas, mapi_spec.cost and mapi_spec.admin.

Quotas are vCPU-seconds

The platform limits compute in vCPU-seconds, not in runs. A two-minute fit on class S (2 vCPU) uses 240 vCPU-seconds. A five-minute fit on class M (4 vCPU) uses 1,200 vCPU-seconds. Each limit in the table is optional. If a limit is not set, there is no limit at this level.

Limit Kind Notes
vcpu_seconds_per_day, vcpu_seconds_per_month Cumulative, UTC day and month The platform compares the limit with the estimate of the run at submit.
concurrent_runs Live count Runs between queued and a terminal state
max_fanout_tasks Per submission The maximum for one fan-out submission, for example a backtest or an SBC (taskCount)
max_resource_class Per submission For example, the public tier runs on class S only. The classes are in the sequence S < M < L < XL < GPU (v0.15), so a maximum below GPU refuses the GPU class. By default, only the internal and the self-hosted-trusted tiers allow it. See Resource classes.
storage_bytes Cumulative Dataset versions, uploads in progress and model bundles (the stored objects of all model versions). An upload counts while it is pending or uploaded (v0.15). A collection version counts the packs that it uses, also packs that it shares with other versions (Image data and collections).
egress_bytes_per_month Cumulative, UTC month Downloads. This is the size of each object that the API gives a signed link for: dataset and model artifacts, ui/ views, and score and render outputs. It also includes public embed views. The platform charges the bytes when it mints the link. The platform never charges GDPR exports.
requests_per_minute Rate API calls

Resource classes

Each run has a resource class. The class sets the CPU, the memory, the scratch space and the size limits of the run. It also sets the price of each second.

Class vCPU Memory Scratch Input data Bundle Accelerator
S 2 8 GiB 3 GiB 0.5 GB 1 GB —
M 4 16 GiB 6 GiB 2 GB 4 GB —
L 8 32 GiB 12 GiB 5 GB 8 GB —
XL (GKE) (GKE) (GKE) 50 GB 50 GB —
GPU v0.15 8 32 GiB 12 GiB 50 GB 8 GB 1 NVIDIA L4

The manifest of a model gives its minimum and default class (resources). A spec can select a class in execution.resource_class. After RESOURCE_EXHAUSTED, the platform offers the next CPU class (S to M to L to XL). XL has no next class. It never offers GPU as a retry. The GPU class has the same memory as L.

The GPU class v0.15

The class GPU is the last class in the sequence. Thus a quota whose maximum is a CPU class refuses it. These rules keep GPU time for the models that use it:

  • Validation refuses GPU for a model whose manifest does not set resources.gpu. The GPU Job of Cloud Run has only the runner image base-dl, so it can run only these models.
  • Validation shows a note when a model can use a GPU and the spec selects a CPU class.
  • In v0.15, dl.cnn_classifier and dl.finetune_classifier set resources.gpu (Deep learning).

The executor decides if the run gets a GPU:

Executor A run of class GPU
cloudrun-jobs Runs in the GPU Job of base-dl, when the deployment has it (Terraform gpu_jobs, off by default)
docker Asks Docker for a GPU when MAPI_DOCKER_GPUS=true
docker without MAPI_DOCKER_GPUS, local-process Runs on the CPU

When a run of class GPU sees no NVIDIA device, the supervisor writes the warning GPU_UNAVAILABLE, and the run continues on the CPU. The ledger meters a run of class GPU with the price of its class, also when the run used the CPU.

Warning

The GPU path is not tested. The base-dl CUDA image and the GPU Job of Cloud Run are not tested against GCP or on a GPU. Do a test with a small run before you use them. See Cloud Run.

Three levels

The platform compares each request with a maximum of three policies. It starts with the most specific policy:

  1. Token: the rate limit and the vCPU-second budget of the token. The person who mints the token sets these values.
  2. Tenant: the policy of the tenant, or the defaults of its trust tier.
  3. Global: platform-wide limits that protect the cloud project. Cloud Run permits 1,000 running executions for each region. The global default stops at 700.

The first limit that a request goes above decides the answer:

Code HTTP When What the client does
RATE_LIMITED 429 Too many requests per minute at one level Wait for Retry-After seconds. Then send the request again.
QUOTA_EXCEEDED 429 All other limits Read detail to find the level and the limit. If the limit resets with the UTC day or month, the response sets Retry-After.

Cumulative limits stop only the requests that ask for more. A tenant that is above its storage quota can continue to read, download and delete.

from datetime import UTC, datetime

from mapi_spec.quotas import (
    QuotaDemand,
    QuotaLimits,
    QuotaPolicy,
    QuotaScope,
    QuotaUsage,
    evaluate_quota,
)
from mapi_spec.resources import ResourceClass

now = datetime(2026, 10, 15, 22, 0, tzinfo=UTC)
tenant = QuotaPolicy(
    scope=QuotaScope.TENANT,
    subject_id="acme",
    limits=QuotaLimits(vcpu_seconds_per_day=86_400, max_resource_class=ResourceClass.L),
)
used = QuotaUsage(vcpu_seconds_today=85_000)

fit = QuotaDemand(new_runs=1, vcpu_seconds=2_400, resource_class=ResourceClass.M)
decision = evaluate_quota([(tenant, used)], fit, now=now)
print(decision.allowed, decision.code, decision.limit_name)
# False QUOTA_EXCEEDED vcpu_seconds_per_day
print(decision.retry_after_seconds)  # 7200: the UTC day resets in two hours
print(decision.to_problem().status, decision.headers())  # 429 {'Retry-After': '7200'}

The table shows the default tenant limits for each trust tier. Operators can change them for one tenant with PUT /v1/admin/quotas.

Tier vCPU-s / day vCPU-s / month Concurrent runs Fan-out tasks Largest class Requests / min
public 3,600 36,000 2 16 S 60
verified 86,400 1,000,000 20 256 L 600
internal — — 100 1,000 — 6,000
self-hosted-trusted — — — — — —

Returned draws. By default, a score request returns a summary and no draws. You can ask for draws with output.draws. Each tier has a maximum (mapi_spec.quotas.MAX_SCORE_DRAWS). The maximum is 300 on public and 500 on all other tiers. If you ask for more, the platform refuses the request with QUOTA_EXCEEDED at /output/draws. This occurs for POST /v1/models/…:score, for an endpoint, and for its …/validate. The score job also contains the maximum, and the runner applies it again.

Interactive and batch work

Each run has a priority class:

  • interactive: score, render, derive, validate and describe runs. Usually, a person waits for these runs.
  • batch: fits, ingest, evaluation, optimisation and fan-out parents.

The dispatcher leases queued runs with weighted fair sharing. For each candidate run, it calculates (tenant's running tasks + the run's tasks) / (tenant weight × class weight). The class weight is 4 for interactive and 1 for batch. The dispatcher leases the smallest value first. It continues until it uses all the free capacity, or the Cloud Run jobs.run rate of 180 calls a minute. A tenant with 40 running tasks must wait for the score of an idle tenant. A 200-task SBC uses the remaining capacity and does not stop the work of other tenants:

from datetime import timedelta

from mapi_spec.quotas import LeaseCandidate, PriorityClass, plan_leases

t0 = datetime(2026, 10, 15, tzinfo=UTC)
queue = [
    LeaseCandidate(f"sbc_{i}", "acme", PriorityClass.BATCH, t0 + timedelta(seconds=i))
    for i in range(200)
] + [LeaseCandidate("score_1", "beta", PriorityClass.INTERACTIVE, t0 + timedelta(minutes=5))]

picked = plan_leases(queue, capacity=3, running_tasks={"acme": 40})
print([c.run_id for c in picked])  # ['score_1', 'sbc_0', 'sbc_1']

If the backend answers 429, the dispatcher waits for an exponentially longer time before each new attempt: 1, 2, 4 … 64 seconds. The time is never less than a Retry-After. The dispatcher submits the run one time only when the limit clears.

Prices and the cost ledger

The platform calculates the cost of each run from its resource class and its wall time:

  • vCPU-seconds = vCPUs of the class × billed seconds × tasks
  • GiB-seconds = memory of the class × billed seconds × tasks
  • billed seconds = wall time, rounded up to 100 ms
  • accelerator-seconds (v0.15, class GPU only) = accelerators of the class × billed seconds × tasks

The prices come from a versioned price table. The default uses the list prices of Cloud Run: 0.000018 USD for each vCPU-second and 0.000002 USD for each GiB-second. From v0.15 the class GPU also costs 0.0001867 USD for each accelerator-second. This is the Cloud Run list price of one L4 GPU. We did not compare it with a GCP bill. Class XL has no default price. A deployment can set its own prices. Unit prices are integer nanos. Amounts are integer micros (1 USD = 1,000,000 micros). Thus, each amount is exact, and you can calculate it again from its ledger row.

Each amount names the version of the price table that the platform used. New prices never change old costs.

from mapi_spec.cost import DEFAULT_PRICE_TABLE, estimate_cost, format_micros

est = estimate_cost(ResourceClass.M, wall_seconds=300)  # an E33-style fit, five minutes on M
print(est.vcpu_seconds, est.gib_seconds)  # 1200.0 4800.0
print(est.amount_micros, format_micros(est.amount_micros, est.currency))  # 31200 0.031200 USD
print(est.price_version == DEFAULT_PRICE_TABLE.version)  # True

sweep = estimate_cost(ResourceClass.S, wall_seconds=120, task_count=50)
print(format_micros(sweep.amount_micros, sweep.currency))  # 0.312000 USD

The cost ledger is append-only. Each finished attempt adds one actual entry. The entry contains the run, the attempt, the resource class, the task count and the wall time. It also contains the vCPU-seconds, the GiB-seconds, the price version and the amount. Operators can add adjustment entries, for example a refund after an infrastructure failure. The cost field of a run combines its entries:

Run.cost field Meaning
estimated true until the platform meters the first attempt
estimate_micros The estimate that the platform made at submit
amount_micros, currency The total at this time, for all attempts
vcpu_seconds, gib_seconds The metered usage
price_version The price table that the platform used
attempts_billed The number of metered attempts
usd Kept from v0.2. The amount in dollars when the currency is USD.

GET /v1/usage gives the ledger totals of a project for a UTC day or month. It groups the totals by resource class, day, token or run. It also shows the usage of the project against each quota. Tenant admins see the full tenant at GET /v1/tenants/{tenant_id}/usage. Operators see the cost of each tenant for the full platform at GET /v1/admin/usage. This is the Cost tab of the admin console.

The platform limits retries by cost and by count. After an infrastructure failure, the platform tries the run again automatically only if these two conditions are true:

  • The cost of the run stays below MAPI_AUTO_RETRY_COST_FACTOR (default 2) times its estimate. This cost includes the metered attempts and the attempt that failed.
  • The number of retries is not more than MAPI_MAX_INFRA_RETRIES.

A retry continues from the newest checkpoints that an earlier attempt wrote. If a run used more than 30 minutes, the platform tries it again only with the consent of the user.

Kill switches

Operators have two platform-wide switches (PUT /v1/admin/kill-switches). The platform audits each change, and the operator must give a reason:

Switch Effect
untrusted_execution_enabled When this switch is off, the platform does not accept or lease runs of plugin code that tenants uploaded. Built-in models continue to work.
global_spend_cap_micros When the spend of the platform in the current UTC day (or month) gets to the cap, no new compute starts. Only internal tenants can start compute. The spend is the cost ledger plus the estimates of work that the platform did not meter yet. Admitted runs count at admission. Started runs count one by one at dispatch.

Both switches answer QUOTA_EXCEEDED at the global level. GET /v1/admin/usage returns the spend in the current window of the cap, next to the cap (spend_cap). The admin console shows this spend as a meter that gives a warning at 70% and at 90%. If you think that code escaped the sandbox, turn off untrusted execution. This is the first step of the incident runbook.