mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 05:12:33 +00:00
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user