Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/atuin-ai/migrations/20260707000000_create_ai_usage.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Cache of the server-reported credit usage snapshot, keyed by a hash of
-- the auth token (the client never learns its hub user id). One row per
-- key; snapshot is the JSON `credits` object from the hub.
CREATE TABLE IF NOT EXISTS usage (
user_key TEXT NOT NULL,
snapshot TEXT NOT NULL,
updated_at INTEGER NOT NULL,

PRIMARY KEY (user_key)
);
34 changes: 33 additions & 1 deletion crates/atuin-ai/src/commands/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,22 @@ async fn run_inline_tui(
.await
.context("failed to open AI session database")?;

// Cached usage renders immediately; a background fetch (spawned below,
// once the event channel exists) replaces it unless it's fresh.
let usage_key = crate::usage::cache_key(&ctx.token);
let (cached_usage, usage_is_fresh) = match service.get_cached_usage(&usage_key).await {
Ok(Some(cached_snapshot)) => {
let age = time::OffsetDateTime::now_utc().unix_timestamp() - cached_snapshot.written_at;
let fresh = age < crate::usage::REFRESH_AFTER.as_secs() as i64;
(Some(cached_snapshot.snapshot), fresh)
}
Ok(None) => (None, false),
Err(e) => {
debug!("failed to read usage cache: {e}");
(None, false)
}
};

let cwd = std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().into_owned());
Expand Down Expand Up @@ -266,7 +282,8 @@ async fn run_inline_tui(
let skill_registry = crate::skills::SkillRegistry::discover(project_root.as_deref()).await;

// ─── Build initial ViewState from FSM ───────────────────────
let initial_view = build_view_state(&fsm, in_git_project, &skill_registry);
let mut initial_view = build_view_state(&fsm, in_git_project, &skill_registry);
initial_view.usage = cached_usage;

// ─── Build IoContext ────────────────────────────────────────
let io = IoContext {
Expand All @@ -288,6 +305,20 @@ async fn run_inline_tui(
// Wrap sender for components: they send AiTuiEvent, we wrap it
let tui_tx = DriverEventSender(tx.clone());

if !usage_is_fresh {
let endpoint = ctx.endpoint.clone();
let token = ctx.token.clone();
let usage_tx = tx.clone();
tokio::spawn(async move {
match crate::usage::fetch_usage(&endpoint, &token).await {
Ok(snapshot) => {
let _ = usage_tx.send(DriverEvent::Usage(snapshot));
}
Err(e) => debug!("background usage fetch failed: {e}"),
}
});
}

println!();

if let Some(prompt) = initial_prompt {
Expand Down Expand Up @@ -412,6 +443,7 @@ fn build_view_state(
archived_events,
model_picker: fsm.ctx.model_picker.clone(),
model: fsm.ctx.model.clone(),
usage: None,
turns,
has_command,
committed_turn_count: 0,
Expand Down
35 changes: 34 additions & 1 deletion crates/atuin-ai/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ pub(crate) enum DriverEvent {
Tui(AiTuiEvent),
/// Internal FSM event (from spawned stream/tool tasks)
Fsm(Event),
/// Fresh credit-usage snapshot (from the done event or a background
/// fetch). Handled by the driver directly — the FSM never sees it.
Usage(crate::usage::UsageSnapshot),
}

// ============================================================================
Expand Down Expand Up @@ -87,6 +90,9 @@ pub(crate) struct ViewState {
pub model_picker: Option<crate::fsm::ModelPicker>,
/// Model alias currently in effect (`None` = server default).
pub model: Option<String>,
/// Latest known credit usage: cached at startup, refreshed from the
/// done event and background fetches.
pub usage: Option<crate::usage::UsageSnapshot>,

// ─── Pre-computed for rendering ────────────────────────────
pub turns: Vec<turn::UiTurn>,
Expand Down Expand Up @@ -187,6 +193,10 @@ pub(crate) fn run_driver(
tracing::trace!(?tui_event, state = ?fsm.state, "TUI event");
translate_tui_event(tui_event, &handle, &io)
}
DriverEvent::Usage(snapshot) => {
update_usage(&handle, &io, snapshot);
None
}
};

if let Some(event) = fsm_event {
Expand Down Expand Up @@ -929,6 +939,21 @@ fn execute_effect(effect: &Effect, ctx: DriverContext) {
// Persistence
// ============================================================================

/// Apply a fresh usage snapshot: sync it to the view and write it to the
/// local cache so the next TUI open can render it immediately.
fn update_usage(handle: &Handle<ViewState>, io: &IoContext, snapshot: crate::usage::UsageSnapshot) {
handle.update({
let snapshot = snapshot.clone();
move |vs| vs.usage = Some(snapshot)
});

let key = crate::usage::cache_key(&io.app_ctx.token);
let rt = tokio::runtime::Handle::current();
if let Err(e) = rt.block_on(io.session_mgr.set_cached_usage(&key, &snapshot)) {
tracing::warn!("Failed to persist usage cache: {e}");
}
}

fn persist(fsm: &AgentFsm, io: &mut IoContext) {
let start = std::time::Instant::now();
let rt = tokio::runtime::Handle::current();
Expand Down Expand Up @@ -1063,7 +1088,15 @@ async fn run_stream_bridge(
},
Ok(StreamFrame::Control(control)) => match control {
StreamControl::StatusChanged(status) => Some(Event::StreamStatusChanged(status)),
StreamControl::Done { session_id } => Some(Event::StreamDone { session_id }),
StreamControl::Done {
session_id,
credits,
} => {
if let Some(snapshot) = credits {
let _ = tx.send(DriverEvent::Usage(snapshot));
}
Some(Event::StreamDone { session_id })
}
StreamControl::Error(msg) => Some(Event::StreamError(msg)),
},
Ok(StreamFrame::SessionIdentity(session_id)) => {
Expand Down
1 change: 1 addition & 0 deletions crates/atuin-ai/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ pub(crate) mod store;
pub(crate) mod stream;
pub(crate) mod tools;
pub(crate) mod tui;
pub(crate) mod usage;
pub(crate) mod user_context;
26 changes: 26 additions & 0 deletions crates/atuin-ai/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ use eyre::Result;
use crate::event_serde;
use crate::store::{AiSessionStore, StoredEvent, StoredSession};
use crate::tui::ConversationEvent;
use crate::usage::UsageSnapshot;

// ---------------------------------------------------------------------------
// Trait
// ---------------------------------------------------------------------------

pub(crate) struct CachedUsageSnapshot {
pub snapshot: UsageSnapshot,
pub written_at: i64,
}

#[async_trait]
pub(crate) trait SessionService: Send + Sync {
async fn create_session(
Expand Down Expand Up @@ -54,6 +60,11 @@ pub(crate) trait SessionService: Send + Sync {

async fn get_metadata(&self, session_id: &str, key: &str) -> Result<Option<String>>;
async fn set_metadata(&self, session_id: &str, key: &str, value: &str) -> Result<()>;

/// Read the cached usage snapshot (JSON, written-at unix timestamp) for
/// a user key. Not session-scoped: usage is per hub account.
async fn get_cached_usage(&self, user_key: &str) -> Result<Option<CachedUsageSnapshot>>;
async fn set_cached_usage(&self, user_key: &str, snapshot: &UsageSnapshot) -> Result<()>;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -139,6 +150,15 @@ impl SessionService for LocalSessionService {
async fn set_metadata(&self, session_id: &str, key: &str, value: &str) -> Result<()> {
self.store.set_metadata(session_id, key, value).await
}

async fn get_cached_usage(&self, user_key: &str) -> Result<Option<CachedUsageSnapshot>> {
self.store.get_usage(user_key).await
}

async fn set_cached_usage(&self, user_key: &str, snapshot: &UsageSnapshot) -> Result<()> {
let snapshot_json = serde_json::to_string(snapshot)?;
self.store.set_usage(user_key, &snapshot_json).await
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -337,6 +357,12 @@ impl SessionManager {
.set_metadata(&self.session_id, key, value)
.await
}

/// Write the usage cache for a user key. Not tied to the current
/// session, so no session row is created.
pub async fn set_cached_usage(&self, user_key: &str, snapshot: &UsageSnapshot) -> Result<()> {
self.service.set_cached_usage(user_key, snapshot).await
}
}

#[cfg(test)]
Expand Down
78 changes: 78 additions & 0 deletions crates/atuin-ai/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use eyre::{Result, eyre};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
use time::OffsetDateTime;

use crate::session::CachedUsageSnapshot;

// Database row mappings — all columns are kept even if not yet read in
// non-test code, since they're part of the schema and used in tests.
#[derive(Debug)]
Expand Down Expand Up @@ -331,11 +333,50 @@ impl AiSessionStore {
.await?;
Ok(())
}

// ── Usage cache (server credit totals, one row per user key) ──

/// Read the cached usage snapshot for a user key. Returns the snapshot
/// JSON and the unix timestamp it was written at.
pub async fn get_usage(&self, user_key: &str) -> Result<Option<CachedUsageSnapshot>> {
let row: Option<(String, i64)> =
sqlx::query_as("SELECT snapshot, updated_at FROM usage WHERE user_key = ?1")
.bind(user_key)
.fetch_optional(&self.pool)
.await?;

let Some((snapshot, written_at)) = row else {
return Ok(None);
};

let snapshot = serde_json::from_str(&snapshot)?;
Ok(Some(CachedUsageSnapshot {
snapshot,
written_at,
}))
}

/// Write the usage snapshot for a user key (upsert).
pub async fn set_usage(&self, user_key: &str, snapshot_json: &str) -> Result<()> {
Comment thread
BinaryMuse marked this conversation as resolved.
Outdated
let now = OffsetDateTime::now_utc().unix_timestamp();
sqlx::query(
"INSERT INTO usage (user_key, snapshot, updated_at)
VALUES (?1, ?2, ?3)
ON CONFLICT (user_key) DO UPDATE SET snapshot = ?2, updated_at = ?3",
)
.bind(user_key)
.bind(snapshot_json)
.bind(now)
.execute(&self.pool)
.await?;
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::usage::UsageSnapshot;

async fn new_test_store() -> AiSessionStore {
AiSessionStore::new("sqlite::memory:", 2.0).await.unwrap()
Expand Down Expand Up @@ -524,6 +565,43 @@ mod tests {
assert_eq!(before.updated_at, after.updated_at);
}

#[tokio::test]
async fn test_usage_cache_roundtrip() {
use crate::usage::UsageBucket;

let store = new_test_store().await;

assert!(store.get_usage("key-a").await.unwrap().is_none());

let snapshot = UsageSnapshot {
period: "calendar_monthly".into(),
resets_at: "2026-08-01T00:00:00Z".into(),
requests: UsageBucket { used: 1, limit: 10 },
input: UsageBucket { used: 2, limit: 20 },
output: UsageBucket { used: 3, limit: 0 },
};
let json = serde_json::to_string(&snapshot).unwrap();
store.set_usage("key-a", &json).await.unwrap();

let cached = store.get_usage("key-a").await.unwrap().unwrap();
assert!(cached.written_at > 0);
assert_eq!(cached.snapshot, snapshot);

// Upsert replaces the snapshot for the same key
let updated = UsageSnapshot {
requests: UsageBucket { used: 4, limit: 10 },
..snapshot
};
let json = serde_json::to_string(&updated).unwrap();
store.set_usage("key-a", &json).await.unwrap();

let cached = store.get_usage("key-a").await.unwrap().unwrap();
assert_eq!(cached.snapshot, updated);

// Other keys are independent
assert!(store.get_usage("key-b").await.unwrap().is_none());
}

#[tokio::test]
async fn test_events_ordered_chronologically() {
let store = new_test_store().await;
Expand Down
13 changes: 10 additions & 3 deletions crates/atuin-ai/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ pub(crate) static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSI
/// Frames that alter the stream lifecycle — terminal or state-changing.
#[derive(Debug, Clone)]
pub(crate) enum StreamControl {
Done { session_id: String },
Done {
session_id: String,
/// Period credit totals from the server, when it sends them.
credits: Option<crate::usage::UsageSnapshot>,
},
Error(String),
StatusChanged(String),
}
Expand Down Expand Up @@ -257,9 +261,12 @@ pub(crate) fn create_chat_stream(
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
yield Ok(StreamFrame::Control(StreamControl::Done { session_id }));
let credits = json.get("credits")
.cloned()
.and_then(|v| serde_json::from_value(v).ok());
yield Ok(StreamFrame::Control(StreamControl::Done { session_id, credits }));
} else {
yield Ok(StreamFrame::Control(StreamControl::Done { session_id: String::new() }));
yield Ok(StreamFrame::Control(StreamControl::Done { session_id: String::new(), credits: None }));
}
break;
}
Expand Down
Loading
Loading