use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::schema::{CostModel, ModelSchema, TrustTier};
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct DiscoveryDoc {
#[serde(default)]
pub models: Vec<ModelSchema>,
}
pub const DISCOVERED_MODELS_FILE: &str = "discovered_models.json";
pub fn cache_path(state_models_dir: &Path) -> PathBuf {
state_models_dir.join(DISCOVERED_MODELS_FILE)
}
pub fn load_cache(path: &Path) -> Vec<ModelSchema> {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<DiscoveryDoc>(&s).ok())
.map(|d| {
d.models
.into_iter()
.map(|mut schema| {
schema.mark_user_registered();
schema
})
.collect()
})
.unwrap_or_default()
}
pub fn save_cache(path: &Path, models: &[ModelSchema]) -> Result<(), String> {
let models = models
.iter()
.cloned()
.map(|mut schema| {
schema.mark_user_registered();
schema
})
.collect();
let doc = DiscoveryDoc { models };
let json = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(path, json).map_err(|e| e.to_string())
}
pub fn is_chat_model(id: &str) -> bool {
let id = id.to_ascii_lowercase();
let is_gen_family = id.starts_with("gpt-")
|| id.starts_with("chatgpt-")
|| (id.starts_with('o') && id[1..].chars().next().is_some_and(|c| c.is_ascii_digit()));
if !is_gen_family {
return false;
}
const DENY: &[&str] = &[
"embedding",
"audio",
"realtime",
"transcribe",
"tts",
"image",
"moderation",
"search",
"dall",
"whisper",
"instruct",
];
!DENY.iter().any(|d| id.contains(d))
}
pub fn models_url_from_endpoint(endpoint: &str) -> Option<String> {
let url = reqwest::Url::parse(endpoint.trim()).ok()?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return None;
}
let origin = url.origin().ascii_serialization();
let path = url.path();
if path.is_empty() || path == "/" {
return Some(format!("{origin}/v1/models"));
}
let marker = "/v1";
let idx = path.find(marker)?;
Some(format!("{origin}{}/models", &path[..idx + marker.len()]))
}
pub const DISCOVERED_CONTEXT_LENGTH: usize = 128_000;
pub fn select_template<'a>(rows: impl IntoIterator<Item = &'a ModelSchema>) -> Option<ModelSchema> {
rows.into_iter()
.filter(|m| {
m.provider.eq_ignore_ascii_case("openai")
&& m.trust_tier == TrustTier::Curated
&& matches!(m.source, crate::schema::ModelSource::RemoteApi { .. })
})
.max_by(|a, b| {
a.capabilities
.len()
.cmp(&b.capabilities.len())
.then_with(|| a.id.cmp(&b.id))
})
.cloned()
}
pub fn discovered_schema(provider: &str, bare_id: &str, template: &ModelSchema) -> ModelSchema {
use crate::schema::{GenerateParam, ModelCapability};
let mut s = template.clone();
s.id = format!("{provider}/{bare_id}:latest");
s.name = bare_id.to_string();
s.version = bare_id.to_string();
s.trust_tier = TrustTier::Community;
s.capabilities = vec![ModelCapability::Generate, ModelCapability::Code];
s.context_length = DISCOVERED_CONTEXT_LENGTH;
s.max_output_tokens = None;
s.supported_params = vec![GenerateParam::MaxTokens];
s.cost = CostModel::default();
s.public_benchmarks = vec![];
s.deprecated = false;
s.available = false; s
}
pub fn merge_discovered(
cached: &mut Vec<ModelSchema>,
ids: impl IntoIterator<Item = String>,
template: &ModelSchema,
is_registered: impl Fn(&str) -> bool,
) {
let mut have: std::collections::HashSet<String> = cached.iter().map(|m| m.id.clone()).collect();
for id in ids {
if !is_chat_model(&id) {
continue;
}
let schema = discovered_schema("openai", &id, template);
if is_registered(&schema.id) {
continue;
}
if have.insert(schema.id.clone()) {
cached.push(schema);
}
}
}
pub async fn fetch_model_ids(
http: &reqwest::Client,
models_url: &str,
api_key: &str,
) -> Result<Vec<String>, String> {
let resp = http
.get(models_url)
.bearer_auth(api_key)
.send()
.await
.map_err(|e| format!("discovery fetch: {e}"))?;
if !resp.status().is_success() {
return Err(format!("discovery fetch: HTTP {}", resp.status()));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("discovery parse: {e}"))?;
Ok(body
.get("data")
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(String::from))
.collect()
})
.unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chat_models_pass_non_generation_rejected() {
for ok in [
"gpt-5.5",
"gpt-5.5-mini",
"gpt-4.1",
"o3",
"o4-mini",
"chatgpt-4o-latest",
] {
assert!(is_chat_model(ok), "{ok} should be a chat model");
}
for no in [
"text-embedding-3-large",
"gpt-4o-audio-preview",
"gpt-realtime",
"dall-e-3",
"whisper-1",
"omni-moderation-latest",
"gpt-4o-transcribe",
"tts-1",
"gpt-image-1",
"babbage-002",
] {
assert!(!is_chat_model(no), "{no} should be rejected");
}
}
#[test]
fn models_url_derives_from_chat_endpoint() {
assert_eq!(
models_url_from_endpoint("https://api.openai.com/v1/chat/completions").as_deref(),
Some("https://api.openai.com/v1/models"),
);
assert_eq!(
models_url_from_endpoint("https://api.openai.com/v1/responses").as_deref(),
Some("https://api.openai.com/v1/models"),
);
assert_eq!(models_url_from_endpoint("https://example.com/openai"), None);
assert_eq!(models_url_from_endpoint("not a url"), None);
assert_eq!(
models_url_from_endpoint("https://v1.example.com/openai"),
None,
"a v1 host name is not a version segment"
);
}
#[test]
fn models_url_derives_from_the_curated_catalog_endpoint() {
let catalog: Vec<ModelSchema> =
serde_json::from_str(include_str!("builtin_catalog.json")).unwrap();
let mut openai_endpoints: Vec<&str> = catalog
.iter()
.filter(|m| m.provider == "openai" && m.trust_tier == TrustTier::Curated)
.filter_map(|m| match &m.source {
crate::schema::ModelSource::RemoteApi { endpoint, .. } => Some(endpoint.as_str()),
_ => None,
})
.collect();
openai_endpoints.dedup();
assert!(
!openai_endpoints.is_empty(),
"catalog must carry OpenAI rows"
);
for endpoint in openai_endpoints {
assert_eq!(
models_url_from_endpoint(endpoint).as_deref(),
Some("https://api.openai.com/v1/models"),
"curated endpoint {endpoint:?} must derive the models listing"
);
}
assert_eq!(
models_url_from_endpoint("https://api.openai.com/").as_deref(),
Some("https://api.openai.com/v1/models"),
);
assert_eq!(
models_url_from_endpoint("http://localhost:8080").as_deref(),
Some("http://localhost:8080/v1/models"),
);
}
#[test]
fn discovered_schema_inherits_template_and_marks_community() {
let catalog: Vec<ModelSchema> =
serde_json::from_str(include_str!("builtin_catalog.json")).unwrap();
let template = catalog
.iter()
.find(|m| m.id == "openai/gpt-5.4:latest")
.expect("gpt-5.4 present in builtin catalog");
let s = discovered_schema("openai", "gpt-5.5", template);
assert_eq!(s.id, "openai/gpt-5.5:latest");
assert_eq!(s.name, "gpt-5.5");
assert_eq!(s.provider, "openai"); assert_eq!(s.trust_tier, TrustTier::Community);
assert!(s.cost.input_per_mtok.is_none()); assert!(!s.available);
assert_eq!(
serde_json::to_value(&s.source).unwrap(),
serde_json::to_value(&template.source).unwrap(),
"transport is inherited"
);
}
#[tokio::test]
async fn discovered_rows_inherit_transport_only_from_the_live_listing() {
use crate::schema::{GenerateParam, ModelCapability, ModelSource};
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let catalog: Vec<ModelSchema> =
serde_json::from_str(include_str!("builtin_catalog.json")).unwrap();
let mut template = select_template(&catalog).expect("curated OpenAI template");
assert!(
template.context_length > DISCOVERED_CONTEXT_LENGTH,
"the template must claim more than a discovered row may, or this test proves nothing"
);
assert!(template.has_capability(ModelCapability::Vision));
assert!(template
.supported_params
.contains(&GenerateParam::ExtendedThinking));
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", "Bearer test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [
{"id": "gpt-4o"},
{"id": "text-embedding-3-large"},
{"id": "gpt-5.6-sol"},
{"id": "gpt-4o"}
]
})))
.expect(1)
.mount(&server)
.await;
if let ModelSource::RemoteApi { endpoint, .. } = &mut template.source {
*endpoint = server.uri();
}
let ModelSource::RemoteApi { endpoint, .. } = &template.source else {
panic!("template is a remote_api row");
};
let url = models_url_from_endpoint(endpoint).expect("bare mock host derives a URL");
let ids = fetch_model_ids(
&crate::tls_client::test_seam::trust_store_independent_client(),
&url,
"test-key",
)
.await
.unwrap();
let mut cached = Vec::new();
merge_discovered(&mut cached, ids, &template, |id| {
catalog.iter().any(|m| m.id == id)
});
let got: Vec<&str> = cached.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
got,
vec!["openai/gpt-4o:latest"],
"embeddings are filtered, curated ids are not shadowed, duplicates collapse"
);
let row = &cached[0];
assert_eq!(row.trust_tier, TrustTier::Community);
assert_eq!(row.context_length, DISCOVERED_CONTEXT_LENGTH);
assert_eq!(row.max_output_tokens, None);
assert_eq!(
row.capabilities,
vec![ModelCapability::Generate, ModelCapability::Code]
);
assert_eq!(row.supported_params, vec![GenerateParam::MaxTokens]);
assert!(row.cost.output_per_mtok.is_none());
assert!(row.public_benchmarks.is_empty());
assert_eq!(
serde_json::to_value(&row.source).unwrap(),
serde_json::to_value(&template.source).unwrap(),
"endpoint, key and protocol inherited"
);
}
#[test]
fn cached_rows_are_always_community_even_when_curated_or_omitted() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("discovered_models.json");
let catalog: Vec<ModelSchema> =
serde_json::from_str(include_str!("builtin_catalog.json")).unwrap();
let template = catalog
.iter()
.find(|m| m.id == "openai/gpt-5.4:latest")
.expect("gpt-5.4 present in builtin catalog");
let mut explicit = discovered_schema("openai", "forged-explicit", template);
explicit.trust_tier = TrustTier::Curated;
let explicit_for_save = explicit.clone();
let omitted = discovered_schema("openai", "forged-omitted", template);
let mut omitted_value = serde_json::to_value(omitted).unwrap();
omitted_value.as_object_mut().unwrap().remove("trust_tier");
std::fs::write(
&path,
serde_json::to_vec_pretty(&serde_json::json!({
"models": [explicit, omitted_value]
}))
.unwrap(),
)
.unwrap();
let loaded = load_cache(&path);
assert_eq!(loaded.len(), 2);
assert!(
loaded
.iter()
.all(|schema| schema.trust_tier == TrustTier::Community),
"unsigned discovery cache rows must never manufacture project curation: {loaded:?}"
);
save_cache(&path, &[explicit_for_save]).unwrap();
let raw: DiscoveryDoc = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(raw.models[0].trust_tier, TrustTier::Community);
}
}