feat(claw): serve the vision flag on model listings

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.
This commit is contained in:
vhaudiquet committed 2026-10-26 23:13:41 -05:00
1 parent 23b13f41b8
commit cfb349508f
3 files changed
+79 -2

No files matched your search

+12
View File
@@ -115,6 +115,18 @@ impl Backend {
list_models: None, 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. /// The currently active backend instance: id plus the model turns run on.
+6 -2
View File
@@ -1864,8 +1864,12 @@ async fn providers_active(
} }
/// `GET /v1/providers/{id}/models`: the instance's live listing, /// `GET /v1/providers/{id}/models`: the instance's live listing,
/// straight from the provider. An id without a listing (unknown, or the /// straight from the provider, the cards serialized as rhema-provider's
/// mock) is a 404. /// `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( async fn providers_models(
State(state): State<ClawState>, State(state): State<ClawState>,
Path(id): Path<String>, Path(id): Path<String>,
+61
View File
@@ -1630,6 +1630,67 @@ async fn providers_are_listed_switched_and_persisted() {
assert_eq!(daemon.backends().active().1, "mock-large"); 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<bool>| 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 /// The credentials surface: a runtime login adds a backend, the list
/// names where its credential came from, a re-add replaces it in place, /// 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 /// and a removal drops it — refusing to remove the last backend, and