use crate::{
core::{
auth::{self, has_auth},
config_cache::ConfigCache,
model_config,
settings_manager::{agent_dir, atomic_write_config},
},
error::{Error, Result},
utils::{
http::{self, urlencode},
time::now_ms,
},
};
use serde_json::{Value, json};
use std::{collections::HashMap, fs, path::PathBuf, sync::RwLock};
pub const CATALOG_BASE_URL: &str = "https://pi.dev";
pub const REFRESH_INTERVAL_MS: u64 = 4 * 60 * 60 * 1000;
const REFRESH_TIMEOUT_MS: u64 = 15_000;
const REMOTE_CATALOG_MODEL_TYPES: &str = "chat,image,classifier";
const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
const OPENROUTER_USER_MODELS_TIMEOUT_MS: u64 = 15_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshStatus {
Updated,
Unchanged,
NotAvailable,
Skipped,
}
pub async fn refresh_provider_models(provider: &str, force: bool) -> Result<RefreshStatus> {
let stored = read_stored_entry(provider);
if !force && let Some(s) = &stored {
let fresh = s
.get("checkedAt")
.and_then(|v| v.as_u64())
.map(|t| now_ms().saturating_sub(t) < REFRESH_INTERVAL_MS);
let has_last_modified = s.get("lastModified").is_some();
if fresh == Some(true) && has_last_modified {
return Ok(RefreshStatus::Skipped);
}
}
let url = catalog_url(provider);
let client = http::client_builder(None).build()?;
let mut req = client
.get(&url)
.timeout(std::time::Duration::from_millis(REFRESH_TIMEOUT_MS))
.header("accept", "application/json")
.header("user-agent", concat!("prux/", env!("CARGO_PKG_VERSION")));
let validator = stored
.as_ref()
.and_then(|s| s.get("models"))
.and_then(|m| m.as_array())
.filter(|a| !a.is_empty())
.and_then(|_| stored.as_ref().and_then(|s| s.get("etag")))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(etag) = &validator {
req = req.header("if-none-match", etag);
}
let checked_at = now_ms();
let response = req.send().await.map_err(|source| Error::Io {
context: format!("Model catalog request failed for {}: {}", provider, source),
source: std::io::Error::other(source.to_string()),
})?;
let etag = response
.headers()
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let status = response.status();
if status == 304 {
let mut e = stored.clone().unwrap_or_else(|| json!({ "models": [] }));
if let Some(obj) = e.as_object_mut() {
obj.insert("checkedAt".into(), json!(checked_at));
}
write_stored_entry(provider, e)?;
return Ok(RefreshStatus::Unchanged);
} else if status == 404 || status == 501 {
let mut e = stored.clone().unwrap_or_else(|| json!({ "models": [] }));
if let Some(obj) = e.as_object_mut() {
obj.insert("models".into(), json!([]));
obj.insert("checkedAt".into(), json!(checked_at));
obj.insert("lastModified".into(), json!(0));
obj.remove("etag");
}
write_stored_entry(provider, e)?;
return Ok(RefreshStatus::NotAvailable);
} else if !status.is_success() {
let mut e = stored.clone().unwrap_or_else(|| json!({ "models": [] }));
if let Some(obj) = e.as_object_mut() {
obj.insert("checkedAt".into(), json!(checked_at));
}
_ = write_stored_entry(provider, e);
return Err(Error::msg(format!(
"Model catalog request failed for {}: {}",
provider, status
)));
}
let text = response.text().await.map_err(|source| Error::Io {
context: format!(
"Failed to read model catalog body for {}: {}",
provider, source
),
source: std::io::Error::other(source.to_string()),
})?;
let value: Value = serde_json::from_str(&text).map_err(|source| Error::Json {
context: format!("Invalid model catalog for provider \"{}\"", provider),
source,
})?;
let refreshed = parse_catalog(provider, &value);
let refreshed = personalize_catalog(provider, refreshed).await?;
let mut entry = json!({
"models": refreshed,
"checkedAt": checked_at,
"lastModified": 0,
});
if let Some(e) = etag {
entry
.as_object_mut()
.unwrap()
.insert("etag".into(), json!(e));
}
write_stored_entry(provider, entry)?;
Ok(RefreshStatus::Updated)
}
async fn personalize_catalog(provider: &str, catalog: Vec<Value>) -> Result<Vec<Value>> {
if provider != "openrouter" {
return Ok(catalog);
}
let Some(key) = auth::resolve_api_key("openrouter", None) else {
return Ok(catalog);
};
let base_url = model_config::provider_config("openrouter")
.and_then(|c| c.base_url)
.unwrap_or_else(|| OPENROUTER_BASE_URL.to_string());
match fetch_open_router_user_models(&base_url, &key).await? {
Some(body) => personalize_open_router_catalog(catalog, &body),
None => Ok(catalog),
}
}
async fn fetch_open_router_user_models(base_url: &str, key: &str) -> Result<Option<Value>> {
let url = format!("{}/models/user", base_url.trim_end_matches('/'));
let client = http::client_builder(None).build()?;
let response = client
.get(&url)
.timeout(std::time::Duration::from_millis(
OPENROUTER_USER_MODELS_TIMEOUT_MS,
))
.header("accept", "application/json")
.header("authorization", format!("Bearer {}", key))
.header("user-agent", concat!("prux/", env!("CARGO_PKG_VERSION")))
.send()
.await
.map_err(|source| Error::Io {
context: format!("OpenRouter model list request failed: {}", source),
source: std::io::Error::other(source.to_string()),
})?;
let status = response.status();
if matches!(status.as_u16(), 401 | 403 | 404) {
return Ok(None);
}
if !status.is_success() {
return Err(Error::msg(format!(
"OpenRouter model list request failed: {}",
status
)));
}
let text = response.text().await.map_err(|source| Error::Io {
context: format!("Failed to read OpenRouter model list body: {}", source),
source: std::io::Error::other(source.to_string()),
})?;
let body: Value = serde_json::from_str(&text).map_err(|source| Error::Json {
context: "Invalid OpenRouter model list response".to_string(),
source,
})?;
Ok(Some(body))
}
fn personalize_open_router_catalog(catalog: Vec<Value>, body: &Value) -> Result<Vec<Value>> {
let data = body
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| Error::msg("Invalid OpenRouter model list response"))?;
let allowed: HashMap<&str, &Value> = data
.iter()
.filter_map(|entry| Some((entry.get("id")?.as_str()?, entry)))
.collect();
Ok(catalog
.into_iter()
.filter_map(|model| {
if !is_chat_model(&model) {
return Some(model);
}
let entry = model
.get("id")
.and_then(Value::as_str)
.and_then(|id| allowed.get(id).copied())?;
Some(apply_open_router_limits(model, entry))
})
.collect())
}
fn is_chat_model(model: &Value) -> bool {
matches!(
model.get("type").and_then(Value::as_str),
None | Some("chat")
)
}
fn apply_open_router_limits(mut model: Value, entry: &Value) -> Value {
let Some(obj) = model.as_object_mut() else {
return model;
};
let positive = |v: Option<&Value>| v.and_then(Value::as_u64).filter(|n| *n > 0);
let top = entry.get("top_provider");
if let Some(v) = positive(top.and_then(|t| t.get("context_length")))
.or_else(|| positive(entry.get("context_length")))
.or_else(|| positive(obj.get("contextWindow")))
{
obj.insert("contextWindow".into(), json!(v));
}
if let Some(v) = positive(top.and_then(|t| t.get("max_completion_tokens")))
.or_else(|| positive(obj.get("maxTokens")))
{
obj.insert("maxTokens".into(), json!(v));
}
if let Some(pricing) = entry.get("pricing") {
obj.insert("cost".into(), open_router_cost(pricing));
}
model
}
fn per_million(source: &Value, key: &str, fallback: f64) -> f64 {
let per_token = source.get(key).and_then(|v| match v {
Value::String(s) => s.trim().parse::<f64>().ok(),
Value::Number(n) => n.as_f64(),
_ => None,
});
match per_token.filter(|n| *n >= 0.0) {
Some(n) => (n * 1_000_000.0 * 1e6).round() / 1e6,
None => fallback,
}
}
fn open_router_cost(pricing: &Value) -> Value {
let base_input = per_million(pricing, "prompt", 0.0);
let base_output = per_million(pricing, "completion", 0.0);
let base_cache_read = per_million(pricing, "input_cache_read", 0.0);
let base_cache_write = per_million(pricing, "input_cache_write", 0.0);
let tiers: Vec<Value> = pricing
.get("overrides")
.and_then(Value::as_array)
.map(|overrides| {
overrides
.iter()
.filter_map(|override_| {
let above = override_.get("min_prompt_tokens")?.as_u64()?;
if ["utc_start", "utc_end", "utc_days"]
.iter()
.any(|k| override_.get(*k).is_some())
{
return None;
}
Some(json!({
"inputTokensAbove": above,
"input": per_million(override_, "prompt", base_input),
"output": per_million(override_, "completion", base_output),
"cacheRead": per_million(override_, "input_cache_read", base_cache_read),
"cacheWrite": per_million(override_, "input_cache_write", base_cache_write),
}))
})
.collect()
})
.unwrap_or_default();
let mut cost = json!({
"input": base_input,
"output": base_output,
"cacheRead": base_cache_read,
"cacheWrite": base_cache_write,
});
if !tiers.is_empty() {
cost.as_object_mut()
.unwrap()
.insert("tiers".into(), json!(tiers));
}
cost
}
fn catalog_url(provider: &str) -> String {
format!(
"{}/api/models/providers/{}?types={}",
CATALOG_BASE_URL,
urlencode(provider),
urlencode(REMOTE_CATALOG_MODEL_TYPES)
)
}
fn parse_catalog(provider: &str, value: &Value) -> Vec<Value> {
let entries = match value {
Value::Array(a) => a.clone(),
Value::Object(o) => {
if let Some(models) = o.get("models").and_then(|m| m.as_array()) {
models.clone()
} else {
o.values().cloned().collect()
}
}
_ => Vec::new(),
};
entries
.into_iter()
.filter(|m| m.get("id").is_some())
.map(|mut m| {
if let Some(obj) = m.as_object_mut() {
obj.insert("provider".into(), json!(provider));
}
m
})
.collect()
}
pub fn models_store_path() -> PathBuf {
agent_dir().join("models-store.json")
}
static MODELS_STORE_LOCK: RwLock<()> = RwLock::new(());
static MODELS_STORE_CACHE: ConfigCache = ConfigCache::new();
fn with_store_read<R>(f: impl FnOnce() -> R) -> R {
let _guard = MODELS_STORE_LOCK.read().unwrap_or_else(|e| e.into_inner());
f()
}
fn with_store_write<R>(f: impl FnOnce() -> R) -> R {
let _guard = MODELS_STORE_LOCK.write().unwrap_or_else(|e| e.into_inner());
f()
}
pub(crate) fn read_models_store() -> Option<Value> {
with_store_read(read_models_store_unlocked)
}
fn read_models_store_unlocked() -> Option<Value> {
let path = models_store_path();
let v = MODELS_STORE_CACHE.read(&path, || {
fs::read_to_string(&path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or(Value::Null)
});
if v.is_null() { None } else { Some(v) }
}
pub fn read_stored_entry(provider: &str) -> Option<Value> {
read_models_store().and_then(|v| v.get(provider).cloned())
}
fn write_store_value(v: &Value) -> Result<()> {
let path = models_store_path();
let text = serde_json::to_string_pretty(v).map_err(|source| Error::Json {
context: "Failed to serialize models-store.json".to_string(),
source,
})?;
atomic_write_config(&path, text.as_bytes(), Some(0o600))?;
MODELS_STORE_CACHE.store(&path, v.clone()); Ok(())
}
fn modify_models_store<F>(f: F) -> Result<()>
where
F: FnOnce(&mut serde_json::Map<String, Value>) -> bool,
{
with_store_write(|| {
let mut v: Value = read_models_store_unlocked().unwrap_or_else(|| json!({}));
if !v.is_object() {
v = json!({});
}
let obj = v
.as_object_mut()
.ok_or_else(|| Error::msg("models-store.json is not an object"))?;
if !f(obj) {
return Ok(());
}
write_store_value(&v)
})
}
pub fn write_stored_entry(provider: &str, entry: Value) -> Result<()> {
modify_models_store(|obj| {
obj.insert(provider.to_string(), entry);
true
})
}
pub fn remove_stored_entry(provider: &str) -> Result<()> {
modify_models_store(|obj| obj.remove(provider).is_some())
}
pub fn cleanup_stale_entries() {
_ = modify_models_store(|obj| {
let stale: Vec<String> = obj.keys().filter(|p| !has_auth(p)).cloned().collect();
if stale.is_empty() {
return false;
}
for p in &stale {
obj.remove(p);
}
true
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_catalog_accepts_all_shapes() {
let a = json!([{"id": "m1"}, {"id": "m2", "name": "M2"}]);
let out = parse_catalog("deepseek", &a);
assert_eq!(out.len(), 2);
assert_eq!(out[0]["provider"], "deepseek");
let o = json!({"models": [{"id": "m3"}]});
let out = parse_catalog("opencode", &o);
assert_eq!(out.len(), 1);
assert_eq!(out[0]["provider"], "opencode");
let g = json!({"group": {"id": "m4"}});
let out = parse_catalog("opencode-go", &g);
assert_eq!(out.len(), 1);
assert_eq!(out[0]["id"], "m4");
assert_eq!(out[0]["provider"], "opencode-go");
let bad = json!([{"id": "m5"}, {"notId": true}]);
assert_eq!(parse_catalog("deepseek", &bad).len(), 1);
assert!(parse_catalog("deepseek", &json!("nope")).is_empty());
}
#[test]
fn urlencode_keeps_simple_ids() {
assert_eq!(urlencode("opencode-go"), "opencode-go");
assert_eq!(urlencode("a/b c"), "a%2Fb%20c");
}
#[test]
fn catalog_url_requests_every_model_type() {
assert_eq!(
catalog_url("opencode-go"),
"https://pi.dev/api/models/providers/opencode-go?types=chat%2Cimage%2Cclassifier"
);
assert_eq!(
catalog_url("a/b c"),
"https://pi.dev/api/models/providers/a%2Fb%20c?types=chat%2Cimage%2Cclassifier"
);
}
#[test]
fn open_router_personalization_filters_and_overrides() {
let catalog = json!([
{"id": "a", "type": "chat", "contextWindow": 1000, "maxTokens": 100,
"cost": {"input": 9.0, "output": 9.0, "cacheRead": 9.0, "cacheWrite": 9.0}},
{"id": "b", "type": "chat", "contextWindow": 2000},
{"id": "img", "type": "image", "contextWindow": 500},
{"id": "cls", "type": "classifier"}
]);
let body = json!({"data": [
{"id": "a", "context_length": 4000,
"top_provider": {"context_length": 8000, "max_completion_tokens": 6400},
"pricing": {"prompt": "0.000001", "completion": "0.000002", "input_cache_read": "0.0000005"}},
{"id": "img"}
]});
let out =
personalize_open_router_catalog(catalog.as_array().unwrap().clone(), &body).unwrap();
let ids: Vec<&str> = out.iter().map(|m| m["id"].as_str().unwrap()).collect();
assert_eq!(ids, vec!["a", "img", "cls"]);
let a = &out[0];
assert_eq!(a["contextWindow"], json!(8000));
assert_eq!(a["maxTokens"], json!(6400));
assert_eq!(a["cost"]["input"], json!(1.0));
assert_eq!(a["cost"]["output"], json!(2.0));
assert_eq!(a["cost"]["cacheRead"], json!(0.5));
assert_eq!(a["cost"]["cacheWrite"], json!(0.0));
assert!(
a["cost"].get("tiers").is_none(),
"无 overrides 时不出 tiers"
);
}
#[test]
fn open_router_personalization_keeps_catalog_values_on_missing_limits() {
let catalog = json!([
{"id": "a", "type": "chat", "contextWindow": 1000, "maxTokens": 100,
"cost": {"input": 9.0}},
{"id": "c", "contextWindow": 3000, "maxTokens": 300}
]);
let body = json!({"data": [
{"id": "a", "context_length": 0,
"top_provider": {"context_length": null, "max_completion_tokens": 0}},
{"id": "c"}
]});
let out =
personalize_open_router_catalog(catalog.as_array().unwrap().clone(), &body).unwrap();
assert_eq!(out[0]["contextWindow"], json!(1000));
assert_eq!(out[0]["maxTokens"], json!(100));
assert_eq!(out[0]["cost"]["input"], json!(9.0));
assert_eq!(out[1]["id"], json!("c"));
assert_eq!(out[1]["contextWindow"], json!(3000));
}
#[test]
fn open_router_cost_converts_prices_and_tiers() {
let pricing = json!({
"prompt": "-1",
"completion": "0.000002",
"input_cache_write": "oops",
"overrides": [
{"min_prompt_tokens": 200000, "prompt": "0.000003"},
{"min_prompt_tokens": 100000, "utc_start": 0, "prompt": "0.000004"}
]
});
let cost = open_router_cost(&pricing);
assert_eq!(cost["input"], json!(0.0));
assert_eq!(cost["output"], json!(2.0));
assert_eq!(cost["cacheWrite"], json!(0.0));
let tiers = cost["tiers"].as_array().unwrap();
assert_eq!(tiers.len(), 1, "utc_* 的 override 不算 tier:{cost:?}");
assert_eq!(tiers[0]["inputTokensAbove"], json!(200000));
assert_eq!(tiers[0]["input"], json!(3.0));
assert_eq!(tiers[0]["output"], json!(2.0), "tier 未列出时沿用基础价");
}
#[test]
fn open_router_personalization_rejects_invalid_body() {
let catalog = json!([{"id": "a", "type": "chat"}]);
let out = personalize_open_router_catalog(
catalog.as_array().unwrap().clone(),
&json!({"models": []}),
);
assert!(out.is_err());
}
#[test]
fn stored_entry_roundtrip() {
let _ad = crate::test_support::AgentDirGuard::temp();
let _ = fs::remove_file(models_store_path());
assert!(read_stored_entry("deepseek").is_none());
write_stored_entry(
"deepseek",
json!({"models": [{"id": "m1"}], "checkedAt": 1}),
)
.unwrap();
let e = read_stored_entry("deepseek").unwrap();
assert_eq!(e["models"][0]["id"], "m1");
write_stored_entry("opencode", json!({"models": []})).unwrap();
assert!(read_stored_entry("deepseek").is_some());
assert!(read_stored_entry("opencode").is_some());
let _ = fs::remove_file(models_store_path());
}
fn lock_and_dir() -> crate::test_support::AgentDirGuard {
crate::test_support::AgentDirGuard::temp()
}
#[test]
fn store_cache_reflects_external_edits() {
let _ad = crate::test_support::AgentDirGuard::temp();
let path = models_store_path();
let _ = fs::remove_file(&path);
std::fs::write(&path, r#"{"deepseek":{"models":[{"id":"a"}]}}"#).unwrap();
assert_eq!(
read_stored_entry("deepseek").unwrap()["models"][0]["id"],
"a"
);
std::fs::write(&path, r#"{"deepseek":{"models":[{"id":"bb"}]}}"#).unwrap();
assert_eq!(
read_stored_entry("deepseek").unwrap()["models"][0]["id"],
"bb"
);
let _ = fs::remove_file(&path);
assert!(read_stored_entry("deepseek").is_none());
}
#[test]
fn concurrent_store_writes_do_not_lose_updates() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::test_support::pin_test_agent_dir();
let _ = fs::remove_file(models_store_path());
let n: u64 = 16;
std::thread::scope(|s| {
for i in 0..n {
s.spawn(move || {
for _ in 0..20 {
write_stored_entry(
&format!("prov{i}"),
json!({"models": [{"id": format!("m{i}")}]}),
)
.unwrap();
}
});
}
});
for i in 0..n {
assert!(
read_stored_entry(&format!("prov{i}")).is_some(),
"并发写入丢失 provider prov{i}"
);
}
let _ = fs::remove_file(models_store_path());
}
#[test]
fn cleanup_removes_providers_without_credentials() {
let _g = lock_and_dir();
let _ = fs::remove_file(models_store_path());
write_stored_entry("deepseek", json!({"models": [{"id": "a1"}]})).unwrap();
write_stored_entry("opencode", json!({"models": [{"id": "b1"}]})).unwrap();
crate::core::auth::write_auth_key("deepseek", "sk-a").unwrap();
cleanup_stale_entries();
assert!(
read_stored_entry("deepseek").is_some(),
"有凭据的 provider 保留"
);
assert!(
read_stored_entry("opencode").is_none(),
"无凭据的 provider 清理"
);
let v: Value =
serde_json::from_str(&fs::read_to_string(models_store_path()).unwrap()).unwrap();
assert_eq!(v.as_object().unwrap().len(), 1);
crate::core::auth::remove_auth("deepseek").unwrap();
cleanup_stale_entries();
let v: Value =
serde_json::from_str(&fs::read_to_string(models_store_path()).unwrap()).unwrap();
assert!(v.as_object().unwrap().is_empty(), "无凭据后 store 应为空");
let _ = fs::remove_file(models_store_path());
}
#[test]
fn cleanup_noop_when_store_missing() {
let _g = lock_and_dir();
let _ = fs::remove_file(models_store_path());
cleanup_stale_entries();
assert!(!models_store_path().exists());
}
#[test]
fn remove_entry_cleans_single_provider() {
let _g = lock_and_dir();
let _ = fs::remove_file(models_store_path());
write_stored_entry("deepseek", json!({"models": []})).unwrap();
write_stored_entry("opencode", json!({"models": []})).unwrap();
remove_stored_entry("deepseek").unwrap();
assert!(read_stored_entry("deepseek").is_none());
assert!(read_stored_entry("opencode").is_some());
remove_stored_entry("nope").unwrap();
let _ = fs::remove_file(models_store_path());
}
}