diff --git a/contracts/extract-cli-contract.py b/contracts/extract-cli-contract.py index ae99d7ff..b45de19d 100644 --- a/contracts/extract-cli-contract.py +++ b/contracts/extract-cli-contract.py @@ -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}/...` @@ -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") @@ -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) diff --git a/contracts/wally-cli-v1.openapi.json b/contracts/wally-cli-v1.openapi.json index 0bf5d948..9ff0d76b 100644 --- a/contracts/wally-cli-v1.openapi.json +++ b/contracts/wally-cli-v1.openapi.json @@ -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" @@ -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" @@ -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" }, @@ -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": [ { @@ -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": { @@ -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" @@ -2382,9 +2425,10 @@ "security": [ { "BearerAuth": [] - } + }, + {} ], - "summary": "List models available to the current key", + "summary": "List models", "tags": [ "inference" ] @@ -2392,7 +2436,7 @@ }, "/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": { @@ -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": [ { @@ -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" } @@ -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" } } diff --git a/src/account/console_contract.rs b/src/account/console_contract.rs index d5eccd7f..693040d6 100644 --- a/src/account/console_contract.rs +++ b/src/account/console_contract.rs @@ -10,7 +10,7 @@ use serde_json::Value; /// SHA-256 of contracts/wally-cli-v1.openapi.json this file was built from. pub const CONTRACT_SHA256: &str = - "298e6c27507aaffc032ea8c2f0640d8e1acf8873baadbb8f481ee4d5f8853d47"; + "4484691b7291848efc9519a9b9af7b7946135eb071ebda744c63fc8dbc78e941"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ApiErrorCode { @@ -51,6 +51,40 @@ pub enum ApiErrorCode { KReconciliationUnverified, KRetentionManagedByRunanywhere, KCardRequired, + KInvalidAccountId, + KAccountNotFound, + KIdentityEmailInUse, + KRoleForbidden, + KOrganizationPlaygroundUnavailable, + KOperatorUnknown, + KOperatorForbidden, + KOperatorScope, + KEmailNotConfirmed, + KInvitationNotFound, + KInvitationExpired, + KAlreadyAMember, + KLastOwner, + KRoleNotAvailable, + KPersonalAccount, + KInvitationPending, + KTooManyPendingInvitations, + KMemberNotFound, + KAccountHasPaidCredit, + KAccountHasLiveReservation, + KAccountHasDedicatedPool, + KAlreadyAPartner, + KNotAPartner, + KGatewayTaken, + KUnknownPriceVersion, + KModelNotPriced, + KGoLiveEvidenceMissing, + KNoticeTooShort, + KStopScopeInvalid, + KInvoicedBillingOff, + KInvoicedBalanceOutstanding, + KPartnerHasOwnBook, + KUnknownPriceBook, + KOperationNotRetryable, } impl ApiErrorCode { @@ -92,6 +126,42 @@ impl ApiErrorCode { ApiErrorCode::KReconciliationUnverified => "reconciliation_unverified", ApiErrorCode::KRetentionManagedByRunanywhere => "retention_managed_by_runanywhere", ApiErrorCode::KCardRequired => "card_required", + ApiErrorCode::KInvalidAccountId => "invalid_account_id", + ApiErrorCode::KAccountNotFound => "account_not_found", + ApiErrorCode::KIdentityEmailInUse => "identity_email_in_use", + ApiErrorCode::KRoleForbidden => "role_forbidden", + ApiErrorCode::KOrganizationPlaygroundUnavailable => { + "organization_playground_unavailable" + } + ApiErrorCode::KOperatorUnknown => "operator_unknown", + ApiErrorCode::KOperatorForbidden => "operator_forbidden", + ApiErrorCode::KOperatorScope => "operator_scope", + ApiErrorCode::KEmailNotConfirmed => "email_not_confirmed", + ApiErrorCode::KInvitationNotFound => "invitation_not_found", + ApiErrorCode::KInvitationExpired => "invitation_expired", + ApiErrorCode::KAlreadyAMember => "already_a_member", + ApiErrorCode::KLastOwner => "last_owner", + ApiErrorCode::KRoleNotAvailable => "role_not_available", + ApiErrorCode::KPersonalAccount => "personal_account", + ApiErrorCode::KInvitationPending => "invitation_pending", + ApiErrorCode::KTooManyPendingInvitations => "too_many_pending_invitations", + ApiErrorCode::KMemberNotFound => "member_not_found", + ApiErrorCode::KAccountHasPaidCredit => "account_has_paid_credit", + ApiErrorCode::KAccountHasLiveReservation => "account_has_live_reservation", + ApiErrorCode::KAccountHasDedicatedPool => "account_has_dedicated_pool", + ApiErrorCode::KAlreadyAPartner => "already_a_partner", + ApiErrorCode::KNotAPartner => "not_a_partner", + ApiErrorCode::KGatewayTaken => "gateway_taken", + ApiErrorCode::KUnknownPriceVersion => "unknown_price_version", + ApiErrorCode::KModelNotPriced => "model_not_priced", + ApiErrorCode::KGoLiveEvidenceMissing => "go_live_evidence_missing", + ApiErrorCode::KNoticeTooShort => "notice_too_short", + ApiErrorCode::KStopScopeInvalid => "stop_scope_invalid", + ApiErrorCode::KInvoicedBillingOff => "invoiced_billing_off", + ApiErrorCode::KInvoicedBalanceOutstanding => "invoiced_balance_outstanding", + ApiErrorCode::KPartnerHasOwnBook => "partner_has_own_book", + ApiErrorCode::KUnknownPriceBook => "unknown_price_book", + ApiErrorCode::KOperationNotRetryable => "operation_not_retryable", } } @@ -133,6 +203,42 @@ impl ApiErrorCode { "reconciliation_unverified" => Ok(ApiErrorCode::KReconciliationUnverified), "retention_managed_by_runanywhere" => Ok(ApiErrorCode::KRetentionManagedByRunanywhere), "card_required" => Ok(ApiErrorCode::KCardRequired), + "invalid_account_id" => Ok(ApiErrorCode::KInvalidAccountId), + "account_not_found" => Ok(ApiErrorCode::KAccountNotFound), + "identity_email_in_use" => Ok(ApiErrorCode::KIdentityEmailInUse), + "role_forbidden" => Ok(ApiErrorCode::KRoleForbidden), + "organization_playground_unavailable" => { + Ok(ApiErrorCode::KOrganizationPlaygroundUnavailable) + } + "operator_unknown" => Ok(ApiErrorCode::KOperatorUnknown), + "operator_forbidden" => Ok(ApiErrorCode::KOperatorForbidden), + "operator_scope" => Ok(ApiErrorCode::KOperatorScope), + "email_not_confirmed" => Ok(ApiErrorCode::KEmailNotConfirmed), + "invitation_not_found" => Ok(ApiErrorCode::KInvitationNotFound), + "invitation_expired" => Ok(ApiErrorCode::KInvitationExpired), + "already_a_member" => Ok(ApiErrorCode::KAlreadyAMember), + "last_owner" => Ok(ApiErrorCode::KLastOwner), + "role_not_available" => Ok(ApiErrorCode::KRoleNotAvailable), + "personal_account" => Ok(ApiErrorCode::KPersonalAccount), + "invitation_pending" => Ok(ApiErrorCode::KInvitationPending), + "too_many_pending_invitations" => Ok(ApiErrorCode::KTooManyPendingInvitations), + "member_not_found" => Ok(ApiErrorCode::KMemberNotFound), + "account_has_paid_credit" => Ok(ApiErrorCode::KAccountHasPaidCredit), + "account_has_live_reservation" => Ok(ApiErrorCode::KAccountHasLiveReservation), + "account_has_dedicated_pool" => Ok(ApiErrorCode::KAccountHasDedicatedPool), + "already_a_partner" => Ok(ApiErrorCode::KAlreadyAPartner), + "not_a_partner" => Ok(ApiErrorCode::KNotAPartner), + "gateway_taken" => Ok(ApiErrorCode::KGatewayTaken), + "unknown_price_version" => Ok(ApiErrorCode::KUnknownPriceVersion), + "model_not_priced" => Ok(ApiErrorCode::KModelNotPriced), + "go_live_evidence_missing" => Ok(ApiErrorCode::KGoLiveEvidenceMissing), + "notice_too_short" => Ok(ApiErrorCode::KNoticeTooShort), + "stop_scope_invalid" => Ok(ApiErrorCode::KStopScopeInvalid), + "invoiced_billing_off" => Ok(ApiErrorCode::KInvoicedBillingOff), + "invoiced_balance_outstanding" => Ok(ApiErrorCode::KInvoicedBalanceOutstanding), + "partner_has_own_book" => Ok(ApiErrorCode::KPartnerHasOwnBook), + "unknown_price_book" => Ok(ApiErrorCode::KUnknownPriceBook), + "operation_not_retryable" => Ok(ApiErrorCode::KOperationNotRetryable), other => Err(format!("unknown ApiErrorCode: {other}")), } } diff --git a/tests/test_contract_sync.py b/tests/test_contract_sync.py index f4090787..2b1c2192 100644 --- a/tests/test_contract_sync.py +++ b/tests/test_contract_sync.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""The CLI contract sync wrapper describes itself and refuses a pin with no provenance.""" +"""The CLI contract sync wrapper describes itself and refuses a pin with no provenance; the extractor carves the slice.""" from __future__ import annotations @@ -13,6 +13,7 @@ REPO = Path(__file__).resolve().parents[1] SYNC = REPO / "contracts" / "sync_from_inferenceinfra.py" EXTRACT = REPO / "contracts" / "wally-cli-v1.openapi.json" +EXTRACTOR = REPO / "contracts" / "extract-cli-contract.py" def _check(*extra: str) -> subprocess.CompletedProcess[str]: @@ -102,5 +103,79 @@ def test_check_refuses_each_missing_provenance_field(self): self._assert_refused(document, field) +class ExtractorNarrowingTests(unittest.TestCase): + """`GET /v1/models` is carved to the one form the CLI reads (extract-cli-contract.py).""" + + def _extract(self, source: dict) -> dict: + with tempfile.TemporaryDirectory() as directory: + source_path = Path(directory) / "control-plane-v1.openapi.json" + source_path.write_text(json.dumps(source), encoding="utf-8") + output = Path(directory) / "extract.json" + result = subprocess.run( + [sys.executable, str(EXTRACTOR), str(source_path), "--output", str(output)], + cwd=REPO, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr + result.stdout) + return json.loads(output.read_text(encoding="utf-8")) + + def _source_with_a_format_query(self) -> dict: + """The committed extract, with listModels as the control plane describes it once the + models list can also answer `?format=openrouter-v2`: a format query, and a `oneOf` + response with no discriminator whose second branch is a shape the generator cannot read.""" + document = json.loads(EXTRACT.read_text(encoding="utf-8")) + components = document["components"] + components["parameters"]["ModelListFormatQuery"] = { + "in": "query", + "name": "format", + "required": False, + "schema": {"enum": ["openrouter-v2"], "type": "string"}, + } + components["schemas"]["OpenRouterModelDocumentList"] = { + "type": "object", + "properties": {"data": {"type": "array", "items": {"type": "object"}}}, + } + components["schemas"]["ModelListResponse"] = { + "oneOf": [ + {"$ref": "#/components/schemas/ModelList"}, + {"$ref": "#/components/schemas/OpenRouterModelDocumentList"}, + ] + } + operation = document["paths"]["/v1/models"]["get"] + operation["parameters"] = [{"$ref": "#/components/parameters/ModelListFormatQuery"}] + operation["responses"]["200"]["content"]["application/json"]["schema"] = { + "$ref": "#/components/schemas/ModelListResponse" + } + return document + + def test_models_list_keeps_only_the_default_form(self): + extract = self._extract(self._source_with_a_format_query()) + operation = extract["paths"]["/v1/models"]["get"] + self.assertEqual( + {"$ref": "#/components/schemas/ModelList"}, + operation["responses"]["200"]["content"]["application/json"]["schema"], + ) + self.assertNotIn("parameters", operation) + self.assertNotIn("ModelListFormatQuery", extract["components"]["parameters"]) + self.assertNotIn("ModelListResponse", extract["components"]["schemas"]) + self.assertNotIn("OpenRouterModelDocumentList", extract["components"]["schemas"]) + + def test_the_other_form_changing_does_not_move_the_slice(self): + before = self._source_with_a_format_query() + after = self._source_with_a_format_query() + after["components"]["schemas"]["OpenRouterModelDocumentList"]["properties"]["next"] = { + "type": "string" + } + self.assertEqual(self._extract(before), self._extract(after)) + + def test_a_source_without_the_format_query_is_carried_unchanged(self): + document = json.loads(EXTRACT.read_text(encoding="utf-8")) + self.assertEqual( + document["paths"], self._extract(document)["paths"] + ) + + if __name__ == "__main__": unittest.main()