use std::time::Instant;
use reqwest::Client;
use serde::Deserialize;
use crate::cm_llm::http_client;
use crate::cm_types::OPENAI_MODELS_REL_PATH;
use crate::cm_types::llm_config::LlmHttpAuthMode;
#[derive(Debug, Deserialize)]
struct ModelsEnvelope {
#[serde(default)]
data: Option<Vec<ModelRow>>,
}
#[derive(Debug, Deserialize)]
struct ModelRow {
id: String,
}
#[derive(Debug, Clone)]
pub struct ModelsEndpointReport {
pub url_display: String,
pub http_status: u16,
pub elapsed_ms: u128,
pub model_ids: Vec<String>,
pub note: Option<String>,
}
fn models_url(api_base: &str) -> String {
format!(
"{}/{}",
api_base.trim_end_matches('/'),
OPENAI_MODELS_REL_PATH
)
}
pub async fn fetch_models_report(
client: &Client,
api_base: &str,
api_key: &str,
auth_mode: LlmHttpAuthMode,
) -> Result<ModelsEndpointReport, Box<dyn std::error::Error + Send + Sync>> {
let url = models_url(api_base);
let url_display = redact_url_for_display(&url);
let t0 = Instant::now();
let mut rb = client.get(&url);
if auth_mode == LlmHttpAuthMode::Bearer {
rb = rb.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", api_key.trim()),
);
}
let resp = rb
.send()
.await
.map_err(http_client::map_reqwest_transport_err)?;
let elapsed_ms = t0.elapsed().as_millis();
let http_status = resp.status().as_u16();
let body = resp
.text()
.await
.map_err(http_client::map_reqwest_transport_err)?;
if !(200..300).contains(&http_status) {
return Ok(ModelsEndpointReport {
url_display,
http_status,
elapsed_ms,
model_ids: Vec::new(),
note: Some(format!(
"HTTP {http_status}(响应体已省略,请检查 API_KEY 与 api_base)"
)),
});
}
match serde_json::from_str::<ModelsEnvelope>(&body) {
Ok(env) => {
let mut model_ids: Vec<String> = env
.data
.unwrap_or_default()
.into_iter()
.map(|r| r.id)
.collect();
model_ids.sort();
model_ids.dedup();
let note = if model_ids.is_empty() {
Some("HTTP 成功但 data 为空或缺 id 字段(网关可能非标准形)".to_string())
} else {
None
};
Ok(ModelsEndpointReport {
url_display,
http_status,
elapsed_ms,
model_ids,
note,
})
}
Err(e) => Ok(ModelsEndpointReport {
url_display,
http_status,
elapsed_ms,
model_ids: Vec::new(),
note: Some(format!(
"JSON 与 OpenAI models 形不一致(不打印响应体): {e}"
)),
}),
}
}
fn redact_url_for_display(url: &str) -> String {
match url.split_once('?') {
Some((base, q)) if !q.is_empty() => format!("{base}?…"),
_ => url.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::redact_url_for_display;
#[test]
fn redact_query_when_present() {
let u = "https://example.com/v1/models?key=sk-xx&foo=1";
let d = redact_url_for_display(u);
assert!(!d.contains("sk-xx"));
assert!(d.ends_with("?…"));
}
}