fix(inference): treat null list_models data as empty catalog (#4121) (#4157)

This commit is contained in:
oxoxDev
2026-06-28 13:19:15 -07:00
committed by GitHub
parent 01c35414b5
commit 65e4e29d65
2 changed files with 104 additions and 4 deletions
+37 -3
View File
@@ -256,13 +256,21 @@ pub async fn list_configured_models_from_config(
/// 1. **Missing `data`/`models` field** — endpoint isn't `/models`-compatible
/// (user typo'd the base URL, pointed at a vector-DB host, etc.).
/// 2. **`data`/`models` field present but wrong type** — provider returned
/// `{"object":"error","data":{…}}`, `{"data":null}`, or similar
/// non-array. The error names the actual JSON type so triage knows what
/// the provider sent.
/// `{"object":"error","data":{…}}` or similar non-array. The error names
/// the actual JSON type so triage knows what the provider sent.
/// 3. **Non-object top-level body** — provider returned a bare array,
/// string, etc. Caught explicitly so the parser doesn't silently
/// drop into the missing-data arm with a `<non-object>` keys list.
///
/// A **null** `data`/`models` field on a **success envelope** is NOT an
/// error — Ollama's OpenAI-compatible `/v1/models` null-encodes the catalog
/// (`{"object":"list","data":null}`) when no models are pulled, so it is
/// treated as an empty model list (TAURI-RUST-874 / TAURI-RUST-875). The
/// null-as-empty short-circuit is gated on `object` being absent or `"list"`:
/// a null `data` on an error envelope (`{"object":"error","data":null}`)
/// instead falls through to failure mode 2 so the provider error still
/// surfaces in the UI and Sentry.
///
/// Per-entry parsing ignores entries that don't have a usable string id/slug
/// (lax on purpose — many OpenAI-compatible servers include malformed rows for
/// capabilities they don't fully implement).
@@ -286,6 +294,32 @@ pub fn parse_models_response(body: &serde_json::Value) -> Result<Vec<ModelInfo>,
)
})?;
// A null `data`/`models` field is a valid empty catalog ONLY on a success
// envelope: Ollama's OpenAI-compatible `/v1/models` returns
// `{"object":"list","data":null}` (object="list", the success marker) when
// no models are pulled. Treat that as an empty model list so a healthy-but-
// empty local runtime doesn't manufacture a hard error (TAURI-RUST-874 /
// TAURI-RUST-875).
//
// An error body such as `{"object":"error","data":null}` ALSO null-encodes
// `data`; swallowing it as an empty catalog would hide provider/endpoint
// failures from the UI and Sentry. So gate on a success envelope: short-
// circuit only when `object` is absent or "list". Any other `object` value
// (e.g. "error") with null `data` falls through to the descriptive error
// below, which surfaces the `object` value for triage. Non-array kinds
// (object/string/number/bool) likewise fall through.
let is_success_envelope = obj
.get("object")
.and_then(|value| value.as_str())
.map_or(true, |object| object.eq_ignore_ascii_case("list"));
if data_value.is_null() && is_success_envelope {
log::info!(
"[providers][list_models] `{field_name}` is null on a success envelope — provider returned an empty catalog (no models)"
);
return Ok(Vec::new());
}
let data = data_value.as_array().ok_or_else(|| {
// Include the sibling `object` field if present — OpenAI-shaped
// servers set it to `"list"` on success and `"error"` (or omit)
+67 -1
View File
@@ -948,13 +948,14 @@ fn parse_models_response_distinguishes_missing_data_field_from_wrong_type() {
// shape (`object` + `data` keys both present, but `data` isn't an
// array). The error MUST NOT say "missing" — it must surface the
// actual JSON type so triage knows what shape the provider sent.
// `null` is deliberately excluded here — it is a valid empty catalog,
// not a wrong type (see `parse_models_response_treats_null_data_as_empty_list`).
for (label, value) in [
(
"object",
serde_json::json!({"object":"error","message":"boom"}),
),
("string", serde_json::json!("models go here")),
("null", serde_json::Value::Null),
("bool", serde_json::json!(true)),
("number", serde_json::json!(42)),
] {
@@ -971,6 +972,71 @@ fn parse_models_response_distinguishes_missing_data_field_from_wrong_type() {
}
}
#[test]
fn parse_models_response_treats_null_data_as_empty_list() {
// TAURI-RUST-874 / TAURI-RUST-875: Ollama's OpenAI-compatible
// `/v1/models` null-encodes the catalog (`{"object":"list","data":null}`)
// when no models are pulled. A null `data`/`models` field is a valid empty
// model list, not a malformed envelope — it MUST parse to an empty Vec
// instead of manufacturing a hard error that floods Sentry.
let data_null = serde_json::json!({ "object": "list", "data": serde_json::Value::Null });
let models = parse_models_response(&data_null)
.expect("null `data` must parse as an empty catalog, not an error");
assert!(
models.is_empty(),
"null `data` must yield an empty model list, got {models:?}"
);
// The sibling `models` key (Codex-shaped envelope) gets the same treatment.
let models_null = serde_json::json!({ "object": "list", "models": serde_json::Value::Null });
let models = parse_models_response(&models_null)
.expect("null `models` must parse as an empty catalog, not an error");
assert!(
models.is_empty(),
"null `models` must yield an empty model list, got {models:?}"
);
// A bare success envelope with no `object` field still null-encodes an
// empty catalog (treated as success — `object` absent ⇒ not an error).
let object_absent = serde_json::json!({ "data": serde_json::Value::Null });
let models = parse_models_response(&object_absent)
.expect("null `data` with no `object` field must parse as an empty catalog");
assert!(
models.is_empty(),
"null `data` (object absent) must yield an empty model list, got {models:?}"
);
}
#[test]
fn parse_models_response_rejects_null_data_on_error_envelope() {
// Codex P2 (PR #4157): an HTTP-200 error body such as
// `{"object":"error","data":null}` ALSO null-encodes `data`. The
// null-as-empty short-circuit MUST NOT swallow it as a successful empty
// catalog — that would hide provider/endpoint failures from the UI and
// Sentry. A non-"list" `object` with null `data` falls through to the
// descriptive malformed/error-envelope error, which surfaces `object`.
for field in ["data", "models"] {
let body = serde_json::json!({ "object": "error", field: serde_json::Value::Null });
let err = match parse_models_response(&body) {
Ok(models) => panic!(
"null `{field}` on an error envelope must fail, not return empty (got {models:?})"
),
Err(err) => err,
};
assert!(
!err.contains("missing"),
"error-envelope null `{field}` must not say `missing`: {err}"
);
// Tighten on the surfaced `object` value, not the literal "error
// envelope" prose, so the assertion proves the provider error is
// actually carried through to triage.
assert!(
err.contains(r#""object" = "error""#),
"error-envelope null `{field}` must surface `\"object\" = \"error\"`: {err}"
);
}
}
#[test]
fn openai_codex_model_hints_are_merged_without_duplicates() {
let mut models = vec![ModelInfo {