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:
1 parent
23b13f41b8
commit
cfb349508f
3 files changed
+79
-2
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user