Skip to main content

backend/
inference.rs

1//! OpenRouter inference key management.
2//!
3//! Creates OpenRouter child keys per user and persists them in the `users`
4//! table. The stable key `hash` is stored alongside the secret so that keys can
5//! be invalidated via the OpenRouter API, `DELETE /keys/{hash}`. We leverage
6//! the OpenRouter API to put credit cap and refresh windows on the keys.
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::app::{AppCtx, AppError};
12
13/// Spending limit per user key, in USD.
14const KEY_LIMIT: f64 = 5.0;
15/// Reset period for the spending limit.
16const KEY_LIMIT_RESET: &str = "daily";
17
18/// The base URL for the OpenRouter key-management API.
19pub const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/v1";
20
21/// Request body for `POST /keys` (create a child key).
22#[derive(Serialize)]
23struct CreateKeyRequest {
24    name: String,
25    limit: f64,
26    limit_reset: String,
27}
28
29/// The `data` object nested inside a `POST /keys` response.
30#[derive(Deserialize)]
31struct CreateKeyData {
32    hash: String,
33}
34
35/// Response from `POST /keys`.
36#[derive(Deserialize)]
37struct CreateKeyResponse {
38    key: String,
39    data: CreateKeyData,
40}
41
42/// Return the user's existing inference key, or create a new one.
43pub async fn get_inference_key(ctx: &AppCtx) -> Result<String, AppError> {
44    let Some(user) = &ctx.user else {
45        return Err(AppError::Unauthorized);
46    };
47
48    let provisioning_key = ctx
49        .state
50        .openrouter_provisioning_key
51        .as_ref()
52        .ok_or(AppError::InferenceUnavailable)?;
53
54    get_or_create_key(
55        &ctx.state.db,
56        &ctx.state.http_client,
57        &ctx.state.openrouter_base_url,
58        provisioning_key,
59        &user.user_id,
60    )
61    .await
62}
63
64/// Invalidate a user's inference key, either by Firebase UID or username.
65pub async fn invalidate_inference_key(
66    db: &sqlx::PgPool,
67    http_client: &reqwest::Client,
68    base_url: &str,
69    provisioning_key: &str,
70    identifier: &str,
71) -> Result<(), AppError> {
72    let row = sqlx::query!(
73        "SELECT id, inference_hash FROM users WHERE id = $1 OR username = $1",
74        identifier,
75    )
76    .fetch_optional(db)
77    .await?;
78
79    let Some(row) = row else {
80        return Ok(());
81    };
82
83    let Some(hash) = row.inference_hash else {
84        return Ok(());
85    };
86
87    openrouter_delete_key(http_client, provisioning_key, base_url, &hash).await?;
88
89    sqlx::query!(
90        "UPDATE users SET inference_key = NULL, inference_hash = NULL WHERE id = $1",
91        row.id,
92    )
93    .execute(db)
94    .await?;
95
96    Ok(())
97}
98
99/// Create a new child key via `POST /keys`.
100async fn get_or_create_key(
101    db: &sqlx::PgPool,
102    http_client: &reqwest::Client,
103    base_url: &str,
104    provisioning_key: &str,
105    user_id: &str,
106) -> Result<String, AppError> {
107    let existing_key: Option<String> =
108        sqlx::query_scalar!("SELECT inference_key FROM users WHERE id = $1", user_id,)
109            .fetch_one(db)
110            .await?;
111
112    if let Some(key) = existing_key {
113        return Ok(key);
114    }
115
116    let response = openrouter_create_key(
117        http_client,
118        provisioning_key,
119        base_url,
120        &key_name(user_id, provisioning_key),
121    )
122    .await?;
123
124    sqlx::query!(
125        "UPDATE users SET inference_key = $2, inference_hash = $3 WHERE id = $1",
126        user_id,
127        response.key,
128        response.data.hash,
129    )
130    .execute(db)
131    .await?;
132
133    Ok(response.key)
134}
135
136/// Create a key using OpenRouter's API.
137async fn openrouter_create_key(
138    client: &reqwest::Client,
139    provisioning_key: &str,
140    base_url: &str,
141    name: &str,
142) -> Result<CreateKeyResponse, AppError> {
143    let request = CreateKeyRequest {
144        name: name.to_string(),
145        limit: KEY_LIMIT,
146        limit_reset: KEY_LIMIT_RESET.to_string(),
147    };
148
149    let body = serde_json::to_vec(&request)
150        .map_err(|e| AppError::OpenRouter(format!("failed to serialize request: {e}")))?;
151
152    let response = client
153        .post(format!("{base_url}/keys"))
154        .bearer_auth(provisioning_key)
155        .header("Content-Type", "application/json")
156        .body(body)
157        .send()
158        .await
159        .map_err(|e| AppError::OpenRouter(e.to_string()))?;
160
161    if !response.status().is_success() {
162        let status = response.status();
163        let body = response.text().await.unwrap_or_default();
164        return Err(AppError::OpenRouter(format!("OpenRouter returned {status}: {body}")));
165    }
166
167    let bytes = response.bytes().await.map_err(|e| AppError::OpenRouter(e.to_string()))?;
168
169    serde_json::from_slice::<CreateKeyResponse>(&bytes)
170        .map_err(|e| AppError::OpenRouter(format!("failed to parse key response: {e}")))
171}
172
173/// Delete a child key via OpenRouter's API.
174///
175/// A `404` response is treated as success: the key is already gone (e.g.
176/// deleted out-of-band), which is the desired end state, so the caller may
177/// still clear its local reference.
178async fn openrouter_delete_key(
179    client: &reqwest::Client,
180    provisioning_key: &str,
181    base_url: &str,
182    hash: &str,
183) -> Result<(), AppError> {
184    let response = client
185        .delete(format!("{base_url}/keys/{hash}"))
186        .bearer_auth(provisioning_key)
187        .send()
188        .await
189        .map_err(|e| AppError::OpenRouter(e.to_string()))?;
190
191    let status = response.status();
192
193    if status.is_success() || status.as_u16() == 404 {
194        return Ok(());
195    }
196
197    let body = response.text().await.unwrap_or_default();
198    Err(AppError::OpenRouter(format!("OpenRouter returned {status}: {body}")))
199}
200
201/// Turn a FireBase user id into a stable name that does not leak details
202/// externally.
203fn key_name(user_id: &str, provisioning_key: &str) -> String {
204    let hash = Sha256::new()
205        .chain_update(user_id.as_bytes())
206        .chain_update(provisioning_key.as_bytes())
207        .finalize();
208    let hex: String = hash[..8].iter().map(|b| format!("{b:02x}")).collect();
209    format!("catcolab:{hex}")
210}