Skip to content

feat: agent-friendly cli output (CON-683, CON-684, CON-685)#306

Open
justinwlin wants to merge 4 commits into
mainfrom
justinlin/con-683-685-runpodctl-agent-output
Open

feat: agent-friendly cli output (CON-683, CON-684, CON-685)#306
justinwlin wants to merge 4 commits into
mainfrom
justinlin/con-683-685-runpodctl-agent-output

Conversation

@justinwlin

Copy link
Copy Markdown
Contributor

Summary

Agent-friendly output improvements to the runpodctl v2 surface, covering three linked tickets from the CON_2026 Q3 Agents Emergency Triage rock. All three are "make the CLI legible to an agent driving it."

  • CON-683 — clean CLI error output
  • CON-684 — invoke URLs in serverless output
  • CON-685 — pricing + per-datacenter availability in gpu list

CON-683: clean CLI error output

Before, a runtime failure printed the error JSON, then the full flag/usage dump, then the error again — and API errors were double-encoded ({"error":"api error: {\"error\":\"pod not found\"} (status 404)"}), so agents misread runtime failures as flag mistakes.

  • Set SilenceUsage (SilenceErrors was already set). Usage is now re-printed only for genuine usage errors (bad flags, wrong arg count, unknown command) via a usageError type + SetFlagErrorFunc.
  • Execute is now the single error sink: one flat JSON error object on stderr with a stable code field (and HTTP status when available). Removed the redundant per-command output.Error(err) calls (88 sites).
  • The REST client unwraps nested API error JSON: APIError is now a real error type carrying the unwrapped message + code + status.
# runtime error (unwrapped, stable code, no usage dump, printed once)
$ runpodctl serverless get does-not-exist
{"error":"failed to get endpoint: endpoint not found","code":"not_found","status":404}

# usage error (still shows usage, tagged usage_error)
$ runpodctl serverless get
{"error":"accepts 1 arg(s), received 0","code":"usage_error"}
Usage:
  runpodctl serverless get <endpoint-id> [flags]
  ...

CON-684: invoke URLs in serverless output

serverless create / get / list now include a urls object (run, runsync, health) computed client-side from the endpoint id, so an agent that just deployed an endpoint can call it without a UI round-trip.

$ runpodctl serverless get jlbr0gof5j76wh
{ ..., "urls": {
  "run":     "https://api.runpod.ai/v2/jlbr0gof5j76wh/run",
  "runsync": "https://api.runpod.ai/v2/jlbr0gof5j76wh/runsync",
  "health":  "https://api.runpod.ai/v2/jlbr0gof5j76wh/health"
}}

CON-685: pricing + per-datacenter availability in gpu list

Added secure/community on-demand $/hr (securePricePerHr, communityPricePerHr) and a per-datacenter availability breakdown alongside the existing best-overall stockStatus, so agents can cost-optimize and pick a DC that will actually schedule.

$ runpodctl gpu list
[{ "gpuId": "NVIDIA A100 80GB PCIe", "securePricePerHr": 1.39, "communityPricePerHr": 1.19,
   "stockStatus": "Low",
   "dataCenterAvailability": [{"dataCenterId":"CA-MTL-3","stockStatus":"Low"}, ...] }, ...]

Testing

  • go build ./..., go vet ./..., go test ./... — 237 pass.
  • Added unit tests: parseAPIError (unwrap/code/status), output.Error with code/status + nil no-op, usage-error classification + SilenceUsage/SilenceErrors, invokeURLs + get/list URL population, ListGpuTypes pricing + per-DC aggregation.
  • e2e against the live API (RUNPOD_API_KEY): gpu list (pricing + per-DC), runtime/usage/unknown-command/unknown-flag error shapes, and full serverless creategetlistdelete cycle (created a temporary serverless template + endpoint with workers-min 0, verified urls, then deleted both — no lingering resources).

Follow-up

  • The runpodctl agent skill (runpod/skills) should get a small refresh for the new gpu list fields, urls, and error shape. Not in this PR.

Kept as a draft for your review.

CON-683: clean cli error output for agent consumption
- set SilenceUsage (SilenceErrors already set) so runtime errors no longer
  dump the flag/usage block; usage is re-printed only for genuine usage
  errors (bad flags, wrong arg count, unknown command) via a usageError type
- Execute is now the single error sink: one flat JSON error object on stderr
  with a stable "code" field (and http "status" when available), instead of
  each command printing then Execute printing again
- unwrap nested API error JSON in the rest client: APIError is now a real
  error type carrying the unwrapped message + code + status, so errors read
  {"error":"pod not found","code":"not_found","status":404} instead of
  {"error":"api error: {\"error\":\"pod not found\"} (status 404)"}
- remove the now-redundant per-command output.Error(err) calls

CON-684: include invoke urls in serverless output
- add urls (run, runsync, health) to serverless create, get, and list output,
  computed client-side from the endpoint id

CON-685: add pricing and per-datacenter availability to gpu list
- add secure/community on-demand $/hr fields (securePrice, communityPrice)
- add per-datacenter availability breakdown alongside the best overall stock
- asUsageError: short-circuit typed *api.APIError / *api.GraphQLError so a
  runtime failure whose server message starts with a usage-ish word (e.g.
  "invalid argument: ...") keeps its real code and doesn't trigger a usage
  dump; tighten the string-prefix fallback (drop the broad "requires ").
- add GraphQLError type with a stable "graphql_error" code + http status, and
  route all graphql error constructions through it; unwrap the graphql http
  transport body ({"errors":[{message}]}) instead of embedding the raw json.
- normalize explicit api error codes to lowercase.
- document the stable error-code vocabulary; drop dead FormatError.
- tests: parseGraphQLHTTPError unwrap/code, GraphQLError shape, and
  asUsageError typed-error short-circuit.
an empty stockStatus is noise (the gpu exists in the dc but has no reported
availability); only list data centers with a real status.
@justinwlin

justinwlin commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Verification

Verified with unit tests + live e2e against the real API. All checks green.

Automated: go build ./..., go vet ./..., go test ./...all tests pass. New tests cover parseAPIError (unwrap/code/status), parseGraphQLHTTPError, output.Error code/status + nil no-op, asUsageError classification incl. typed-error short-circuit, invokeURLs + get/list population, and ListGpuTypes pricing + per-DC aggregation (including the empty-stock → "none" case).

Live e2e (real RUNPOD_API_KEY), captured from the built binary:

CON-685 — gpu list pricing + per-DC availability

{
  "gpuId": "NVIDIA A100 80GB PCIe",
  "displayName": "A100 PCIe",
  "memoryInGb": 80,
  "secureCloud": true,
  "communityCloud": true,
  "securePricePerHr": 1.39,
  "communityPricePerHr": 1.19,
  "stockStatus": "Low",
  "available": true,
  "dataCenterAvailability": [
    { "dataCenterId": "CA-MTL-3", "stockStatus": "Low" },
    { "dataCenterId": "EU-RO-1",  "stockStatus": "Low" },
    { "dataCenterId": "US-KS-2",  "stockStatus": "Low" },
    { "dataCenterId": "US-MO-1",  "stockStatus": "none" }
  ]
}

Every datacenter the GPU appears in is listed; a datacenter with no reported stock is shown explicitly as "none" (not hidden, not an empty string) so an agent can tell "offered here, currently no stock" from a missing entry.

CON-684 — invoke URLs on serverless get (live endpoint)

{
  "id": "jlbr0gof5j76wh",
  "name": "live-whisper-flash",
  "urls": {
    "run":     "https://api.runpod.ai/v2/jlbr0gof5j76wh/run",
    "runsync": "https://api.runpod.ai/v2/jlbr0gof5j76wh/runsync",
    "health":  "https://api.runpod.ai/v2/jlbr0gof5j76wh/health"
  }
}

create and list were verified the same way — I stood up a temporary serverless template + endpoint (--workers-min 0, no cost), confirmed urls on create/get/list, then deleted both (no lingering resources).

CON-683 — clean error output

# runtime error: unwrapped, stable code + status, printed once, no usage dump
$ runpodctl serverless get nope-nope
{"error":"failed to get endpoint: endpoint not found","code":"not_found","status":404}

# usage errors: JSON with code + usage text
$ runpodctl serverless get
{"error":"accepts 1 arg(s), received 0","code":"usage_error"}
  ...usage...

$ runpodctl gpu list --bogus
{"error":"unknown flag: --bogus","code":"usage_error"}
  ...usage...

Follow-up to sync the runpodctl agent skill after merge: CON-752.

do not silently drop data centers with no reported stock; list them with an
explicit "none" status so an agent can distinguish "offered here, no stock"
from a missing entry.
@justinwlin
justinwlin marked this pull request as ready for review July 16, 2026 14:53
@promptless

promptless Bot commented Jul 16, 2026

Copy link
Copy Markdown

Promptless prepared a documentation update related to this change.

Triggered by runpodctl PR #306

Documented the three agent-friendly output changes in the runpodctl CLI reference: the new gpu list pricing (securePricePerHr/communityPricePerHr) and per-datacenter availability breakdown, the urls object (run/runsync/health) on serverless create/get/list, and the structured JSON error format with stable code/status. The runpodctl agent skill refresh (CON-752) was left out of scope.

Review: Document runpodctl agent-friendly output (pricing, invoke URLs, error format)

@LarveyOfficial LarveyOfficial left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Three linked agent-legibility improvements to runpodctl v2: (683) a single flat JSON error sink with stable code/status fields, SilenceUsage, unwrapped API/GraphQL errors, and removal of ~88 redundant per-command output.Error calls; (684) client-computed invoke urls on serverless create/get/list; (685) on-demand pricing + per-datacenter availability in gpu list.

Strengths

  • The error-handling redesign is the right architecture. Making APIError/GraphQLError real error types with ErrorCode()/HTTPStatus(), then having output.Error pull those out via errors.As through the wrap chain, means a wrapped fmt.Errorf("failed to X: %w", apiErr) still surfaces the correct code/status. Collapsing 88 call sites into a single Execute sink is a genuine consistency win and removes the double-print/usage-dump bug.
  • SilenceUsage + typed usageError split is correct. Usage text now shows only for real usage mistakes, and the SetFlagErrorFunc route + nil no-op guard in output.Error are good touches.
  • Good, targeted testsparseAPIError (unwrap/code/status), the "must not be double-encoded" assertions, usage-vs-runtime classification including the typed-error bail, and the per-DC "none" vs missing distinction.

Concerns / suggestions

  1. asUsageError string-prefix fallback is the main fragility. After the (good) typed APIError/GraphQLError bail-out, any plain fmt.Errorf runtime error whose message happens to start with "invalid argument", "accepts ", "requires at least", etc. would be misclassified as a usage error and dump usage text. Today's plain runtime errors don't collide (e.g. pod/list.go's "invalid --created-after format…" starts with "invalid ", not "invalid argument"), but this couples correctness to Cobra's internal message strings and to every command author avoiding those prefixes. TestAsUsageError guards the Cobra strings, which helps — consider a short comment warning command authors off those leading words, and note it may need revisiting on a Cobra upgrade.

  2. Hardcoded invoke base https://api.runpod.ai/v2 in invokeURLs. Correct as long as the serverless invoke domain is always prod-global and independent of the control-plane API URL. If someone points runpodctl at a dev/staging API, the emitted urls would still target prod — worth a one-line confirmation that invoke is always this host regardless of apiUrl.

  3. GraphQL errors collapse to a single graphql_error code with no status (200-with-errors body). Acceptable and documented, but it means an agent can't branch a GraphQL "not found" the way it can a REST 404. Fine as a known tradeoff — flagging only so it's a conscious one.

Nits

  • The APIError field rename ErrorMessage (method vs field) is contained to internal/ with no external importers and go build/go vet pass, so no compatibility risk — just the kind of change worth a line in the description.
  • PR is still marked draft; scope (51 files, 3 tickets) is large but cohesive. No objection to the bundling given they're one theme.

Solid, well-tested work with the right architecture. Only #1 (string-prefix fragility) is worth a follow-up thought; nothing blocking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants