Repository navigation
feat: add rp catalog for gpu and cpu pricing and stock #637
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+1,321
−2
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
2759ad2
feat: add rp catalog for gpu and cpu pricing and stock
KAJdev 15a69b4
test: cover rp catalog and shared compute resolution
KAJdev ed08599
fix: keep table ids and values whole on narrow terminals
KAJdev 619e23b
refactor: consolidate CLI resolution and rendering helpers
KAJdev b33a271
refactor: share SDK log streaming across resource commands
KAJdev 329f7d5
fix: clarify log stream completion handling
KAJdev 9880c1d
fix: fit resource picker labels to terminal width
KAJdev eb6a82d
refactor: scope catalog shared helpers to hardware and output
KAJdev f03757b
fix: honor catalog cloud and cuda constraints
KAJdev 83e0415
fix: reconcile catalog completion with main signal cleanup
KAJdev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| """pagination and error reporting for rest-backed cli commands.""" | ||
|
|
||
| from contextlib import contextmanager | ||
|
|
||
| import click | ||
| from requests import RequestException | ||
|
|
||
| from runpod import error | ||
| from runpod.api.rest import run_rest_request | ||
| from runpod.rp_cli.compute import ResolutionError | ||
|
|
||
|
|
||
| def list_all(path: str, collection: str, params: dict | None = None) -> list[dict]: | ||
| query = dict(params or {}) | ||
| records = [] | ||
| while True: | ||
| page = run_rest_request("GET", path, params=query) | ||
| records.extend(page[collection]) | ||
| pagination = page["pagination"] | ||
| if not pagination["hasNextPage"]: | ||
| return records | ||
| query["cursor"] = pagination["nextCursor"] | ||
|
|
||
|
|
||
| @contextmanager | ||
| def api_errors(): | ||
| try: | ||
| yield | ||
| except (error.RunPodError, ResolutionError, RequestException) as exc: | ||
| raise click.ClickException(str(exc)) from exc |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,284 @@ | ||
| """`rp catalog`: gpu and cpu pricing and stock search.""" | ||
|
|
||
| from typing import Optional | ||
|
|
||
| import click | ||
|
|
||
| from runpod.rp_cli import compute | ||
| from runpod.rp_cli.api import api_errors | ||
| from runpod.rp_cli.output import json_option, print_json, print_table | ||
|
|
||
|
|
||
| def _money(value: Optional[float]) -> str: | ||
| return f"${value:.2f}" if value else "-" | ||
|
|
||
|
|
||
| def _stock(entries: list[dict]) -> str: | ||
| ranked = sorted( | ||
| entries, key=lambda dc: -compute.availability_rank(dc["availability"]) | ||
| ) | ||
| return ", ".join(f"{dc['id']} {dc['availability'].lower()}" for dc in ranked) | ||
|
|
||
|
|
||
| def _filter_stock(records: list[dict], regions: tuple[str, ...], available: bool): | ||
| allowed = ( | ||
| set(compute.resolve_regions(regions, compute.list_datacenters())) | ||
| if regions | ||
| else None | ||
| ) | ||
| for record in records: | ||
| row = dict(record) | ||
| if allowed is not None: | ||
| row["dataCenters"] = [ | ||
| dc for dc in record.get("dataCenters", []) if dc["id"] in allowed | ||
| ] | ||
| row["availability"] = compute.best_availability( | ||
| dc["availability"] for dc in row["dataCenters"] | ||
| ) | ||
| if not available or compute.availability_rank(row.get("availability")) > 0: | ||
| yield row | ||
|
|
||
|
|
||
| def _unit_price(gpu: dict, product: str, cloud: Optional[str]) -> Optional[float]: | ||
| price = gpu["price"] | ||
| if product == "SERVERLESS": | ||
| return price.get("serverless") or None | ||
| if cloud: | ||
| return price.get(cloud) or None | ||
| offered = [ | ||
| value for value in (price.get("secure"), price.get("community")) if value | ||
| ] | ||
| return min(offered, default=None) | ||
|
|
||
|
|
||
| def _gpu_rows( | ||
| query, product, count, regions, cloud, cuda, min_vram, max_price, available | ||
| ) -> list[dict]: | ||
| min_cuda, exact_cuda = compute.parse_cuda(cuda) | ||
| gpus = compute.list_gpus( | ||
| product=product, | ||
| count=count, | ||
| cloud=cloud.upper() if cloud else None, | ||
| cuda_versions=exact_cuda, | ||
| min_cuda_version=min_cuda, | ||
| ) | ||
| if query: | ||
| gpus = compute.resolve_gpus(query, gpus) | ||
| if product == "SERVERLESS": | ||
| gpus = [gpu for gpu in gpus if gpu.get("pool")] | ||
| rows = [] | ||
| for row in _filter_stock(gpus, regions, available): | ||
| unit = _unit_price(row, product, cloud) | ||
| if min_vram and row["memory"] < min_vram: | ||
| continue | ||
| if max_price is not None and (unit is None or unit > max_price): | ||
| continue | ||
| row["count"] = count | ||
| row["totalPrice"] = { | ||
| tier: round(value * count, 4) if value else None | ||
| for tier, value in row["price"].items() | ||
| } | ||
| rows.append(row) | ||
| rows.sort( | ||
| key=lambda row: ( | ||
| -compute.availability_rank(row.get("availability")), | ||
| _unit_price(row, product, cloud) or float("inf"), | ||
| row["name"], | ||
| ) | ||
| ) | ||
| return rows | ||
|
|
||
|
|
||
| def _gpu_columns(product: str, count: int, cloud: Optional[str]): | ||
| suffix = f" x{count}" if count > 1 else "" | ||
| columns = [ | ||
| ("GPU", lambda row: row["name"]), | ||
| ("VRAM", lambda row: f"{row['memory']}GB"), | ||
| ] | ||
| if product == "SERVERLESS": | ||
| columns += [ | ||
| ("POOL", lambda row: row["pool"]), | ||
| (f"$/HR{suffix}", lambda row: _money(row["totalPrice"].get("serverless"))), | ||
| ] | ||
| else: | ||
| tiers = [cloud] if cloud else ["secure", "community"] | ||
| for tier in tiers: | ||
| columns.append( | ||
| ( | ||
| f"{tier.upper()} $/HR{suffix}", | ||
| lambda row, tier=tier: _money(row["totalPrice"].get(tier)), | ||
| ) | ||
| ) | ||
| columns.append( | ||
| ( | ||
| "MAX", | ||
| lambda row: ( | ||
| row["maxCount"].get(cloud) | ||
| if cloud | ||
| else max(row["maxCount"].values(), default=None) | ||
| ), | ||
| ) | ||
| ) | ||
| columns += [ | ||
| ("STOCK", lambda row: (row.get("availability") or "NONE").lower()), | ||
| ("DATA CENTERS", lambda row: _stock(row.get("dataCenters") or [])), | ||
| ] | ||
| return columns | ||
|
|
||
|
|
||
| def _cpu_rows(query, product, vcpu, regions, available) -> list[dict]: | ||
| cpus = compute.list_cpus(product=product, vcpu_count=vcpu) | ||
| if query: | ||
| cpus = compute.match_cpus(query, cpus) | ||
| if not cpus: | ||
| raise compute.ResolutionError(f"unknown cpu '{query}'") | ||
| return sorted( | ||
| _filter_stock(cpus, regions, available), | ||
| key=lambda row: -compute.availability_rank(row.get("availability")), | ||
| ) | ||
|
|
||
|
|
||
| def _cpu_columns(product: str): | ||
| tier = "serverlessPerVcpu" if product == "SERVERLESS" else "securePerVcpu" | ||
| return [ | ||
| ("CPU", lambda row: row["id"]), | ||
| ("NAME", lambda row: row["name"]), | ||
| ("GROUP", lambda row: row["group"]), | ||
| ("VCPU", lambda row: "{min}-{max}".format(**row["vcpu"])), | ||
| ("RAM/VCPU", lambda row: f"{row['ramGbPerVcpu']:g}GB"), | ||
| ("$/VCPU-HR", lambda row: _money(row["price"].get(tier))), | ||
| ("STOCK", lambda row: (row.get("availability") or "NONE").lower()), | ||
| ("DATA CENTERS", lambda row: _stock(row.get("dataCenters") or [])), | ||
| ] | ||
|
|
||
|
|
||
| _DATACENTER_COLUMNS = [ | ||
| ("DATA CENTER", lambda row: row["id"]), | ||
| ("REGION", lambda row: row["region"].lower().replace("_", " ")), | ||
| ( | ||
| "VOLUME TYPES", | ||
| lambda row: [kind.lower() for kind in row["networkVolumeTypes"]], | ||
| ), | ||
| ("GLOBAL NETWORK", lambda row: row["globalNetwork"]), | ||
| ("COMPLIANCE", lambda row: row["compliance"]), | ||
| ] | ||
|
|
||
|
|
||
| @click.command() | ||
| @click.argument("query", required=False) | ||
| @click.option( | ||
| "--pod", | ||
| "product", | ||
| flag_value="POD", | ||
| default=True, | ||
| help="Pod pricing and stock (default).", | ||
| ) | ||
| @click.option( | ||
| "--serverless", | ||
| "product", | ||
| flag_value="SERVERLESS", | ||
| help="Serverless pricing, pools, and stock.", | ||
| ) | ||
| @click.option( | ||
| "--cpu", "cpu_mode", is_flag=True, help="Search CPU flavors instead of GPUs." | ||
| ) | ||
| @click.option( | ||
| "--datacenters", is_flag=True, help="List data centers and their regions." | ||
| ) | ||
| @click.option( | ||
| "--count", | ||
| type=click.IntRange(min=1), | ||
| default=1, | ||
| show_default=True, | ||
| help="GPUs per pod or worker; prices and stock account for it.", | ||
| ) | ||
| @click.option( | ||
| "--vcpu", | ||
| type=click.IntRange(min=2), | ||
| default=None, | ||
| help="vCPUs per pod or worker (with --cpu).", | ||
| ) | ||
| @click.option( | ||
| "--region", | ||
| "regions", | ||
| multiple=True, | ||
| help="Region (eu, na, asia), id prefix (us, eu-ro), or data center id. Repeatable.", | ||
| ) | ||
| @click.option( | ||
| "--cloud", | ||
| type=click.Choice(["secure", "community"], case_sensitive=False), | ||
| default=None, | ||
| help="Pod cloud tier.", | ||
| ) | ||
| @click.option( | ||
| "--cuda", | ||
| multiple=True, | ||
| help="CUDA filter: '>=12.4' for a floor, 12.4 or 12.4,12.8 for exact versions.", | ||
| ) | ||
| @click.option("--min-vram", type=int, default=None, help="Minimum VRAM per GPU in GB.") | ||
| @click.option("--max-price", type=float, default=None, help="Maximum USD per GPU-hour.") | ||
| @click.option("--available", is_flag=True, help="Only show hardware that is in stock.") | ||
| @json_option | ||
| def catalog( | ||
| query, | ||
| product, | ||
| cpu_mode, | ||
| datacenters, | ||
| count, | ||
| vcpu, | ||
| regions, | ||
| cloud, | ||
| cuda, | ||
| min_vram, | ||
| max_price, | ||
| available, | ||
| as_json, | ||
| ): # pylint: disable=too-many-arguments | ||
| """Search GPU and CPU pricing and stock by data center. | ||
|
|
||
| QUERY is a human-readable GPU (4090, h100, a100-sxm4), a serverless | ||
| pool id, or with --cpu a CPU flavor. | ||
|
|
||
| \b | ||
| rp catalog h100 --count 2 --region eu --cuda '>=12.4' | ||
| rp catalog 4090 --serverless | ||
| rp catalog --cpu --vcpu 8 --available | ||
| rp catalog --datacenters --region us | ||
| """ | ||
| if cpu_mode and datacenters: | ||
| raise click.UsageError("use either --cpu or --datacenters") | ||
| if cloud and product == "SERVERLESS": | ||
| raise click.UsageError("--cloud applies to pods only") | ||
| cloud = cloud.lower() if cloud else None | ||
| if cpu_mode and cloud == "community": | ||
| raise click.UsageError("--cpu is only available in the secure cloud") | ||
|
|
||
| with api_errors(): | ||
| if datacenters: | ||
| values = regions + ((query,) if query else ()) | ||
| rows = compute.list_datacenters() | ||
| if values: | ||
| allowed = set(compute.resolve_regions(values, rows)) | ||
| rows = [row for row in rows if row["id"] in allowed] | ||
| columns, empty = _DATACENTER_COLUMNS, "no data centers match" | ||
| elif cpu_mode: | ||
| rows = _cpu_rows(query, product, vcpu, regions, available) | ||
| columns, empty = _cpu_columns(product), "no cpu flavors match" | ||
| else: | ||
| rows = _gpu_rows( | ||
| query, | ||
| product, | ||
| count, | ||
| regions, | ||
| cloud, | ||
| cuda, | ||
| min_vram, | ||
| max_price, | ||
| available, | ||
| ) | ||
| columns, empty = _gpu_columns(product, count, cloud), "no gpus match" | ||
|
|
||
| if as_json: | ||
| print_json(rows) | ||
| return | ||
| print_table(rows, columns, empty) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.