evals/availability-and-fallback.eval.md
# Availability is on by default, and the GPU fallback list is rebuilt
## Prompt
Migrate this to v2:
```python
body = {
"name": "trainer",
"imageName": "org/trainer:v3",
"gpuTypeIds": ["NVIDIA GeForce RTX 4090", "NVIDIA RTX A5000"],
"gpuTypePriority": "availability",
"gpuCount": 1,
"containerDiskInGb": 60,
"volumeInGb": 100,
"volumeMountPath": "/workspace",
}
requests.post("https://rest.runpod.io/v1/pods", json=body, headers=H)
```
## Expected behavior
Per `runpod-migrate/SKILL.md` and `reference/breaking-changes.md`:
1. Renames to the v2 body: `image`, `disk`, `gpu.{id,count}`,
`mounts.persistent.{size,path}`.
2. Recognizes that v2's `gpu.id` takes **one** GPU type, so the `gpuTypeIds` +
`gpuTypePriority: availability` behavior must be rebuilt client-side as a loop.
3. That loop reads `GET /v2/catalog/gpus` **with `include=AVAILABILITY&product=POD`** and
orders the preference list by the returned availability level. The include is not
optional — it is the default for every catalog call this skill writes — and `product`
is not optional either: the API rejects `include=AVAILABILITY` without it. `POD` is
the right context here because the code creates pods.
4. Flags the removal in the summary rather than silently dropping to a single GPU type.
## Assertions
- Emits `include=AVAILABILITY` on the catalog request.
- **Pairs it with `product=POD`.** `include=AVAILABILITY` alone is a `400`, so an answer
that omits `product` has written a call that cannot run. Asserted separately from the
include so this eval cannot pass code that 400s.
- Produces a fallback loop over the original preference list; does NOT quietly reduce it
to `gpu: {"id": "NVIDIA GeForce RTX 4090"}` with the other type discarded.
- Uses `mounts.persistent` with an explicit `path` (no reliance on a `/workspace`
default, which v2 does not have).
- Does NOT invent a v2 `gpuTypeIds`, `gpuTypePriority`, or `gpuCount` field — all three
would 422.
- Scopes the client-side loop to **pods**. If the same code is asked about an endpoint,
the answer is `gpu.pools` with the preference list mapped to pool IDs — `pools` is a
list and v2 places workers on whichever listed pool has capacity, so writing a retry
loop around an endpoint create is rebuilding something v2 did not remove.
evals/inventory-before-editing.eval.md
# Inventory before editing, and leave the job API alone
## Prompt
Here's our repo. Move us over to the new Runpod REST API. Files:
```
gpu_farm/runpod_client.py calls https://rest.runpod.io/v1/pods
scripts/submit_job.py calls https://api.runpod.ai/v2/{endpoint_id}/run
ops/volumes_v2.py calls https://api.runpod.io/v2/network-volumes
```
## Expected behavior
Per `runpod-migrate/SKILL.md`:
1. Runs `scripts/rp_api_inventory.py` **before** editing anything, and shows the user
the resulting table.
2. Reports that `ops/volumes_v2.py` is **already on v2** and needs no work — users
frequently do not know this about their own repo.
3. Reports that `scripts/submit_job.py` is the serverless **job API**
(`api.runpod.ai/v2/<endpointId>/run`), a different API that is out of scope, and
does **not** rewrite it — despite the `v2` in the path.
4. Migrates only `gpu_farm/runpod_client.py`.
## Assertions
- **Resolves the scanner's path before running it.** The script ships in the installed
skill directory, not in the user's repo; the agent's working directory is the repo.
Running `python3 scripts/rp_api_inventory.py .` verbatim is a `No such file or
directory` failure, and the agent must not respond to that by abandoning the inventory
and falling back to ad-hoc grep.
- Inventory is produced and shown before any file edit.
- `scripts/submit_job.py` is left byte-for-byte unchanged and is explicitly called out
as out of scope.
- `ops/volumes_v2.py` is identified as already migrated and left unchanged.
- Does NOT treat `api.runpod.ai/v2/...` as evidence the codebase is already on REST v2.
evals/rest-only-scope.eval.md
# Honor a REST-only scope, and name what cannot move
## Prompt
Migrate our REST v1 calls to v2. Don't touch the GraphQL — the team still depends on it
and we don't want that churn right now.
The repo has `billing.py` on REST v1 (`/billing/endpoints`) and `account.js` on GraphQL
(`myself { clientBalance }`).
## Expected behavior
Per `runpod-migrate/SKILL.md`:
1. Treats the scope as `rest` — runs the inventory with `--scope rest` and migrates only
the REST v1 call sites.
2. Leaves `account.js` untouched. Notes that `myself` has **no REST v2 equivalent**
anyway, so it would have stayed on GraphQL even under a full-scope migration.
3. Migrating `billing.py`, maps v1 `/billing/endpoints` (serverless spend) to
**`/v2/billing/serverless`**, not to `/v2/billing/endpoints`, and flags this as a
same-name-different-meaning change.
4. Handles the response-shape change: v1 returned a bare array; v2 returns
`{records, metadata}`.
## Assertions
- No GraphQL file is edited.
- `/billing/endpoints` → `/v2/billing/serverless`; the agent explicitly warns that
`/v2/billing/endpoints` exists, returns `200`, and correctly bills a *different*
product (public endpoints) — so leaving the path unchanged fails silently.
- Reports that `myself` has no v2 equivalent rather than implying the migration is
incomplete or that GraphQL could be fully retired.
reference/breaking-changes.md
# Breaking changes: v1 / GraphQL → REST v2
Three classes, in increasing order of how much they should worry you.
Everything here was checked against the live API (`api.runpod.io/v2`, verified
2026-08-18). Where the published spec and the running service disagree, the observed
behavior is what is written down — and called out as such.
The path claims and the Class-3 table are also checked mechanically against the live
spec by `hooks/check_migrate_tables.py` and `hooks/check_migrate_class3.py`, so a row
that goes stale fails CI rather than waiting to be re-read.
---
## Class 1 — Loud: renamed, moved, or removed request fields
v2 sets `additionalProperties: false` on every request body. A leftover v1 field is a
`422` that names it:
```json
{"detail": "Request validation failed.",
"errors": ["$: missing property 'dataCenter'",
"$: additional properties 'dataCenterId' not allowed"],
"status": 422, "title": "Unprocessable Entity"}
```
**This is the good news, and worth telling the user explicitly.** No renamed request
field can silently do nothing in v2. A migration that runs is a migration whose request
bodies are right. Full tables: [rest-v1-to-v2.md](rest-v1-to-v2.md) ·
[graphql-to-v2.md](graphql-to-v2.md).
Renamed **paths** are just as loud — `/v2/networkvolumes` is a `404`; the correct path is
`/v2/network-volumes`. Same for `/v2/billing/network-volumes`.
**Moved is not removed.** The CUDA constraints are the case to watch: v1's top-level
`allowedCudaVersions` / `minCudaVersion` are now `gpu.allowedCudaVersions` and
`gpu.minCudaVersion` on both pod and endpoint create, deliberately nested so they are
unrepresentable on a CPU workload. Left at the top level they are a `422`; assumed
deleted, you drop a constraint the user still has. A non-empty `allowedCudaVersions` and
`minCudaVersion` are mutually exclusive (`400` if both are sent).
The exception to "loud" is **query parameters**: `GET /v2/pods` ignores unknown ones. A
v1 filter you forget to port (`?desiredStatus=RUNNING`) does not error — it returns every
pod with a `200`. Port list filters into client-side code deliberately.
---
## Class 2 — Quiet: same name, different behavior
These pass schema validation and can pass tests. This is the class users are right to
fear, and the one to walk through explicitly.
### 1. `flashboot`: boolean → enum
v1/REST `flashboot: true` · GraphQL `flashBootType: FLASHBOOT` → v2
`flashboot: "OFF" | "FLASHBOOT" | "PRIORITY_FLASHBOOT"`.
`true` is a `422`, so the *rename* is loud — but `false` → `"OFF"` and a **default of
`OFF` when omitted** is quiet. Dropping the field during migration silently turns
FlashBoot off, and the symptom is slower cold starts, not an error.
### 2. `/billing/endpoints` means a different product
| Path | v1 | v2 |
| --- | --- | --- |
| `/billing/endpoints` | **serverless** spend | **[Runpod public endpoints](../../runpod/golden-paths/11-public-endpoints.md)** spend |
| `/billing/serverless` | — | serverless spend |
Both return `200` and neither is broken — v2's `/billing/endpoints` reports public
endpoint spend accurately. The problem is that it answers a question you did not ask.
Carry the path across unchanged and you get a correct total for the wrong product, and
if you use no public endpoints that total is `0` — which reads as "we spent nothing on
serverless" rather than "this is not the serverless route any more".
### 3. `ports` lost its default
v1 pod/template create defaulted `ports` to `8888/http,22/tcp`. v2 defaults to nothing.
Observed on a v2 template created without `ports`: `"ports": []`. Code that relied on
SSH being reachable "because it always was" gets a pod with no exposed ports.
### 4. Omitting storage now means *no storage*
v1 pod create defaulted `volumeInGb: 20` at `volumeMountPath: "/workspace"` — every pod
got a persistent volume whether you asked or not. In v2, omitting `mounts` gives the pod
**no persistent storage at all**; only the ephemeral container disk exists, and it is
wiped on restart. A workload that wrote to `/workspace` keeps working right up until the
first restart, then loses the data.
Related: `mounts.network[].path` is **required**. There is no `/workspace` default to
inherit.
### 5. `idleTimeout` is now conditionally illegal
`workers.idleTimeout` is rejected on a queue endpoint scaling on `requestCount`:
```
422 {"detail": "idleTimeout does not apply to queue-based endpoints scaling on requestCount"}
```
A v1 config that set both `scalerType: REQUEST_COUNT` and `idleTimeout` was accepted.
Same two fields, now mutually exclusive.
### 6. `timeout` does not get the documented default
The v2 spec documents `timeout` defaulting to `300000` ms. **Observed behavior differs:**
an endpoint created without `timeout` comes back with `"timeout": 0`. Do not drop
`executionTimeoutMs` on the assumption that v2 fills in a sane 5-minute default — carry
the value across explicitly.
### 7. `status` reports reality, not intent
v1 `desiredStatus` was the *requested* state (3 values). v2 `status` is the *observed*
lifecycle state (6 values, adding `PROVISIONING`, `STARTING`, `ERROR`). A poll loop
written as `while pod["desiredStatus"] != "RUNNING"` translated literally to
`while pod["status"] != "RUNNING"` changes meaning: it now correctly waits for the pod to
actually be up — which is usually what you wanted, but it will also loop forever on a pod
that has gone to `ERROR` unless you add that branch.
### 8. `env` shape (GraphQL only)
`env: [{key: "K", value: "v"}]` → `env: {"K": "v"}`. A list survives JSON encoding and
fails schema validation, so this one is mostly loud — but any code that *reads* env back
and iterates `for e in env: e["key"]` breaks quietly on the map.
### 9. `stockStatus` → `availability` also flipped case (GraphQL only)
GraphQL's `lowestPrice.stockStatus` returns **`High` / `Medium` / `Low` / `None`**.
v2's `availability` returns **`HIGH` / `MEDIUM` / `LOW` / `NONE`**.
A lookup carried across verbatim — `RANK = {"High": 0, "Medium": 1, …}` or
`if stock == "High"` — stops matching every value and silently falls through to its
default branch. No error, no exception; capacity logic just quietly stops working.
### 10. `mounts` cannot be PATCHed the way `volumeInGb` could
v1 accepted a PATCH containing `volumeInGb` or `volumeMountPath` alone. v2 fixes the
mount kind at create, rejects `volumeId` changes and mount-clearing with `400`, and
requires every mount entry to carry its full schema (`422` otherwise). Migration code
that resizes or remounts storage in place needs rethinking, not translating — full table
in [rest-v1-to-v2.md](rest-v1-to-v2.md#pods--request-body).
### 11. `dockerStartCmd` → `args` collapses an argv array into one string
v1 took `["bash", "-lc", "python /app/render.py"]` — a real argv, where element
boundaries are explicit. v2's `args` is a single string. A naive `" ".join(...)` gives:
```
bash -lc python /app/render.py
```
which runs `python` **as the `-lc` script text** with `/app/render.py` as `$0` — not the
command that was intended. The container starts, so there is no `422` and no crash at
create time; it just does the wrong thing at runtime.
Quote any element that was a unit: `bash -lc 'python /app/render.py'`. Anything
containing a space, a quote, or a shell metacharacter needs the same treatment. Argv
arrays whose elements are all bare words join safely.
Related: v1's separate `dockerEntrypoint` override has no v2 field at all, so an image
that relied on overriding ENTRYPOINT *and* CMD independently cannot be expressed.
### 12. `cloudType: ALL` is gone (GraphQL only)
v2 `cloud` is `SECURE` or `COMMUNITY`. Code that asked for `ALL` to widen capacity must
now pick one, or try one and fall back.
### 13. `templateId` still works, but the link is gone
`templateId` is accepted on pod and endpoint **create** and **update**, so the field
carries across unchanged and nothing errors. What changed is what it means: v2 resolves
the template once, at request time, into the same container fields you could have spread
into the body yourself. Explicit body fields win over the template's, except `env`, which
merges per key with body values winning.
The consequence is the quiet one: **later edits to the template do not reach the
resource.** Code that edited a template to roll out a new image to existing pods or
endpoints silently stops rolling anything out. A pod's `template` response field stays
`null`, and endpoint responses have no `template`/`templateId` at all, so nothing in the
response reveals the template it came from either.
Two smaller edges, both `422`: a serverless template on a pod create, or a pod template
on an endpoint create. An unknown or inaccessible ID is a `404`.
---
## Class 3 — Capability removed: no v2 equivalent at any price
Not a translation problem. If the code depends on these, either it stays on the old API
or the behavior changes. Decide with the user; do not silently drop them.
| Capability | v1 / GraphQL | Status in v2 |
| --- | --- | --- |
| **GPU fallback list (pods)** | `gpuTypeIds: [a, b, c]` + `gpuTypePriority: availability` | a **pod**'s `gpu.id` is a single type. Move the loop into your code — see below. Endpoints keep multi-target placement: `gpu.pools` is a list and workers land on whichever listed pool has capacity, so no loop is needed there. |
| **Spot / interruptible pods** | `interruptible: true`, `podRentInterruptable`, `podBidResume` | none |
| **Savings plans** | `Pod.savingsPlans`, `adjustedCostPerHr` | not exposed |
| **Pod `reset`** | `POST /pods/{id}/reset` | `422` — actions are `start`/`stop`/`restart`/`terminate` |
| **Placement constraints** | `countryCodes`, `minRAMPerGPU`, `minVCPUPerGPU`, `minDownloadMbps`, `minUploadMbps`, `minDiskBandwidthMBps`, `supportPublicIp` | no create-time equivalent. `countryCodes` is the one with a rebuild: catalog filter → `dataCenterIds` → verify where it landed, [below](#replacing-countrycodes-and-the-rest-of-the-placement-constraints). The rest have no v2 filter at all. |
| **Entrypoint override** | `dockerEntrypoint` (array) separate from `dockerStartCmd` | only `args` (one string) |
| **Server-side list filters / expansions** | `?desiredStatus=`, `?includeMachine=`, … | filter client-side |
| **Host machine identity** | `machineId`, `machine { podHostId }` | only `dataCenterId` |
| **Account identity / balance** | `myself { email clientBalance currentSpendPerHr }` | no v2 route — keep GraphQL |
| **Secrets** | `secretCreate` / `secretDelete` | no v2 route — keep GraphQL |
| **Volume encryption flag** | `volumeEncrypted` | not exposed |
### Replacing the GPU fallback list (pods only)
The one removal that needs real code. v1 walked `gpuTypeIds` server-side; a v2 **pod**
rents one type. The replacement is *better* than what it replaces, because v2 will tell
you the stock level first — v1 made you guess:
For **endpoints**, do not write this loop. `gpu.pools` already takes a list and workers
are placed on whichever pool has capacity; narrow a pool with `gpu.excludedTypes`.
```python
PREFERENCE = ["NVIDIA GeForce RTX 4090", "NVIDIA RTX A5000", "NVIDIA L40S"]
RANK = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "NONE": 3}
cat = session.get(f"{V2}/catalog/gpus",
params={"include": "AVAILABILITY", "product": "POD",
"count": 1, "cloud": "SECURE"}).json()["gpus"]
stock = {g["id"]: g.get("availability", "NONE") for g in cat}
last = None
for gpu_id in sorted(PREFERENCE, key=lambda g: RANK[stock.get(g, "NONE")]):
r = session.post(f"{V2}/pods", json={**body, "gpu": {"id": gpu_id, "count": 1}})
if r.status_code == 201:
return r.json()
# 422 is a bad *body*, not scarce capacity — the next GPU will fail identically.
# During a migration this is the likeliest failure, so surface it immediately
# instead of walking the list and blaming availability.
if r.status_code == 422:
raise RuntimeError(f"request rejected, not a capacity problem: {r.text}")
last = f"{gpu_id}: {r.status_code} {r.text[:200]}"
raise RuntimeError(f"no GPU available from {PREFERENCE} — last error: {last}")
```
Keep the response body in the error. A bare "no GPU available" during a migration sends
people hunting for capacity when the real cause is usually a field they missed.
For per-datacenter placement, read `dataCenters[].availability` off the same response
instead of the top-level `availability`.
### Replacing `countryCodes` (and the rest of the placement constraints)
There **is** a migration here — say so rather than leaving the user with "removed."
`countryCodes` was one field on create, enforced by the scheduler. In v2 it becomes a
lookup plus an explicit data center list:
```python
# v1: one call, the server enforced it
session.post(f"{V1}/pods", json={**body, "countryCodes": ["FR", "DE"]})
# v2: ask which data centers are in those countries, then name them
cat = session.get(f"{V2}/catalog/gpus", params={
"include": "AVAILABILITY", "countryCodes": "FR,DE", "product": "POD",
}).json()["gpus"]
allowed = sorted({dc["id"] for g in cat for dc in g.get("dataCenters", [])})
if not allowed:
raise RuntimeError("no data centers in FR,DE with capacity for this GPU type")
pod = session.post(f"{V2}/pods", json={**body, "dataCenterIds": allowed}).json()
```
Let the filter do the country-to-data-center mapping. Data center IDs look like `EU-FR-1`,
so the country appears to be a parseable prefix; do not parse it, the list changes.
**`dataCenterIds` is enforced, despite the wording.** The v2 spec describes it as
*preferred* data centers, which reads like a hint the scheduler may override. It is not.
Verified 2026-08-18 against the live API: an RTX 4090 requested in `["US-KS-2","US-IL-1"]`
— two data centers where that GPU is not offered — was **refused**, not relocated,
while the same request naming `EU-RO-1` succeeded. The scheduler will not place you
outside the list to satisfy capacity. That makes it a usable basis for a data residency
requirement.
**But the refusal looks like a capacity problem, not a placement one:**
```
400 {"detail": "There are no longer any instances available with the requested
specifications. Please refresh and try again.", "status": 400}
```
Nothing in that message mentions data centers. Over-narrow the list — one country, one
scarce GPU type — and you get a message that sends people hunting for stock when the
real fix is widening `dataCenterIds` or picking a different GPU. During a migration this
is the second-likeliest failure after a bad body, so name it in the error you raise:
```python
if r.status_code == 400 and allowed:
raise RuntimeError(
f"no capacity for {gpu_id} within {allowed} — widen the country list, "
f"pick another GPU type, or drop the restriction. Original: {r.text}")
```
**What to actually ask the user.** This still belongs in the stop-and-ask bucket, but the
question is narrower than "what should I do here": *was the country restriction a
preference or a compliance requirement?* Both are satisfied by the code above, since the
restriction is enforced — what differs is the fallback. A preference can widen the
country list when capacity runs out; a compliance requirement must fail closed instead,
which is the opposite reflex to the GPU-fallback loop above and worth writing explicitly
into the code rather than leaving to whoever edits it next.
For a compliance requirement, also raise `GET /v2/catalog/datacenters`: it reports a
`compliance` array per data center (`GDPR`, `ISO_IEC_27001`, `SOC_2_TYPE_2`, `HIPAA`),
which is a sounder basis for the allowed list than a country code. Country and
certification are not the same question, and the user may have been approximating the
second with the first because v1 gave them no other way to say it.
The other placement constraints — `minRAMPerGPU`, `minVCPUPerGPU`, `minDownloadMbps`,
`minUploadMbps`, `minDiskBandwidthMBps`, `supportPublicIp` — have no equivalent recipe.
There is no v2 filter for them, so those really are accept-the-change, stay-on-v1, or
redesign.
---
## Reading a 422
| Message | What it actually means |
| --- | --- |
| `additional properties 'X' not allowed` | leftover v1/GraphQL field named `X` — rename or drop it |
| `missing property 'X'` | v2 requires `X`. Schema-required: pod create `name`; endpoint create `name`, `type`, `scaling`. `image` is not schema-required because `templateId` can supply it — a create with neither fails, so treat "`image` or `templateId`" as the real requirement. `gpu` is the field most often lost in a `gpuTypeIds` → `gpu.pools` rewrite. |
| `value must be one of '…'` | enum tightened (`action`, `flashboot`, `category`, `cloud`) |
| `missing property 'image'` **plus** `additional properties 'gpu', 'name', 'scaling', 'type' not allowed` — where those fields are obviously valid | **Look at the missing one only.** A missing required field knocks the body out of its schema branch, and the validator then reports every valid field as unexpected. Add the missing field and the rest of the noise disappears. |
| `4xx` with prose instead of a schema path | a resource-level constraint, not schema validation — e.g. a datacenter that does not support network volumes (`400`, and the message enumerates the ones that do), or a container image that is not on the registry (`422`). Read the prose; there is no `$.field` to fix. |
| `500 {"error": "…"}` | you are still talking to **v1** — v2 never uses that envelope |
That last row is a useful tell during a partial migration: the error *shape* tells you
which API answered. v1 errors are `{"error": "...", "status": …}`; v2 errors are
`{"title", "status", "detail", "errors"[]}`.
reference/graphql-to-v2.md
# GraphQL → REST v2 mapping
`POST https://api.runpod.io/graphql` → **`https://api.runpod.io/v2/…`**
Auth: GraphQL accepts `?api_key=…` *or* `Authorization: Bearer …`. REST v2 accepts only
the header. If the code passes the key in the query string, that moves into a header —
which is also the security upgrade (keys stop landing in URLs, logs, and referrers).
GraphQL schema reference: <https://graphql-spec.runpod.io/> (introspection is disabled in
production, so use that page, not `__schema`). **That page is incomplete** — it omits
`saveEndpoint`, `deleteEndpoint`, `saveTemplate`, `deleteTemplate`, `secretCreate`,
`secretDelete`, and the network-volume mutations, all of which are real and in use. For
anything missing there, the worked examples in the Runpod docs
(`docs.runpod.io` → SDKs → GraphQL) are the better source. Do not conclude an operation
does not exist just because that page omits it.
## Operation map
| GraphQL | REST v2 |
| --- | --- |
| `podFindAndDeployOnDemand(input:)` | `POST /v2/pods` |
| `pod(input: {podId})` | `GET /v2/pods/{id}` |
| `myself { pods { … } }` | `GET /v2/pods` |
| `podStop(input: {podId})` | `POST /v2/pods/{id}/action` `{"action":"stop"}` |
| `podResume(input: {podId, gpuCount})` | `POST /v2/pods/{id}/action` `{"action":"start"}` — you cannot change GPU count on resume |
| `podTerminate(input: {podId})` | `DELETE /v2/pods/{id}` |
| `podEditJob(input:)` | `PATCH /v2/pods/{id}` |
| `myself { endpoints { … } }` | `GET /v2/serverless` |
| `saveEndpoint(input:)` — no `id` | `POST /v2/serverless` |
| `saveEndpoint(input:)` — with `id` | `PATCH /v2/serverless/{id}` |
| `deleteEndpoint(id:)` | `DELETE /v2/serverless/{id}` |
| `saveTemplate(input:)` | `POST /v2/templates` / `PATCH /v2/templates/{id}` |
| `deleteTemplate(templateName:)` | `DELETE /v2/templates/{id}` — **by ID, not name** |
| `gpuTypes` / `gpuTypes(input: {id})` | `GET /v2/catalog/gpus?include=AVAILABILITY&product=POD` — `product` is **required** with `include` (`400` without it); use `SERVERLESS` when sizing an endpoint |
| `cpuTypes` | `GET /v2/catalog/cpus?include=AVAILABILITY&product=POD` — same required pairing |
| `saveRegistryAuth(input:)` | `POST /v2/registries` |
| `createNetworkVolume(input:)` | `POST /v2/network-volumes` |
| `updateNetworkVolume(input:)` | `PATCH /v2/network-volumes/{id}` |
| `deleteNetworkVolume(input:)` | `DELETE /v2/network-volumes/{id}` |
`saveEndpoint` is an upsert keyed on `id`; REST splits that into POST and PATCH. If the
code branches on "did I pass an id", that branch becomes the method choice.
## No REST v2 equivalent — keep these on GraphQL
| GraphQL | Why it stays |
| --- | --- |
| `myself { id email clientBalance currentSpendPerHr }` | v2 has no user/account route. (Spend *history* is `/v2/billing`; live balance is not.) |
| `secretCreate` / `secretDelete` | no v2 secrets API |
| `podRentInterruptable`, `podBidResume` | v2 has no spot/interruptible pods |
| `createCluster` / `deleteCluster` | v2 exposes cluster **billing** only |
A codebase that uses these ends up bilingual after the migration. That is expected —
say so in the summary rather than leaving the user to wonder if you missed something.
## Field translation
### Pods — `podFindAndDeployOnDemand` → `POST /v2/pods`
| GraphQL input | v2 |
| --- | --- |
| `imageName` | `image` |
| `name` | `name` |
| `gpuTypeId` (single) | `gpu.id` |
| `gpuCount` | `gpu.count` |
| `containerDiskInGb` | `disk` |
| `volumeInGb` + `volumeMountPath` | `mounts.persistent.{size,path}` |
| `networkVolumeId` | `mounts.network[0].{volumeId,path}` — `path` required |
| `dockerArgs` | `args` |
| `ports: "8888/http,22/tcp"` (comma string) | `ports: ["8888/http","22/tcp"]` (array) |
| `env: [{key,value}]` | `env: {"KEY": "value"}` (map) |
| `cloudType: SECURE \| COMMUNITY` | `cloud` — **`ALL` is gone**, pick one |
| `minVcpuCount`, `minMemoryInGb` | removed — GPU pods size RAM/vCPU from the GPU type |
| `allowedCudaVersions` | `gpu.allowedCudaVersions` — moved under `gpu`, not removed (GPU pods only). Mutually exclusive with a non-empty `gpu.minCudaVersion`. |
| `startSsh`, `startJupyter` | removed — express these through `ports` / `args` / the image |
| `templateId` | `templateId` — still accepted, but resolved once with no link retained ([Class 2 §13](breaking-changes.md#13-templateid-still-works-but-the-link-is-gone)) |
A full conversion, showing the four shape changes that a field-by-field rename misses —
comma-string ports become an array, the env pair-list becomes a map, `dockerArgs`
becomes `args`, and the whole thing stops being a string template:
```js
// ── before (GraphQL) ──────────────────────────────────────────────────────
await gql(`
mutation {
podFindAndDeployOnDemand(input: {
cloudType: SECURE,
gpuTypeId: "NVIDIA RTX A6000",
gpuCount: 1,
name: "${name}",
imageName: "${image}",
containerDiskInGb: 40,
volumeInGb: 40,
volumeMountPath: "/workspace",
minVcpuCount: 8,
minMemoryInGb: 32,
ports: "8888/http,22/tcp",
dockerArgs: "",
env: [{ key: "JUPYTER_PASSWORD", value: "${pw}" }]
}) { id imageName machineId machine { podHostId } }
}
`);
// ── after (REST v2) ───────────────────────────────────────────────────────
const res = await fetch("https://api.runpod.io/v2/pods", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RUNPOD_API_KEY}`,
"Content-Type": "application/json" },
body: JSON.stringify({
name,
image,
cloud: "SECURE",
gpu: { id: "NVIDIA RTX A6000", count: 1 },
disk: 40,
mounts: { persistent: { size: 40, path: "/workspace" } },
ports: ["8888/http", "22/tcp"], // array, not a comma string
env: { JUPYTER_PASSWORD: pw }, // map, not [{key, value}]
// minVcpuCount / minMemoryInGb: removed, GPU type sizes the host
// dockerArgs: "" -> omit `args` entirely rather than sending ""
}),
});
if (!res.ok) { const e = await res.json(); throw new Error(`${e.status} ${e.title}: ${e.detail}`); }
const pod = await res.json(); // pod.id, pod.status, pod.cost — no machineId
```
Also note the interpolation disappears. GraphQL forced string-building, so a value
containing a quote could break the query; v2 takes real JSON and `JSON.stringify`
escapes for you.
Response fields: `desiredStatus` → `status`, `costPerHr` → `cost`,
`machineId` / `machine { podHostId }` → gone (`dataCenterId` remains),
`runtime.uptimeInSeconds` → `runtime.uptime`,
`runtime.gpus[].gpuUtilPercent` → `runtime.gpus[].util`,
`runtime.gpus[].memoryUtilPercent` → `runtime.gpus[].memoryUtil`,
`runtime.container.cpuPercent` / `.memoryPercent` → `runtime.cpu.util` / `runtime.memory.util`.
### Serverless — `saveEndpoint` → `POST|PATCH /v2/serverless`
| GraphQL input | v2 |
| --- | --- |
| `gpuIds: "AMPERE_16"` (comma string of pool IDs) | `gpu.pools: ["AMPERE_16"]` (array) — pool IDs carry over unchanged |
| `workersMin` / `workersMax` | `workers.min` / `workers.max` |
| `idleTimeout` | `workers.idleTimeout` |
| `scalerType` + `scalerValue` | `scaling: {"type": "QUEUE_DELAY", "queueDelay": N}` or `{"type": "REQUEST_COUNT", "requestCount": N}` |
| `flashBootType: FLASHBOOT` | `flashboot: "FLASHBOOT"` (string, same values: `OFF`/`FLASHBOOT`/`PRIORITY_FLASHBOOT`) |
| `locations: "US"` (region/country string) | `dataCenterIds: ["US-KS-2", …]` — explicit IDs |
| `templateId` | `templateId` — still accepted, but resolved once with no link retained ([Class 2 §13](breaking-changes.md#13-templateid-still-works-but-the-link-is-gone)) |
| `networkVolumeId` | `networkVolumes: ["vol1"]` |
| — | `type: "QUEUE" \| "LOAD_BALANCER"` — **required on create** |
| `executionTimeoutMs` | `timeout` (still ms) |
GraphQL's `gpuIds` already used **pool** IDs (`AMPERE_16`, `ADA_24`), so GraphQL users
get an easier ride here than REST v1 users, whose `gpuTypeIds` held individual GPU names.
`locations: "US"` has no one-to-one translation — it was a region hint, v2 takes
datacenter IDs. Enumerate them instead of hardcoding:
```bash
curl -s -H "Authorization: Bearer $RUNPOD_API_KEY" \
'https://api.runpod.io/v2/catalog/datacenters?include=GPU_AVAILABILITY' \
| python3 -c 'import json,sys; [print(d["id"], d["region"]) for d in json.load(sys.stdin)["dataCenters"]]'
```
### Templates — `saveTemplate` → `/v2/templates`
Same renames as pods (`imageName`→`image`, `containerDiskInGb`→`disk`,
`dockerArgs`→`args`, `volumeInGb`/`volumeMountPath`→`mounts.persistent`,
`env` list→map, `isServerless`→`serverless`), plus `readme` is dropped.
**`deleteTemplate` took a name; `DELETE /v2/templates/{id}` takes an ID.** Any code
holding template *names* as its handle needs to hold IDs instead — usually the largest
non-obvious change in a GraphQL template workflow.
### Catalog — `gpuTypes` → `/v2/catalog/gpus`
| GraphQL | v2 |
| --- | --- |
| `id` | `id` |
| `displayName` | `name` |
| `memoryInGb` | `memory` |
| `secureCloud` / `communityCloud` | `secure` / `community` |
| `lowestPrice(input:{gpuCount, secureCloud}).uninterruptablePrice` | `price.secure` / `price.community` |
| `lowestPrice(…).stockStatus` — `High`/`Medium`/`Low`/`None` | `availability` — **`HIGH`/`MEDIUM`/`LOW`/`NONE`**. ⚠ The case flips. A `{"High": 0, …}` rank table or `== "High"` comparison carried over stops matching *silently*. |
| `lowestPrice(…).minimumBidPrice` | gone with spot pods |
| — | `dataCenters[].availability` — per-datacenter, new |
| — | `pool` — the serverless pool this GPU belongs to |
| — | `maxCount.{secure,community}`, `manufacturer` |
One `GET /v2/catalog/gpus?include=AVAILABILITY&product=POD` replaces the N per-GPU `lowestPrice`
queries a capacity loop used to make.
## Error handling
GraphQL always answers `200` with an `errors[]` array in the body, so client code checks
`json.errors`. REST v2 uses status codes plus `{title, status, detail, errors[]}`.
```python
# before
data = resp.json()
if data.get("errors"):
raise RuntimeError(data["errors"])
return data["data"]["podFindAndDeployOnDemand"]
# after
if not resp.ok:
body = resp.json()
raise RuntimeError(f"{body['status']} {body['title']}: {body['detail']}")
return resp.json()
```
Any retry logic keyed on "GraphQL returned 200 so the transport worked" needs rewriting
against real status codes — see [breaking-changes.md](breaking-changes.md#reading-a-422).
reference/rest-v1-to-v2.md
# REST v1 → REST v2 mapping
Base URL: `https://rest.runpod.io/v1` → **`https://api.runpod.io/v2`**
Auth is unchanged: `Authorization: Bearer $RUNPOD_API_KEY`.
Authoritative specs — diff them yourself when in doubt:
`https://rest.runpod.io/v1/openapi.json` · `https://api.runpod.io/v2/openapi.json`
## Two global changes that touch every call site
**1. Every list response is wrapped.** v1 returned a bare JSON array; v2 returns an
object with a named key. `for pod in resp.json()` silently iterates the *keys* of a dict
instead of failing, so this one can pass a smoke test and corrupt behavior.
| v1 | v2 |
| --- | --- |
| `GET /pods` → `[ … ]` | `GET /v2/pods` → `{"pods": [ … ]}` |
| `GET /endpoints` → `[ … ]` | `GET /v2/serverless` → `{"endpoints": [ … ]}` |
| `GET /templates` → `[ … ]` | `GET /v2/templates` → `{"templates": [ … ]}` |
| `GET /networkvolumes` → `[ … ]` | `GET /v2/network-volumes` → `{"networkVolumes": [ … ]}` |
| `GET /containerregistryauth` → `[ … ]` | `GET /v2/registries` → `{"registries": [ … ]}` |
**2. List filtering is client-side now.** v1's `GET /pods` accepted `computeType`,
`desiredStatus`, `gpuTypeId`, `name`, `templateId`, `include*` expansions and more. v2's
`GET /v2/pods` takes **no query parameters** — unknown ones are ignored, not rejected, so
a filter you forget to port returns *everything* with a `200`. Filter in your own code.
## Paths
| v1 | v2 | Note |
| --- | --- | --- |
| `POST /pods` | `POST /v2/pods` | `201`; body fully restructured (below) |
| `GET /pods` | `GET /v2/pods` | envelope; no filters |
| `GET /pods/{id}` | `GET /v2/pods/{id}` | |
| `PATCH /pods/{id}` | `PATCH /v2/pods/{id}` | |
| `POST /pods/{id}/update` | `PATCH /v2/pods/{id}` | the `/update` POST alias is gone (404) |
| `DELETE /pods/{id}` | `DELETE /v2/pods/{id}` | |
| `POST /pods/{id}/start` | `POST /v2/pods/{id}/action` `{"action":"start"}` | |
| `POST /pods/{id}/stop` | `POST /v2/pods/{id}/action` `{"action":"stop"}` | |
| `POST /pods/{id}/restart` | `POST /v2/pods/{id}/action` `{"action":"restart"}` | |
| `POST /pods/{id}/reset` | **no equivalent** | `reset` is not a v2 action (`422`). Closest is `restart`; a true reset is stop + start. |
| — | `GET /v2/pods/{id}/logs` | new: SSE log stream |
| `POST /endpoints` | `POST /v2/serverless` | |
| `GET /endpoints` | `GET /v2/serverless` | |
| `GET|PATCH|DELETE /endpoints/{id}` | `…/v2/serverless/{id}` | |
| `POST /endpoints/{id}/update` | `PATCH /v2/serverless/{id}` | alias gone |
| — | `GET /v2/serverless/{id}/workers` | new |
| — | `GET /v2/serverless/{id}/releases` | new |
| — | `GET /v2/serverless/{id}/workers/{workerId}/logs` | new: SSE |
| `POST|GET /templates`, `…/{id}` | `…/v2/templates` | `/update` alias gone |
| `…/networkvolumes` | **`…/v2/network-volumes`** | hyphenated. `/v2/networkvolumes` is a `404`. `POST …/{id}/update` alias gone — use PATCH. |
| `…/containerregistryauth` | `…/v2/registries` | |
| — | `/v2/registries/delegations` | new: ECR delegation |
| — | `/v2/catalog/gpus`, `/cpus`, `/datacenters` | new: v1 had no catalog |
| `GET /billing/pods` | `GET /v2/billing/pods` | |
| `GET /billing/endpoints` | **`GET /v2/billing/serverless`** | ⚠ see below |
| `GET /billing/networkvolumes` | **`GET /v2/billing/network-volumes`** | hyphenated, like the resource path |
| — | `GET /v2/billing`, `/v2/billing/clusters` | new |
⚠ **`/billing/endpoints` is the trap.** In v1 it meant *serverless* spend. In v2 that is
`/v2/billing/serverless`; `/v2/billing/endpoints` still exists but reports spend on
**[Runpod public endpoints](../../runpod/golden-paths/11-public-endpoints.md)**, a
different product. Both return `200` and both are correct — v2 just answers a different
question under the same path, so an unchanged call quietly reports the wrong product's
total (`0`, if you run no public endpoints).
Billing responses also changed shape: v1 returned a bare array of records; v2 returns
`{"records": [...], "metadata": {"query", "recordCount", "totals"}}`, and adds `lastN`
(e.g. `?bucketSize=day&lastN=30`) as an alternative to `startTime`/`endTime`. v1's
`grouping` parameter is gone — v2 emits one record per resource per bucket.
**The money field was renamed too: `amount` → `totalAmount`.** Records now break the
figure down (`gpuAmount`, `cpuAmount`, `diskAmount`, `feeAmount`, `totalAmount`), and
`metadata.totals` carries the same shape across the whole window. Porting
`sum(r["amount"] for r in resp.json())` to v2 without this is a `KeyError` — noisy, but
easy to miss because it sits one line away from the far quieter `/billing/endpoints`
trap above.
```python
# v1
total = sum(r["amount"] for r in resp.json())
# v2 — per record, or just read the precomputed total
total = sum(r["totalAmount"] for r in resp.json()["records"])
total = resp.json()["metadata"]["totals"]["totalAmount"]
```
## Pods — request body
```jsonc
// v1 // v2
{ {
"name": "trainer", "name": "trainer", // required in v2
"imageName": "org/img:tag", "image": "org/img:tag", // required in v2
"containerDiskInGb": 60, "disk": 60,
"volumeInGb": 100, "mounts": {"persistent": {"size": 100, "path": "/workspace"}},
"volumeMountPath": "/workspace",
"networkVolumeId": "vol123", "mounts": {"network": [{"volumeId": "vol123", "path": "/workspace"}]},
"gpuTypeIds": ["A", "B"], "gpu": {"id": "A", "count": 1}, // single type — see breaking-changes
"gpuCount": 1,
"cloudType": "SECURE", "cloud": "SECURE",
"containerRegistryAuthId": "auth1", "registry": "auth1",
"dockerStartCmd": ["python","x.py"], "args": "python x.py", // string, not array
"env": {"K": "v"}, "env": {"K": "v"}, // unchanged
"ports": ["8888/http"], "ports": ["8888/http"], // unchanged, but no default now
"dataCenterIds": [...] "dataCenterIds": [...] // unchanged
} }
```
`mounts.persistent` and `mounts.network` are **mutually exclusive** (`400` if both).
`mounts.network[].path` is **required** — v2 has no `/workspace` default.
⚠ **`mounts.persistent` is deprecated in v2**, and a literal `volumeInGb` →
`mounts.persistent` translation inherits that. It is host-local storage pinned to one
machine — *data does not survive a host failure* — it is disallowed on CPU pods, and
`size` has a 10 GB floor. For anything the user cannot recreate, migrate `volumeInGb` to
a **network volume** (`mounts.network`) instead and say why you changed the shape.
**`mounts` is far less malleable on PATCH than v1's `volumeInGb`/`volumeMountPath` were.**
v1 let you PATCH either field alone; v2 enforces:
| PATCH attempt | Result |
| --- | --- |
| omit `mounts`, or send `{}` | existing mount unchanged |
| `network: []` to clear mounts | `400` — clearing is unsupported |
| add a mount kind not present at create (incl. any mount on a mountless pod) | `400` — kind is fixed at create |
| change a network mount's `volumeId` | `400` — immutable |
| partial entry (e.g. `path` without `size`/`volumeId`) | `422` — every entry needs its full schema |
Dropped from pod create with no v2 equivalent: `computeType` (implied by `gpu` vs `cpu`),
`interruptible`, `locked` (PATCH only), `gpuTypePriority`, `dataCenterPriority`,
`cpuFlavorPriority`, `countryCodes`, `supportPublicIp`, `minRAMPerGPU`, `minVCPUPerGPU`,
`minDownloadMbps`, `minUploadMbps`, `minDiskBandwidthMBps`.
**Moved, not dropped** — do not delete these:
| v1 pod create | v2 |
| --- | --- |
| `allowedCudaVersions` | `gpu.allowedCudaVersions` (GPU pods only — a CPU pod has no `gpu` block, and ignores a template's constraint) |
| `templateId` | `templateId`, still accepted — but resolved once at create time, with no link retained. See [breaking-changes.md Class 2 §13](breaking-changes.md#13-templateid-still-works-but-the-link-is-gone). |
(`minCudaVersion` was never a v1 *pod* create field — it is a v1 **endpoint** create
field, and in v2 it is `gpu.minCudaVersion` on both. It is mutually exclusive with a
non-empty `allowedCudaVersions` (`400` if both are sent). `countryCodes` survives only as
a `/v2/catalog/gpus` read filter, not a create-time constraint. `volumeEncrypted` was a
v1 Pod **response** field, not an input.)
### CPU pods
`computeType: "CPU"` + `cpuFlavorIds: [...]` + `vcpuCount` becomes one `cpu` object.
Send `gpu` **or** `cpu`, never both.
```jsonc
// v1 // v2
{ {
"name": "ingest-worker", "name": "ingest-worker",
"imageName": "python:3.11-slim", "image": "python:3.11-slim",
"computeType": "CPU", // implied by using `cpu` instead of `gpu`
"cpuFlavorIds": ["cpu3c", "cpu5c"], "cpu": {"id": "cpu3c", "vcpuCount": 2},
"cpuFlavorPriority": "availability", // no fallback list — loop client-side
"vcpuCount": 2,
"containerDiskInGb": 20, "disk": 20
"volumeInGb": 20, // mounts.persistent is DISALLOWED on CPU
"volumeMountPath": "/workspace" // pods — use mounts.network instead
} }
```
Two CPU-only traps beyond the rename:
- **`mounts.persistent` is rejected on CPU pods.** A literal `volumeInGb` translation
fails. Persist to a network volume instead.
- **`vcpuCount` must be a power of two** and within the flavor's `vcpu.min`..`vcpu.max`.
Valid flavor IDs and their vCPU ranges come from the catalog — never hardcode them:
```bash
curl -s -H "Authorization: Bearer $RUNPOD_API_KEY" \
'https://api.runpod.io/v2/catalog/cpus?include=AVAILABILITY&product=POD' \
| python3 -c 'import json,sys; [print(c["id"].ljust(10), c["vcpu"], c["availability"]) for c in json.load(sys.stdin)["cpus"]]'
```
### Pod lifecycle actions
Four separate v1 endpoints collapse into one, with the verb in the body:
```python
# v1 — one path per verb
SESSION.post(f"{V1}/pods/{pod_id}/stop")
SESSION.post(f"{V1}/pods/{pod_id}/start")
SESSION.post(f"{V1}/pods/{pod_id}/restart")
SESSION.post(f"{V1}/pods/{pod_id}/reset") # no v2 equivalent
# v2 — one path, action in the body, returns the updated pod
SESSION.post(f"{V2}/pods/{pod_id}/action", json={"action": "stop"})
SESSION.post(f"{V2}/pods/{pod_id}/action", json={"action": "start"})
SESSION.post(f"{V2}/pods/{pod_id}/action", json={"action": "restart"})
SESSION.delete(f"{V2}/pods/{pod_id}") # "terminate" is also a valid action
```
The old paths are `404`, and `{"action": "reset"}` is a `422` listing the four legal
values. Before acting, `pod["actions"]` tells you which transitions are legal *right
now* — v1 had no equivalent, so code guessed and handled the error.
### The same create in curl
For codebases that aren't Python, the minimal shape:
```bash
# v1
curl -X POST https://rest.runpod.io/v1/pods \
-H "Authorization: Bearer $RUNPOD_API_KEY" -H 'Content-Type: application/json' \
-d '{"name":"trainer","imageName":"runpod/pytorch:latest","gpuTypeIds":["NVIDIA GeForce RTX 4090"],
"gpuCount":1,"containerDiskInGb":50,"volumeInGb":20,"volumeMountPath":"/workspace"}'
# v2
curl -X POST https://api.runpod.io/v2/pods \
-H "Authorization: Bearer $RUNPOD_API_KEY" -H 'Content-Type: application/json' \
-d '{"name":"trainer","image":"runpod/pytorch:latest",
"gpu":{"id":"NVIDIA GeForce RTX 4090","count":1},"disk":50,
"mounts":{"persistent":{"size":20,"path":"/workspace"}}}'
```
### Listing pods
```python
# v1 — server-side filters, bare array
pods = SESSION.get(f"{V1}/pods", params={"computeType": "GPU",
"desiredStatus": "RUNNING"}).json()
# v2 — envelope, and the filters are yours. Unknown params are IGNORED, not
# rejected, so a filter you forget to port silently returns everything.
pods = [p for p in SESSION.get(f"{V2}/pods").json()["pods"]
if p["status"] == "RUNNING" and p.get("gpu")]
```
## Pods — response
| v1 | v2 |
| --- | --- |
| `desiredStatus` (`RUNNING`/`EXITED`/`TERMINATED`) | `status` (`PROVISIONING`/`STARTING`/`RUNNING`/`EXITED`/`ERROR`/`TERMINATED`) |
| `costPerHr`, `adjustedCostPerHr` | `cost` (savings-plan adjustment is not exposed) |
| `imageName` | `image` |
| `containerDiskInGb` | `disk` |
| `machine {...}`, `machineId` | **gone** — only `dataCenterId` survives |
| `publicIp`, `portMappings` | `runtime.ports[] {private, public, type, ip}` (null unless RUNNING) |
| `networkVolume {...}` | `mounts.network[]` |
| `savingsPlans[]` | **gone** |
| `gpu {...}` (pricing blob) | `gpu {id, count}` |
| — | `actions[]` — transitions legal right now |
| — | `runtime {uptime, gpus[], cpu, memory, ports[]}` |
| — | `globalNetworking {enabled, ip, internalDns}` |
## Serverless endpoints
```jsonc
// v1 // v2
{ {
"name": "sdxl", "name": "sdxl",
"templateId": "tpl123", "templateId": "tpl123", // ⚠ still accepted, but
// resolved once — template edits no longer
// reach the endpoint. Or inline the fields:
"image": "org/worker:tag", "disk": 20,
"env": {...}, "args": "python -u handler.py",
"type": "QUEUE", // required, new
"gpuTypeIds": ["NVIDIA GeForce RTX 4090"],
"gpuCount": 1, "gpu": {"pools": ["ADA_24"], "count": 1}, // POOL ids
"workersMin": 0, "workersMax": 5, "workers": {"min": 0, "max": 5, "idleTimeout": 10},
"idleTimeout": 10,
"scalerType": "QUEUE_DELAY", "scaling": {"type": "QUEUE_DELAY", "queueDelay": 4},
"scalerValue": 4, // or {"type": "REQUEST_COUNT", "requestCount": N}
"executionTimeoutMs": 600000, "timeout": 600000, // still milliseconds
"flashboot": true, "flashboot": "FLASHBOOT", // enum, not boolean
"networkVolumeId" / "networkVolumeIds", "networkVolumes": ["vol1"],
"dataCenterIds": [...] "dataCenterIds": [...]
} }
```
**`gpu.pools` takes pool IDs, not GPU type IDs.** `"NVIDIA GeForce RTX 4090"` is not a
valid pool. Resolve it at runtime — never hardcode the table, it grows:
```bash
curl -s -H "Authorization: Bearer $RUNPOD_API_KEY" \
'https://api.runpod.io/v2/catalog/gpus?include=AVAILABILITY&product=SERVERLESS' \
| python3 -c 'import json,sys; [print(g["pool"].ljust(16), g["id"]) for g in json.load(sys.stdin)["gpus"] if g["pool"]]'
```
Endpoint responses now carry **`requestUrls`** — `run`, `runSync`, `status`, `stream`,
`cancel`, `retry`, `purgeQueue`, `health` for `QUEUE` endpoints, or `base` + `health` for
`LOAD_BALANCER`. Delete any code that builds `https://api.runpod.ai/v2/<id>/run` by hand.
Other endpoint response changes: `templateId`/`template` gone from the **response** (the
config is inline, even when you created from a template), `workers[]` (full pod objects) →
`GET /v2/serverless/{id}/workers`, `version` → `GET /v2/serverless/{id}/releases`,
`scalerType`/`scalerValue` → `scaling`, `computeType` → presence of `gpu` vs `cpu`.
CPU endpoints are writable in v2: send `cpu` instead of `gpu`, as a list of eligible
`{id, vcpuCount}` configurations (flavor IDs from `GET /v2/catalog/cpus`; `vcpuCount` must
be a power of two and valid for the flavor). Memory is derived from the flavor's catalog
RAM multiplier. Exact duplicate configurations are rejected, though the same flavor may
be listed at different vCPU counts. Note the CUDA constraints live under `gpu`
specifically so they are unrepresentable here.
## Templates
| v1 | v2 |
| --- | --- |
| `imageName` | `image` |
| `containerDiskInGb` | `disk` |
| `volumeInGb` / `volumeMountPath` | `mounts.persistent.{size,path}` (no `network` on templates — `422`) |
| `volumeInGb: 0` | **omit `mounts` entirely.** Zero meant "no volume" in v1; `{"size": 0}` is invalid in v2 (10 GB floor) and there is no `path` to supply. |
| `dockerStartCmd` / `dockerEntrypoint` | `args` (string) |
| `containerRegistryAuthId` | `registry` |
| `isServerless` | `serverless` |
| `isPublic` | `public` |
| `category` | `category` — unchanged (already `CPU`/`NVIDIA`/`AMD`, default `NVIDIA`, in v1) |
| `readme`, `earned`, `isRunpod`, `runtimeInMin` | **gone** |
Templates are still worth keeping as a config preset — but v2 pods and endpoints do not
reference one by ID. Fetch the template and spread its container fields into the create
body. Deleting a template is rejected while a pod references it or an endpoint is bound
to it.
## Network volumes
| v1 | v2 |
| --- | --- |
| `POST /networkvolumes` `{name, size, dataCenterId}` | `POST /v2/network-volumes` `{name, size, dataCenter, type?}` |
| response `dataCenterId` | `dataCenter` |
| — | `type`: `STANDARD` \| `HIGH_PERFORMANCE`, set at create, immutable |
`size` can still only grow. Not every datacenter supports volumes — a bad one returns
`400` and **the error message lists the datacenters that do**. Check
`GET /v2/catalog/datacenters` → `networkVolumeTypes` first.
Three changes land at once here — the hyphenated path, the `dataCenterId` → `dataCenter`
field, and the response envelope:
```python
# ── v1 ────────────────────────────────────────────────────────────────────
def ensure_volume(name, size_gb, dc):
for vol in SESSION.get(f"{V1}/networkvolumes").json(): # bare array
if vol["name"] == name:
return vol
return SESSION.post(f"{V1}/networkvolumes",
json={"name": name, "size": size_gb,
"dataCenterId": dc}).json()
# ── v2 ────────────────────────────────────────────────────────────────────
def ensure_volume(name, size_gb, dc):
listing = SESSION.get(f"{V2}/network-volumes").json()["networkVolumes"] # envelope
for vol in listing:
if vol["name"] == name:
return vol
return SESSION.post(f"{V2}/network-volumes",
json={"name": name, "size": size_gb,
"dataCenter": dc, # renamed
"type": "HIGH_PERFORMANCE"} # new, optional, immutable
).json()
```
`/v2/networkvolumes` (unhyphenated) is a `404`, and `dataCenterId` is a `422` — so both
of those fail loudly. The envelope is the quiet one: `for vol in resp.json()` iterates
the dict's *keys* instead of raising.
**Attaching a volume to a pod** changed shape as well, and `path` is now mandatory:
```python
# v1 — one field, mount path implied (/workspace by default)
body["networkVolumeId"] = vol["id"]
# v2 — an array of mounts, each needing an explicit path
body["mounts"] = {"network": [{"volumeId": vol["id"], "path": "/workspace"}]}
```
## Container registry auth → registries
`POST /v2/registries` `{name, username, password}` → `{id, name}`. Credentials are
write-only in both versions. Deleting is rejected if a pod is using it; templates that
reference it silently drop to `registry: null` instead of blocking the delete.
The request body is unchanged — only the path, the list envelope, and the field that
references the credential from a pod or template:
```python
# ── v1 ────────────────────────────────────────────────────────────────────
auth = SESSION.post(f"{V1}/containerregistryauth",
json={"name": "dockerhub", "username": u, "password": p}).json()
all_auths = SESSION.get(f"{V1}/containerregistryauth").json() # bare array
pod_body["containerRegistryAuthId"] = auth["id"]
# ── v2 ────────────────────────────────────────────────────────────────────
auth = SESSION.post(f"{V2}/registries",
json={"name": "dockerhub", "username": u, "password": p}).json()
all_auths = SESSION.get(f"{V2}/registries").json()["registries"] # envelope
pod_body["registry"] = auth["id"] # renamed
```
## Templates — a worked pair
The endpoint example above spreads a template into a create body. Standalone, the
template itself converts like this:
```python
# ── v1 ────────────────────────────────────────────────────────────────────
tpl = SESSION.post(f"{V1}/templates", json={
"name": "sdxl-worker",
"imageName": "org/worker:v3",
"containerDiskInGb": 20,
"volumeInGb": 40,
"volumeMountPath": "/workspace",
"dockerStartCmd": ["python", "-u", "handler.py"],
"env": {"MODEL_ID": "stabilityai/sdxl-turbo"},
"ports": ["8888/http"],
"isServerless": True,
"isPublic": False,
"readme": "## SDXL worker",
}).json()
# ── v2 ────────────────────────────────────────────────────────────────────
tpl = SESSION.post(f"{V2}/templates", json={
"name": "sdxl-worker",
"image": "org/worker:v3",
"disk": 20,
"mounts": {"persistent": {"size": 40, "path": "/workspace"}},
"args": "python -u handler.py", # one string; quote any element with spaces
"env": {"MODEL_ID": "stabilityai/sdxl-turbo"},
"ports": ["8888/http"],
"serverless": True,
"public": False,
"category": "NVIDIA", # same enum and default as v1
# "readme" has no v2 field — drop it, or keep the text in your own repo
}).json()
```
Templates accept only `mounts.persistent`; a `network` key is a `422`. And v2 refuses to
delete a template while a pod references it or an endpoint is bound to it.
reference/rollback-flag.md
# Rollback flag: keeping v1 one env var away
While v2 is new to a team, an env-var switch that returns to the old code path is cheap
insurance. It is what makes a migration shippable on a Friday.
**Offer it, do not impose it.** Worth it for a service in production or anything on a
schedule. Skip it for a one-off script, a notebook, or a codebase with two call sites —
there the flag is more code than the migration.
Say out loud that it is **temporary**: v1 is deprecated, so this scaffolding comes out
once v2 has run clean for a release or two. Leave a note in the code saying so, or it
becomes permanent.
## The shape
Gate at the **client boundary**, not at each call site. One `USE_V1` read, one branch per
operation, both paths in the same function so they cannot drift apart unnoticed:
```python
import os
USE_V1 = os.environ.get("RUNPOD_API_V1", "").lower() in ("1", "true", "yes")
V1_BASE = "https://rest.runpod.io/v1"
V2_BASE = "https://api.runpod.io/v2"
def list_pods() -> list[dict]:
if USE_V1:
pods = session.get(f"{V1_BASE}/pods", params={"desiredStatus": "RUNNING"}).json()
return [{**p, "status": p["desiredStatus"], "cost": p["costPerHr"]} for p in pods]
pods = session.get(f"{V2_BASE}/pods").json()["pods"]
return [p for p in pods if p["status"] == "RUNNING"]
```
Note what that example does: the v1 branch **normalizes to the v2 field names**. Callers
see one shape either way, so the flag stays contained in the client instead of leaking
`if USE_V1` into business logic.
## Rules that keep it honest
1. **Default to v2.** The flag turns v1 back *on*. A flag that defaults to the old path
never gets removed, because nothing exercises the new one.
2. **Normalize responses to v2's shape** in the v1 branch (above). The alternative —
normalizing to v1 — means you migrate twice.
3. **Log which path ran, once at startup.** `log.info("Runpod API: %s", "v1 (rollback)"
if USE_V1 else "v2")`. Otherwise nobody can tell from the outside which one
production is on, which defeats the point.
4. **Do not flag things v1 cannot do.** Availability-aware GPU selection, `requestUrls`,
worker `isStale`, SSE logs — these have no v1 branch to fall back to. Either keep the
feature v2-only and degrade gracefully under the flag, or leave it out of the
migration commit entirely.
5. **One flag for the whole client.** Per-resource flags (`RUNPOD_PODS_V1`,
`RUNPOD_ENDPOINTS_V1`) multiply the states you have to test and nobody tests them.
## Tell the scanner the v1 code is deliberate
A rollback path is legacy code you meant to keep, so `rp_api_inventory.py` would
otherwise report it forever and `--fail-on-legacy` could never pass. Mark it:
```python
V1_BASE = "https://rest.runpod.io/v1" # rp-migrate: keep-v1
def _list_pods_v1():
# rp-migrate: keep-v1 start (rollback path, delete with the RUNPOD_API_V1 flag)
pods = session.get(f"{V1_BASE}/pods", params={"desiredStatus": "RUNNING"}).json()
return [{**p, "status": p["desiredStatus"], "cost": p["costPerHr"]} for p in pods]
# rp-migrate: keep-v1 end
```
| Marker | Scope |
| --- | --- |
| `rp-migrate: keep-v1` or `rp-migrate: ignore` | that line |
| `rp-migrate: keep-v1 start` … `end` | the region between them |
| `rp-migrate: keep-v1 file` | the whole file |
Marked sites still appear in the report — under *kept on purpose* — but drop out of the
migration plan and out of `--fail-on-legacy`. Use the same markers for the GraphQL calls
that have no v2 equivalent (`myself`, secrets, spot pods, clusters), so a clean exit code
means "everything that can be migrated has been".
Deleting the markers is how you find the rollback code again when it is time to remove
it: `rg 'rp-migrate: keep-v1'`.
## Removing it
The exit criteria are worth writing into the PR description:
- v2 has served production traffic for N releases with no rollback,
- the v1 branches have no coverage in CI that the v2 branches lack,
- then delete the flag and the v1 branches in one commit, and re-run
`rp_api_inventory.py --fail-on-legacy` to prove nothing was left behind.
reference/unlocks.md
# What v2 unlocks, keyed to what the code already does
Do **not** paste this file at the user. It is a lookup table: find the patterns their
codebase actually contains, then write two or three sentences about *their* code. A
generic feature list is the failure mode this section exists to avoid.
The strongest version of this also uses what you already know from the session — what
they have been building, what they were debugging last week, what they said was
annoying. "The capacity retries you added last month can become one catalog call" lands;
"v2 adds a catalog API" does not.
## Look for these patterns
| If their code does this… | v2 offers | Why they will care |
| --- | --- | --- |
| Retries pod creation across GPU types until one works; sleeps and retries on capacity errors | `GET /v2/catalog/gpus?include=AVAILABILITY&product=POD` → `availability` per GPU **and** per datacenter | Stop renting blind. Check stock, then ask for the one that has it. This is the #1 thing users say they want. |
| Hardcodes a GPU or datacenter list in a constant | catalog endpoints return the live set | v1 froze IDs as spec enums, so new hardware needed a spec release. There are datacenters live today that do not exist in the v1 enum. |
| Builds `https://api.runpod.ai/v2/{id}/run` by string concatenation | `endpoint.requestUrls.{run,runSync,status,stream,cancel,retry,purgeQueue,health}` | Delete the URL-building helper and its tests. |
| Polls pod status in a loop with a timeout | `status` includes `PROVISIONING`/`STARTING`/`ERROR`, plus `actions[]` | Fail in seconds on a broken pod instead of waiting out a 10-minute timeout. Distinguish "still coming up" from "stuck". |
| Polls `/logs` or shells in to tail logs | `GET /v2/pods/{id}/logs` and `/v2/serverless/{id}/workers/{workerId}/logs` — SSE, with `tail`, `since`, and `Last-Event-ID` resume | Live logs with reconnect, no polling loop. |
| Tracks "did my endpoint update actually roll out?" | `GET /v2/serverless/{id}/releases` (history + `diff` + `rollout` summary) and `workers[].isStale` | Answer "are all workers on the new config" without inference. |
| Counts workers by scraping the endpoint's `workers[]` pod list | `GET /v2/serverless/{id}/workers` → `summary` histogram (`running`/`idle`/`initializing`/`throttled`/`unhealthy`/`total`) | `throttled` in particular is a capacity signal v1 could not express. |
| Sums per-resource billing calls to get total spend | `GET /v2/billing` — one call, all resources, with components broken out | Also `?lastN=30&bucketSize=day` instead of computing date ranges. |
| Has no visibility into Instant Cluster or public endpoint spend | `/v2/billing/clusters`, `/v2/billing/endpoints` | Line items that did not exist in v1. |
| Runs an HTTP server in the worker and fights the job queue | endpoint `type: "LOAD_BALANCER"` with `requestUrls.base` | Direct HTTP/WebSocket to workers; no queue wrapper. |
| Creates network volumes and hopes they are fast enough | `type: "HIGH_PERFORMANCE"` on create; `GET /v2/catalog/datacenters` → `networkVolumeTypes` | Pick the storage tier deliberately, and check the datacenter supports it before creating. |
| Sets `globalNetworking: true` but has to discover the pod's private address out of band | the **response** object `globalNetworking.{enabled, ip, internalDns}` (`<podId>.runpod.internal`) | The flag itself already existed in v1 — what's new is that v2 hands back the assigned IP and DNS name instead of leaving you to find them. |
| Pulls images from ECR with long-lived AWS creds | `POST /v2/registries/delegations` | Delegate ECR access instead of storing static credentials. |
| Picks datacenters by guesswork for compliance | `GET /v2/catalog/datacenters` → `compliance[]` (`GDPR`, `HIPAA`, `SOC_2_TYPE_2`, `ITAR`, …), `region`, `globalNetwork` | Filter placement on certifications with a query parameter. |
| Has ad-hoc rate-limit backoff | `ratelimit` / `ratelimit-policy` response headers (on authenticated responses; omitted for rate-limit-exempt callers) | e.g. `"minute";r=176;t=1, "hour";r=7193;t=2701` — throttle proactively instead of reacting to `429`s. |
| Retries on any non-2xx | correct status codes: `422` for validation, `400` for resource constraints | v1 returned `500` for user errors like a bad image tag; retrying those was wasted time. |
| Correlates failures with support | `x-request-id` on every response | Paste it into a ticket. |
| Reads pod CPU/GPU utilization via GraphQL because REST had none | `pod.runtime.{uptime, gpus[], cpu, memory, ports[]}` | One API for provisioning *and* telemetry — a reason to retire the GraphQL client entirely. |
## The framing that works
Users are not migrating because v2 is newer. Two sentences that consistently land:
1. **"You can now see capacity before you commit to it."** Every retry loop, every
hardcoded GPU fallback, every "why did this fail at 3am" exists because v1 could not
answer *is this GPU available right now, in this datacenter*. v2 can, in one call.
2. **"The API tells you things instead of you inferring them."** Job URLs, legal state
transitions, worker staleness, rollout progress, rate-limit budget, request IDs — all
things codebases currently reconstruct by convention or by guessing.
reference/worked-example.md
# Worked example: a mixed v1 + GraphQL codebase
A representative "we wrote this with an agent 18 months ago" repo: a Python batch
renderer on REST v1, an ops dashboard on GraphQL, a job submitter on the serverless job
API, and one file an agent already wrote against v2 without telling anyone.
```
gpu_farm/runpod_client.py REST v1 pods + network volumes
gpu_farm/endpoints.py REST v1 templates + serverless + billing
dashboard/capacity.js GraphQL myself, gpuTypes, saveEndpoint
dashboard/provision.js GraphQL podFindAndDeployOnDemand, podStop/Resume/Terminate
scripts/submit_job.py job API ← out of scope, do not touch
ops/volumes_v2.py REST v2 ← already migrated
```
## Step 1 — inventory
```
$ python3 scripts/rp_api_inventory.py .
| Generation | Call sites | Files |
| GraphQL (legacy) | 13 | 2 |
| REST v1 (legacy) | 12 | 2 |
| v1/GraphQL field names | 71 | 4 |
| REST v2 (current) | 1 | 1 |
| Serverless job API (out of scope) | 3 | 2 |
```
The two facts the user did not know: `ops/volumes_v2.py` was already on v2, and
`scripts/submit_job.py` is a different API that must not be rewritten.
## Step 2 — pod creation, with the fallback list replaced
The `gpuTypeIds` fallback is the only removal that needs new code.
```python
# ── before (v1) ───────────────────────────────────────────────────────────
body = {
"name": name,
"imageName": image,
"cloudType": "SECURE",
"computeType": "GPU",
"gpuTypeIds": ["NVIDIA GeForce RTX 4090", "NVIDIA RTX A5000", "NVIDIA L40S"],
"gpuTypePriority": "availability", # server walked the list for us
"gpuCount": 1,
"containerDiskInGb": 60,
"volumeInGb": 100,
"volumeMountPath": "/workspace",
"dockerStartCmd": ["bash", "-lc", "python /app/render.py"],
"minRAMPerGPU": 16, "minVCPUPerGPU": 4, # no v2 equivalent
"interruptible": False, # no v2 equivalent
}
resp = SESSION.post(f"{V1_BASE}/pods", json=body)
```
```python
# ── after (v2) ────────────────────────────────────────────────────────────
body = {
"name": name,
"image": image,
"cloud": "SECURE", # computeType is implied by gpu vs cpu
"gpu": {"id": GPU_PREFERENCE[0], "count": 1},
"disk": 60,
"mounts": {"persistent": {"size": 100, "path": "/workspace"}},
"args": "bash -lc 'python /app/render.py'",
"dataCenterIds": DATA_CENTERS,
}
# v1's gpuTypePriority=availability is now ours — and we can see stock first.
stock = SESSION.get(f"{V2_BASE}/catalog/gpus", params={
"include": "AVAILABILITY", "product": "POD", "count": 1, "cloud": "SECURE",
}).json()["gpus"]
rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "NONE": 3}
levels = {g["id"]: g.get("availability", "NONE") for g in stock}
for gpu_id in sorted(GPU_PREFERENCE, key=lambda g: rank[levels.get(g, "NONE")]):
body["gpu"] = {"id": gpu_id, "count": 1}
resp = SESSION.post(f"{V2_BASE}/pods", json=body)
if resp.status_code == 201:
return resp.json()
raise RuntimeError(f"no GPU available from {GPU_PREFERENCE}")
```
The wait loop gets strictly better, because v2 can say a pod has failed:
```python
# before: only three states, so a broken pod burns the whole timeout
if pod["desiredStatus"] == "RUNNING" and pod.get("publicIp"):
return pod
# after: fail in seconds instead of ten minutes
if pod["status"] == "RUNNING":
return pod
if pod["status"] == "ERROR":
raise RuntimeError(f"pod {pod_id} entered ERROR")
```
And list filtering moves client-side — easy to miss, because forgetting it still returns
`200`:
```python
# before: server filtered
SESSION.get(f"{V1_BASE}/pods", params={"computeType": "GPU", "desiredStatus": "RUNNING"}).json()
# after: envelope + filter here
pods = SESSION.get(f"{V2_BASE}/pods").json()["pods"]
[p for p in pods if p["status"] == "RUNNING" and p.get("gpu")]
```
## Step 3 — the endpoint, where `templateId` stops meaning a link
```python
# ── before (v1): create template, reference it by id ──────────────────────
template_id = SESSION.post(f"{V1_BASE}/templates", json={
"name": name, "imageName": image, "containerDiskInGb": 20,
"isServerless": True, "dockerStartCmd": ["python", "-u", "handler.py"],
}).json()["id"]
SESSION.post(f"{V1_BASE}/endpoints", json={
"name": name, "templateId": template_id,
"gpuTypeIds": ["NVIDIA GeForce RTX 4090"], "gpuCount": 1,
"workersMin": 0, "workersMax": 5, "idleTimeout": 10,
"scalerType": "QUEUE_DELAY", "scalerValue": 4,
"executionTimeoutMs": 600000, "flashboot": True,
})
```
```python
# ── after (v2): container config is inline; GPUs are named by POOL ────────
CONTAINER = {"image": image, "disk": 20, "args": "python -u handler.py",
"env": {"MODEL_ID": "stabilityai/sdxl-turbo"}}
# "NVIDIA GeForce RTX 4090" is not a pool — resolve it, never hardcode.
gpus = SESSION.get(f"{V2_BASE}/catalog/gpus",
params={"include": "AVAILABILITY", "product": "SERVERLESS"}).json()["gpus"]
pool = next(g["pool"] for g in gpus if g["id"] == "NVIDIA GeForce RTX 4090") # -> "ADA_24"
endpoint = SESSION.post(f"{V2_BASE}/serverless", json={
**CONTAINER,
"name": name,
"type": "QUEUE", # required, new in v2
"gpu": {"pools": [pool], "count": 1},
"workers": {"min": 0, "max": 5, "idleTimeout": 10},
"scaling": {"type": "QUEUE_DELAY", "queueDelay": 4},
"timeout": 600000, # carry it: v2 does not default it
"flashboot": "FLASHBOOT", # enum, not boolean
}).json()
```
`templateId` is still a legal v2 field, so the shortest possible migration keeps it. This
example inlines the container config instead, for a reason worth stating to the user: v2
resolves a template **once**, at request time, and retains no link to it. If anything in
this codebase edited a template to roll a new image out to existing endpoints, passing
`templateId` across unchanged leaves that rollout silently doing nothing. Inlining makes
the config's real source visible in the code. See
[breaking-changes.md Class 2 §13](breaking-changes.md#13-templateid-still-works-but-the-link-is-gone).
Two deletions fall out of this file for free:
```python
# before — hand-built, and wrong the day the host changes
def endpoint_run_url(endpoint_id):
return f"https://api.runpod.ai/v2/{endpoint_id}/run"
# after
def endpoint_run_url(endpoint):
return endpoint["requestUrls"]["run"]
```
```python
# before: v1 /billing/endpoints meant serverless
SESSION.get(f"{V1_BASE}/billing/endpoints", params={"bucketSize": "month", "grouping": "endpointId"})
# after: that name now means a different product — serverless moved
SESSION.get(f"{V2_BASE}/billing/serverless", params={"bucketSize": "month", "lastN": 1}
).json()["metadata"]["totals"]["totalAmount"]
```
## Step 4 — the GraphQL dashboard, which stays partly GraphQL
```js
// stays on GraphQL — no v2 equivalent for account identity/balance
export async function accountSummary() {
return gql(`query { myself { id email currentSpendPerHr clientBalance } }`);
}
// N per-GPU lowestPrice queries -> one catalog call
export async function gpuPrices() {
const r = await fetch(`${V2}/catalog/gpus?include=AVAILABILITY&product=POD`, { headers });
return (await r.json()).gpus; // price.secure, availability, dataCenters[].availability
}
// myself { pods } -> GET /v2/pods
export async function runningPods() {
const r = await fetch(`${V2}/pods`, { headers });
return (await r.json()).pods.filter((p) => p.status === "RUNNING");
// desiredStatus -> status, costPerHr -> cost,
// runtime.uptimeInSeconds -> runtime.uptime,
// runtime.gpus[].gpuUtilPercent -> runtime.gpus[].util,
// machineId -> gone
}
```
## Step 5 — the summary the user reads
```markdown
## Required for the migration
- gpu_farm/runpod_client.py — pod create rewritten (image/disk/gpu/mounts); the
gpuTypeIds fallback became an availability-ordered loop; list filtering moved
client-side; start/stop now POST /action.
- gpu_farm/endpoints.py — container config inlined instead of templateId (still
legal, but v2 resolves it once and keeps no link); GPU named by pool ADA_24;
scaling/workers nested; flashboot is an enum; billing moved to
/billing/serverless.
- dashboard/provision.js — 4 GraphQL pod mutations → REST v2.
- dashboard/capacity.js — gpuTypes/saveEndpoint/deleteEndpoint → REST v2.
## Cleanup enabled by v2
- Deleted endpoint_run_url(): endpoints return requestUrls.run.
- Deleted 3 per-GPU lowestPrice queries: one catalog call replaces them.
- wait_until_running() now fails fast on status ERROR instead of a 10-min timeout.
## Behavior changes to watch
- flashboot: true → "FLASHBOOT". Omitting it means OFF, i.e. slower cold starts.
- timeout: v2 does not apply the documented 300000 default (observed 0) — carried
the v1 value explicitly.
- /billing/endpoints in v1 meant serverless. In v2 that is /billing/serverless;
the old path still returns 200, correctly billing public endpoints instead.
- Dropped with no v2 equivalent: minRAMPerGPU, minVCPUPerGPU, interruptible.
Pods are now on-demand only — confirm that is acceptable.
## Still on GraphQL (no v2 equivalent)
- dashboard/capacity.js accountSummary() — myself { email, clientBalance }.
## Unlocks: what you can build now
- Your renderer retries pod creation blind. /v2/catalog/gpus?include=AVAILABILITY&product=POD
returns stock per GPU per datacenter, so it can pick a GPU that exists instead of
discovering capacity by failing.
- Worker health during a rollout: /v2/serverless/{id}/workers gives a status
histogram and an isStale flag, which is the "did my deploy land" question you were
answering by eye.
```
## Untouched, deliberately
`scripts/submit_job.py` (`api.runpod.ai/v2/<id>/run`) and `ops/volumes_v2.py` (already
v2). Both appear in the inventory, neither is edited. Say this in the summary — "I did
not touch these, here is why" is what stops the user re-opening the migration later.
scripts/rp_api_inventory.py
#!/usr/bin/env python3
"""Inventory a codebase by Runpod API version.
Scans a directory tree and reports every place it talks to Runpod, tagged with
which API generation it uses: GraphQL, REST v1, REST v2, the serverless *job*
API (not part of this migration), or an SDK/CLI that wraps one of them.
Standard library only, no install step:
python3 rp_api_inventory.py . # markdown report
python3 rp_api_inventory.py . --json # machine-readable
python3 rp_api_inventory.py . --scope rest # ignore GraphQL call sites
python3 rp_api_inventory.py . --fail-on-legacy # exit 1 if v1/GraphQL remain
Exit codes: 0 clean, 1 legacy usage found with --fail-on-legacy, 2 bad usage.
Two markers keep a hit out of the plan and out of --fail-on-legacy. They mean
opposite things and the report keeps them apart, so pick the accurate one:
# rp-migrate: keep-v1 legacy left behind on purpose — a RUNPOD_API_V1
rollback path, or a GraphQL call with no v2 equivalent
# rp-migrate: ignore a false positive on code that is already correct
Each takes three scopes:
# rp-migrate: <marker> this line
# rp-migrate: <marker> start / end the region between them
# rp-migrate: <marker> file the whole file
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from collections import Counter, defaultdict
# --------------------------------------------------------------------------
# Signal table
#
# Each signal is (generation, resource, regex, note) or
# (generation, resource, regex, note, unless_regex). `generation` drives the
# inventory buckets; `note` is what the report tells the migrating agent to do;
# `unless_regex` suppresses the hit when it also matches the line — needed
# because several names are legal in *both* versions (`/pods` is a v1 path and a
# v2 path; `idleTimeout` is top-level in v1 and nested under `workers` in v2).
# Ordering matters only for readability — every pattern is tested on every line.
# --------------------------------------------------------------------------
# A line that already carries a v2 marker is v2 code, whatever else it contains.
# Structural markers only — do NOT list guessed variable names here. Real code holds
# the base URL in a constant whose name we cannot predict; those are derived per file
# by BASE_ASSIGN below, which is honest where a hardcoded name list is wishful.
V2_CONTEXT = r"/v2/|api\.runpod\.io/v2"
# Resource names that are also ordinary code identifiers (`networkvolumes`,
# `containerregistryauth`) match inside package declarations, imports and module
# paths, which are not call sites. `github.com/acme/app/internal/networkvolumes`
# even carries the leading `/` that otherwise distinguishes a real path. Suppress
# the whole line in those positions rather than trying to spot the name itself:
# a language keyword at line start, or a VCS-style module path anywhere.
DECLARATION = (
r"^\s*(package|import|from|use|require|mod|namespace|using)\b"
r"|\b(github|gitlab|bitbucket|golang\.org|gopkg\.in)\.?[a-z]*/"
)
# `IDENT = "...api.runpod.io/v2..."` — captures whatever the file calls its base URL,
# so `f"{BASE}/pods"` eleven lines later is recognized as v2 rather than reported as a
# leftover v1 path. Without this, a correctly migrated file fails --fail-on-legacy.
BASE_ASSIGN_V2 = re.compile(r"""(\w+)\s*[:=]\s*[fr]?['"`][^'"`]*api\.runpod\.io/v2""")
BASE_ASSIGN_V1 = re.compile(r"""(\w+)\s*[:=]\s*[fr]?['"`][^'"`]*rest\.runpod\.io/v1""")
JOB_API = "job-api" # api.runpod.ai/v2/<endpointId>/run — NOT the control plane
SIGNALS: list[tuple[str, str, str, str]] = [
# ---- serverless job API: looks like "v2" but is a different API ---------
(JOB_API, "job", r"api\.runpod\.ai/v2/", "Serverless job API — unaffected by the control-plane migration. Leave it alone."),
(JOB_API, "job", r"\brunpod\.Endpoint\s*\(", "Python SDK job client (`runpod.Endpoint`) — job API, not the control plane. Leave it alone."),
# ---- GraphQL -----------------------------------------------------------
("graphql", "endpoint", r"api\.runpod\.io/graphql", "GraphQL endpoint. → REST v2 https://api.runpod.io/v2"),
("graphql", "pod", r"\bpodFindAndDeployOnDemand\b", "→ POST /v2/pods"),
("graphql", "pod", r"\bpodRentInterruptable\b", "Spot/interruptible pods have no REST v2 equivalent. Keep on GraphQL or move to on-demand."),
("graphql", "pod", r"\bpodBidResume\b", "Spot/interruptible pods have no REST v2 equivalent. Keep on GraphQL or move to on-demand."),
("graphql", "pod", r"\bpodResume\b", "→ POST /v2/pods/{id}/action {\"action\":\"start\"}"),
("graphql", "pod", r"\bpodStop\b", "→ POST /v2/pods/{id}/action {\"action\":\"stop\"}"),
("graphql", "pod", r"\bpodTerminate\b", "→ DELETE /v2/pods/{id}"),
("graphql", "pod", r"\bpodEditJob\b", "→ PATCH /v2/pods/{id}"),
("graphql", "pod", r"\bpod\s*\(\s*input\s*:", "→ GET /v2/pods/{id}"),
("graphql", "serverless", r"\bsaveEndpoint\b", "→ POST /v2/serverless (create) or PATCH /v2/serverless/{id} (update)"),
("graphql", "serverless", r"\bdeleteEndpoint\b", "→ DELETE /v2/serverless/{id}"),
("graphql", "template", r"\bsaveTemplate\b", "→ POST /v2/templates or PATCH /v2/templates/{id}"),
("graphql", "template", r"\bdeleteTemplate\b", "→ DELETE /v2/templates/{id} — note v2 deletes by ID, GraphQL deleted by NAME."),
("graphql", "catalog", r"\bgpuTypes\b", "→ GET /v2/catalog/gpus?include=AVAILABILITY&product=POD (product is REQUIRED with include; 400 without it. Use product=SERVERLESS when picking a GPU for an endpoint.)"),
("graphql", "catalog", r"\bcpuTypes\b", "→ GET /v2/catalog/cpus?include=AVAILABILITY&product=POD (product is REQUIRED with include; 400 without it)"),
("graphql", "catalog", r"\blowestPrice\s*\(", "→ GET /v2/catalog/gpus?include=AVAILABILITY&product=POD (price + availability in one call; product is REQUIRED with include, 400 without it)"),
("graphql", "volume", r"\b(createNetworkVolume|updateNetworkVolume|deleteNetworkVolume)\b", "→ /v2/network-volumes"),
("graphql", "registry", r"\bsaveRegistryAuth\b", "→ POST /v2/registries"),
("graphql", "user", r"\bmyself\s*\{[^}]*\b(pods|endpoints)\b", "`myself { pods }` → GET /v2/pods; `myself { endpoints }` → GET /v2/serverless"),
("graphql", "user", r"\bmyself\b", "Account fields (email, clientBalance, currentSpendPerHr) have no REST v2 equivalent — keep this GraphQL call. If you only use `myself` to reach `pods`/`endpoints`, use GET /v2/pods and GET /v2/serverless instead."),
("graphql", "secret", r"\bsecret(Create|Delete)\b", "No REST v2 equivalent. Keep this GraphQL call."),
("graphql", "cluster", r"\b(createCluster|deleteCluster)\b", "No REST v2 write equivalent (v2 exposes cluster billing only). Keep this GraphQL call."),
# ---- REST v1 -----------------------------------------------------------
("v1", "base", r"rest\.runpod\.io/v1", "REST v1 base URL. → https://api.runpod.io/v2"),
# Require a real path separator: `["pods"]` is v2 envelope-unwrapping, the
# opposite of a v1 call site.
("v1", "pod", r"/pods\b(?!/[a-z]*\{)", "v1 /pods → /v2/pods (response is now {\"pods\": [...]}, not a bare array)", V2_CONTEXT),
("v1", "serverless", r"/endpoints\b", "v1 /endpoints → /v2/serverless", V2_CONTEXT),
# Both of these need a leading `/`, for the same reason `/pods` does. `networkvolumes`
# and `containerregistryauth` are ordinary identifiers: Go packages, Python modules,
# import paths, directory names. Matching the bare token flagged every one of them,
# which made `--fail-on-legacy` fail on codebases that were already fully v2 — the one
# thing that flag must never do. V2_CONTEXT then suppresses `/v2/...` lines, so the
# correct hyphenated path and `/v2/billing/networkvolumes` do not report as v1.
("v1", "volume", r"/networkvolumes\b", "v1 /networkvolumes → /v2/network-volumes (hyphenated)", V2_CONTEXT + r"|network-volumes|" + DECLARATION),
("v1", "registry", r"/containerregistryauth\b", "v1 /containerregistryauth → /v2/registries", V2_CONTEXT + r"|" + DECLARATION),
("v1", "pod", r"/pods/[^'\"`\s]*/(start|stop|restart|reset)\b", "→ POST /v2/pods/{id}/action with {\"action\": \"start|stop|restart\"}. v1 `reset` has no direct v2 action.", V2_CONTEXT),
("v1", "any", r"/(pods|endpoints|templates|networkvolumes)/[^'\"`\s]*/update\b", "v1 POST .../update alias is gone. Use PATCH on the resource.", V2_CONTEXT),
("v1", "billing", r"/billing/(pods|endpoints|networkvolumes)\b", "→ /v2/billing/{pods,serverless,endpoints,network-volumes} — note v1 /billing/endpoints (serverless) is v2 /billing/serverless, and v2 /billing/network-volumes is hyphenated.", V2_CONTEXT),
# ---- v1/GraphQL request-body field names ------------------------------
("v1-field", "pod", r"\bimageName\b", "→ `image`"),
("v1-field", "pod", r"\bcontainerDiskInGb\b", "→ `disk`"),
("v1-field", "pod", r"\bvolumeInGb\b", "→ `mounts.persistent.size`"),
("v1-field", "pod", r"\bvolumeMountPath\b", "→ `mounts.persistent.path` (or `mounts.network[0].path`)"),
("v1-field", "pod", r"\bnetworkVolumeId\b", "→ `mounts.network[0].volumeId` + an explicit `path` (v2 has no default mount path)"),
("v1-field", "pod", r"\bgpuTypeIds?\b", "→ `gpu.id` (pods, single type) or `gpu.pools` (serverless, pool IDs). v2 takes no fallback list — see breaking-changes.md."),
("v1-field", "pod", r"\bgpuCount\b", "→ `gpu.count`"),
("v1-field", "pod", r"\bcloudType\b", "→ `cloud`"),
("v1-field", "pod", r"\bcontainerRegistryAuthId\b", "→ `registry`"),
("v1-field", "pod", r"\bdocker(StartCmd|Args|Entrypoint)\b", "→ `args` (a single string). v2 has no separate entrypoint override."),
("v1-field", "pod", r"\bdesiredStatus\b", "→ `status` (enum gained PROVISIONING, STARTING, ERROR)"),
("v1-field", "pod", r"\bcostPerHr\b", "→ `cost`"),
("v1-field", "pod", r"\b(cpuFlavorIds|vcpuCount)\b", "→ `cpu.id` / `cpu.vcpuCount`"),
("v1-field", "pod", r"\b(gpuTypePriority|dataCenterPriority|cpuFlavorPriority)\b", "Removed in v2. Order/fallback is now client-side — see breaking-changes.md."),
("v1-field", "pod", r"\b(minRAMPerGPU|minVCPUPerGPU|minDownloadMbps|minUploadMbps|minDiskBandwidthMBps|supportPublicIp|volumeEncrypted|interruptible)\b", "Removed in v2 — no equivalent. Drop it or stay on v1/GraphQL for this call."),
("v1-field", "pod", r"\bcountryCodes\b", "No create-time equivalent, but there IS a migration: filter /v2/catalog/gpus?include=AVAILABILITY&product=POD&countryCodes=.. to get the matching data centers, then pass their IDs as dataCenterIds on create. dataCenterIds is enforced (verified 2026-08-18) despite the spec calling it `preferred`, so it is a sound basis for data residency. Over-narrow it and the create fails 400 `no instances available`, with no mention of data centers. Working code: breaking-changes.md -> Replacing countryCodes."),
("v1-field", "pod", r"\b(allowedCudaVersions|minCudaVersion)\b", "Moved, not removed → `gpu.allowedCudaVersions` / `gpu.minCudaVersion` on pod and endpoint create. Nested under `gpu` so they are unrepresentable on a CPU workload; left at the top level they 422. A non-empty allowedCudaVersions and minCudaVersion are mutually exclusive (400).", r"\bgpu\s*[.\[]|[\"']gpu[\"']\s*:"),
("v1-field", "serverless", r"\bworkers(Min|Max)\b", "→ `workers.min` / `workers.max`"),
("v1-field", "serverless", r"\bidleTimeout\b", "→ `workers.idleTimeout`", r"\bworkers\b"),
("v1-field", "serverless", r"\bscaler(Type|Value)\b", "→ `scaling.type` + `scaling.queueDelay` | `scaling.requestCount`"),
("v1-field", "serverless", r"\bexecutionTimeoutMs\b", "→ `timeout` (still milliseconds)"),
("v1-field", "serverless", r"\bflash[Bb]oot(Type)?\b", "→ `flashboot`, now the enum OFF | FLASHBOOT | PRIORITY_FLASHBOOT (was a boolean in v1)", r"[\"'](OFF|FLASHBOOT|PRIORITY_FLASHBOOT)[\"']"),
("v1-field", "serverless", r"\btemplateId\b", "Still accepted on v2 create and update, but resolved once at request time with no link retained — later template edits no longer reach the resource. If this code edits templates to roll out changes, that rollout silently stops working; inline the container fields instead. See breaking-changes.md Class 2."),
("v1-field", "serverless", r"\blocations\b", "GraphQL `locations` string → `dataCenterIds` array"),
("v1-field", "serverless", r"\bgpuIds\b", "GraphQL `gpuIds` → `gpu.pools` (array of pool IDs)"),
("v1-field", "template", r"\bis(Serverless|Public)\b", "→ `serverless` / `public`"),
("v1-field", "volume", r"\bdataCenterId\b", "On network volumes: → `dataCenter`. On pods it stays `dataCenterId`."),
("v1-field", "pod", r"\b(machineId|podHostId)\b", "Removed in v2. Host identity is no longer exposed; `dataCenterId` is the placement field that remains."),
("v1-field", "pod", r"\bmachine\s*[({]", "The v1/GraphQL `machine` object is gone in v2. Only `dataCenterId` survives."),
("v1-field", "pod", r"\buptimeInSeconds\b", "→ `runtime.uptime` (still seconds)"),
("v1-field", "pod", r"\bgpuUtilPercent\b", "→ `runtime.gpus[].util`"),
("v1-field", "pod", r"\bmemoryUtilPercent\b", "→ `runtime.gpus[].memoryUtil`"),
("v1-field", "pod", r"\b(cpuPercent|memoryPercent)\b", "→ `runtime.cpu.util` / `runtime.memory.util`"),
("v1-field", "pod", r"\b(publicIp|portMappings)\b", "→ `runtime.ports[]` (`{private, public, type, ip}`), populated only while RUNNING."),
("v1-field", "pod", r"\b(minVcpuCount|minMemoryInGb)\b", "Removed in v2 — no equivalent. GPU pods size RAM/vCPU from the GPU type."),
("v1-field", "pod", r"cloudType\s*:\s*ALL\b", "v2 `cloud` has no ALL. Pick SECURE or COMMUNITY, or try one then the other."),
("v1-field", "catalog", r"\bstockStatus\b", "→ `availability` (NONE | LOW | MEDIUM | HIGH), plus per-datacenter `dataCenters[].availability`."),
("v1-field", "catalog", r"\b(uninterruptablePrice|memoryInGb|displayName|secureCloud|communityCloud)\b", "Catalog field renames: → `price.secure` / `memory` / `name` / `secure` / `community`."),
("v1-field", "any", r"\benv\s*:\s*\[\s*\{", "GraphQL `env: [{key, value}]` → v2 `env` is a plain string map: `{\"KEY\": \"value\"}`."),
# ---- already on v2 -----------------------------------------------------
("v2", "base", r"api\.runpod\.io/v2\b", "Already on REST v2."),
("v2", "volume", r"/v2/network-volumes\b", "Already on REST v2."),
("v2", "catalog", r"/v2/catalog/", "Already on REST v2."),
("v2", "pod", r"/v2/pods\b", "Already on REST v2."),
("v2", "serverless", r"/v2/serverless\b", "Already on REST v2."),
# ---- wrappers ----------------------------------------------------------
("sdk", "python", r"^\s*import\s+runpod\b|^\s*from\s+runpod\b", "Python `runpod` SDK — wraps GraphQL/v1 internally. Check the SDK version before assuming a generation."),
("sdk", "python", r"\brunpod\.(create_pod|stop_pod|resume_pod|terminate_pod|get_pods|get_pod|get_gpus?|create_template|create_endpoint|update_endpoint_template)\b", "Python SDK control-plane call — wraps GraphQL. Replace with a v2 REST call to migrate."),
("sdk", "js", r"require\(['\"]runpod-sdk['\"]\)|from\s+['\"]runpod-sdk['\"]", "JS `runpod-sdk` — job API oriented; check what it is used for."),
("cli", "runpodctl", r"\brunpodctl\s+\w", "runpodctl already speaks the current API — nothing to migrate, but check pinned versions."),
("mcp", "mcp", r"@runpod/mcp-server|mcp\.getrunpod\.io", "Runpod MCP server — follows its own REST version (see serverInfo.version). Nothing to migrate."),
# ---- indirect: resource names as bare strings --------------------------
# A helper like `_url("pods", pod_id, "stop")` builds a v1 path with no literal
# path anywhere. Advisory only — `resp.json()["pods"]` looks identical and is v2
# envelope-unwrapping — so these are reported for review, never auto-planned.
("indirect", "any", r"[\"'](pods|endpoints|templates|networkvolumes|containerregistryauth)[\"']",
"Resource name used as a bare string. If a helper joins it onto a base URL, it is a hidden call site — check how the path is built.", V2_CONTEXT),
]
COMPILED = [
(s[0], s[1], re.compile(s[2]), s[3],
re.compile(s[4]) if len(s) > 4 else None,
len(s) > 4 and s[4] == V2_CONTEXT) # does this signal defer to v2 context?
for s in SIGNALS
]
def comment_index(line: str) -> int:
"""Index where a trailing comment starts, or -1. Quote-aware, so a `#` or `//`
inside a string literal (a URL fragment, say) is not mistaken for a comment.
Needed because the skill's own house style annotates migrations inline —
`"image": image, # was imageName` — and a whole-line-only comment test scores
that as a live v1 field, which makes --fail-on-legacy fail on correct code."""
quote = None
i = 0
while i < len(line):
c = line[i]
if quote:
if c == "\\":
i += 2
continue
if c == quote:
quote = None
elif c in "\"'`":
quote = c
elif c == "#":
return i
elif c == "/" and line[i + 1:i + 2] == "/" and line[i - 1:i] != ":":
return i # `//` comment, but never the `//` in `https://`
elif c == "-" and line[i + 1:i + 2] == "-" and line[:i].strip() == "":
return i
i += 1
return -1
# Lines that are prose about the API rather than calls to it — comments and docs.
# Still reported (commented-out v1 code is worth seeing) but kept out of the plan.
COMMENT_START = re.compile(r"^\s*(#|//|\*|--|<!--)")
TRIPLE_QUOTE = re.compile(r'"""' + "|'''")
PROSE_SUFFIXES = {".md", ".mdx", ".rst", ".txt", ".adoc"}
# Cheap whole-file gate so we only line-scan files that mention Runpod at all.
#
# DERIVED from SIGNALS on purpose — a hand-written gate drifts out of sync with the
# signal table and then silently skips whole files. The case that motivated this: a
# module that reads `p["costPerHr"]` off a wrapper's return value contains no Runpod
# URL, no operation name, and no import — nothing but a renamed response field. That
# is exactly the file a v2 migration breaks quietly, and a stale gate never opened it.
PREFILTER = re.compile("|".join(f"(?:{s[2]})" for s in SIGNALS))
def md_cell(s: str) -> str:
"""Escape a value for a GitHub-flavored markdown table cell."""
return s.replace("|", "\\|").replace("\n", " ")
# Suppression markers. Both keep a hit out of the plan and out of --fail-on-legacy,
# but they say opposite things and the report must not conflate them:
#
# keep-v1 legacy code left behind on purpose — a `RUNPOD_API_V1=1` rollback path,
# or a GraphQL-only call (myself/secrets/spot) with no v2 equivalent.
# ignore not legacy at all — a false positive on code that is already correct.
#
# Reporting an `ignore` as `keep-v1` would claim the migration deliberately left v1
# behind when it left none, which is the exact lie the skill warns against.
#
# Each accepts three scopes:
# rp-migrate: <marker> file anywhere in a file -> whole file
# rp-migrate: <marker> start / end bracket a region
# rp-migrate: <marker> the matching line
MARKERS = ("keep-v1", "ignore")
MARK_FILE = {m: re.compile(rf"rp-migrate:\s*{m}\s+file") for m in MARKERS}
MARK_START = {m: re.compile(rf"rp-migrate:\s*{m}\s+start") for m in MARKERS}
MARK_END = {m: re.compile(rf"rp-migrate:\s*{m}\s+end") for m in MARKERS}
MARK_LINE = {m: re.compile(rf"rp-migrate:\s*{m}\b(?!\s+(file|start|end))") for m in MARKERS}
def intentional_lines(text: str) -> tuple[str | None, dict[int, str]]:
"""
Return (whole_file_marker, {line number: marker}).
`keep-v1` wins over `ignore` when both cover the same line, because claiming
legacy was kept on purpose is the safer error: it leaves the hit visible as
legacy rather than dismissing it as a false positive.
"""
for marker in MARKERS:
if MARK_FILE[marker].search(text):
return marker, {}
marked: dict[int, str] = {}
inside: set[str] = set()
for lineno, line in enumerate(text.splitlines(), 1):
for marker in MARKERS:
if MARK_START[marker].search(line):
inside.add(marker)
for marker in MARKERS:
if marker in inside or MARK_LINE[marker].search(line):
# MARKERS is ordered keep-v1 first, so it wins a tie.
marked.setdefault(lineno, marker)
for marker in MARKERS:
if MARK_END[marker].search(line):
inside.discard(marker)
return None, marked
# Directories whose contents are not the user's source. `.claude` matters more than it
# looks: agent worktrees under `.claude/worktrees/` are full copies of the repo, so
# scanning one repo with six worktrees reports every finding six or seven times. This
# skill ships as a Claude Code plugin, which makes its users exactly the people who have
# that directory. Measured on one real repo: 10,763 hits reported, 1,821 real.
SKIP_DIRS = {
".git", "node_modules", ".venv", "venv", "env", "__pycache__", "dist", "build",
".next", ".nuxt", "target", "vendor", ".terraform", ".mypy_cache", ".pytest_cache",
".tox", "site-packages", ".gradle", "coverage", ".idea", ".vscode",
".claude", ".worktrees", ".cache", ".ruff_cache", ".pnpm-store", "bower_components",
}
SKIP_SUFFIXES = {
".lock", ".min.js", ".map", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
".pdf", ".zip", ".gz", ".tar", ".whl", ".so", ".dylib", ".bin", ".pt", ".pth",
".safetensors", ".onnx", ".parquet", ".pyc",
}
# Text formats worth scanning. Anything else is sniffed for NUL bytes instead.
MAX_BYTES = 2_000_000
LEGACY = {"graphql", "v1", "v1-field"}
def iter_files(root: str):
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".git"))
for fn in sorted(filenames):
if any(fn.endswith(s) for s in SKIP_SUFFIXES):
continue
path = os.path.join(dirpath, fn)
try:
if os.path.getsize(path) > MAX_BYTES:
continue
except OSError:
continue
yield path
def scan_file(path: str, root: str):
try:
with open(path, "rb") as fh:
raw = fh.read()
except OSError:
return []
if b"\0" in raw[:4096]:
return []
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return []
if not PREFILTER.search(text):
return []
rel = os.path.relpath(path, root)
whole_file_marker, marked = intentional_lines(text)
ext = os.path.splitext(path)[1].lower()
is_doc = ext in PROSE_SUFFIXES
is_py = ext in {".py", ".pyi"}
in_docstring = False
# Whatever this file calls its v2 base URL counts as v2 context anywhere in it.
# A name bound to a v1 base URL is deliberately NOT added — those lines are v1.
v2_names = set(BASE_ASSIGN_V2.findall(text)) - set(BASE_ASSIGN_V1.findall(text))
file_v2_ctx = (
re.compile(r"\b(" + "|".join(re.escape(n) for n in sorted(v2_names)) + r")\b")
if v2_names else None
)
hits = []
for lineno, line in enumerate(text.splitlines(), 1):
if len(line) > 2000:
line = line[:2000]
docstring_line = in_docstring
if is_py:
quotes = len(TRIPLE_QUOTE.findall(line))
if quotes:
docstring_line = True # the delimiter line itself is prose
if quotes % 2: # odd count flips the state
in_docstring = not in_docstring
cmt = comment_index(line)
whole_line_comment = is_doc or docstring_line or bool(COMMENT_START.match(line))
for gen, res, rx, note, unless, defers_to_v2 in COMPILED:
m = rx.search(line)
if not m:
continue
if unless and unless.search(line):
continue
if defers_to_v2 and file_v2_ctx and file_v2_ctx.search(line):
continue # e.g. f"{BASE}/pods" where BASE is this file's v2 base URL
# A hit after a `#` / `//` is an annotation about the migration, not a
# call site — report it, but keep it out of the plan.
prose = whole_line_comment or (cmt != -1 and m.start() > cmt)
hits.append({
"prose": prose,
"file": rel,
"line": lineno,
"generation": gen,
"resource": res,
"match": m.group(0)[:80],
"note": note,
"text": line.strip()[:200],
# Which marker, not just whether one was present: the two mean
# opposite things and the report keeps them apart.
"marker": whole_file_marker or marked.get(lineno),
})
return hits
def dedupe(hits):
"""Collapse identical (file, line, generation) rows, keeping every note."""
grouped = {}
for h in hits:
key = (h["file"], h["line"], h["generation"])
if key in grouped:
if h["note"] not in grouped[key]["notes"]:
grouped[key]["notes"].append(h["note"])
grouped[key]["matches"].append(h["match"])
else:
g = dict(h)
g["notes"] = [h.pop("note")]
g["matches"] = [h["match"]]
g.pop("note", None)
grouped[key] = g
return sorted(grouped.values(), key=lambda h: (h["file"], h["line"]))
def render_markdown(hits, root, scope):
by_gen = defaultdict(list)
for h in hits:
by_gen[h["generation"]].append(h)
files_by_gen = {g: sorted({h["file"] for h in v}) for g, v in by_gen.items()}
out = []
out.append(f"# Runpod API inventory — `{root}`\n")
order = ["graphql", "v1", "v1-field", "indirect", "v2", JOB_API, "sdk", "cli", "mcp"]
label = {
"graphql": "GraphQL (legacy)",
"v1": "REST v1 (legacy)",
"v1-field": "v1/GraphQL field names",
"indirect": "Possible indirect call sites (review by hand)",
"v2": "REST v2 (current)",
JOB_API: "Serverless job API (out of scope)",
"sdk": "SDK wrapper",
"cli": "runpodctl",
"mcp": "Runpod MCP",
}
out.append("| Generation | Call sites | Files |")
out.append("| --- | --- | --- |")
for gen in order:
if gen in by_gen:
out.append(f"| {label[gen]} | {len(by_gen[gen])} | {len(files_by_gen[gen])} |")
if not hits:
out.append("| _no Runpod API usage found_ | 0 | 0 |")
out.append("")
legacy_files = sorted({h["file"] for h in hits
if h["generation"] in LEGACY and not h["marker"] and not h["prose"]})
kept = [h for h in hits if h["generation"] in LEGACY and h["marker"] == "keep-v1"]
ignored = [h for h in hits if h["generation"] in LEGACY and h["marker"] == "ignore"]
prose_hits = [h for h in hits if h["generation"] in LEGACY and h["prose"] and not h["marker"]]
v2_files = sorted(files_by_gen.get("v2", []))
mixed = sorted(set(legacy_files) & set(v2_files))
out.append("## Verdict\n")
if not legacy_files:
out.append("- **Nothing to migrate.** No REST v1 or GraphQL control-plane calls found.")
else:
out.append(f"- **{len(legacy_files)} file(s) need migration.**")
if prose_hits:
out.append(
f"- **{len(prose_hits)} legacy mention(s) are in comments or docs**, not live call "
"sites. Reported below, excluded from the plan — but check for commented-out v1 code."
)
if kept:
out.append(
f"- **{len(kept)} legacy call site(s) are kept on purpose** "
f"(`rp-migrate: keep-v1`) in {len({h['file'] for h in kept})} file(s) — "
"rollback paths or GraphQL-only calls. Excluded from the plan and from `--fail-on-legacy`."
)
if ignored:
out.append(
f"- **{len(ignored)} hit(s) are marked false positives** "
f"(`rp-migrate: ignore`) in {len({h['file'] for h in ignored})} file(s) — "
"code that is already correct, not legacy being retained. Excluded from the plan "
"and from `--fail-on-legacy`."
)
if v2_files:
out.append(f"- **{len(v2_files)} file(s) are already on REST v2** — leave them alone.")
if mixed:
out.append(f"- **{len(mixed)} file(s) mix generations**: {', '.join(f'`{m}`' for m in mixed)}")
if JOB_API in by_gen:
out.append(
f"- **{len(files_by_gen[JOB_API])} file(s) call the serverless *job* API** "
"(`api.runpod.ai/v2/<endpointId>/run…`). That is a different API from the control "
"plane and is **not** part of this migration — do not rewrite it."
)
if scope == "rest":
out.append("- Scope is `rest`: GraphQL call sites are reported but excluded from the migration plan.")
out.append("")
for gen in order:
rows = by_gen.get(gen)
if not rows:
continue
out.append(f"## {label[gen]}\n")
out.append("| Location | Match | Action |")
out.append("| --- | --- | --- |")
for h in rows:
notes = md_cell("<br>".join(h["notes"]))
if h["marker"] == "keep-v1":
keep = " _(kept on purpose)_"
elif h["marker"] == "ignore":
keep = " _(false positive)_"
else:
keep = " _(comment/doc)_" if h["prose"] else ""
out.append(f"| `{h['file']}:{h['line']}`{keep} | `{md_cell(h['matches'][0])}` | {notes} |")
out.append("")
if legacy_files:
out.append("## Migration order\n")
counts = Counter(h["file"] for h in hits
if h["generation"] in LEGACY and not h["marker"] and not h["prose"])
out.append("Fewest call sites first — each file is one reviewable commit.\n")
for f, n in sorted(counts.items(), key=lambda kv: (kv[1], kv[0])):
out.append(f"1. `{f}` — {n} call site(s)")
out.append("")
return "\n".join(out)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("path", nargs="?", default=".", help="directory (or file) to scan")
ap.add_argument("--json", action="store_true", help="emit JSON instead of markdown")
ap.add_argument("--scope", choices=["all", "rest", "graphql"], default="all",
help="all (default) | rest: only migrate REST v1 | graphql: only migrate GraphQL")
ap.add_argument("--fail-on-legacy", action="store_true", help="exit 1 when v1/GraphQL usage remains")
args = ap.parse_args(argv)
root = os.path.abspath(args.path)
if not os.path.exists(root):
print(f"error: no such path: {args.path}", file=sys.stderr)
return 2
hits = []
if os.path.isfile(root):
base = os.path.dirname(root)
hits = scan_file(root, base)
root = base
else:
for path in iter_files(root):
hits.extend(scan_file(path, root))
hits = dedupe(hits)
scoped = {"rest": {"v1", "v1-field"}, "graphql": {"graphql"}}.get(args.scope, LEGACY)
in_plan = [h for h in hits if h["generation"] in scoped and not h["marker"] and not h["prose"]]
if args.json:
print(json.dumps({
"root": root,
"scope": args.scope,
"counts": dict(Counter(h["generation"] for h in hits)),
"files_needing_migration": sorted({h["file"] for h in in_plan}),
"kept_on_purpose": sorted({h["file"] for h in hits
if h["marker"] == "keep-v1" and h["generation"] in LEGACY}),
"marked_false_positive": sorted({h["file"] for h in hits
if h["marker"] == "ignore" and h["generation"] in LEGACY}),
"prose_only": sorted({h["file"] for h in hits if h["prose"] and h["generation"] in LEGACY}),
"already_v2": sorted({h["file"] for h in hits if h["generation"] == "v2"}),
"job_api_leave_alone": sorted({h["file"] for h in hits if h["generation"] == JOB_API}),
"hits": hits,
}, indent=2))
else:
print(render_markdown(hits, root, args.scope))
if args.fail_on_legacy and in_plan:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
SKILL.md
---
name: runpod-migrate
description: >-
Migrate a codebase from the Runpod GraphQL API or REST v1 to REST v2 — inventory
which parts use which API version, rewrite the call sites, flag breaking changes,
and verify. Use when someone asks to move to v2, asks what v2 would change, or
asks which Runpod API their code is on. For managing infrastructure rather than
migrating code, use runpod-mcp or runpodctl.
user-invocable: true
allowed-tools: Bash(python3:*), Bash(curl:*), Bash(rg:*), Bash(git:*)
compatibility: Linux, macOS, Windows
metadata:
author: runpod
version: "1.2.0" # x-release-please-version
license: Apache-2.0
---
# Migrate to Runpod REST v2
Moves a codebase off the **GraphQL API** (`api.runpod.io/graphql`) and **REST v1**
(`rest.runpod.io/v1`) onto **REST v2** (`api.runpod.io/v2`).
**The payoff, in one line each** — you deliver these to the user at step 6, matched to
their actual code. Don't recite them now:
- **See stock before you rent** — `GET /v2/catalog/gpus?include=AVAILABILITY&product=POD`. v1 had no
catalog at all, so every capacity retry loop was blind.
- **Endpoints return their own job URLs** — `requestUrls.run`, no more string-building.
- **Real lifecycle states** — `PROVISIONING`/`STARTING`/`ERROR` and an `actions` list, so
wait-loops fail fast instead of timing out.
- **Mistakes fail loudly** — unknown request fields are rejected by name, with structured
errors and honest status codes.
The full set, organized as *if their code does X → v2 offers Y*:
**[reference/unlocks.md](reference/unlocks.md)** — open it at step 6.
## Before you touch any code
**Infer the scope, state it, and move** — do not open with a questionnaire:
| The user says | Scope |
| --- | --- |
| "migrate to v2" / nothing specific | `all` — REST v1 **and** GraphQL |
| "just the REST stuff", "leave GraphQL alone" | `rest` — REST v1 only |
| "get us off GraphQL" | `graphql` — GraphQL only |
The table resolves every phrasing, so scope is not the thing to interrupt for. Say which
row you matched and carry on. **The question that does need asking comes later** — at
step 3, when the inventory shows the code depends on a capability v2 removed. That one
is a real fork and you cannot answer it for them.
Some things **have no v2 equivalent and must stay on GraphQL regardless of scope**:
account/billing identity (`myself`), secrets, spot/interruptible pods, cluster
create/delete. A "full" migration still leaves those calls in place — say so up front
rather than letting the user discover it at the end.
**Never rewrite the serverless job API.** `https://api.runpod.ai/v2/<endpointId>/run`,
`/runsync`, `/status`, `/stream`, `/cancel` is a *different API* that happens to have
`v2` in its path. It is unchanged and out of scope. The inventory reports it separately
so you do not touch it.
## The workflow
### 1. Inventory — never migrate what you have not counted
The scanner ships **beside this file**, in the installed skill directory — not in the
user's repo. Resolve its path first; your working directory is their project:
```bash
# 1. Claude Code plugin installs expose the plugin root:
SCAN="$CLAUDE_PLUGIN_ROOT/skills/runpod-migrate/scripts/rp_api_inventory.py"
# 2. Otherwise substitute the directory you loaded this SKILL.md from — you know it:
[ -f "$SCAN" ] || SCAN="<directory containing this SKILL.md>/scripts/rp_api_inventory.py"
# 3. Last resort, search the usual install roots:
[ -f "$SCAN" ] || SCAN=$(find ~/.claude ~/.agents ~/.codex ~/.config -name rp_api_inventory.py 2>/dev/null | head -1)
python3 "$SCAN" --help >/dev/null || echo "scanner not found — resolve it before continuing"
```
Then, from the root of the user's repo:
```bash
python3 "$SCAN" . > runpod-api-inventory.md
python3 "$SCAN" . --json > runpod-api-inventory.json # if you want to drive edits from it
python3 "$SCAN" . --scope rest # REST-only migrations
```
`runpod-api-inventory.md` lands in the user's repo — mention it, and remove it or
gitignore it before you hand the migration back.
Stdlib-only Python, no install. It reports every call site bucketed by generation —
GraphQL, REST v1, v1/GraphQL **field names**, REST v2 **already**, serverless job API,
SDK/CLI wrappers — plus a suggested file-by-file order.
**Show the user the inventory table before editing anything.** Users routinely do not
know what they are on: an agent picked a version for them months ago and wrote it down
nowhere. "3 files on v1, 2 on GraphQL, 1 already on v2, 2 on the job API — leave those
alone" is often the single most useful output of this whole skill.
#### What it detects, and what it cannot
It is regex line-scanning, but the classification is what makes it usable — plain
`grep -r runpod` gets two things actively wrong:
- **`api.runpod.ai/v2` vs `api.runpod.io/v2`.** One letter apart. `.ai` is the serverless
job API and must not be touched; `.io` is the control plane you are migrating to.
Grepping for `v2` tells you the codebase is "already migrated" when it is not.
- **Names legal in both versions.** `/pods` is a v1 path *and* a v2 path; `["pods"]` is
v2 envelope-unwrapping; `idleTimeout` is top-level in v1 and nested under `workers` in
v2. The scanner suppresses a hit when the same line carries v2 context, so it reports
work that remains rather than every occurrence of a word.
It also looks for **field names, not just URLs**, which is what catches the files that
never spell "runpod": a module reading `p["costPerHr"]` off a wrapper's return value has
no URL, no import, no operation name — and is exactly what a v2 rename breaks silently.
Four things it genuinely cannot resolve. Check them by hand, every time:
| Blind spot | How to close it |
| --- | --- |
| **Base URL lives in config**, not code (`settings.yaml`, `.env`, a ConfigMap, Terraform) | The scanner does read those files, so the URL surfaces — but the *call sites* using it are elsewhere. Grep for whoever reads that config key. |
| **Paths assembled by a helper** — `_url("pods", pod_id, "stop")` | Reported under *possible indirect call sites*. Advisory, because `resp.json()["pods"]` looks identical. Open each one. |
| **SDK wrappers** (`import runpod`) | The API generation is a property of the installed *version*, not the code. Check `requirements.txt` / lockfile and the SDK's own release notes. |
| **Generated clients** | The OpenAPI/GraphQL document is the real source. Regenerate from the v2 spec instead of editing generated files. |
Then read the code the scanner flagged. It finds call sites; it does not understand your
wrappers. Trace who calls them — a renamed response field like `costPerHr → cost` breaks
every caller, not just the request builder. This is the one step where a code-graph or
LSP index earns its keep, if one is already available.
### 2. Brief the breaking changes — before the diff, not after
Read **[reference/breaking-changes.md](reference/breaking-changes.md)** and tell the
user which ones actually apply to *their* code. Two classes, and the second is the one
they are afraid of:
1. **Renames and moves** — loud. v2 rejects unknown request fields with `422` listing
them by name, so a missed rename cannot slip into production silently.
2. **Same name, different meaning** — quiet, and the reason a green test suite is not
proof. The reference enumerates every one of them; the two that bite hardest:
`flashboot` went from boolean to a three-value enum, and v1's `/billing/endpoints`
(serverless spend) is v2's `/billing/serverless` — v2's `/billing/endpoints` bills a
*different product* (public endpoints) and answers `200` with a correct total for
that product, which is not the one the caller asked for.
### 3. Plan, split into required vs cleanup
Write the plan down before editing, and keep these buckets separate all the way through
to the final summary:
- **Required** — it does not work on v2 without this.
- **Cleanup** — it works either way, but v2 lets you delete code (hand-built job URLs,
hand-rolled availability retry, polling loops that can now be SSE).
- **Decisions the user must make** — the code depends on something v2 removed outright:
spot/interruptible pods, savings plans, `dockerEntrypoint`, placement constraints
(`countryCodes`, `minRAMPerGPU`, …), pod `reset`, per-pod GPU fallback. See
[breaking-changes.md](reference/breaking-changes.md) Class 3 — and check it rather
than working from memory, because things leave this bucket as v2 grows. CUDA pinning,
`templateId` and CPU endpoint writes all used to be here and are not any more.
**Stop and ask before writing code in that third bucket** — but bring the replacement
with you. Some of these have a working rebuild and some genuinely have nothing, and the
difference decides what you are asking. Where a rebuild exists (`countryCodes` →
[catalog filter + `dataCenterIds` + a placement
assert](reference/breaking-changes.md#replacing-countrycodes-and-the-rest-of-the-placement-constraints),
per-pod GPU fallback → [an availability-ordered
loop](reference/breaking-changes.md#replacing-the-gpu-fallback-list-pods-only)), show it
and ask the one question that changes it — for `countryCodes`, whether the restriction
was a preference or a compliance requirement. Where nothing exists, the options are
accept the behavior change, keep that call on v1/GraphQL, or redesign around it, and only
the user can pick.
Either way, do not present a removal as a dead end when a rebuild exists — that pushes
the user into keeping a v1 call they did not need to keep. And never drop the field with
a `# no v2 equivalent` comment: that is the failure mode this bucket exists to prevent,
because it silently changes what their infrastructure does. If the bucket is empty, say
so — that is reassuring and takes one line.
### 4. Migrate, one file per commit
Work in the scanner's suggested order (fewest call sites first) — **with one override:
if several call sites share a transport helper, migrate the helper first**, whatever its
count. The scanner orders by call-site count and cannot see imports, so it will happily
put a consumer ahead of the module it imports its client from. Migrating a consumer
first means writing against an interface you are about to change.
Per file:
- Map paths and fields with **[reference/rest-v1-to-v2.md](reference/rest-v1-to-v2.md)**
or **[reference/graphql-to-v2.md](reference/graphql-to-v2.md)**. If a field isn't in
the tables, or the API disagrees with them, check the spec directly —
[Ground truth](#ground-truth-check-the-spec-yourself).
- **`gpuTypeIds` + `gpuTypePriority` on a *pod* means you are writing new code, not
renaming fields.** A v2 pod takes one GPU type, so the server-side fallback becomes a
client-side loop over the catalog. Working implementation:
[breaking-changes.md → Replacing the GPU fallback list](reference/breaking-changes.md#replacing-the-gpu-fallback-list-pods-only).
**Endpoints need no loop** — `gpu.pools` is already a list and workers land on
whichever listed pool has capacity.
- **Always request availability on catalog reads — with `product`.** Any
`GET /v2/catalog/gpus`, `/catalog/cpus`, or `/catalog/datacenters` this migration
introduces gets `include=AVAILABILITY` (`GPU_AVAILABILITY`/`CPU_AVAILABILITY` for
datacenters). Availability is the top-of-mind question for every Runpod user and the
call costs the same. Do not omit it because the current code did not ask for it — v1
could not.
**`include=AVAILABILITY` alone is a `400`.** On `/catalog/gpus` and `/catalog/cpus`,
`product` is required with it and invalid without it — `400` either way. There is no
default, deliberately: the same GPU can be scarce for `POD` and plentiful for
`SERVERLESS`, so the context has to be stated. Pick the one matching what you are
creating (`POD`, `SERVERLESS`, or `CLUSTER`; CPUs take `POD` or `SERVERLESS`):
```
GET /v2/catalog/gpus?include=AVAILABILITY&product=POD
```
- Offer the **rollback flag** (`RUNPOD_API_V1=1`) while v2 is new to them:
**[reference/rollback-flag.md](reference/rollback-flag.md)**. Worth it for a service
in production; skip it for a one-off script.
- **Never change behavior and API version in the same commit** — including the
improvements v2 makes possible. Failing fast on `status == "ERROR"` instead of timing
out is a genuine win and it belongs in the *next* commit; folding it into the
migration commit means a rollback has to give up both. Land those in the **cleanup**
bucket, separately.
A full before/after of a real client — pod create with GPU fallback, endpoint create
with the container config inlined, GraphQL dashboard — is in
**[reference/worked-example.md](reference/worked-example.md)**.
### 5. Verify against the live API
Static review is not enough; v2's validator is strict and its errors are precise.
Re-run the scanner to prove the call sites are gone, then exercise the real paths:
```bash
python3 "$SCAN" . --scope rest --fail-on-legacy # exit 1 if v1 remains
```
Two markers, and they mean different things — do not reach for the wrong one:
| Marker | Use it for |
| --- | --- |
| `rp-migrate: keep-v1` | legacy code kept **on purpose** — a `RUNPOD_API_V1` rollback branch, or a GraphQL call with no v2 equivalent. Reported under *kept on purpose*. |
| `rp-migrate: ignore` | a **false positive** on code that is already correct. Says "this isn't legacy", not "this is legacy I'm keeping". Reported under *marked false positives*. |
Both accept `line`, `start`/`end` region, or `file` scope, and both drop out of the plan
and out of `--fail-on-legacy`. Using `keep-v1` to silence a false positive records a lie
in the report — reach for `ignore` there.
**Where the gate can still be wrong.** The same blind spots from step 1 invert after a
migration: before, they hide v1 code; after, they can flag correct v2 code. The scanner
handles the two common cases — trailing `# was imageName` annotations, and a base URL
held in a constant (`f"{BASE}/pods"` where `BASE` is a v2 URL defined anywhere in the
file). Beyond those — a base URL imported from another module, or built at runtime from
config — it can still misread correct code as legacy. Read the flagged lines before
believing the exit code, and mark true false positives with `ignore` rather than
weakening the gate.
- **Reads** are free — list pods, endpoints, volumes, catalog. Confirm you unwrap the
new envelope (`{"pods": [...]}`, not a bare array).
- **Writes** cost money. Create → assert → delete, on the smallest thing that proves the
shape. Never test against resources the user already has.
- Decode `422`s with the table in
[reference/breaking-changes.md](reference/breaking-changes.md#reading-a-422) — including
the confusing one where a *missing required field* makes the validator report your
*valid* fields as "additional properties not allowed".
### 6. Summarize — this is the artifact they will actually read
Most users read the summary and not the diff. Structure it exactly like this:
```
## Required for the migration
<file:line> — what changed and why it had to
## Cleanup enabled by v2
<file:line> — what got deleted or simplified
## Behavior changes to watch
the same-name-different-meaning items that applied
## Still on GraphQL (no v2 equivalent)
myself / secrets / spot pods / clusters — and why
## Unlocks: what you can build now
tied to what this codebase already does
```
That last section is the highest-value part. Do not paste a generic feature list —
look at what this user has been building and struggling with, including anything you
already know from the session, and name where v2 changes it. "Your `wait_until_running`
loop times out on failed pods; v2's `ERROR` status lets it fail in seconds" beats "v2 has
richer status values". [reference/unlocks.md](reference/unlocks.md) is organized as
*if the code does X → v2 offers Y* for exactly this.
## Ground truth: check the spec yourself
The mapping tables in `reference/` were verified against the live API on **2026-08-10**.
v2 is actively developed, so treat them as a fast path, not as the authority. Both specs
are public and need no auth:
```bash
curl -s https://api.runpod.io/v2/openapi.json -o /tmp/rp-v2.json
curl -s https://rest.runpod.io/v1/openapi.json -o /tmp/rp-v1.json
```
**What a request body actually accepts, and what is required** (`*`). Worth running
before writing any create call — it resolves `allOf` composition, which a naive read of
the raw JSON misses:
```bash
python3 - CreateEndpointRequest <<'PY'
import json, sys
S = json.load(open("/tmp/rp-v2.json"))["components"]["schemas"]
def merge(n, acc=None):
acc = acc if acc is not None else {"props": {}, "req": set()}
if "$ref" in n: return merge(S[n["$ref"].split("/")[-1]], acc)
for sub in n.get("allOf", []): merge(sub, acc)
acc["props"].update(n.get("properties", {})); acc["req"].update(n.get("required", []))
return acc
def kind(v):
if "$ref" in v: return v["$ref"].split("/")[-1]
if "allOf" in v: return kind(v["allOf"][0])
return v.get("type", "?")
m = merge(S[sys.argv[1]])
for k, v in sorted(m["props"].items()):
print(f" {'*' if k in m['req'] else ' '} {k:16} {kind(v)}")
PY
```
Swap the argument for `CreatePodRequest`, `UpdatePodRequest`, `CreateTemplateRequest`,
`CreateNetworkVolumeRequest`, … **Which schemas mention a field** — useful when a `422`
names something you cannot place:
```bash
python3 -c 'import json,sys; S=json.load(open("/tmp/rp-v2.json"))["components"]["schemas"]; [print(" ",n) for n,s in S.items() if sys.argv[1] in json.dumps(s)]' flashboot
```
### Precedence when sources disagree
**observed live behavior > the spec > these tables.** The spec is not always right, and
the reference docs say so where it is known to be wrong — `timeout` is documented to
default to `300000` ms but comes back `0`. If you hit a case where the running API
contradicts the spec, trust the API, and **say so in your summary** so the user knows a
documented default cannot be relied on.
If you find a mapping in `reference/` that no longer matches the spec, fix the call and
flag the drift — the tables carry a verification date precisely so staleness is
detectable rather than silent.
For GraphQL there is no machine-readable schema (introspection is disabled), so that
side cannot be checked this way — see the caveat in
[reference/graphql-to-v2.md](reference/graphql-to-v2.md).
## Tooling notes
The inventory scanner is deliberately a **grep-class script, not a code-graph index**:
API generation is a property of URL strings and field names, it must work on any
language in an arbitrary customer repo, and it has to give the same answer for every
user with zero setup. A code-intelligence index (LSP, or an MCP graph server if one is
already running) earns its keep at a different step — step 1's *blast radius* question,
"who calls this wrapper whose response field just got renamed" — not at detection.
Use one there if it is already available; do not stand one up just for this.