diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 069553ac..093313a2 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -11,6 +11,12 @@ a candidate do not establish public availability or authorize publication. ## [Unreleased] +### Hosted service + +- `model list` no longer lists models the service marks `hidden`, in human + output or in `result.catalog`. They stay accepted when named (`--model`, a + job spec's `model`), and nothing else enumerates them. + ## [0.15.0-rc.2] — 2026-10-02 These are accumulated changes in the 0.15 candidate train; some capabilities diff --git a/cli/bun/src/core/service/discovery.ts b/cli/bun/src/core/service/discovery.ts index 89087a28..1769f8ac 100644 --- a/cli/bun/src/core/service/discovery.ts +++ b/cli/bun/src/core/service/discovery.ts @@ -159,7 +159,7 @@ async function modelList(context: Context): Promise { export function projectModels(shape: Shape, body: JsonObject): JsonObject { const models = shape.list(shape.field(body, "models", "models"), 64, "models").map((item) => shape.model(item, "models")); const result: JsonObject = { models, default_model: shape.model(shape.field(body, "default_model", "default_model"), "default_model") }; - const entries = catalog(body); + const entries = catalog(body, true); if (entries !== undefined) result.catalog = entries; return result; } @@ -180,9 +180,11 @@ const isModelId = (value: unknown): value is string => typeof value === "string" * each entry's `id` and `status`, plus `tier`, `summary` and `successor` when * valid. A missing or non-list catalog is undefined; the first 64 entries are * read; an entry without a model id or status token, a repeated id or a - * `deprecated` model is skipped; an invalid optional field is left out. + * `deprecated` model is skipped; an invalid optional field is left out. A + * listing also skips `hidden` models: they are accepted when named but never + * shown. */ -function catalog(body: JsonObject): JsonObject[] | undefined { +function catalog(body: JsonObject, listing = false): JsonObject[] | undefined { const entries = body.catalog; if (!Array.isArray(entries)) return undefined; const projected: JsonObject[] = []; @@ -190,7 +192,7 @@ function catalog(body: JsonObject): JsonObject[] | undefined { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) continue; const { id, status } = raw; if (!isModelId(id) || !token(status)) continue; - if (status === DEPRECATED || projected.some((known) => known.id === id)) continue; + if (status === DEPRECATED || (listing && status === HIDDEN) || projected.some((known) => known.id === id)) continue; const item: JsonObject = { id, status }; if (token(raw.tier)) item.tier = raw.tier; const summary = raw.summary; @@ -232,14 +234,6 @@ function modelsHuman(result: JsonObject, environment: Environment): string { } text += nextLine(environment, TOPUP); } - const hidden = entries.filter((entry) => entry.status === HIDDEN); - if (hidden.length > 0) { - text += "Also accepted (not recommended):\n"; - for (const entry of hidden) { - const id = humanSafeScalar(entry.id as string); - text += typeof entry.successor === "string" ? ` ${id} → use ${humanSafeScalar(entry.successor)}\n` : ` ${id}\n`; - } - } return text + nextLine(environment, ["run", "quote"]); } diff --git a/cli/conformance/cases/service/discovery/model-list-catalog-human.json b/cli/conformance/cases/service/discovery/model-list-catalog-human.json index 6c49df6b..2d1156cb 100644 --- a/cli/conformance/cases/service/discovery/model-list-catalog-human.json +++ b/cli/conformance/cases/service/discovery/model-list-catalog-human.json @@ -2,7 +2,7 @@ "id": "model-list-catalog-human", "feature": "discovery", "operation": "model.list", - "description": "With a /models catalog, human `model list` prints the models this account can run (default marked), the premium models any wallet top-up unlocks with their summary and a top-up preview, and the older ids still accepted with the model to use instead. No model id is built in: every line comes from the response.", + "description": "With a /models catalog, human `model list` prints the models this account can run (default marked) and the premium models any wallet top-up unlocks with their summary and a top-up preview; hidden models, still accepted when named, are not listed. No model id is built in: every line comes from the response.", "argv": [ "cli", "model", @@ -59,6 +59,6 @@ }, "exitCode": 0, "stdout": { - "text": "Available:\n model-sol (default)\n model-luna\nPremium — unlocks with any wallet top-up:\n model-astra: Most capable model for hard, long tasks. Higher rate per run. Unlocks with any wallet top-up.\nNext: prose cli wallet topup --amount-cents 500 --preview\nAlso accepted (not recommended):\n model-sol-legacy → use model-sol\nNext: prose cli run quote\n" + "text": "Available:\n model-sol (default)\n model-luna\nPremium — unlocks with any wallet top-up:\n model-astra: Most capable model for hard, long tasks. Higher rate per run. Unlocks with any wallet top-up.\nNext: prose cli wallet topup --amount-cents 500 --preview\nNext: prose cli run quote\n" } } diff --git a/cli/conformance/cases/service/discovery/model-list-catalog-json.json b/cli/conformance/cases/service/discovery/model-list-catalog-json.json index f3fe20f8..38e670b1 100644 --- a/cli/conformance/cases/service/discovery/model-list-catalog-json.json +++ b/cli/conformance/cases/service/discovery/model-list-catalog-json.json @@ -2,7 +2,7 @@ "id": "model-list-catalog-json", "feature": "discovery", "operation": "model.list", - "description": "`catalog` is projected through an allowlist (id, status, tier, summary, successor): internal entry fields are dropped, a deprecated model is never shown, an entry without a valid model id is skipped, an unknown status is kept, and an invalid tier or summary is left out.", + "description": "`catalog` is projected through an allowlist (id, status, tier, summary, successor): internal entry fields are dropped, a deprecated or hidden model is never shown, an entry without a valid model id is skipped, an unknown status is kept, and an invalid tier or summary is left out.", "argv": [ "--output", "json", @@ -105,11 +105,6 @@ "id": "model-luna", "status": "available" }, - { - "id": "model-sol-legacy", - "status": "hidden", - "successor": "model-sol" - }, { "id": "model-comet", "status": "future_status" diff --git a/cli/rust/crates/prose-runner-core/src/service/discovery.rs b/cli/rust/crates/prose-runner-core/src/service/discovery.rs index 9bff1c18..d3e24ebe 100644 --- a/cli/rust/crates/prose-runner-core/src/service/discovery.rs +++ b/cli/rust/crates/prose-runner-core/src/service/discovery.rs @@ -270,7 +270,7 @@ fn project_models(shape: &Shape<'_>, body: &Map) -> Result) -> Option> { +/// optional field is left out (identical in the Bun product). A listing also +/// skips `hidden` models: they are accepted when named but never shown. +fn catalog(body: &Map, listing: bool) -> Option> { let entries = body.get("catalog")?.as_array()?; let mut projected: Vec = Vec::new(); for entry in entries.iter().take(64) { @@ -309,7 +310,10 @@ fn catalog(body: &Map) -> Option> { ) else { continue; }; - if status == DEPRECATED || projected.iter().any(|known| known["id"] == id) { + if status == DEPRECATED + || (listing && status == HIDDEN) + || projected.iter().any(|known| known["id"] == id) + { continue; } let mut item = json!({"id": id, "status": status}); @@ -409,21 +413,6 @@ fn models_human(result: &Value) -> String { } text.push_str(&render::next_line(&TOPUP)); } - let hidden = with_status(HIDDEN); - if !hidden.is_empty() { - text.push_str("Also accepted (not recommended):\n"); - for entry in hidden { - let id = human_safe_scalar(entry["id"].as_str().unwrap_or_default()); - match entry["successor"].as_str() { - Some(successor) => { - let _ = writeln!(text, " {id} \u{2192} use {}", human_safe_scalar(successor)); - } - None => { - let _ = writeln!(text, " {id}"); - } - } - } - } text.push_str(&render::next_line(&["run", "quote"])); text } @@ -472,7 +461,7 @@ pub(super) fn premium_in_catalog( model: &str, mode: crate::OutputMode, ) -> Option { - let entries = catalog(body)?; + let entries = catalog(body, false)?; let entry = entries.iter().find(|entry| entry["id"] == model)?; (entry["status"] == PREMIUM).then(|| { premium_refusal( @@ -488,7 +477,7 @@ pub(super) fn premium_in_catalog( /// Whether the `/models` catalog lists `model` as one the service accepts /// (any status but premium; retired models are never listed). pub(super) fn accepted_in_catalog(body: &Map, model: &str) -> bool { - catalog(body).is_some_and(|entries| { + catalog(body, false).is_some_and(|entries| { entries .iter() .any(|entry| entry["id"] == model && entry["status"] != PREMIUM) @@ -1018,9 +1007,9 @@ mod tests { json!([ {"id": "model-astra", "status": "paid_top_up", "tier": "premium", "summary": "Big."}, {"id": "model-sol", "status": "available"}, - {"id": "model-sol-legacy", "status": "hidden", "successor": "model-sol"}, ]) ); + // Hidden models are never listed but stay accepted when named. assert!(accepted_in_catalog(&body, "model-sol-legacy")); assert!(!accepted_in_catalog(&body, "model-astra")); assert!(!accepted_in_catalog(&body, "model-old")); diff --git a/cli/shared/schemas/service/discovery.schema.json b/cli/shared/schemas/service/discovery.schema.json index dbe33098..05c13aae 100644 --- a/cli/shared/schemas/service/discovery.schema.json +++ b/cli/shared/schemas/service/discovery.schema.json @@ -60,7 +60,7 @@ "$ref": "common.schema.json#/$defs/modelId" }, "catalog": { - "description": "Every model the service names with its status, projected through an allowlist: `available` (listed in models), `paid_top_up` (premium: unlocks with any wallet top-up), `hidden` (still accepted, not recommended; `successor` names the model to use) or a newer status. Absent when the service sends no catalog; retired models are never listed.", + "description": "Every model the service names with its status, projected through an allowlist: `available` (listed in models), `paid_top_up` (premium: unlocks with any wallet top-up), or a newer status. Absent when the service sends no catalog; `hidden` models (still accepted when named) and retired models are never listed.", "type": "array", "maxItems": 64, "items": { diff --git a/docs/service/discovery.md b/docs/service/discovery.md index 8e7ebf8c..31dcfd78 100644 --- a/docs/service/discovery.md +++ b/docs/service/discovery.md @@ -140,13 +140,13 @@ Next: prose cli run quote A newer service also sends `catalog`: every model it names with a `status`. The CLI projects each entry through an allowlist (`id`, `status`, `tier`, `summary`, `successor`) into `result.catalog`, skips entries without a valid -model id or status and never shows a `deprecated` model; without a catalog +model id or status and never shows a `deprecated` or `hidden` model; without a catalog (older servers) the output above is unchanged. No model id is built into the CLI: every line comes from the response. With a catalog, human mode prints sections: the models this account can run (`models`, default marked), the -premium models (`paid_top_up`) with their summary and a top-up preview, and -the older ids the service still accepts (`hidden`) with the model to use -instead. +premium models (`paid_top_up`) with their summary and a top-up preview. +Older ids the service still accepts (`hidden`) are not listed anywhere; they +keep working when named, for example `--model` or a job spec's `model`. ```text Available: @@ -155,8 +155,6 @@ Available: Premium — unlocks with any wallet top-up: model-astra: Most capable model for hard, long tasks. Higher rate per run. Unlocks with any wallet top-up. Next: prose cli wallet topup --amount-cents 500 --preview -Also accepted (not recommended): - model-sol-legacy → use model-sol Next: prose cli run quote ```