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 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}