Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions contracts/extract-cli-contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@
# export behind `wally account usage --requests`.
"listCliUsageRequests",
}
# Operations whose source description also carries a form the CLI never asks for. The CLI sends
# `GET /v1/models` with no `format` query, so it only ever reads the default `ModelList`; the
# `?format=openrouter-v2` document is a second 200 shape the control plane serves to other clients
# (a `oneOf` with no discriminator, because the query picks the variant, not a field). It is cut out
# of the slice rather than generated: the slice is what the CLI consumes, the binding generator reads
# closed, discriminated shapes only, and a change to the other form must not flag the CLI as stale.
# `parameters` names component parameters to leave out; `schemas` maps a component schema the
# operation references to the one the CLI reads instead. An operation that does not reference them
# (an older source contract) is carried unchanged.
CLI_NARROWED_OPERATIONS = {
"listModels": {
"parameters": {"ModelListFormatQuery"},
"schemas": {"ModelListResponse": "ModelList"},
},
}
HTTP_METHODS = {"get", "post", "put", "delete", "patch"}
# The component sections a kept operation may reference. Until wally #81 only
# `schemas` was carried, so every `#/components/{parameters,responses}/...`
Expand Down Expand Up @@ -78,6 +93,30 @@ def _refs(value: object, out: set[str]) -> None:
_refs(child, out)


def _narrow(value: object, parameters: set[str], schemas: dict[str, str]) -> object:
"""`value` with the narrowing of CLI_NARROWED_OPERATIONS applied; a copy, never the source."""
if isinstance(value, dict):
ref = value.get("$ref")
if isinstance(ref, str):
target = ref.rsplit("/", 1)[-1]
if ref.startswith("#/components/schemas/") and target in schemas:
return {**value, "$ref": f"#/components/schemas/{schemas[target]}"}
return {key: _narrow(child, parameters, schemas) for key, child in value.items()}
if isinstance(value, list):
kept = [
child
for child in value
if not (
isinstance(child, dict)
and isinstance(child.get("$ref"), str)
and child["$ref"].startswith("#/components/parameters/")
and child["$ref"].rsplit("/", 1)[-1] in parameters
)
]
return [_narrow(child, parameters, schemas) for child in kept]
return value


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path, help="path to control-plane-v1.openapi.json")
Expand Down Expand Up @@ -129,6 +168,13 @@ def visit(key: str) -> None:
kept = {}
for method, operation in item.items():
if method in HTTP_METHODS and operation.get("operationId") in CLI_OPERATION_IDS:
narrowing = CLI_NARROWED_OPERATIONS.get(operation["operationId"])
if narrowing:
had_parameters = bool(operation.get("parameters"))
operation = _narrow(operation, narrowing["parameters"], narrowing["schemas"])
if had_parameters and not operation["parameters"]:
# Every parameter was one the CLI does not send: no empty list stays behind.
del operation["parameters"]
kept[method] = operation
found: set[str] = set()
_refs(operation, found)
Expand Down
83 changes: 72 additions & 11 deletions contracts/wally-cli-v1.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,41 @@
"gateway_operation_pending",
"reconciliation_unverified",
"retention_managed_by_runanywhere",
"card_required"
"card_required",
"invalid_account_id",
"account_not_found",
"identity_email_in_use",
"role_forbidden",
"organization_playground_unavailable",
"operator_unknown",
"operator_forbidden",
"operator_scope",
"email_not_confirmed",
"invitation_not_found",
"invitation_expired",
"already_a_member",
"last_owner",
"role_not_available",
"personal_account",
"invitation_pending",
"too_many_pending_invitations",
"member_not_found",
"account_has_paid_credit",
"account_has_live_reservation",
"account_has_dedicated_pool",
"already_a_partner",
"not_a_partner",
"gateway_taken",
"unknown_price_version",
"model_not_priced",
"go_live_evidence_missing",
"notice_too_short",
"stop_scope_invalid",
"invoiced_billing_off",
"invoiced_balance_outstanding",
"partner_has_own_book",
"unknown_price_book",
"operation_not_retryable"
],
"title": "ApiErrorCode",
"type": "string"
Expand Down Expand Up @@ -1148,7 +1182,7 @@
},
"ModelReasoning": {
"additionalProperties": false,
"description": "How this model reasons (docs/reference/gateway.md, Reasoning budget). levels: the native reasoning_effort levels, empty for a model that only switches thinking on or off; default_level: the level a request naming none runs at; thinking_off: whether reasoning_effort none turns thinking off; thinking_ceiling_tokens: the safety ceiling on thinking; budget_enforced: whether max_tokens caps the answer with thinking budgeted separately (reasoning.max_tokens a hard thinking limit).",
"description": "How this model reasons (docs/reference/models.md, Reasoning levels and thinking ceilings). levels: the native reasoning_effort levels, empty for a model that only switches thinking on or off; default_level: the level a request naming none runs at; thinking_off: whether reasoning_effort none turns thinking off; thinking_ceiling_tokens: the safety ceiling on thinking; budget_enforced: whether max_tokens caps the answer with thinking budgeted separately (reasoning.max_tokens a hard thinking limit).",
"properties": {
"budget_enforced": {
"type": "boolean"
Expand Down Expand Up @@ -1413,7 +1447,7 @@
"type": "integer"
},
"datacenters": {
"description": "Where the model is served from: one entry per country its serving nodes are in. OpenRouter's legacy provider field (openrouter.ai/docs 'Provider Integration (Legacy Format)'), added by the gateway to an entry whose model is described to OpenRouter; OpenAI clients ignore it.",
"description": "Where the model is served from: one entry per country. OpenRouter's legacy provider field (openrouter.ai/docs 'Provider Integration (Legacy Format)'), added by the gateway to an entry whose model is described to OpenRouter; OpenAI clients ignore it.",
"items": {
"$ref": "#/components/schemas/OpenRouterDatacenter"
},
Expand Down Expand Up @@ -2313,7 +2347,7 @@
},
"/v1/me": {
"get": {
"description": "`rcli whoami`. Authenticated by the API key the CLI was issued, because\nthat is the only credential it has.\n\nThe contract declares that key as `BearerAuth`. The same Authorization\nheader also carries the console's signed PropelAuth session token, and\nwhere PropelAuth is not configured, never in production, the `X-User-Id`\nand `X-User-Email` development identity headers are accepted instead.\nNeither is a separate security scheme: this contract declares no console\nsession scheme on any operation, and the development headers are not a\ncredential.",
"description": "`rcli whoami`. Authenticated by the API key the CLI was issued, because\nthat is the only credential it has.\n\nThe contract declares that key as `BearerAuth`. The same Authorization\nheader also carries the console session token, and where no identity\nprovider is configured, never in production, the `X-User-Id`\nand `X-User-Email` development identity headers are accepted instead.\nNeither is a separate security scheme: this contract declares no console\nsession scheme on any operation, and the development headers are not a\ncredential.",
"operationId": "getCurrentIdentity",
"parameters": [
{
Expand Down Expand Up @@ -2357,6 +2391,7 @@
},
"/v1/models": {
"get": {
"description": "With a key, the models that key may call. With no credentials at all, every model this deployment serves publicly, in the same format. An invalid or revoked key is refused 401, never answered with the public list.",
"operationId": "listModels",
"responses": {
"200": {
Expand All @@ -2367,7 +2402,15 @@
}
}
},
"description": "Available model aliases"
"description": "Available model aliases",
"headers": {
"x-request-id": {
"description": "Request correlation id",
"schema": {
"$ref": "#/components/schemas/RequestId"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
Expand All @@ -2382,17 +2425,18 @@
"security": [
{
"BearerAuth": []
}
},
{}
],
"summary": "List models available to the current key",
"summary": "List models",
"tags": [
"inference"
]
}
},
"/v1/models/catalog": {
"get": {
"description": "Which models we serve and what they cost.\n\nThe console used to name a single model from a config constant, which was\ntrue when the endpoint served one and became wrong the moment LiteLLM sat in\nfront of three. Prices come from the same catalog the gate charges against,\nso a published price cannot drift from a billed one.",
"description": "Which models we serve and what they cost.\n\nThe console used to name a single model from a config constant, which was\ntrue when the endpoint served one and became wrong the moment LiteLLM sat in\nfront of three. Prices come from the same catalog the gate charges against,\nso a published price cannot drift from a billed one.\n\nAnonymous, and the same answer for everyone, except that a signed-in account also sees the\nprivate models granted to it (`private_grants.models_by_account`), named and priced, so the\nconsole does not show its usage of one as \"Retired\". The credential is read from the request\nand not declared, so the published contract is unchanged; a caller who cannot be shown to be\nsigned in gets the anonymous answer, never an error, and nothing is made for them\n(`security.optional_account`). When no grant exists at all nothing is queried for the caller.",
"operationId": "getModelCatalog",
"responses": {
"200": {
Expand All @@ -2417,7 +2461,7 @@
},
"/v1/requests/{request_id}/cancel": {
"post": {
"description": "End an in-flight request the caller's key started (#440).\n\n`request_id` is the `x-request-id` the customer received on the response\nbeing cancelled. The GPU nodes are asked on this service's behalf with the\ncredential they already hold for this ledger (see services/cancel.py), and\neach ends the request only if that key started it. Anything else -- an id\nnobody is serving, a request that already finished, someone else's -- is\nthe same 404, so this route cannot be used to learn about other customers'\nrequests. A successful cancel is 202: the node has told the engine to stop;\nthe stream's end and its settlement follow on their own path.",
"description": "End an in-flight request the caller's key started (#440).\n\n`request_id` is the `x-request-id` the customer received on the response\nbeing cancelled. In `push` mode (the default) the meters are asked on this\nservice's behalf with an ID token for their own address (see services/cancel.py). In `pull`\nmode (`RA_CANCEL_MODE`, ADR 0449) this service calls nothing: it writes a cancel intent that\neach registered cell fetches, and waits up to 3 seconds for the cell's answer. Either way each\nnode ends the request only if that key started it, and anything else -- an id nobody is\nserving, a request that already finished, someone else's, or (pull) a cancel no\ncell confirmed in time -- is the same 404, so this route cannot be used to learn\nabout other customers' requests. A successful cancel is 202: the node has told\nthe engine to stop; the stream's end and its settlement follow on their own path.",
"operationId": "cancelRequest",
"parameters": [
{
Expand All @@ -2441,6 +2485,23 @@
"422": {
"$ref": "#/components/responses/ValidationFailed"
},
"429": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
},
"description": "The key has made 30 cancel calls in the last minute, or too many of its calls (4) or this instance's (64) are waiting (only when cancel_mode is pull); ask again after the Retry-After",
"headers": {
"Retry-After": {
"schema": {
"type": "string"
}
}
}
},
"default": {
"$ref": "#/components/responses/ApiError"
}
Expand All @@ -2459,8 +2520,8 @@
},
"x-runanywhere-source": {
"artifact": "contracts/control-plane-v1.openapi.json",
"branch": "fix/codex-harness-live",
"commit": "3fb1fd00aa4045ee9f2f6bfc5b7fefd6062dbc52",
"branch": "track/api-parity",
"commit": "a82593928943186f0191ae957c78184d1669c9a9",
"repository": "RunanywhereAI/InferenceInfra"
}
}
Loading
Loading