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
6 changes: 6 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 6 additions & 12 deletions cli/bun/src/core/service/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ async function modelList(context: Context): Promise<Json> {
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;
}
Expand All @@ -180,17 +180,19 @@ 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[] = [];
for (const raw of entries.slice(0, 64)) {
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;
Expand Down Expand Up @@ -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"]);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -105,11 +105,6 @@
"id": "model-luna",
"status": "available"
},
{
"id": "model-sol-legacy",
"status": "hidden",
"successor": "model-sol"
},
{
"id": "model-comet",
"status": "future_status"
Expand Down
33 changes: 11 additions & 22 deletions cli/rust/crates/prose-runner-core/src/service/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ fn project_models(shape: &Shape<'_>, body: &Map<String, Value>) -> Result<Value,
"default_model",
)?;
let mut result = json!({"models": models, "default_model": default});
if let Some(catalog) = catalog(body) {
if let Some(catalog) = catalog(body, true) {
result["catalog"] = Value::Array(catalog);
}
Ok(result)
Expand All @@ -289,8 +289,9 @@ const DEPRECATED: &str = "deprecated";
/// newer one may add statuses: a missing or non-list catalog is `None`, the
/// first 64 entries are read, an entry without a model `id` or a status
/// token, a repeated id or a `deprecated` model is skipped, and an invalid
/// optional field is left out (identical in the Bun product).
fn catalog(body: &Map<String, Value>) -> Option<Vec<Value>> {
/// 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<String, Value>, listing: bool) -> Option<Vec<Value>> {
let entries = body.get("catalog")?.as_array()?;
let mut projected: Vec<Value> = Vec::new();
for entry in entries.iter().take(64) {
Expand All @@ -309,7 +310,10 @@ fn catalog(body: &Map<String, Value>) -> Option<Vec<Value>> {
) 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});
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -472,7 +461,7 @@ pub(super) fn premium_in_catalog(
model: &str,
mode: crate::OutputMode,
) -> Option<RunnerError> {
let entries = catalog(body)?;
let entries = catalog(body, false)?;
let entry = entries.iter().find(|entry| entry["id"] == model)?;
(entry["status"] == PREMIUM).then(|| {
premium_refusal(
Expand All @@ -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<String, Value>, 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)
Expand Down Expand Up @@ -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"));
Expand Down
2 changes: 1 addition & 1 deletion cli/shared/schemas/service/discovery.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 4 additions & 6 deletions docs/service/discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
```

Expand Down
Loading