diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 4087380..ff615e5 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -107,6 +107,10 @@ async fn main() -> anyhow::Result<()> { let service = AppService::new(cfg.clone()).await?; slog::info!(log, "AppService initialized"); + // Spawn background task: sync OpenRouter models immediately on startup, + // then every 10 minutes. + let _model_sync_handle = service.clone().start_sync_task(); + let (shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel::<()>(1); let worker_service = service.clone(); let log_for_http = log.clone(); diff --git a/libs/service/agent/sync.rs b/libs/service/agent/sync.rs index cf32f57..c803fe2 100644 --- a/libs/service/agent/sync.rs +++ b/libs/service/agent/sync.rs @@ -6,6 +6,14 @@ //! OpenRouter returns rich metadata per model including `context_length`, //! `pricing`, and `architecture.modality` — these are used to populate all //! five model tables without any hard-coded heuristics. +//! +//! Usage: call `start_sync_task()` to launch a background task that syncs +//! immediately and then every 10 minutes. On app startup, run it once +//! eagerly before accepting traffic. + +use std::time::Duration; +use tokio::time::interval; +use tokio::task::JoinHandle; use crate::AppService; use crate::error::AppError; @@ -546,9 +554,9 @@ async fn upsert_parameter_profile( impl AppService { /// Sync models from OpenRouter into the local database. /// - /// Calls OpenRouter's `GET /api/v1/models` using `OPENROUTER_API_KEY` - /// (falls back to `AI_API_KEY` if not set), then upserts provider / - /// model / version / pricing / capability / parameter-profile records. + /// Calls OpenRouter's public `GET /api/v1/models` endpoint (no auth required), + /// then upserts provider / model / version / pricing / capability / + /// parameter-profile records. /// /// OpenRouter returns `context_length`, `pricing`, and `architecture.modality` /// per model — these drive all inference-free field population. @@ -557,20 +565,9 @@ impl AppService { &self, _ctx: &Session, ) -> Result { - // Resolve API key: prefer OPENROUTER_API_KEY env var, fall back to AI_API_KEY. - let api_key = std::env::var("OPENROUTER_API_KEY") - .ok() - .or_else(|| self.config.ai_api_key().ok()) - .ok_or_else(|| { - AppError::InternalServerError( - "OPENROUTER_API_KEY or AI_API_KEY must be configured to sync models".into(), - ) - })?; - let client = reqwest::Client::new(); let resp: OpenRouterResponse = client .get("https://openrouter.ai/api/v1/models") - .header("Authorization", format!("Bearer {api_key}")) .send() .await .map_err(|e| AppError::InternalServerError(format!("OpenRouter API request failed: {}", e)))? @@ -639,4 +636,130 @@ impl AppService { profiles_created, }) } + + /// Spawn a background task that syncs OpenRouter models immediately + /// and then every 10 minutes. Returns the `JoinHandle`. + /// + /// Failures are logged but do not stop the task — it keeps retrying. + pub fn start_sync_task(self) -> JoinHandle<()> { + let db = self.db.clone(); + + tokio::spawn(async move { + // Run once immediately on startup before taking traffic. + Self::sync_once(&db).await; + + let mut tick = interval(Duration::from_secs(60 * 10)); + loop { + tick.tick().await; + Self::sync_once(&db).await; + } + }) + } + + /// Perform a single sync pass. Errors are logged and silently swallowed + /// so the periodic task never stops. + async fn sync_once(db: &AppDatabase) { + let client = reqwest::Client::new(); + let resp = match client + .get("https://openrouter.ai/api/v1/models") + .send() + .await + { + Ok(r) => match r.error_for_status() { + Ok(resp) => match resp.json::().await { + Ok(resp) => resp, + Err(e) => { + tracing::error!("OpenRouter model sync: failed to parse response: {}", e); + return; + } + }, + Err(e) => { + tracing::error!("OpenRouter model sync: API error: {}", e); + return; + } + }, + Err(e) => { + tracing::error!("OpenRouter model sync: request failed: {}", e); + return; + } + }; + + let mut models_created = 0i64; + let mut models_updated = 0i64; + let mut versions_created = 0i64; + let mut pricing_created = 0i64; + let mut capabilities_created = 0i64; + let mut profiles_created = 0i64; + + for or_model in resp.data { + if or_model.id == "openrouter/auto" { + continue; + } + + let provider_slug = extract_provider(&or_model.id); + let provider = match upsert_provider(db, provider_slug).await { + Ok(p) => p, + Err(e) => { + tracing::warn!("OpenRouter model sync: upsert_provider error: {:?}", e); + continue; + } + }; + + let model_record = match upsert_model(db, provider.id, &or_model.id, &or_model).await { + Ok((m, true)) => { + models_created += 1; + m + } + Ok((m, false)) => { + models_updated += 1; + m + } + Err(e) => { + tracing::warn!("OpenRouter model sync: upsert_model error: {:?}", e); + continue; + } + }; + + match upsert_version(db, model_record.id).await { + Ok((version_record, version_is_new)) => { + if version_is_new { + versions_created += 1; + } + + if upsert_pricing(db, version_record.id, or_model.pricing.as_ref(), &or_model.id) + .await + .unwrap_or(false) + { + pricing_created += 1; + } + + capabilities_created += + upsert_capabilities(db, version_record.id, &or_model.id) + .await + .unwrap_or(0); + + if upsert_parameter_profile(db, version_record.id, &or_model.id) + .await + .unwrap_or(false) + { + profiles_created += 1; + } + } + Err(e) => { + tracing::warn!("OpenRouter model sync: upsert_version error: {:?}", e); + } + } + } + + tracing::info!( + "OpenRouter model sync complete: created={} updated={} \ + versions={} pricing={} capabilities={} profiles={}", + models_created, + models_updated, + versions_created, + pricing_created, + capabilities_created, + profiles_created + ); + } }