Skip to main content

gunmetal_storage/
lib.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Mutex;
4
5use anyhow::{Context, Result, anyhow, bail};
6use chrono::{DateTime, Utc};
7use gunmetal_core::{
8    CreatedGunmetalKey, GunmetalKey, KeyScope, KeyState, ModelDescriptor, NewGunmetalKey,
9    NewProviderProfile, NewRequestLogEntry, ProviderContext, ProviderKind, ProviderProfile,
10    RequestLogEntry, TokenUsage,
11};
12use rusqlite::{Connection, OptionalExtension, params};
13use sha2::{Digest, Sha256};
14use uuid::Uuid;
15
16pub trait Storage: Send + Sync {
17    fn create_key(&self, draft: NewGunmetalKey) -> Result<CreatedGunmetalKey>;
18    fn list_keys(&self) -> Result<Vec<GunmetalKey>>;
19    fn get_key(&self, id: Uuid) -> Result<Option<GunmetalKey>>;
20    fn authenticate_key(&self, secret: &str) -> Result<Option<GunmetalKey>>;
21    fn set_key_state(&self, id: Uuid, state: KeyState) -> Result<()>;
22    fn delete_key(&self, id: Uuid) -> Result<()>;
23    fn create_profile(&self, draft: NewProviderProfile) -> Result<ProviderProfile>;
24    fn delete_profile(&self, id: Uuid) -> Result<()>;
25    fn list_profiles(&self) -> Result<Vec<ProviderProfile>>;
26    fn get_profile(&self, id: Uuid) -> Result<Option<ProviderProfile>>;
27    fn update_profile_credentials(
28        &self,
29        id: Uuid,
30        credentials: Option<serde_json::Value>,
31    ) -> Result<()>;
32    fn replace_models_for_profile(
33        &self,
34        provider: &ProviderKind,
35        profile_id: Option<Uuid>,
36        models: &[ModelDescriptor],
37    ) -> Result<()>;
38    fn list_models(&self) -> Result<Vec<ModelDescriptor>>;
39    fn get_model(&self, id: &str) -> Result<Option<ModelDescriptor>>;
40    fn log_request(&self, entry: NewRequestLogEntry) -> Result<RequestLogEntry>;
41    fn list_request_logs(&self, limit: usize) -> Result<Vec<RequestLogEntry>>;
42}
43
44const LAST_USED_TOUCH_INTERVAL_SECONDS: i64 = 60;
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AppPaths {
48    pub root: PathBuf,
49    pub database: PathBuf,
50    pub empty_workspace_dir: PathBuf,
51    pub helpers_dir: PathBuf,
52    pub logs_dir: PathBuf,
53    pub runtime_dir: PathBuf,
54}
55
56impl AppPaths {
57    pub fn resolve() -> Result<Self> {
58        if let Ok(path) = std::env::var("GUNMETAL_HOME") {
59            return Self::from_root(PathBuf::from(path));
60        }
61
62        let Some(home) = dirs::home_dir() else {
63            bail!("could not resolve user home directory");
64        };
65
66        Self::from_root(home.join(".gunmetal"))
67    }
68
69    pub fn from_root(root: PathBuf) -> Result<Self> {
70        let paths = Self {
71            database: root.join("state").join("gunmetal.db"),
72            empty_workspace_dir: root.join("empty-workspace"),
73            helpers_dir: root.join("helpers"),
74            logs_dir: root.join("logs"),
75            runtime_dir: root.join("runtime"),
76            root,
77        };
78        paths.ensure()?;
79        Ok(paths)
80    }
81
82    pub fn ensure(&self) -> Result<()> {
83        std::fs::create_dir_all(&self.root)
84            .with_context(|| format!("failed to create {}", self.root.display()))?;
85        std::fs::create_dir_all(self.database.parent().expect("database parent exists"))
86            .with_context(|| format!("failed to create {}", self.database.display()))?;
87        std::fs::create_dir_all(&self.helpers_dir)
88            .with_context(|| format!("failed to create {}", self.helpers_dir.display()))?;
89        std::fs::create_dir_all(&self.logs_dir)
90            .with_context(|| format!("failed to create {}", self.logs_dir.display()))?;
91        std::fs::create_dir_all(&self.runtime_dir)
92            .with_context(|| format!("failed to create {}", self.runtime_dir.display()))?;
93        std::fs::create_dir_all(&self.empty_workspace_dir)
94            .with_context(|| format!("failed to create {}", self.empty_workspace_dir.display()))?;
95        Ok(())
96    }
97
98    pub fn storage_handle(&self) -> Result<StorageHandle> {
99        StorageHandle::new(self.database.clone())
100    }
101
102    pub fn daemon_pid_file(&self) -> PathBuf {
103        self.runtime_dir.join("daemon.pid")
104    }
105
106    pub fn daemon_stdout_log(&self) -> PathBuf {
107        self.logs_dir.join("daemon.stdout.log")
108    }
109
110    pub fn daemon_stderr_log(&self) -> PathBuf {
111        self.logs_dir.join("daemon.stderr.log")
112    }
113}
114
115impl ProviderContext for AppPaths {
116    fn helpers_dir(&self) -> &Path {
117        &self.helpers_dir
118    }
119
120    fn empty_workspace_dir(&self) -> &Path {
121        &self.empty_workspace_dir
122    }
123}
124
125#[derive(Debug, Clone)]
126pub struct StorageHandle {
127    path: PathBuf,
128}
129
130impl StorageHandle {
131    pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
132        let handle = Self { path: path.into() };
133        handle.storage()?;
134        Ok(handle)
135    }
136
137    pub fn path(&self) -> &Path {
138        &self.path
139    }
140
141    pub fn storage(&self) -> Result<SqliteStorage> {
142        SqliteStorage::open(&self.path)
143    }
144
145    pub fn create_key(&self, draft: NewGunmetalKey) -> Result<CreatedGunmetalKey> {
146        self.storage()?.create_key(draft)
147    }
148
149    pub fn list_keys(&self) -> Result<Vec<GunmetalKey>> {
150        self.storage()?.list_keys()
151    }
152
153    pub fn get_key(&self, id: Uuid) -> Result<Option<GunmetalKey>> {
154        self.storage()?.get_key(id)
155    }
156
157    pub fn authenticate_key(&self, secret: &str) -> Result<Option<GunmetalKey>> {
158        self.storage()?.authenticate_key(secret)
159    }
160
161    pub fn set_key_state(&self, id: Uuid, state: KeyState) -> Result<()> {
162        self.storage()?.set_key_state(id, state)
163    }
164
165    pub fn delete_key(&self, id: Uuid) -> Result<()> {
166        self.storage()?.delete_key(id)
167    }
168
169    pub fn create_profile(&self, draft: NewProviderProfile) -> Result<ProviderProfile> {
170        self.storage()?.create_profile(draft)
171    }
172
173    pub fn delete_profile(&self, id: Uuid) -> Result<()> {
174        self.storage()?.delete_profile(id)
175    }
176
177    pub fn list_profiles(&self) -> Result<Vec<ProviderProfile>> {
178        self.storage()?.list_profiles()
179    }
180
181    pub fn get_profile(&self, id: Uuid) -> Result<Option<ProviderProfile>> {
182        self.storage()?.get_profile(id)
183    }
184
185    pub fn update_profile_credentials(
186        &self,
187        id: Uuid,
188        credentials: Option<serde_json::Value>,
189    ) -> Result<()> {
190        self.storage()?.update_profile_credentials(id, credentials)
191    }
192
193    pub fn replace_models_for_profile(
194        &self,
195        provider: &ProviderKind,
196        profile_id: Option<Uuid>,
197        models: &[ModelDescriptor],
198    ) -> Result<()> {
199        self.storage()?
200            .replace_models_for_profile(provider, profile_id, models)
201    }
202
203    pub fn list_models(&self) -> Result<Vec<ModelDescriptor>> {
204        self.storage()?.list_models()
205    }
206
207    pub fn get_model(&self, id: &str) -> Result<Option<ModelDescriptor>> {
208        self.storage()?.get_model(id)
209    }
210
211    pub fn log_request(&self, entry: NewRequestLogEntry) -> Result<RequestLogEntry> {
212        self.storage()?.log_request(entry)
213    }
214
215    pub fn list_request_logs(&self, limit: usize) -> Result<Vec<RequestLogEntry>> {
216        self.storage()?.list_request_logs(limit)
217    }
218}
219
220impl Storage for StorageHandle {
221    fn create_key(&self, draft: NewGunmetalKey) -> Result<CreatedGunmetalKey> {
222        self.storage()?.create_key(draft)
223    }
224
225    fn list_keys(&self) -> Result<Vec<GunmetalKey>> {
226        self.storage()?.list_keys()
227    }
228
229    fn get_key(&self, id: Uuid) -> Result<Option<GunmetalKey>> {
230        self.storage()?.get_key(id)
231    }
232
233    fn authenticate_key(&self, secret: &str) -> Result<Option<GunmetalKey>> {
234        self.storage()?.authenticate_key(secret)
235    }
236
237    fn set_key_state(&self, id: Uuid, state: KeyState) -> Result<()> {
238        self.storage()?.set_key_state(id, state)
239    }
240
241    fn delete_key(&self, id: Uuid) -> Result<()> {
242        self.storage()?.delete_key(id)
243    }
244
245    fn create_profile(&self, draft: NewProviderProfile) -> Result<ProviderProfile> {
246        self.storage()?.create_profile(draft)
247    }
248
249    fn delete_profile(&self, id: Uuid) -> Result<()> {
250        self.storage()?.delete_profile(id)
251    }
252
253    fn list_profiles(&self) -> Result<Vec<ProviderProfile>> {
254        self.storage()?.list_profiles()
255    }
256
257    fn get_profile(&self, id: Uuid) -> Result<Option<ProviderProfile>> {
258        self.storage()?.get_profile(id)
259    }
260
261    fn update_profile_credentials(
262        &self,
263        id: Uuid,
264        credentials: Option<serde_json::Value>,
265    ) -> Result<()> {
266        self.storage()?.update_profile_credentials(id, credentials)
267    }
268
269    fn replace_models_for_profile(
270        &self,
271        provider: &ProviderKind,
272        profile_id: Option<Uuid>,
273        models: &[ModelDescriptor],
274    ) -> Result<()> {
275        self.storage()?
276            .replace_models_for_profile(provider, profile_id, models)
277    }
278
279    fn list_models(&self) -> Result<Vec<ModelDescriptor>> {
280        self.storage()?.list_models()
281    }
282
283    fn get_model(&self, id: &str) -> Result<Option<ModelDescriptor>> {
284        self.storage()?.get_model(id)
285    }
286
287    fn log_request(&self, entry: NewRequestLogEntry) -> Result<RequestLogEntry> {
288        self.storage()?.log_request(entry)
289    }
290
291    fn list_request_logs(&self, limit: usize) -> Result<Vec<RequestLogEntry>> {
292        self.storage()?.list_request_logs(limit)
293    }
294}
295
296pub struct InMemoryStorage {
297    keys: Mutex<Vec<GunmetalKey>>,
298    key_secrets: Mutex<HashMap<Uuid, String>>,
299    profiles: Mutex<Vec<ProviderProfile>>,
300    models: Mutex<Vec<ModelDescriptor>>,
301    request_logs: Mutex<Vec<RequestLogEntry>>,
302}
303
304impl InMemoryStorage {
305    pub fn new() -> Self {
306        Self {
307            keys: Mutex::new(Vec::new()),
308            key_secrets: Mutex::new(HashMap::new()),
309            profiles: Mutex::new(Vec::new()),
310            models: Mutex::new(Vec::new()),
311            request_logs: Mutex::new(Vec::new()),
312        }
313    }
314
315    fn hash_secret(secret: &str) -> String {
316        let mut hasher = Sha256::new();
317        hasher.update(secret.as_bytes());
318        format!("{:x}", hasher.finalize())
319    }
320}
321
322impl Default for InMemoryStorage {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328impl Storage for InMemoryStorage {
329    fn create_key(&self, draft: NewGunmetalKey) -> Result<CreatedGunmetalKey> {
330        if draft.name.trim().is_empty() {
331            bail!("key name cannot be empty");
332        }
333        if draft.scopes.is_empty() {
334            bail!("at least one scope is required");
335        }
336
337        let id = Uuid::new_v4();
338        let now = Utc::now();
339        let secret = format!("gm_{}_{}", id.simple(), Uuid::new_v4().simple());
340        let prefix = format!("gm_{}", &id.simple().to_string()[..8]);
341
342        let key = GunmetalKey {
343            id,
344            name: draft.name,
345            prefix,
346            state: KeyState::Active,
347            scopes: draft.scopes,
348            allowed_providers: draft.allowed_providers,
349            expires_at: draft.expires_at,
350            created_at: now,
351            updated_at: now,
352            last_used_at: None,
353        };
354
355        let mut keys = self.keys.lock().unwrap();
356        keys.push(key.clone());
357        drop(keys);
358
359        self.key_secrets.lock().unwrap().insert(id, secret.clone());
360
361        Ok(CreatedGunmetalKey {
362            record: key,
363            secret,
364        })
365    }
366
367    fn list_keys(&self) -> Result<Vec<GunmetalKey>> {
368        Ok(self.keys.lock().unwrap().clone())
369    }
370
371    fn get_key(&self, id: Uuid) -> Result<Option<GunmetalKey>> {
372        Ok(self
373            .keys
374            .lock()
375            .unwrap()
376            .iter()
377            .find(|k| k.id == id)
378            .cloned())
379    }
380
381    fn authenticate_key(&self, secret: &str) -> Result<Option<GunmetalKey>> {
382        let hash = Self::hash_secret(secret);
383        let keys = self.keys.lock().unwrap();
384        let secrets = self.key_secrets.lock().unwrap();
385
386        for (id, stored_secret) in secrets.iter() {
387            if Self::hash_secret(stored_secret) == hash
388                && let Some(key) = keys.iter().find(|k| k.id == *id)
389            {
390                let now = Utc::now();
391                if !key.is_usable_at(now) {
392                    return Ok(None);
393                }
394                return Ok(Some(key.clone()));
395            }
396        }
397        Ok(None)
398    }
399
400    fn set_key_state(&self, id: Uuid, state: KeyState) -> Result<()> {
401        let mut keys = self.keys.lock().unwrap();
402        let key = keys.iter_mut().find(|k| k.id == id);
403        match key {
404            Some(k) => {
405                k.state = state;
406                k.updated_at = Utc::now();
407                Ok(())
408            }
409            None => bail!("key not found"),
410        }
411    }
412
413    fn delete_key(&self, id: Uuid) -> Result<()> {
414        let mut keys = self.keys.lock().unwrap();
415        let pos = keys.iter().position(|k| k.id == id);
416        match pos {
417            Some(p) => {
418                keys.remove(p);
419                drop(keys);
420                self.key_secrets.lock().unwrap().remove(&id);
421                Ok(())
422            }
423            None => bail!("key not found"),
424        }
425    }
426
427    fn create_profile(&self, draft: NewProviderProfile) -> Result<ProviderProfile> {
428        let name = draft.name.trim();
429        if name.is_empty() {
430            bail!("profile name cannot be empty");
431        }
432
433        let mut profiles = self.profiles.lock().unwrap();
434        let now = Utc::now();
435
436        if let Some(existing) = profiles.iter_mut().find(|p| p.provider == draft.provider) {
437            existing.name = name.to_owned();
438            existing.base_url = draft.base_url;
439            existing.enabled = draft.enabled;
440            existing.credentials = draft.credentials;
441            existing.updated_at = now;
442            return Ok(existing.clone());
443        }
444
445        let profile = ProviderProfile {
446            id: Uuid::new_v4(),
447            provider: draft.provider,
448            name: name.to_owned(),
449            base_url: draft.base_url,
450            enabled: draft.enabled,
451            credentials: draft.credentials,
452            created_at: now,
453            updated_at: now,
454        };
455        profiles.push(profile.clone());
456        Ok(profile)
457    }
458
459    fn delete_profile(&self, id: Uuid) -> Result<()> {
460        let mut profiles = self.profiles.lock().unwrap();
461        let pos = profiles.iter().position(|p| p.id == id);
462        match pos {
463            Some(p) => {
464                profiles.remove(p);
465                drop(profiles);
466                let mut models = self.models.lock().unwrap();
467                models.retain(|m| m.profile_id != Some(id));
468                Ok(())
469            }
470            None => bail!("profile not found"),
471        }
472    }
473
474    fn list_profiles(&self) -> Result<Vec<ProviderProfile>> {
475        Ok(self.profiles.lock().unwrap().clone())
476    }
477
478    fn get_profile(&self, id: Uuid) -> Result<Option<ProviderProfile>> {
479        Ok(self
480            .profiles
481            .lock()
482            .unwrap()
483            .iter()
484            .find(|p| p.id == id)
485            .cloned())
486    }
487
488    fn update_profile_credentials(
489        &self,
490        id: Uuid,
491        credentials: Option<serde_json::Value>,
492    ) -> Result<()> {
493        let mut profiles = self.profiles.lock().unwrap();
494        let profile = profiles.iter_mut().find(|p| p.id == id);
495        match profile {
496            Some(p) => {
497                p.credentials = credentials;
498                p.updated_at = Utc::now();
499                Ok(())
500            }
501            None => bail!("profile not found"),
502        }
503    }
504
505    fn replace_models_for_profile(
506        &self,
507        provider: &ProviderKind,
508        _profile_id: Option<Uuid>,
509        models: &[ModelDescriptor],
510    ) -> Result<()> {
511        let mut stored = self.models.lock().unwrap();
512        stored.retain(|m| m.provider != *provider);
513        for model in models {
514            stored.push(model.clone());
515        }
516        Ok(())
517    }
518
519    fn list_models(&self) -> Result<Vec<ModelDescriptor>> {
520        Ok(self.models.lock().unwrap().clone())
521    }
522
523    fn get_model(&self, id: &str) -> Result<Option<ModelDescriptor>> {
524        Ok(self
525            .models
526            .lock()
527            .unwrap()
528            .iter()
529            .find(|m| m.id == id)
530            .cloned())
531    }
532
533    fn log_request(&self, entry: NewRequestLogEntry) -> Result<RequestLogEntry> {
534        let log = RequestLogEntry {
535            id: Uuid::new_v4(),
536            started_at: Utc::now(),
537            key_id: entry.key_id,
538            profile_id: entry.profile_id,
539            provider: entry.provider,
540            model: entry.model,
541            endpoint: entry.endpoint,
542            status_code: entry.status_code,
543            duration_ms: entry.duration_ms,
544            usage: entry.usage,
545            error_message: entry.error_message,
546        };
547        self.request_logs.lock().unwrap().push(log.clone());
548        Ok(log)
549    }
550
551    fn list_request_logs(&self, limit: usize) -> Result<Vec<RequestLogEntry>> {
552        let logs = self.request_logs.lock().unwrap();
553        let mut result: Vec<_> = logs.iter().rev().take(limit).cloned().collect();
554        result.reverse();
555        Ok(result)
556    }
557}
558
559pub struct SqliteStorage {
560    conn: Connection,
561}
562
563impl SqliteStorage {
564    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
565        let path = path.as_ref();
566
567        if let Some(parent) = path.parent() {
568            std::fs::create_dir_all(parent)
569                .with_context(|| format!("failed to create {}", parent.display()))?;
570        }
571
572        let conn =
573            Connection::open(path).with_context(|| format!("failed to open {}", path.display()))?;
574        Self::from_connection(conn)
575    }
576
577    pub fn open_in_memory() -> Result<Self> {
578        Self::from_connection(Connection::open_in_memory()?)
579    }
580
581    fn from_connection(conn: Connection) -> Result<Self> {
582        let storage = Self { conn };
583        storage.migrate()?;
584        Ok(storage)
585    }
586
587    pub fn create_key(&self, draft: NewGunmetalKey) -> Result<CreatedGunmetalKey> {
588        if draft.name.trim().is_empty() {
589            bail!("key name cannot be empty");
590        }
591
592        if draft.scopes.is_empty() {
593            bail!("at least one scope is required");
594        }
595
596        let id = Uuid::new_v4();
597        let now = Utc::now();
598        let secret = format!("gm_{}_{}", id.simple(), Uuid::new_v4().simple());
599        let prefix = format!("gm_{}", &id.simple().to_string()[..8]);
600        let secret_hash = hash_secret(&secret);
601
602        self.conn.execute(
603            "insert into keys (
604                id, name, prefix, secret_hash, state, expires_at, created_at, updated_at, last_used_at
605            ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
606            params![
607                id.to_string(),
608                draft.name,
609                prefix,
610                secret_hash,
611                KeyState::Active.to_string(),
612                draft.expires_at.map(to_rfc3339),
613                to_rfc3339(now),
614                to_rfc3339(now),
615                Option::<String>::None,
616            ],
617        )?;
618
619        self.replace_key_scopes(id, &draft.scopes)?;
620        self.replace_key_providers(id, &draft.allowed_providers)?;
621
622        let record = self
623            .get_key(id)?
624            .ok_or_else(|| anyhow!("created key was not persisted"))?;
625
626        Ok(CreatedGunmetalKey { record, secret })
627    }
628
629    pub fn list_keys(&self) -> Result<Vec<GunmetalKey>> {
630        let mut stmt = self.conn.prepare(
631            "select id, name, prefix, state, expires_at, created_at, updated_at, last_used_at
632             from keys
633             order by created_at desc",
634        )?;
635
636        let rows = stmt.query_map([], |row| {
637            Ok((
638                parse_uuid(row.get::<_, String>(0)?)?,
639                row.get::<_, String>(1)?,
640                row.get::<_, String>(2)?,
641                parse_key_state(row.get::<_, String>(3)?)?,
642                parse_optional_datetime(row.get::<_, Option<String>>(4)?)?,
643                parse_datetime(row.get::<_, String>(5)?)?,
644                parse_datetime(row.get::<_, String>(6)?)?,
645                parse_optional_datetime(row.get::<_, Option<String>>(7)?)?,
646            ))
647        })?;
648
649        rows.map(|row| {
650            let (id, name, prefix, state, expires_at, created_at, updated_at, last_used_at) = row?;
651            Ok(GunmetalKey {
652                id,
653                name,
654                prefix,
655                state,
656                scopes: self.list_key_scopes(id)?,
657                allowed_providers: self.list_key_providers(id)?,
658                expires_at,
659                created_at,
660                updated_at,
661                last_used_at,
662            })
663        })
664        .collect()
665    }
666
667    pub fn get_key(&self, id: Uuid) -> Result<Option<GunmetalKey>> {
668        let mut stmt = self.conn.prepare(
669            "select id, name, prefix, state, expires_at, created_at, updated_at, last_used_at
670             from keys
671             where id = ?1",
672        )?;
673
674        let maybe = stmt
675            .query_row([id.to_string()], |row| {
676                Ok(GunmetalKey {
677                    id: parse_uuid(row.get::<_, String>(0)?)?,
678                    name: row.get(1)?,
679                    prefix: row.get(2)?,
680                    state: parse_key_state(row.get::<_, String>(3)?)?,
681                    scopes: Vec::new(),
682                    allowed_providers: Vec::new(),
683                    expires_at: parse_optional_datetime(row.get::<_, Option<String>>(4)?)?,
684                    created_at: parse_datetime(row.get::<_, String>(5)?)?,
685                    updated_at: parse_datetime(row.get::<_, String>(6)?)?,
686                    last_used_at: parse_optional_datetime(row.get::<_, Option<String>>(7)?)?,
687                })
688            })
689            .optional()?;
690
691        maybe
692            .map(|mut key| {
693                key.scopes = self.list_key_scopes(key.id)?;
694                key.allowed_providers = self.list_key_providers(key.id)?;
695                Ok(key)
696            })
697            .transpose()
698    }
699
700    pub fn authenticate_key(&self, secret: &str) -> Result<Option<GunmetalKey>> {
701        let hash = hash_secret(secret);
702        let mut stmt = self.conn.prepare(
703            "select id, name, prefix, state, expires_at, created_at, updated_at, last_used_at
704             from keys
705             where secret_hash = ?1
706             limit 1",
707        )?;
708        let maybe_key = stmt
709            .query_row([hash], |row| {
710                Ok(GunmetalKey {
711                    id: parse_uuid(row.get::<_, String>(0)?)?,
712                    name: row.get(1)?,
713                    prefix: row.get(2)?,
714                    state: parse_key_state(row.get::<_, String>(3)?)?,
715                    scopes: Vec::new(),
716                    allowed_providers: Vec::new(),
717                    expires_at: parse_optional_datetime(row.get::<_, Option<String>>(4)?)?,
718                    created_at: parse_datetime(row.get::<_, String>(5)?)?,
719                    updated_at: parse_datetime(row.get::<_, String>(6)?)?,
720                    last_used_at: parse_optional_datetime(row.get::<_, Option<String>>(7)?)?,
721                })
722            })
723            .optional()?;
724
725        let Some(mut key) = maybe_key else {
726            return Ok(None);
727        };
728        let now = Utc::now();
729        key.scopes = self.list_key_scopes(key.id)?;
730        key.allowed_providers = self.list_key_providers(key.id)?;
731
732        if !key.is_usable_at(now) {
733            return Ok(None);
734        }
735
736        if should_touch_last_used(key.last_used_at, now) {
737            self.conn.execute(
738                "update keys set last_used_at = ?2, updated_at = ?2 where id = ?1",
739                params![key.id.to_string(), to_rfc3339(now)],
740            )?;
741            key.last_used_at = Some(now);
742            key.updated_at = now;
743        }
744
745        Ok(Some(key))
746    }
747
748    pub fn set_key_state(&self, id: Uuid, state: KeyState) -> Result<()> {
749        let changed = self.conn.execute(
750            "update keys set state = ?2, updated_at = ?3 where id = ?1",
751            params![id.to_string(), state.to_string(), to_rfc3339(Utc::now())],
752        )?;
753
754        if changed == 0 {
755            bail!("key not found");
756        }
757
758        Ok(())
759    }
760
761    pub fn delete_key(&self, id: Uuid) -> Result<()> {
762        self.conn
763            .execute("delete from key_scopes where key_id = ?1", [id.to_string()])?;
764        self.conn.execute(
765            "delete from key_allowed_providers where key_id = ?1",
766            [id.to_string()],
767        )?;
768        let changed = self
769            .conn
770            .execute("delete from keys where id = ?1", [id.to_string()])?;
771
772        if changed == 0 {
773            bail!("key not found");
774        }
775
776        Ok(())
777    }
778
779    pub fn create_profile(&self, draft: NewProviderProfile) -> Result<ProviderProfile> {
780        let name = draft.name.trim();
781        if name.is_empty() {
782            bail!("profile name cannot be empty");
783        }
784
785        let now = Utc::now();
786        let provider = draft.provider.to_string();
787        if let Some(existing) = self
788            .conn
789            .query_row(
790                "select id from provider_profiles where provider = ?1",
791                params![provider],
792                |row| row.get::<_, String>(0),
793            )
794            .optional()?
795        {
796            let id = parse_uuid(existing)?;
797            self.conn.execute(
798                "update provider_profiles
799                 set name = ?2,
800                     base_url = ?3,
801                     enabled = ?4,
802                     credentials_json = ?5,
803                     updated_at = ?6
804                 where id = ?1",
805                params![
806                    id.to_string(),
807                    name,
808                    draft.base_url,
809                    if draft.enabled { 1 } else { 0 },
810                    draft.credentials.map(|value| value.to_string()),
811                    to_rfc3339(now),
812                ],
813            )?;
814
815            return self
816                .get_profile(id)?
817                .ok_or_else(|| anyhow!("updated profile was not persisted"));
818        }
819
820        let id = Uuid::new_v4();
821        self.conn.execute(
822            "insert into provider_profiles (
823                id, provider, name, base_url, enabled, credentials_json, created_at, updated_at
824            ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
825            params![
826                id.to_string(),
827                provider,
828                name,
829                draft.base_url,
830                if draft.enabled { 1 } else { 0 },
831                draft.credentials.map(|value| value.to_string()),
832                to_rfc3339(now),
833                to_rfc3339(now),
834            ],
835        )?;
836
837        self.get_profile(id)?
838            .ok_or_else(|| anyhow!("created profile was not persisted"))
839    }
840
841    pub fn delete_profile(&self, id: Uuid) -> Result<()> {
842        self.conn
843            .execute("delete from models where profile_id = ?1", [id.to_string()])?;
844        let changed = self.conn.execute(
845            "delete from provider_profiles where id = ?1",
846            [id.to_string()],
847        )?;
848
849        if changed == 0 {
850            bail!("profile not found");
851        }
852
853        Ok(())
854    }
855
856    pub fn list_profiles(&self) -> Result<Vec<ProviderProfile>> {
857        let mut stmt = self.conn.prepare(
858            "select id, provider, name, base_url, enabled, credentials_json, created_at, updated_at
859             from provider_profiles
860             order by created_at desc",
861        )?;
862
863        let rows = stmt.query_map([], |row| {
864            Ok(ProviderProfile {
865                id: parse_uuid(row.get::<_, String>(0)?)?,
866                provider: parse_provider(row.get::<_, String>(1)?)?,
867                name: row.get(2)?,
868                base_url: row.get(3)?,
869                enabled: row.get::<_, i64>(4)? == 1,
870                credentials: parse_optional_json(row.get::<_, Option<String>>(5)?)?,
871                created_at: parse_datetime(row.get::<_, String>(6)?)?,
872                updated_at: parse_datetime(row.get::<_, String>(7)?)?,
873            })
874        })?;
875
876        rows.collect::<rusqlite::Result<Vec<_>>>()
877            .map_err(Into::into)
878    }
879
880    pub fn get_profile(&self, id: Uuid) -> Result<Option<ProviderProfile>> {
881        let mut stmt = self.conn.prepare(
882            "select id, provider, name, base_url, enabled, credentials_json, created_at, updated_at
883             from provider_profiles
884             where id = ?1",
885        )?;
886
887        stmt.query_row([id.to_string()], |row| {
888            Ok(ProviderProfile {
889                id: parse_uuid(row.get::<_, String>(0)?)?,
890                provider: parse_provider(row.get::<_, String>(1)?)?,
891                name: row.get(2)?,
892                base_url: row.get(3)?,
893                enabled: row.get::<_, i64>(4)? == 1,
894                credentials: parse_optional_json(row.get::<_, Option<String>>(5)?)?,
895                created_at: parse_datetime(row.get::<_, String>(6)?)?,
896                updated_at: parse_datetime(row.get::<_, String>(7)?)?,
897            })
898        })
899        .optional()
900        .map_err(Into::into)
901    }
902
903    pub fn update_profile_credentials(
904        &self,
905        id: Uuid,
906        credentials: Option<serde_json::Value>,
907    ) -> Result<()> {
908        let changed = self.conn.execute(
909            "update provider_profiles set credentials_json = ?2, updated_at = ?3 where id = ?1",
910            params![
911                id.to_string(),
912                credentials.map(|value| value.to_string()),
913                to_rfc3339(Utc::now())
914            ],
915        )?;
916
917        if changed == 0 {
918            bail!("profile not found");
919        }
920
921        Ok(())
922    }
923
924    pub fn replace_models_for_profile(
925        &self,
926        provider: &ProviderKind,
927        _profile_id: Option<Uuid>,
928        models: &[ModelDescriptor],
929    ) -> Result<()> {
930        let tx = self.conn.unchecked_transaction()?;
931        // Public model ids are provider-scoped today (for example `codex/gpt-5.4`), so
932        // Gunmetal can only expose one synced catalog per provider without collisions.
933        tx.execute(
934            "delete from models where provider = ?1",
935            params![provider.to_string()],
936        )?;
937
938        for model in models {
939            tx.execute(
940                "insert into models (id, provider, profile_id, upstream_name, display_name, metadata_json)
941                 values (?1, ?2, ?3, ?4, ?5, ?6)",
942                params![
943                    model.id,
944                    model.provider.to_string(),
945                    model.profile_id.map(|value| value.to_string()),
946                    model.upstream_name,
947                    model.display_name,
948                    model
949                        .metadata
950                        .as_ref()
951                        .map(serde_json::to_string)
952                        .transpose()?,
953                ],
954            )?;
955        }
956
957        tx.commit()?;
958        Ok(())
959    }
960
961    pub fn list_models(&self) -> Result<Vec<ModelDescriptor>> {
962        let mut stmt = self.conn.prepare(
963            "select id, provider, profile_id, upstream_name, display_name, metadata_json
964             from models
965             order by provider asc, id asc",
966        )?;
967
968        let rows = stmt.query_map([], |row| {
969            Ok(ModelDescriptor {
970                id: row.get(0)?,
971                provider: parse_provider(row.get::<_, String>(1)?)?,
972                profile_id: row
973                    .get::<_, Option<String>>(2)?
974                    .map(parse_uuid)
975                    .transpose()?,
976                upstream_name: row.get(3)?,
977                display_name: row.get(4)?,
978                metadata: row
979                    .get::<_, Option<String>>(5)?
980                    .map(|value| serde_json::from_str(&value).map_err(to_from_sql_err))
981                    .transpose()?,
982            })
983        })?;
984
985        rows.collect::<rusqlite::Result<Vec<_>>>()
986            .map_err(Into::into)
987    }
988
989    pub fn get_model(&self, id: &str) -> Result<Option<ModelDescriptor>> {
990        let mut stmt = self.conn.prepare(
991            "select id, provider, profile_id, upstream_name, display_name, metadata_json
992             from models
993             where id = ?1
994             limit 1",
995        )?;
996
997        stmt.query_row([id], |row| {
998            Ok(ModelDescriptor {
999                id: row.get(0)?,
1000                provider: parse_provider(row.get::<_, String>(1)?)?,
1001                profile_id: row
1002                    .get::<_, Option<String>>(2)?
1003                    .map(parse_uuid)
1004                    .transpose()?,
1005                upstream_name: row.get(3)?,
1006                display_name: row.get(4)?,
1007                metadata: row
1008                    .get::<_, Option<String>>(5)?
1009                    .map(|value| serde_json::from_str(&value).map_err(to_from_sql_err))
1010                    .transpose()?,
1011            })
1012        })
1013        .optional()
1014        .map_err(Into::into)
1015    }
1016
1017    pub fn log_request(&self, entry: NewRequestLogEntry) -> Result<RequestLogEntry> {
1018        let id = Uuid::new_v4();
1019        let started_at = Utc::now();
1020
1021        self.conn.execute(
1022            "insert into request_logs (
1023                id, started_at, key_id, profile_id, provider, model, endpoint, status_code,
1024                duration_ms, input_tokens, output_tokens, total_tokens, error_message
1025            ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
1026            params![
1027                id.to_string(),
1028                to_rfc3339(started_at),
1029                entry.key_id.map(|value| value.to_string()),
1030                entry.profile_id.map(|value| value.to_string()),
1031                entry.provider.to_string(),
1032                entry.model,
1033                entry.endpoint,
1034                entry.status_code.map(i64::from),
1035                to_i64(entry.duration_ms)?,
1036                entry.usage.input_tokens.map(i64::from),
1037                entry.usage.output_tokens.map(i64::from),
1038                entry.usage.total_tokens.map(i64::from),
1039                entry.error_message,
1040            ],
1041        )?;
1042
1043        self.list_request_logs(1)?
1044            .into_iter()
1045            .next()
1046            .ok_or_else(|| anyhow!("request log was not persisted"))
1047    }
1048
1049    pub fn list_request_logs(&self, limit: usize) -> Result<Vec<RequestLogEntry>> {
1050        let mut stmt = self.conn.prepare(
1051            "select id, started_at, key_id, profile_id, provider, model, endpoint, status_code,
1052                    duration_ms, input_tokens, output_tokens, total_tokens, error_message
1053             from request_logs
1054             order by started_at desc
1055             limit ?1",
1056        )?;
1057
1058        let rows = stmt.query_map([to_i64(limit as u64)?], |row| {
1059            Ok(RequestLogEntry {
1060                id: parse_uuid(row.get::<_, String>(0)?)?,
1061                started_at: parse_datetime(row.get::<_, String>(1)?)?,
1062                key_id: row
1063                    .get::<_, Option<String>>(2)?
1064                    .map(parse_uuid)
1065                    .transpose()?,
1066                profile_id: row
1067                    .get::<_, Option<String>>(3)?
1068                    .map(parse_uuid)
1069                    .transpose()?,
1070                provider: parse_provider(row.get::<_, String>(4)?)?,
1071                model: row.get(5)?,
1072                endpoint: row.get(6)?,
1073                status_code: row
1074                    .get::<_, Option<i64>>(7)?
1075                    .map(u16::try_from)
1076                    .transpose()
1077                    .map_err(to_from_sql_err)?,
1078                duration_ms: row.get::<_, i64>(8)?.try_into().map_err(to_from_sql_err)?,
1079                usage: TokenUsage {
1080                    input_tokens: row
1081                        .get::<_, Option<i64>>(9)?
1082                        .map(u32::try_from)
1083                        .transpose()
1084                        .map_err(to_from_sql_err)?,
1085                    output_tokens: row
1086                        .get::<_, Option<i64>>(10)?
1087                        .map(u32::try_from)
1088                        .transpose()
1089                        .map_err(to_from_sql_err)?,
1090                    total_tokens: row
1091                        .get::<_, Option<i64>>(11)?
1092                        .map(u32::try_from)
1093                        .transpose()
1094                        .map_err(to_from_sql_err)?,
1095                },
1096                error_message: row.get(12)?,
1097            })
1098        })?;
1099
1100        rows.collect::<rusqlite::Result<Vec<_>>>()
1101            .map_err(Into::into)
1102    }
1103
1104    fn migrate(&self) -> Result<()> {
1105        self.conn.execute_batch(
1106            "
1107            pragma journal_mode = wal;
1108            pragma foreign_keys = on;
1109
1110            create table if not exists keys (
1111                id text primary key,
1112                name text not null,
1113                prefix text not null unique,
1114                secret_hash text not null unique,
1115                state text not null,
1116                expires_at text null,
1117                created_at text not null,
1118                updated_at text not null,
1119                last_used_at text null
1120            );
1121
1122            create table if not exists key_scopes (
1123                key_id text not null,
1124                scope text not null,
1125                primary key (key_id, scope),
1126                foreign key (key_id) references keys(id) on delete cascade
1127            );
1128
1129            create table if not exists key_allowed_providers (
1130                key_id text not null,
1131                provider text not null,
1132                primary key (key_id, provider),
1133                foreign key (key_id) references keys(id) on delete cascade
1134            );
1135
1136            create table if not exists provider_profiles (
1137                id text primary key,
1138                provider text not null,
1139                name text not null,
1140                base_url text null,
1141                enabled integer not null,
1142                credentials_json text null,
1143                created_at text not null,
1144                updated_at text not null
1145            );
1146
1147            create table if not exists models (
1148                id text primary key,
1149                provider text not null,
1150                profile_id text null,
1151                upstream_name text not null,
1152                display_name text not null,
1153                metadata_json text null,
1154                foreign key (profile_id) references provider_profiles(id) on delete set null
1155            );
1156
1157            create table if not exists request_logs (
1158                id text primary key,
1159                started_at text not null,
1160                key_id text null,
1161                profile_id text null,
1162                provider text not null,
1163                model text not null,
1164                endpoint text not null,
1165                status_code integer null,
1166                duration_ms integer not null,
1167                input_tokens integer null,
1168                output_tokens integer null,
1169                total_tokens integer null,
1170                error_message text null,
1171                foreign key (key_id) references keys(id) on delete set null,
1172                foreign key (profile_id) references provider_profiles(id) on delete set null
1173            );
1174            ",
1175        )?;
1176
1177        if !self.column_exists("models", "metadata_json")? {
1178            self.conn
1179                .execute("alter table models add column metadata_json text null", [])?;
1180        }
1181
1182        Ok(())
1183    }
1184
1185    fn column_exists(&self, table: &str, column: &str) -> Result<bool> {
1186        let mut stmt = self.conn.prepare(&format!("pragma table_info({table})"))?;
1187        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
1188
1189        for value in rows {
1190            if value? == column {
1191                return Ok(true);
1192            }
1193        }
1194
1195        Ok(false)
1196    }
1197
1198    fn replace_key_scopes(&self, key_id: Uuid, scopes: &[KeyScope]) -> Result<()> {
1199        self.conn.execute(
1200            "delete from key_scopes where key_id = ?1",
1201            [key_id.to_string()],
1202        )?;
1203
1204        for scope in scopes {
1205            self.conn.execute(
1206                "insert into key_scopes (key_id, scope) values (?1, ?2)",
1207                params![key_id.to_string(), scope.to_string()],
1208            )?;
1209        }
1210
1211        Ok(())
1212    }
1213
1214    fn replace_key_providers(&self, key_id: Uuid, providers: &[ProviderKind]) -> Result<()> {
1215        self.conn.execute(
1216            "delete from key_allowed_providers where key_id = ?1",
1217            [key_id.to_string()],
1218        )?;
1219
1220        for provider in providers {
1221            self.conn.execute(
1222                "insert into key_allowed_providers (key_id, provider) values (?1, ?2)",
1223                params![key_id.to_string(), provider.to_string()],
1224            )?;
1225        }
1226
1227        Ok(())
1228    }
1229
1230    fn list_key_scopes(&self, key_id: Uuid) -> Result<Vec<KeyScope>> {
1231        let mut stmt = self
1232            .conn
1233            .prepare("select scope from key_scopes where key_id = ?1 order by scope asc")?;
1234        let rows = stmt.query_map([key_id.to_string()], |row| row.get::<_, String>(0))?;
1235
1236        rows.map(|row| parse_scope(row?))
1237            .collect::<Result<Vec<_>, _>>()
1238    }
1239
1240    fn list_key_providers(&self, key_id: Uuid) -> Result<Vec<ProviderKind>> {
1241        let mut stmt = self.conn.prepare(
1242            "select provider from key_allowed_providers where key_id = ?1 order by provider asc",
1243        )?;
1244        let rows = stmt.query_map([key_id.to_string()], |row| row.get::<_, String>(0))?;
1245
1246        let raw = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1247        raw.into_iter()
1248            .map(parse_provider_anyhow)
1249            .collect::<Result<Vec<_>, _>>()
1250    }
1251}
1252
1253fn hash_secret(secret: &str) -> String {
1254    let mut hasher = Sha256::new();
1255    hasher.update(secret.as_bytes());
1256    format!("{:x}", hasher.finalize())
1257}
1258
1259fn should_touch_last_used(last_used_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> bool {
1260    match last_used_at {
1261        Some(last_used_at) => {
1262            (now - last_used_at).num_seconds() >= LAST_USED_TOUCH_INTERVAL_SECONDS
1263        }
1264        None => true,
1265    }
1266}
1267
1268fn to_rfc3339(value: DateTime<Utc>) -> String {
1269    value.to_rfc3339()
1270}
1271
1272fn parse_datetime(value: String) -> rusqlite::Result<DateTime<Utc>> {
1273    DateTime::parse_from_rfc3339(&value)
1274        .map(|value| value.with_timezone(&Utc))
1275        .map_err(to_from_sql_err)
1276}
1277
1278fn parse_optional_datetime(value: Option<String>) -> rusqlite::Result<Option<DateTime<Utc>>> {
1279    value.map(parse_datetime).transpose()
1280}
1281
1282fn parse_optional_json(value: Option<String>) -> rusqlite::Result<Option<serde_json::Value>> {
1283    value
1284        .map(|item| serde_json::from_str(&item).map_err(to_from_sql_err))
1285        .transpose()
1286}
1287
1288fn parse_uuid(value: String) -> rusqlite::Result<Uuid> {
1289    Uuid::parse_str(&value).map_err(to_from_sql_err)
1290}
1291
1292fn parse_provider(value: String) -> rusqlite::Result<ProviderKind> {
1293    value.parse::<ProviderKind>().map_err(to_from_sql_message)
1294}
1295
1296fn parse_scope(value: String) -> Result<KeyScope> {
1297    value.parse::<KeyScope>().map_err(|error| anyhow!(error))
1298}
1299
1300fn parse_key_state(value: String) -> rusqlite::Result<KeyState> {
1301    value.parse::<KeyState>().map_err(to_from_sql_message)
1302}
1303
1304fn parse_provider_anyhow(value: String) -> Result<ProviderKind> {
1305    value
1306        .parse::<ProviderKind>()
1307        .map_err(|error| anyhow!(error))
1308}
1309
1310fn to_i64(value: u64) -> Result<i64> {
1311    i64::try_from(value).context("value exceeds sqlite integer range")
1312}
1313
1314fn to_from_sql_err<E>(error: E) -> rusqlite::Error
1315where
1316    E: std::error::Error + Send + Sync + 'static,
1317{
1318    rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(error))
1319}
1320
1321fn to_from_sql_message(error: String) -> rusqlite::Error {
1322    rusqlite::Error::FromSqlConversionFailure(
1323        0,
1324        rusqlite::types::Type::Text,
1325        Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, error)),
1326    )
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use chrono::Duration;
1332    use gunmetal_core::{KeyScope, KeyState, NewProviderProfile, NewRequestLogEntry, ProviderKind};
1333    use serde_json::json;
1334    use tempfile::TempDir;
1335
1336    use super::{AppPaths, SqliteStorage, StorageHandle};
1337
1338    #[test]
1339    fn creates_authenticates_and_revokes_keys() {
1340        let storage = SqliteStorage::open_in_memory().unwrap();
1341
1342        let created = storage
1343            .create_key(gunmetal_core::NewGunmetalKey {
1344                name: "default".to_owned(),
1345                scopes: vec![KeyScope::Inference, KeyScope::ModelsRead],
1346                allowed_providers: vec![ProviderKind::Codex, ProviderKind::Copilot],
1347                expires_at: Some(chrono::Utc::now() + Duration::days(1)),
1348            })
1349            .unwrap();
1350
1351        assert!(created.secret.starts_with("gm_"));
1352        assert_eq!(created.record.name, "default");
1353        assert_eq!(created.record.allowed_providers.len(), 2);
1354
1355        let authenticated = storage.authenticate_key(&created.secret).unwrap().unwrap();
1356        assert_eq!(authenticated.id, created.record.id);
1357        assert!(authenticated.last_used_at.is_some());
1358
1359        storage
1360            .set_key_state(created.record.id, KeyState::Disabled)
1361            .unwrap();
1362        assert!(storage.authenticate_key(&created.secret).unwrap().is_none());
1363
1364        storage
1365            .set_key_state(created.record.id, KeyState::Revoked)
1366            .unwrap();
1367        let revoked = storage.get_key(created.record.id).unwrap().unwrap();
1368        assert_eq!(revoked.state, KeyState::Revoked);
1369    }
1370
1371    #[test]
1372    fn deletes_keys_cleanly() {
1373        let storage = SqliteStorage::open_in_memory().unwrap();
1374        let created = storage
1375            .create_key(gunmetal_core::NewGunmetalKey {
1376                name: "throwaway".to_owned(),
1377                scopes: vec![KeyScope::Inference],
1378                allowed_providers: vec![],
1379                expires_at: None,
1380            })
1381            .unwrap();
1382
1383        storage.delete_key(created.record.id).unwrap();
1384        assert!(storage.get_key(created.record.id).unwrap().is_none());
1385    }
1386
1387    #[test]
1388    fn creates_profiles_and_model_registry() {
1389        let storage = SqliteStorage::open_in_memory().unwrap();
1390        let profile = storage
1391            .create_profile(NewProviderProfile {
1392                provider: ProviderKind::OpenRouter,
1393                name: "team".to_owned(),
1394                base_url: Some("https://openrouter.ai/api/v1".to_owned()),
1395                enabled: true,
1396                credentials: Some(json!({ "api_key": "secret" })),
1397            })
1398            .unwrap();
1399
1400        let profiles = storage.list_profiles().unwrap();
1401        assert_eq!(profiles.len(), 1);
1402        assert_eq!(profiles[0].id, profile.id);
1403
1404        storage
1405            .replace_models_for_profile(
1406                &ProviderKind::OpenRouter,
1407                Some(profile.id),
1408                &[gunmetal_core::ModelDescriptor {
1409                    id: "openrouter/openai/gpt-5.1".to_owned(),
1410                    provider: ProviderKind::OpenRouter,
1411                    profile_id: Some(profile.id),
1412                    upstream_name: "openai/gpt-5.1".to_owned(),
1413                    display_name: "GPT-5.1".to_owned(),
1414                    metadata: Some(gunmetal_core::ModelMetadata {
1415                        family: Some("gpt".to_owned()),
1416                        context_window: Some(272_000),
1417                        max_output_tokens: Some(16_384),
1418                        supports_tools: Some(true),
1419                        ..Default::default()
1420                    }),
1421                }],
1422            )
1423            .unwrap();
1424
1425        let models = storage.list_models().unwrap();
1426        assert_eq!(models.len(), 1);
1427        assert_eq!(models[0].id, "openrouter/openai/gpt-5.1");
1428        let fetched = storage
1429            .get_model("openrouter/openai/gpt-5.1")
1430            .unwrap()
1431            .unwrap();
1432        assert_eq!(fetched.id, "openrouter/openai/gpt-5.1");
1433        assert_eq!(
1434            models[0]
1435                .metadata
1436                .as_ref()
1437                .and_then(|value| value.family.as_deref()),
1438            Some("gpt")
1439        );
1440    }
1441
1442    #[test]
1443    fn creating_same_provider_updates_existing_connection() {
1444        let storage = SqliteStorage::open_in_memory().unwrap();
1445        let first = storage
1446            .create_profile(NewProviderProfile {
1447                provider: ProviderKind::OpenAi,
1448                name: "default".to_owned(),
1449                base_url: Some("https://one.example/v1".to_owned()),
1450                enabled: true,
1451                credentials: Some(json!({ "api_key": "first" })),
1452            })
1453            .unwrap();
1454
1455        let updated = storage
1456            .create_profile(NewProviderProfile {
1457                provider: ProviderKind::OpenAi,
1458                name: "browser".to_owned(),
1459                base_url: Some("https://two.example/v1".to_owned()),
1460                enabled: true,
1461                credentials: Some(json!({ "api_key": "second" })),
1462            })
1463            .unwrap();
1464
1465        let profiles = storage.list_profiles().unwrap();
1466        assert_eq!(profiles.len(), 1);
1467        assert_eq!(updated.id, first.id);
1468        assert_eq!(profiles[0].id, first.id);
1469        assert_eq!(profiles[0].name, "browser");
1470        assert_eq!(
1471            profiles[0].base_url.as_deref(),
1472            Some("https://two.example/v1")
1473        );
1474        assert_eq!(
1475            profiles[0]
1476                .credentials
1477                .as_ref()
1478                .and_then(|value| value.get("api_key"))
1479                .and_then(|value| value.as_str()),
1480            Some("second")
1481        );
1482    }
1483
1484    #[test]
1485    fn deletes_profiles_and_their_models() {
1486        let storage = SqliteStorage::open_in_memory().unwrap();
1487        let profile = storage
1488            .create_profile(NewProviderProfile {
1489                provider: ProviderKind::OpenRouter,
1490                name: "team".to_owned(),
1491                base_url: Some("https://openrouter.ai/api/v1".to_owned()),
1492                enabled: true,
1493                credentials: Some(json!({ "api_key": "secret" })),
1494            })
1495            .unwrap();
1496
1497        storage
1498            .replace_models_for_profile(
1499                &ProviderKind::OpenRouter,
1500                Some(profile.id),
1501                &[gunmetal_core::ModelDescriptor {
1502                    id: "openrouter/openai/gpt-5.1".to_owned(),
1503                    provider: ProviderKind::OpenRouter,
1504                    profile_id: Some(profile.id),
1505                    upstream_name: "openai/gpt-5.1".to_owned(),
1506                    display_name: "GPT-5.1".to_owned(),
1507                    metadata: None,
1508                }],
1509            )
1510            .unwrap();
1511
1512        storage.delete_profile(profile.id).unwrap();
1513
1514        assert!(storage.get_profile(profile.id).unwrap().is_none());
1515        assert!(storage.list_models().unwrap().is_empty());
1516    }
1517
1518    #[test]
1519    fn authenticate_key_throttles_last_used_updates() {
1520        let storage = SqliteStorage::open_in_memory().unwrap();
1521        let created = storage
1522            .create_key(gunmetal_core::NewGunmetalKey {
1523                name: "default".to_owned(),
1524                scopes: vec![KeyScope::Inference],
1525                allowed_providers: vec![ProviderKind::Codex],
1526                expires_at: None,
1527            })
1528            .unwrap();
1529
1530        let first = storage.authenticate_key(&created.secret).unwrap().unwrap();
1531        let second = storage.authenticate_key(&created.secret).unwrap().unwrap();
1532
1533        assert_eq!(first.last_used_at, second.last_used_at);
1534        assert_eq!(first.updated_at, second.updated_at);
1535    }
1536
1537    #[test]
1538    fn writes_lightweight_request_logs() {
1539        let storage = SqliteStorage::open_in_memory().unwrap();
1540        let log = storage
1541            .log_request(NewRequestLogEntry {
1542                key_id: None,
1543                profile_id: None,
1544                provider: ProviderKind::Codex,
1545                model: "codex/gpt-5.4".to_owned(),
1546                endpoint: "/v1/chat/completions".to_owned(),
1547                status_code: Some(200),
1548                duration_ms: 182,
1549                usage: gunmetal_core::TokenUsage {
1550                    input_tokens: Some(42),
1551                    output_tokens: Some(12),
1552                    total_tokens: Some(54),
1553                },
1554                error_message: None,
1555            })
1556            .unwrap();
1557
1558        let logs = storage.list_request_logs(10).unwrap();
1559        assert_eq!(logs.len(), 1);
1560        assert_eq!(logs[0].id, log.id);
1561        assert_eq!(logs[0].usage.total_tokens, Some(54));
1562    }
1563
1564    #[test]
1565    fn storage_handle_reopens_file_backed_state() {
1566        let temp = TempDir::new().unwrap();
1567        let handle = StorageHandle::new(temp.path().join("gunmetal.db")).unwrap();
1568
1569        let created = handle
1570            .create_key(gunmetal_core::NewGunmetalKey {
1571                name: "default".to_owned(),
1572                scopes: vec![KeyScope::Inference],
1573                allowed_providers: vec![ProviderKind::Codex],
1574                expires_at: None,
1575            })
1576            .unwrap();
1577
1578        let reopened = StorageHandle::new(handle.path().to_path_buf()).unwrap();
1579        let authenticated = reopened.authenticate_key(&created.secret).unwrap().unwrap();
1580        assert_eq!(authenticated.id, created.record.id);
1581    }
1582
1583    #[test]
1584    fn app_paths_create_expected_layout() {
1585        let temp = TempDir::new().unwrap();
1586        let paths = AppPaths::from_root(temp.path().join("gunmetal-home")).unwrap();
1587
1588        assert!(paths.root.exists());
1589        assert!(paths.empty_workspace_dir.exists());
1590        assert!(paths.helpers_dir.exists());
1591        assert!(paths.logs_dir.exists());
1592        assert!(paths.runtime_dir.exists());
1593        assert_eq!(paths.database.file_name().unwrap(), "gunmetal.db");
1594        assert_eq!(paths.daemon_pid_file().file_name().unwrap(), "daemon.pid");
1595    }
1596
1597    #[test]
1598    fn replacing_models_keeps_other_providers_and_refreshes_one_provider_catalog() {
1599        let storage = SqliteStorage::open_in_memory().unwrap();
1600        let codex = storage
1601            .create_profile(NewProviderProfile {
1602                provider: ProviderKind::Codex,
1603                name: "codex".to_owned(),
1604                base_url: None,
1605                enabled: true,
1606                credentials: None,
1607            })
1608            .unwrap();
1609        let openrouter = storage
1610            .create_profile(NewProviderProfile {
1611                provider: ProviderKind::OpenRouter,
1612                name: "openrouter".to_owned(),
1613                base_url: None,
1614                enabled: true,
1615                credentials: None,
1616            })
1617            .unwrap();
1618
1619        storage
1620            .replace_models_for_profile(
1621                &ProviderKind::Codex,
1622                Some(codex.id),
1623                &[gunmetal_core::ModelDescriptor {
1624                    id: "codex/gpt-5.4".to_owned(),
1625                    provider: ProviderKind::Codex,
1626                    profile_id: Some(codex.id),
1627                    upstream_name: "gpt-5.4".to_owned(),
1628                    display_name: "GPT-5.4".to_owned(),
1629                    metadata: None,
1630                }],
1631            )
1632            .unwrap();
1633        storage
1634            .replace_models_for_profile(
1635                &ProviderKind::OpenRouter,
1636                Some(openrouter.id),
1637                &[gunmetal_core::ModelDescriptor {
1638                    id: "openrouter/openai/gpt-5.1".to_owned(),
1639                    provider: ProviderKind::OpenRouter,
1640                    profile_id: Some(openrouter.id),
1641                    upstream_name: "openai/gpt-5.1".to_owned(),
1642                    display_name: "GPT-5.1".to_owned(),
1643                    metadata: None,
1644                }],
1645            )
1646            .unwrap();
1647        storage
1648            .replace_models_for_profile(
1649                &ProviderKind::Codex,
1650                Some(codex.id),
1651                &[gunmetal_core::ModelDescriptor {
1652                    id: "codex/gpt-5.5".to_owned(),
1653                    provider: ProviderKind::Codex,
1654                    profile_id: Some(codex.id),
1655                    upstream_name: "gpt-5.5".to_owned(),
1656                    display_name: "GPT-5.5".to_owned(),
1657                    metadata: None,
1658                }],
1659            )
1660            .unwrap();
1661
1662        let models = storage.list_models().unwrap();
1663        assert_eq!(models.len(), 2);
1664        assert!(models.iter().any(|model| model.id == "codex/gpt-5.5"));
1665        assert!(
1666            models
1667                .iter()
1668                .any(|model| model.id == "openrouter/openai/gpt-5.1")
1669        );
1670    }
1671
1672    #[test]
1673    fn replacing_models_for_second_profile_of_same_provider_replaces_provider_catalog() {
1674        let storage = SqliteStorage::open_in_memory().unwrap();
1675        let first = storage
1676            .create_profile(NewProviderProfile {
1677                provider: ProviderKind::Codex,
1678                name: "codex-a".to_owned(),
1679                base_url: None,
1680                enabled: true,
1681                credentials: None,
1682            })
1683            .unwrap();
1684        let second = storage
1685            .create_profile(NewProviderProfile {
1686                provider: ProviderKind::Codex,
1687                name: "codex-b".to_owned(),
1688                base_url: None,
1689                enabled: true,
1690                credentials: None,
1691            })
1692            .unwrap();
1693
1694        storage
1695            .replace_models_for_profile(
1696                &ProviderKind::Codex,
1697                Some(first.id),
1698                &[gunmetal_core::ModelDescriptor {
1699                    id: "codex/gpt-5.4".to_owned(),
1700                    provider: ProviderKind::Codex,
1701                    profile_id: Some(first.id),
1702                    upstream_name: "gpt-5.4".to_owned(),
1703                    display_name: "GPT-5.4".to_owned(),
1704                    metadata: None,
1705                }],
1706            )
1707            .unwrap();
1708
1709        storage
1710            .replace_models_for_profile(
1711                &ProviderKind::Codex,
1712                Some(second.id),
1713                &[gunmetal_core::ModelDescriptor {
1714                    id: "codex/gpt-5.4".to_owned(),
1715                    provider: ProviderKind::Codex,
1716                    profile_id: Some(second.id),
1717                    upstream_name: "gpt-5.4".to_owned(),
1718                    display_name: "GPT-5.4".to_owned(),
1719                    metadata: None,
1720                }],
1721            )
1722            .unwrap();
1723
1724        let models = storage.list_models().unwrap();
1725        assert_eq!(models.len(), 1);
1726        assert_eq!(models[0].id, "codex/gpt-5.4");
1727        assert_eq!(models[0].profile_id, Some(second.id));
1728    }
1729
1730    #[test]
1731    fn updates_profile_credentials_in_place() {
1732        let storage = SqliteStorage::open_in_memory().unwrap();
1733        let profile = storage
1734            .create_profile(NewProviderProfile {
1735                provider: ProviderKind::Copilot,
1736                name: "copilot".to_owned(),
1737                base_url: None,
1738                enabled: true,
1739                credentials: None,
1740            })
1741            .unwrap();
1742
1743        storage
1744            .update_profile_credentials(profile.id, Some(json!({ "token": "abc" })))
1745            .unwrap();
1746
1747        let updated = storage.get_profile(profile.id).unwrap().unwrap();
1748        assert_eq!(updated.credentials, Some(json!({ "token": "abc" })));
1749    }
1750}