feat(service): auto-sync OpenRouter models on app startup and every 10 minutes

- Add `start_sync_task()` in agent/sync.rs: spawns a background task
  that syncs immediately on app startup, then every 10 minutes.
- `sync_once()` performs a single pass; errors are logged and swallowed
  so the periodic task never stops.
- Remove authentication requirement from OpenRouter API (no API key needed).
- Call `service.start_sync_task()` from main.rs after AppService init.
- Also update the existing `sync_upstream_models` (HTTP API) to remove
  the now-unnecessary API key requirement for consistency.
This commit is contained in:
ZhenYi 2026-04-16 22:35:34 +08:00
parent 329b526bfb
commit 9368df54da
2 changed files with 141 additions and 14 deletions

View File

@ -107,6 +107,10 @@ async fn main() -> anyhow::Result<()> {
let service = AppService::new(cfg.clone()).await?; let service = AppService::new(cfg.clone()).await?;
slog::info!(log, "AppService initialized"); 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 (shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel::<()>(1);
let worker_service = service.clone(); let worker_service = service.clone();
let log_for_http = log.clone(); let log_for_http = log.clone();

View File

@ -6,6 +6,14 @@
//! OpenRouter returns rich metadata per model including `context_length`, //! OpenRouter returns rich metadata per model including `context_length`,
//! `pricing`, and `architecture.modality` — these are used to populate all //! `pricing`, and `architecture.modality` — these are used to populate all
//! five model tables without any hard-coded heuristics. //! 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::AppService;
use crate::error::AppError; use crate::error::AppError;
@ -546,9 +554,9 @@ async fn upsert_parameter_profile(
impl AppService { impl AppService {
/// Sync models from OpenRouter into the local database. /// Sync models from OpenRouter into the local database.
/// ///
/// Calls OpenRouter's `GET /api/v1/models` using `OPENROUTER_API_KEY` /// Calls OpenRouter's public `GET /api/v1/models` endpoint (no auth required),
/// (falls back to `AI_API_KEY` if not set), then upserts provider / /// then upserts provider / model / version / pricing / capability /
/// model / version / pricing / capability / parameter-profile records. /// parameter-profile records.
/// ///
/// OpenRouter returns `context_length`, `pricing`, and `architecture.modality` /// OpenRouter returns `context_length`, `pricing`, and `architecture.modality`
/// per model — these drive all inference-free field population. /// per model — these drive all inference-free field population.
@ -557,20 +565,9 @@ impl AppService {
&self, &self,
_ctx: &Session, _ctx: &Session,
) -> Result<SyncModelsResponse, AppError> { ) -> Result<SyncModelsResponse, AppError> {
// 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 client = reqwest::Client::new();
let resp: OpenRouterResponse = client let resp: OpenRouterResponse = client
.get("https://openrouter.ai/api/v1/models") .get("https://openrouter.ai/api/v1/models")
.header("Authorization", format!("Bearer {api_key}"))
.send() .send()
.await .await
.map_err(|e| AppError::InternalServerError(format!("OpenRouter API request failed: {}", e)))? .map_err(|e| AppError::InternalServerError(format!("OpenRouter API request failed: {}", e)))?
@ -639,4 +636,130 @@ impl AppService {
profiles_created, 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::<OpenRouterResponse>().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
);
}
} }