feat(login): custom provider logins fetch and select models
After the base URL, name, and key steps, the login now opens a model
selection screen instead of blindly saving. The listing fetch runs
concurrently with a manual-id input — a WAF challenge page or a 404 no
longer passes validation silently: a base without a /vN tail is probed
once as {base}/v1 and the working base is adopted, an anthropic-wire
listing that 401s on x-api-key retries once with Bearer (relays gate
GET /models on Bearer while /messages takes the key header), and a
failed fetch shows its reason while manual add stays usable. The chosen
ids persist as [platforms.<name>] models = [...]; catalog assembly
synthesizes selected ids the listing never served (before [model.*]
overrides so they compose) and filters unselected provider entries
(after overrides, authoritative in the provider namespace). Provider-
owned entries keep their declared wire against donor backend
inheritance, and listing errors quote line/column only — a reflected
body could carry the key.
This commit is contained in:
1 parent
1d631a07fa
commit
9a2a9eac99
20 files changed
+1655
-68
No files matched your search
@@ -0,0 +1,233 @@
|
||||
//! The `[platforms.<name>] models` allowlist: keep the selected, synthesize the manual.
|
||||
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::CredentialedProvider;
|
||||
use crate::agent::config::{ModelEntry, PlatformsConfig};
|
||||
|
||||
/// Synthesizes selected ids the listing never served; runs before `[model.*]` overrides.
|
||||
pub(crate) fn synthesize_selected(
|
||||
resolved: &mut IndexMap<String, ModelEntry>,
|
||||
platforms: &PlatformsConfig,
|
||||
keyed: &[CredentialedProvider],
|
||||
) {
|
||||
for credentialed in keyed {
|
||||
let provider = &credentialed.provider;
|
||||
let Some(list) = platforms
|
||||
.entries
|
||||
.get(&provider.name)
|
||||
.and_then(|e| e.models.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for id in list.iter().map(|m| m.trim()) {
|
||||
if let Err(e) = kigi_models::custom::validate_model_id(id) {
|
||||
tracing::warn!(provider = %provider.name, %e, "skipping an invalid selected model id");
|
||||
continue;
|
||||
}
|
||||
let key = provider.managed_model_key(id);
|
||||
if resolved.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
let entry = super::fetch::manual_entry(provider, id);
|
||||
resolved.insert(key, ModelEntry::from_config_entry(&entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops `{name}/*` entries outside the provider's selection; runs after `[model.*]` overrides.
|
||||
pub(crate) fn filter_unselected(
|
||||
resolved: &mut IndexMap<String, ModelEntry>,
|
||||
platforms: &PlatformsConfig,
|
||||
keyed: &[CredentialedProvider],
|
||||
) {
|
||||
for credentialed in keyed {
|
||||
let provider = &credentialed.provider;
|
||||
let Some(list) = platforms
|
||||
.entries
|
||||
.get(&provider.name)
|
||||
.and_then(|e| e.models.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let list: Vec<&str> = list.iter().map(|m| m.trim()).collect();
|
||||
let prefix = format!("{}/", provider.name);
|
||||
resolved.retain(|key, entry| {
|
||||
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
|
||||
id.strip_prefix(&prefix)
|
||||
.is_none_or(|bare| list.contains(&bare))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kigi_models::custom::{CustomApi, CustomProvider};
|
||||
|
||||
use crate::agent::models::PlatformApiKeys;
|
||||
|
||||
fn provider(name: &str, base: &str) -> CustomProvider {
|
||||
CustomProvider::new(name, CustomApi::OpenAi, base).unwrap()
|
||||
}
|
||||
|
||||
fn fetched_entry(provider: &CustomProvider, id: &str) -> (String, ModelEntry) {
|
||||
let cfg = super::super::fetch::manual_entry(provider, id);
|
||||
let key = cfg.id.clone().unwrap();
|
||||
(key, ModelEntry::from_config_entry(&cfg))
|
||||
}
|
||||
|
||||
fn keyed(provider: CustomProvider) -> Vec<CredentialedProvider> {
|
||||
vec![CredentialedProvider::for_test(provider, "sk-x")]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_filters_the_listing_and_synthesizes_the_manual() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
for id in ["m1", "m2"] {
|
||||
let (key, entry) = fetched_entry(&proxy, id);
|
||||
resolved.insert(key, entry);
|
||||
}
|
||||
let platforms: PlatformsConfig = toml::from_str(
|
||||
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"m2\", \"manual-1\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
let ids: Vec<_> = resolved.keys().map(String::as_str).collect();
|
||||
assert_eq!(ids, ["proxy/m2", "proxy/manual-1"]);
|
||||
assert_eq!(resolved["proxy/manual-1"].info.model, "manual-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_provider_without_the_models_key_keeps_its_whole_listing() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
let (key, entry) = fetched_entry(&proxy, "m1");
|
||||
resolved.insert(key, entry);
|
||||
let platforms: PlatformsConfig =
|
||||
toml::from_str("[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\n")
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
assert!(resolved.contains_key("proxy/m1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_selection_drops_every_fetched_entry() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
let (key, entry) = fetched_entry(&proxy, "m1");
|
||||
resolved.insert(key, entry);
|
||||
let platforms: PlatformsConfig = toml::from_str(
|
||||
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = []\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_selected_ids_are_skipped_with_a_warning() {
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let mut resolved = IndexMap::new();
|
||||
let platforms: PlatformsConfig = toml::from_str(
|
||||
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"ok\", \"has space\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
|
||||
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
|
||||
|
||||
assert!(resolved.contains_key("proxy/ok"));
|
||||
assert_eq!(resolved.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_applies_through_resolve_model_list_and_gets_the_key() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"manual-1\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let keys =
|
||||
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, None, &keys);
|
||||
|
||||
let entry = resolved
|
||||
.get("proxy/manual-1")
|
||||
.expect("a manual model survives without any fetched catalog");
|
||||
assert_eq!(entry.api_key.as_deref(), Some("sk-proxy"));
|
||||
assert!(entry.has_own_credentials());
|
||||
assert_eq!(entry.info.context_window.get(), 256_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_model_override_composes_with_the_synthesized_entry() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"manual-1\"]\n\n[model.\"proxy/manual-1\"]\ncontext_window = 1000000\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let keys =
|
||||
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, None, &keys);
|
||||
|
||||
let entry = &resolved["proxy/manual-1"];
|
||||
assert_eq!(entry.info.context_window.get(), 1_000_000);
|
||||
assert_eq!(entry.info.base_url, "https://p.example/v1");
|
||||
assert_eq!(entry.api_key.as_deref(), Some("sk-proxy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_is_authoritative_in_the_provider_namespace() {
|
||||
// A context-only override cannot resurrect a deselected model.
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"m2\"]\n\n[model.\"proxy/m1\"]\ncontext_window = 123456\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let (key, entry) = fetched_entry(&proxy, "m1");
|
||||
let prefetched = IndexMap::from([(key, entry)]);
|
||||
let keys =
|
||||
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, Some(prefetched), &keys);
|
||||
|
||||
assert!(!resolved.contains_key("proxy/m1"));
|
||||
assert!(resolved.contains_key("proxy/m2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_owned_entries_keep_their_declared_wire_against_donors() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"shared\"]\n\n[model.donorx]\nmodel = \"shared\"\napi_backend = \"messages\"\ncontext_window = 500000\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
|
||||
let proxy = provider("proxy", "https://p.example/v1");
|
||||
let keys =
|
||||
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
|
||||
|
||||
let resolved = crate::agent::config::resolve_model_list(&cfg, None, &keys);
|
||||
|
||||
let entry = &resolved["proxy/shared"];
|
||||
assert_eq!(
|
||||
entry.info.api_backend,
|
||||
crate::sampling::ApiBackend::ChatCompletions
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user