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:
- It estimates the cost of the run before the run starts.
- It compares the run with quotas at three levels.
- It schedules the run fairly with the work of all other users.
- 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
GPUfor a model whose manifest does not setresources.gpu. The GPU Job of Cloud Run has only the runner imagebase-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_classifieranddl.finetune_classifiersetresources.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:
- Token: the rate limit and the vCPU-second budget of the token. The person who mints the token sets these values.
- Tenant: the policy of the tenant, or the defaults of its trust tier.
- 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
GPUonly) = 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.