From cfb349508facea92fabd87d05106abd99270c591 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Mon, 26 Oct 2026 20:02:28 -0500 Subject: [PATCH] feat(claw): serve the vision flag on model listings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The models route's cards serialize as rhema-provider's ModelCard spells them, so supports_vision rides exactly when the adapter knows it and is absent when it does not — unknown stays absent, never false. The route's doc now pins that contract, and a listing builder on Backend lets the surface tests mount one offline. --- crates/rhema-claw/src/backends.rs | 12 ++++++ crates/rhema-claw/src/http.rs | 8 +++- crates/rhema-claw/tests/http.rs | 61 +++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/rhema-claw/src/backends.rs b/crates/rhema-claw/src/backends.rs index 0b51c07..58b17b6 100644 --- a/crates/rhema-claw/src/backends.rs +++ b/crates/rhema-claw/src/backends.rs @@ -115,6 +115,18 @@ impl Backend { list_models: None, } } + + /// Attaches a live listing. [`construct`] builds the real ones off + /// the provider; this shape exists so the surface's model routes + /// can be exercised offline — a test mounts a listing over a + /// scripted card set. + pub fn with_listing( + mut self, + list_models: impl Fn() -> ModelListing + Send + Sync + 'static, + ) -> Self { + self.list_models = Some(Arc::new(list_models)); + self + } } /// The currently active backend instance: id plus the model turns run on. diff --git a/crates/rhema-claw/src/http.rs b/crates/rhema-claw/src/http.rs index ee46341..28c178b 100644 --- a/crates/rhema-claw/src/http.rs +++ b/crates/rhema-claw/src/http.rs @@ -1864,8 +1864,12 @@ async fn providers_active( } /// `GET /v1/providers/{id}/models`: the instance's live listing, -/// straight from the provider. An id without a listing (unknown, or the -/// mock) is a 404. +/// straight from the provider, the cards serialized as rhema-provider's +/// `ModelCard` spells them. `supports_vision` rides when the adapter +/// knows — `true` or `false` — and is absent when it does not: unknown +/// stays absent, never `false`, and a client reads the key's absence as +/// no verdict at all. An id without a listing (unknown, or the mock) is +/// a 404. async fn providers_models( State(state): State, Path(id): Path, diff --git a/crates/rhema-claw/tests/http.rs b/crates/rhema-claw/tests/http.rs index b874f48..3387fff 100644 --- a/crates/rhema-claw/tests/http.rs +++ b/crates/rhema-claw/tests/http.rs @@ -1630,6 +1630,67 @@ async fn providers_are_listed_switched_and_persisted() { assert_eq!(daemon.backends().active().1, "mock-large"); } +/// The models route: the cards serialize as rhema-provider's +/// `ModelCard`, so a model the adapter knows is vision-capable rides +/// with `supports_vision: true` and an unknown one omits the key +/// entirely — unknown stays absent, never `false`. +#[tokio::test] +async fn model_listings_carry_the_vision_flag_only_when_known() { + let card = |id: &str, vision: Option| rhema_provider::ModelCard { + id: id.to_owned(), + display_name: id.to_owned(), + description: String::new(), + input_token_limit: None, + output_token_limit: None, + supports_vision: vision, + generation_methods: vec!["generateContent".to_owned()], + pricing: None, + }; + let cards = vec![ + card("claude-sonnet-4-5", Some(true)), + card("some-foreign-model", None), + ]; + let backend = + rhema_claw::Backend::headless("anthropic", Arc::new(MockProvider::new([reply("on it")]))) + .with_listing(move || { + let cards = cards.clone(); + Box::pin(async move { Ok(cards) }) + }); + let dir = tempfile::tempdir().expect("tempdir"); + let daemon = Daemon::build( + &config(&dir, None), + Arc::new( + rhema_claw::BackendRegistry::new(vec![backend], None) + .expect("a single backend always constructs"), + ), + rhema_rsd::DispatcherConfig::default(), + ) + .expect("the daemon assembles"); + let base = serve(&daemon).await; + + let listed: Value = reqwest::Client::new() + .get(format!("{base}/v1/providers/anthropic/models")) + .send() + .await + .expect("the listing reads") + .json() + .await + .expect("the listing parses"); + let entries = listed["models"].as_array().expect("the models array"); + assert_eq!(entries.len(), 2, "{listed}"); + assert_eq!(entries[0]["id"], "claude-sonnet-4-5"); + assert_eq!( + entries[0]["supports_vision"], true, + "a known-vision model says so: {listed}" + ); + assert_eq!(entries[1]["id"], "some-foreign-model"); + assert!( + entries[1].get("supports_vision").is_none(), + "unknown stays absent, not false: {:?}", + entries[1] + ); +} + /// The credentials surface: a runtime login adds a backend, the list /// names where its credential came from, a re-add replaces it in place, /// and a removal drops it — refusing to remove the last backend, and