|
| 1 | +# Research & Design: LLM Cost Tracking -- Phases B+C |
| 2 | + |
| 3 | +## 1. Problem Restatement and Scope |
| 4 | + |
| 5 | +### Problem |
| 6 | +Phase A delivered `LlmUsage` types, `chat_completion_with_usage()`, and the `GenAiClient` adapter. Usage metadata now flows from API responses back to callers. However: |
| 7 | + |
| 8 | +- **Pricing is still hardcoded** in two disconnected systems (`terraphim_multi_agent::tracking::ModelPricing` with per-1K pricing for 3 models, `terraphim_types::llm_usage::ModelPricing` with per-million pricing and model patterns) |
| 9 | +- **`TokenUsageTracker` has zero persistence** -- all records lost on process exit. No `flush()` method exists. |
| 10 | +- **`token_tracker` is never written to in production code** -- `record_usage()` only called from tests |
| 11 | +- **3 of 5 providers are stubs** (Claude, OpenCode Go, Kimi return "Not yet implemented") |
| 12 | +- **CLI `handle_usage()` creates an empty `UsageRegistry`** -- no providers registered, so `usage show` returns nothing |
| 13 | +- **`terraphim_usage` does not depend on `terraphim_ccusage`** -- Claude provider can't access ccusage data |
| 14 | +- **`terraphim_multi_agent` does not depend on `terraphim_usage`** -- no bridge exists |
| 15 | + |
| 16 | +### IN Scope (Phases B+C) |
| 17 | +- Configurable pricing module (TOML file + embedded defaults) |
| 18 | +- Wire `TokenUsageTracker` to persist records into `UsageStore` |
| 19 | +- Complete Claude provider (via ccusage) |
| 20 | +- Complete OpenCode Go provider (via SQLite) |
| 21 | +- Add ccusage as dependency of terraphim_usage and create ccusage adapter provider |
| 22 | +- Register all providers in CLI `handle_usage()` |
| 23 | +- Keep Kimi as stub (API unknown, low priority) |
| 24 | + |
| 25 | +### OUT of Scope |
| 26 | +- Phase D CLI enhancements (`usage history --by model`, `usage alert --budget N`) -- separate PR |
| 27 | +- Web dashboard |
| 28 | +- Rate limiting based on costs |
| 29 | +- Kimi provider implementation (Moonshot API undocumented) |
| 30 | + |
| 31 | +## 2. System Elements and Dependencies |
| 32 | + |
| 33 | +### Current State After Phase A |
| 34 | + |
| 35 | +| Element | Crate | Status | Notes | |
| 36 | +|---|---|---|---| |
| 37 | +| `LlmUsage` | `terraphim_types` | DONE | input/output tokens, model, provider, cost_usd, latency_ms | |
| 38 | +| `LlmResult` | `terraphim_types` | DONE | content + optional LlmUsage | |
| 39 | +| `ModelPricing` (types) | `terraphim_types` | DONE | per-million pricing, model_pattern | |
| 40 | +| `ModelPricing` (multi_agent) | `terraphim_multi_agent` | LEGACY | per-1K pricing, 3 hardcoded models, to be replaced | |
| 41 | +| `chat_completion_with_usage()` | `terraphim_service` | DONE | GenAiClient extracts usage | |
| 42 | +| `ExecutionRecord::from_llm_usage()` | `terraphim_usage` | DONE | Bridge from LlmUsage to persistent record | |
| 43 | +| `UsageStore::save_execution()` | `terraphim_usage` | DONE | Persists ExecutionRecord | |
| 44 | +| `UsageProvider` trait | `terraphim_usage` | DONE | fetch_usage() -> ProviderUsage | |
| 45 | +| MiniMax provider | `terraphim_usage` | DONE | Full API integration | |
| 46 | +| ZAI provider | `terraphim_usage` | DONE | Full API integration | |
| 47 | +| Claude provider | `terraphim_usage` | STUB | Returns error | |
| 48 | +| OpenCode Go provider | `terraphim_usage` | STUB | Returns error | |
| 49 | +| Kimi provider | `terraphim_usage` | STUB | Returns error | |
| 50 | +| `CcusageClient` | `terraphim_ccusage` | DONE | Shells out to `bun dlx ccusage`, returns DailyUsageReport | |
| 51 | +| `TokenUsageTracker` | `terraphim_multi_agent` | IN-MEMORY ONLY | No persistence | |
| 52 | +| `CostTracker` | `terraphim_multi_agent` | IN-MEMORY ONLY | Hardcoded pricing | |
| 53 | +| `handle_usage()` | `terraphim_cli` | BROKEN | Empty registry, no providers registered | |
| 54 | + |
| 55 | +### Dependency Graph (Desired) |
| 56 | + |
| 57 | +``` |
| 58 | +terraphim_types (LlmUsage, ModelPricing) |
| 59 | + ^ ^ |
| 60 | + | | |
| 61 | +terraphim_service terraphim_usage ----> terraphim_ccusage (new dep) |
| 62 | +(return usage) (persist usage) (Claude token data) |
| 63 | + | | |
| 64 | + v v |
| 65 | +terraphim_multi_agent ---> terraphim_usage (new dep) |
| 66 | +(flush TokenUsageTracker) (persist records) |
| 67 | +``` |
| 68 | + |
| 69 | +### Key Constraint: terraphim_multi_agent adding terraphim_usage dependency |
| 70 | +Currently `terraphim_multi_agent` does NOT depend on `terraphim_usage`. Adding this dep is safe (no circular risk) since `terraphim_usage` depends only on `terraphim_types`, `terraphim_persistence`, `terraphim_settings`, and `opendal` -- none of which create cycles back to `terraphim_multi_agent`. |
| 71 | + |
| 72 | +## 3. Constraints |
| 73 | + |
| 74 | +| Constraint | Implication | |
| 75 | +|---|---| |
| 76 | +| No circular deps | terraphim_usage cannot depend on terraphim_multi_agent (safe: it doesn't) | |
| 77 | +| Fire-and-forget persistence | flush_to_store must not block LLM calls | |
| 78 | +| Sub-cent precision | ExecutionRecord uses cost_sub_cents (1/1,000,000 cent) | |
| 79 | +| Feature flags | providers gated behind `providers` feature in terraphim_usage | |
| 80 | +| ccusage requires bun/pnpm | Claude provider graceful degradation if runner missing | |
| 81 | +| SQLite dependency for OpenCode Go | Need rusqlite or shell out to sqlite3 CLI | |
| 82 | +| Existing test suite must pass | 128+ tests, clippy clean | |
| 83 | + |
| 84 | +## 4. Risks and Unknowns |
| 85 | + |
| 86 | +| Risk | Likelihood | Mitigation | |
| 87 | +|---|---|---| |
| 88 | +| Adding terraphim_usage dep to multi_agent causes compile issues | Low | Check dep graph first; terraphim_usage only pulls in terraphim_types/persistence | |
| 89 | +| ccusage not installed / bun missing | Medium | Return ProviderNotFound gracefully, same as OpenCode Go pattern | |
| 90 | +| OpenCode SQLite schema differs from assumed | Medium | Query sqlite_master first; handle missing columns gracefully | |
| 91 | +| Two ModelPricing types confuse callers | High | Unify: use terraphim_types::ModelPricing everywhere, deprecate multi_agent version | |
| 92 | +| flush_to_store loses data on crash | Low | Acceptable trade-off per design; records accumulate in memory until flush | |
| 93 | + |
| 94 | +### Assumptions |
| 95 | +- [ASSUMPTION] OpenCode Go SQLite at `~/.local/share/opencode/opencode.db` has a `message` table with cost fields |
| 96 | +- [ASSUMPTION] ccusage@18.0.10 JSON output format is stable (DailyUsageReport already handles it) |
| 97 | +- [ASSUMPTION] Kimi/Moonshot usage API is not publicly documented -- keep as stub |
| 98 | + |
| 99 | +## 5. Design: Step-by-Step Plan |
| 100 | + |
| 101 | +### Step B1: Configurable Pricing Module |
| 102 | + |
| 103 | +**File**: `crates/terraphim_usage/src/pricing.rs` (NEW) |
| 104 | + |
| 105 | +Create `PricingTable` struct: |
| 106 | +- `entries: Vec<terraphim_types::ModelPricing>` -- reuses existing type |
| 107 | +- `load(path: &Path) -> Result<Self>` -- reads TOML, falls back to embedded defaults |
| 108 | +- `embedded_defaults() -> Self` -- comprehensive default pricing for 20+ models |
| 109 | +- `find_pricing(model: &str) -> Option<&ModelPricing>` -- glob/substring match on model_pattern |
| 110 | +- `calculate_cost(model: &str, input: u64, output: u64) -> Option<f64>` -- convenience wrapper |
| 111 | + |
| 112 | +TOML format: |
| 113 | +```toml |
| 114 | +[[models]] |
| 115 | +pattern = "openai/gpt-4o*" |
| 116 | +input_per_m = 2.50 |
| 117 | +output_per_m = 10.00 |
| 118 | + |
| 119 | +[[models]] |
| 120 | +pattern = "anthropic/claude-sonnet*" |
| 121 | +input_per_m = 3.00 |
| 122 | +output_per_m = 15.00 |
| 123 | +``` |
| 124 | + |
| 125 | +Config file location: `~/.config/terraphim/pricing.toml` (optional, falls back to embedded) |
| 126 | + |
| 127 | +Also add `toml` dependency to terraphim_usage Cargo.toml. |
| 128 | + |
| 129 | +### Step B2: TokenUsageTracker flush_to_store |
| 130 | + |
| 131 | +**File**: `crates/terraphim_multi_agent/src/tracking.rs` (MODIFY) |
| 132 | + |
| 133 | +Add to `TokenUsageTracker`: |
| 134 | +```rust |
| 135 | +pub fn drain_records(&mut self) -> Vec<TokenUsageRecord> { |
| 136 | + std::mem::take(&mut self.records) |
| 137 | + // Keep totals intact for in-memory queries |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +**File**: `crates/terraphim_multi_agent/src/agent.rs` (MODIFY) |
| 142 | + |
| 143 | +Add a `flush_usage` method to `TerraphimAgent` that: |
| 144 | +1. Takes `self.token_tracker.write().drain_records()` |
| 145 | +2. Converts each `TokenUsageRecord` into `LlmUsage` (already has all fields) |
| 146 | +3. Creates `ExecutionRecord::from_llm_usage()` for each |
| 147 | +4. Calls `UsageStore::save_execution()` for each |
| 148 | +5. Fire-and-forget via `tokio::spawn` |
| 149 | + |
| 150 | +Requires: Add `terraphim_usage` dependency to `terraphim_multi_agent/Cargo.toml` |
| 151 | + |
| 152 | +### Step B3: Wire Production Usage Recording |
| 153 | + |
| 154 | +**File**: `crates/terraphim_multi_agent/src/genai_llm_client.rs` (MODIFY) |
| 155 | + |
| 156 | +After each `exec_chat()` call that returns usage metadata, record it into `token_tracker`: |
| 157 | +- Extract usage from genai response |
| 158 | +- Create `TokenUsageRecord` |
| 159 | +- Call `token_tracker.write().record_usage(record)` |
| 160 | + |
| 161 | +This is the missing production write path -- currently only tests call `record_usage()`. |
| 162 | + |
| 163 | +### Step C1: Add ccusage Dependency + Adapter Provider |
| 164 | + |
| 165 | +**File**: `crates/terraphim_usage/Cargo.toml` (MODIFY) |
| 166 | +- Add `terraphim_ccusage = { path = "../terraphim_ccusage", optional = true }` |
| 167 | +- Add to `providers` feature: `"dep:terraphim_ccusage"` |
| 168 | + |
| 169 | +**File**: `crates/terraphim_usage/src/providers/ccusage.rs` (NEW) |
| 170 | +- `CcusageProvider` struct wrapping `terraphim_ccusage::CcusageClient` |
| 171 | +- Implements `UsageProvider` trait |
| 172 | +- `fetch_usage()` queries ccusage for last 30 days |
| 173 | +- Builds `MetricLine::Progress` entries for daily/weekly/monthly spend |
| 174 | +- `id() -> "ccusage"`, `display_name() -> "Claude Code (ccusage)"` |
| 175 | + |
| 176 | +**File**: `crates/terraphim_usage/src/providers/mod.rs` (MODIFY) |
| 177 | +- Add `#[cfg(feature = "providers")] pub mod ccusage;` |
| 178 | + |
| 179 | +### Step C2: Complete Claude Provider |
| 180 | + |
| 181 | +**File**: `crates/terraphim_usage/src/providers/claude.rs` (MODIFY) |
| 182 | + |
| 183 | +Two-layer approach: |
| 184 | +1. Primary: Read OAuth token from `~/.claude/.credentials.json`, call Anthropic usage API |
| 185 | +2. Fallback: If OAuth fails or API unavailable, delegate to ccusage adapter |
| 186 | + |
| 187 | +Since Anthropic's OAuth usage API (`/api/oauth/usage`) endpoint and response format are not publicly documented, implement via ccusage as primary method for now. The Claude provider will: |
| 188 | +- Create internal `CcusageClient::new(CcusageProvider::Claude)` |
| 189 | +- Query last 7 days of usage |
| 190 | +- Build `MetricLine::Progress` for daily and weekly windows |
| 191 | +- Add `terraphim_ccusage` as internal dependency |
| 192 | + |
| 193 | +### Step C3: Complete OpenCode Go Provider |
| 194 | + |
| 195 | +**File**: `crates/terraphim_usage/src/providers/opencode_go.rs` (MODIFY) |
| 196 | + |
| 197 | +SQLite query approach (avoid rusqlite heavy dep): |
| 198 | +- Shell out to `sqlite3` CLI to query the database |
| 199 | +- `SELECT role, COUNT(*), SUM(cost) FROM message GROUP BY role` |
| 200 | +- Parse TSV output |
| 201 | +- If `sqlite3` not available, return ProviderNotFound |
| 202 | +- Build `MetricLine::Progress` entries |
| 203 | + |
| 204 | +Alternative: Use `rusqlite` with `bundled-sqlite` feature. This is more robust but adds a heavy C dependency. Decision: use `sqlite3` CLI shell-out for now (matches ccusage pattern of shelling out). |
| 205 | + |
| 206 | +### Step C4: Register All Providers in CLI |
| 207 | + |
| 208 | +**File**: `crates/terraphim_cli/src/main.rs` (MODIFY) |
| 209 | + |
| 210 | +In `handle_usage()`, register all available providers: |
| 211 | +```rust |
| 212 | +let mut registry = UsageRegistry::new(); |
| 213 | +registry.register(Box::new(ClaudeProvider::new())); |
| 214 | +registry.register(Box::new(OpenCodeGoProvider::new())); |
| 215 | +registry.register(Box::new(MiniMaxProvider::new())); |
| 216 | +registry.register(Box::new(ZaiProvider::new())); |
| 217 | +registry.register(Box::new(CcusageProvider::new())); |
| 218 | +``` |
| 219 | + |
| 220 | +This fixes the "empty registry" bug where `usage show` returns nothing. |
| 221 | + |
| 222 | +### Step C5: Keep Kimi as Stub |
| 223 | + |
| 224 | +No changes -- Kimi/Moonshot usage API is undocumented. Leave as-is with clear TODO. |
| 225 | + |
| 226 | +## 6. File Change Summary |
| 227 | + |
| 228 | +| File | Action | Purpose | |
| 229 | +|---|---|---| |
| 230 | +| `terraphim_usage/src/pricing.rs` | CREATE | Configurable pricing from TOML + defaults | |
| 231 | +| `terraphim_usage/Cargo.toml` | MODIFY | Add `toml` dep, add `terraphim_ccusage` dep | |
| 232 | +| `terraphim_usage/src/lib.rs` | MODIFY | Add `pub mod pricing;` | |
| 233 | +| `terraphim_usage/src/providers/ccusage.rs` | CREATE | CcusageProvider wrapping CcusageClient | |
| 234 | +| `terraphim_usage/src/providers/claude.rs` | MODIFY | Implement via ccusage | |
| 235 | +| `terraphim_usage/src/providers/opencode_go.rs` | MODIFY | Implement via sqlite3 CLI | |
| 236 | +| `terraphim_usage/src/providers/mod.rs` | MODIFY | Add ccusage module | |
| 237 | +| `terraphim_multi_agent/Cargo.toml` | MODIFY | Add `terraphim_usage` dependency | |
| 238 | +| `terraphim_multi_agent/src/tracking.rs` | MODIFY | Add `drain_records()` method | |
| 239 | +| `terraphim_multi_agent/src/agent.rs` | MODIFY | Add `flush_usage()` method | |
| 240 | +| `terraphim_multi_agent/src/genai_llm_client.rs` | MODIFY | Record usage after each LLM call | |
| 241 | +| `terraphim_cli/src/main.rs` | MODIFY | Register all providers in handle_usage() | |
| 242 | + |
| 243 | +## 7. Testing Strategy |
| 244 | + |
| 245 | +| AC / Invariant | Test Type | Location | |
| 246 | +|---|---|---| |
| 247 | +| PricingTable loads from TOML | Unit | `terraphim_usage/src/pricing.rs` | |
| 248 | +| PricingTable embedded defaults cover 20+ models | Unit | `terraphim_usage/src/pricing.rs` | |
| 249 | +| PricingTable pattern matching (gpt-4o-mini matches openai/gpt-4o*) | Unit | `terraphim_usage/src/pricing.rs` | |
| 250 | +| TokenUsageTracker::drain_records() empties vec, keeps totals | Unit | `terraphim_multi_agent/src/tracking.rs` | |
| 251 | +| ExecutionRecord::from_llm_usage() preserves all fields | Unit | `terraphim_usage/src/store.rs` (existing) | |
| 252 | +| CcusageProvider returns ProviderNotFound if no runner | Unit | `terraphim_usage/src/providers/ccusage.rs` | |
| 253 | +| OpenCode Go provider returns ProviderNotFound if no db | Unit | `terraphim_usage/src/providers/opencode_go.rs` | |
| 254 | +| Full test suite passes | Integration | `cargo test --workspace` | |
| 255 | +| Clippy clean | Lint | `cargo clippy --workspace` | |
| 256 | + |
| 257 | +## 8. Implementation Order |
| 258 | + |
| 259 | +1. **B1**: Pricing module (no deps on other steps) |
| 260 | +2. **C1**: ccusage dependency + adapter (no deps on other steps) |
| 261 | +3. **C2**: Claude provider (depends on C1) |
| 262 | +4. **C3**: OpenCode Go provider (independent) |
| 263 | +5. **B2**: TokenUsageTracker drain + flush (depends on B1 for pricing) |
| 264 | +6. **B3**: Wire production recording in genai_llm_client (depends on B2) |
| 265 | +7. **C4**: Register providers in CLI (depends on C1, C2, C3) |
| 266 | +8. **Verify**: Full test suite + clippy |
| 267 | + |
| 268 | +Steps 1-4 can be done in parallel. Steps 5-6 are sequential. Step 7 is last. |
0 commit comments