Skip to content
Merged
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
26 changes: 25 additions & 1 deletion contracts/generate_console_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,37 @@ def _enum_constant(value: str) -> str:
def _resolve_type(schema: dict, schemas: dict) -> tuple[str, bool]:
"""Return (c++ type, is_optional). Nullable/anyOf-null collapses to optional."""
if "$ref" in schema:
return schema["$ref"].split("/")[-1], False
name = schema["$ref"].split("/")[-1]
target = schemas.get(name, {})
# A constrained/plain string newtype (type "string", not an enum, not an
# object) has no emitted type of its own -- only objects and enums get
# one -- so inline it as std::string rather than name an undefined type.
if target.get("type") == "string" and "enum" not in target:
return "std::string", False
return name, False
if "const" in schema:
# A fixed literal (e.g. `object: {const: "model"}`). Typed by its value;
# the reader still parses it, it just can only be that one value.
const = schema["const"]
if isinstance(const, bool):
return "bool", False
if isinstance(const, int):
return INT, False
return "std::string", False
if "anyOf" in schema:
branches = [b for b in schema["anyOf"] if b.get("type") != "null"]
had_null = any(b.get("type") == "null" for b in schema["anyOf"])
inner, _ = _resolve_type(branches[0], schemas)
return inner, had_null
kind = schema.get("type")
# JSON Schema nullable form `type: ["integer", "null"]`: strip the null,
# resolve the remaining type, and mark it optional. Same meaning as an
# anyOf-with-null, just spelled the compact way OpenAPI 3.1 emits.
if isinstance(kind, list):
non_null = [t for t in kind if t != "null"]
had_null = "null" in kind
inner, _ = _resolve_type({**schema, "type": non_null[0]}, schemas)
return inner, had_null
if kind == "string":
return "std::string", False
if kind == "integer":
Expand Down
217 changes: 217 additions & 0 deletions contracts/wally-cli-v1.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,163 @@
],
"title": "UsageTotals",
"type": "object"
},
"ModelList": {
"additionalProperties": false,
"properties": {
"data": {
"items": {
"$ref": "#/components/schemas/PublicModel"
},
"type": "array"
},
"object": {
"const": "list"
}
},
"required": [
"object",
"data"
],
"type": "object"
},
"ModelOwner": {
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"PublicModel": {
"additionalProperties": false,
"properties": {
"created": {
"description": "OpenAI-shaped creation timestamp (unix seconds) the gateway reports; informational.",
"minimum": 0,
"type": "integer"
},
"id": {
"$ref": "#/components/schemas/PublicModelId"
},
"max_input_tokens": {
"description": "The advertised ceiling on input tokens for this model -- what an OpenAI-shaped coding agent reads to decide when to compact (contracts/public/status_semantics.json context_window). null means the deployment has not established one.",
"minimum": 1,
"type": [
"integer",
"null"
]
},
"max_output_tokens": {
"description": "The advertised ceiling on output tokens, when the deployment declares one separately from the context window (gateway/litellm/config/proxy_config.yaml model_info; gemma-4 does, qwen3.8-27b shares one budget and omits it).",
"minimum": 1,
"type": [
"integer",
"null"
]
},
"mode": {
"description": "The gateway's model mode; every Wally model is a chat model.",
"enum": [
"chat"
],
"type": "string"
},
"object": {
"const": "model"
},
"owned_by": {
"$ref": "#/components/schemas/ModelOwner"
}
},
"required": [
"id",
"object",
"owned_by"
],
"type": "object"
},
"PublicModelId": {
"maxLength": 128,
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]*$",
"type": "string"
},
"CatalogModelResponse": {
"additionalProperties": false,
"description": "One entitled model, priced. Straight from `pricing.catalog()` - the same\nnumbers the credit gate charges against, so a published price cannot drift\nfrom a billed one (G7).",
"properties": {
"cached_input_per_mtok": {
"maximum": 9007199254740991.0,
"minimum": 0.0,
"title": "Cached Input Per Mtok",
"type": "integer"
},
"display_name": {
"maxLength": 255,
"minLength": 1,
"title": "Display Name",
"type": "string"
},
"id": {
"maxLength": 128,
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]*$",
"title": "Id",
"type": "string"
},
"input_per_mtok": {
"maximum": 9007199254740991.0,
"minimum": 0.0,
"title": "Input Per Mtok",
"type": "integer"
},
"output_per_mtok": {
"maximum": 9007199254740991.0,
"minimum": 0.0,
"title": "Output Per Mtok",
"type": "integer"
}
},
"required": [
"id",
"display_name",
"input_per_mtok",
"output_per_mtok",
"cached_input_per_mtok"
],
"title": "CatalogModelResponse",
"type": "object"
},
"ModelCatalogResponse": {
"additionalProperties": false,
"properties": {
"effective_from": {
"maxLength": 35,
"minLength": 20,
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}",
"title": "Effective From",
"type": "string"
},
"models": {
"items": {
"$ref": "#/components/schemas/CatalogModelResponse"
},
"title": "Models",
"type": "array"
},
"pricing_version": {
"maxLength": 40,
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$",
"title": "Pricing Version",
"type": "string"
}
},
"required": [
"pricing_version",
"effective_from",
"models"
],
"title": "ModelCatalogResponse",
"type": "object"
}
}
},
Expand Down Expand Up @@ -963,6 +1120,66 @@
"cli auth"
]
}
},
"/v1/models": {
"get": {
"operationId": "listModels",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelList"
}
}
},
"description": "Available model aliases"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"429": {
"$ref": "#/components/responses/RateLimited"
},
"default": {
"$ref": "#/components/responses/Error"
}
},
"security": [
{
"BearerAuth": []
}
],
"summary": "List models available to the current key",
"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.",
"operationId": "getModelCatalog",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelCatalogResponse"
}
}
},
"description": "Successful Response"
},
"default": {
"$ref": "#/components/responses/ApiError"
}
},
"summary": "Model Catalog",
"tags": [
"console"
]
}
}
}
}
85 changes: 85 additions & 0 deletions src/account/console.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,91 @@ IdentityResult ConsoleClient::WhoAmI(const std::string& console_url,
return IdentityResult::Ok;
}

IdentityResult ConsoleClient::FetchModels(const std::string& console_url,
const std::string& access_token,
std::vector<ModelInfo>* models,
std::string* error) const {
if (models == nullptr || !SessionTokenIsSafe(access_token)) {
if (error != nullptr) {
*error = "no access token is available";
}
return IdentityResult::Failed;
}
std::string origin;
if (!ConsoleOrigin(console_url, &origin, error)) {
return IdentityResult::Failed;
}
HttpResponse response;
if (!Send(transport_, {"GET", origin + "/v1/models", {}, access_token}, &response, error)) {
return IdentityResult::Failed;
}
if (response.status == 401) {
if (error != nullptr) {
*error = "console session expired";
}
return IdentityResult::Unauthorized;
}
if (response.status != 200) {
HttpError("models request", origin, response, error);
return IdentityResult::Failed;
}
contract::ModelList parsed;
if (!ParseContract(response, &parsed, error)) {
return IdentityResult::Failed;
}
models->clear();
models->reserve(parsed.data.size());
for (const contract::PublicModel& model : parsed.data) {
ModelInfo info;
info.id = model.id;
info.context_window = model.max_input_tokens.value_or(0);
info.max_output_tokens = model.max_output_tokens.value_or(0);
models->push_back(std::move(info));
}
return IdentityResult::Ok;
}

IdentityResult ConsoleClient::FetchCatalog(const std::string& console_url,
const std::string& access_token,
std::vector<CatalogPrice>* prices,
std::string* error) const {
if (prices == nullptr || !SessionTokenIsSafe(access_token)) {
if (error != nullptr) {
*error = "no access token is available";
}
return IdentityResult::Failed;
}
std::string origin;
if (!ConsoleOrigin(console_url, &origin, error)) {
return IdentityResult::Failed;
}
HttpResponse response;
if (!Send(transport_, {"GET", origin + "/v1/models/catalog", {}, access_token}, &response,
error)) {
return IdentityResult::Failed;
}
if (response.status == 401) {
if (error != nullptr) {
*error = "console session expired";
}
return IdentityResult::Unauthorized;
}
if (response.status != 200) {
HttpError("model catalog request", origin, response, error);
return IdentityResult::Failed;
}
contract::ModelCatalogResponse parsed;
if (!ParseContract(response, &parsed, error)) {
return IdentityResult::Failed;
}
prices->clear();
prices->reserve(parsed.models.size());
for (const contract::CatalogModelResponse& model : parsed.models) {
prices->push_back(CatalogPrice{model.id, model.input_per_mtok, model.output_per_mtok});
}
return IdentityResult::Ok;
}

namespace {

/// A console string that is safe to print in a terminal. Anything else is
Expand Down
Loading
Loading