diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 83e7f31038..5dcd413a0a 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -115,7 +115,15 @@ struct SpendDashboardPane: View { self.controller.refresh() } label: { if self.controller.isRefreshing { - ProgressView().controlSize(.small) + HStack(spacing: 6) { + ProgressView().controlSize(.small) + if let progress = self.controller.codexScanProgress, progress.total > 0 { + Text(L("Scanning history %d/%d", progress.scanned, progress.total)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } } else { Label(L("Refresh"), systemImage: "arrow.clockwise") } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 656e84539a..7287eeaed5 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "لا يوجد سجل تكاليف محلي بعد"; "Turn on cost tracking or refresh after using a supported provider." = "فعّل تتبّع التكاليف أو حدّث بعد استخدام مزوّد مدعوم."; "Refresh failures" = "حالات فشل التحديث"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "تبقى العملات الأصلية منفصلة؛ تستبعد صفوف حساب Codex سجل جلسات Pi."; "Spend unavailable" = "الإنفاق غير متاح"; "Model breakdown unavailable" = "تفصيل الإنفاق حسب النموذج غير متاح"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 1921f5a94d..b292c649e3 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1265,6 +1265,7 @@ "No local cost history yet" = "Encara no hi ha historial local de costos"; "Turn on cost tracking or refresh after using a supported provider." = "Activa el seguiment de costos o actualitza després d’utilitzar un proveïdor compatible."; "Refresh failures" = "Actualitzacions fallides"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Les monedes originals es mantenen separades; les files de comptes de Codex exclouen l’historial de sessions de Pi."; "Spend unavailable" = "Despesa no disponible"; "Model breakdown unavailable" = "Desglossament per model no disponible"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 2986c311d4..c9e291b722 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1263,6 +1263,7 @@ "No local cost history yet" = "Noch kein lokaler Kostenverlauf"; "Turn on cost tracking or refresh after using a supported provider." = "Aktivieren Sie die Kostenverfolgung oder aktualisieren Sie nach der Nutzung eines unterstützten Anbieters."; "Refresh failures" = "Fehlgeschlagene Aktualisierungen"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Originalwährungen bleiben getrennt; Codex-Kontozeilen schließen den Pi-Sitzungsverlauf aus."; "Spend unavailable" = "Ausgaben nicht verfügbar"; "Model breakdown unavailable" = "Modellaufschlüsselung nicht verfügbar"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 18b42e8801..5f417ecd8c 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1267,6 +1267,7 @@ "No local cost history yet" = "No local cost history yet"; "Turn on cost tracking or refresh after using a supported provider." = "Turn on cost tracking or refresh after using a supported provider."; "Refresh failures" = "Refresh failures"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Native currencies stay separate; Codex account rows exclude Pi session history."; "Spend unavailable" = "Spend unavailable"; "Model breakdown unavailable" = "Model breakdown unavailable"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 656ac7975c..31225e1532 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1261,6 +1261,7 @@ "No local cost history yet" = "Aún no hay historial local de costes"; "Turn on cost tracking or refresh after using a supported provider." = "Activa el seguimiento local de costes o actualiza después de usar un proveedor compatible."; "Refresh failures" = "Actualizaciones fallidas"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Las divisas originales se mantienen separadas; las filas de cuentas de Codex excluyen el historial de sesiones de Pi."; "Spend unavailable" = "Gasto no disponible"; "Model breakdown unavailable" = "Desglose por modelo no disponible"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index cdea6d030b..db2fd21c6b 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "هنوز تاریخچه هزینه محلی وجود ندارد"; "Turn on cost tracking or refresh after using a supported provider." = "پیگیری هزینه را روشن کنید یا پس از استفاده از یک ارائه‌دهنده پشتیبانی‌شده تازه‌سازی کنید."; "Refresh failures" = "خطاهای تازه‌سازی"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "ارزهای اصلی جدا نگه داشته می‌شوند؛ ردیف‌های حساب Codex تاریخچه نشست‌های Pi را دربر نمی‌گیرند."; "Spend unavailable" = "هزینه در دسترس نیست"; "Model breakdown unavailable" = "تفکیک مدل در دسترس نیست"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index ff583450d9..06d68c7200 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1262,6 +1262,7 @@ "No local cost history yet" = "Aucun historique local des coûts pour l’instant"; "Turn on cost tracking or refresh after using a supported provider." = "Activez le suivi local des coûts ou actualisez après avoir utilisé un fournisseur pris en charge."; "Refresh failures" = "Échecs d’actualisation"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Les devises d’origine restent séparées ; les lignes de compte Codex excluent l’historique des sessions Pi."; "Spend unavailable" = "Dépenses indisponibles"; "Model breakdown unavailable" = "Répartition par modèle indisponible"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 43bd695894..5b069b2b66 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1262,6 +1262,7 @@ "No local cost history yet" = "Aínda non hai historial local de custos"; "Turn on cost tracking or refresh after using a supported provider." = "Activa o seguimento de custos ou actualiza despois de usar un provedor compatible."; "Refresh failures" = "Actualizacións falladas"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "As moedas orixinais mantéñense separadas; as filas das contas de Codex exclúen o historial de sesións de Pi."; "Spend unavailable" = "Gasto non dispoñible"; "Model breakdown unavailable" = "Desglose por modelo non dispoñible"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 275d7b769e..8035b65dd0 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "Belum ada riwayat biaya lokal"; "Turn on cost tracking or refresh after using a supported provider." = "Aktifkan pelacakan biaya atau segarkan setelah menggunakan penyedia yang didukung."; "Refresh failures" = "Kegagalan penyegaran"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Mata uang asli tetap dipisahkan; baris akun Codex tidak menyertakan riwayat sesi Pi."; "Spend unavailable" = "Data pengeluaran tidak tersedia"; "Model breakdown unavailable" = "Rincian per model tidak tersedia"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 6ce92c2a94..86bf398e2c 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "Ancora nessuna cronologia locale dei costi"; "Turn on cost tracking or refresh after using a supported provider." = "Attiva il monitoraggio dei costi o aggiorna dopo aver usato un provider supportato."; "Refresh failures" = "Aggiornamenti non riusciti"; +"Scanning history %d/%d" = "Scansione cronologia %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Le valute originali rimangono separate; le righe degli account Codex escludono la cronologia delle sessioni Pi."; "Spend unavailable" = "Spesa non disponibile"; "Model breakdown unavailable" = "Ripartizione per modello non disponibile"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 7b6911423d..d0f4379d51 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1263,6 +1263,7 @@ "No local cost history yet" = "ローカルのコスト履歴はまだありません"; "Turn on cost tracking or refresh after using a supported provider." = "コスト追跡をオンにするか、対応プロバイダの使用後に更新してください。"; "Refresh failures" = "更新失敗"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "各通貨は別々に扱われ、Codex アカウント行には Pi セッション履歴を含めません。"; "Spend unavailable" = "支出を取得できません"; "Model breakdown unavailable" = "モデル別の内訳を取得できません"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 7458d95078..872125e1ae 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1230,6 +1230,7 @@ "No local cost history yet" = "아직 로컬 비용 내역이 없습니다"; "Turn on cost tracking or refresh after using a supported provider." = "비용 추적을 켜거나 지원되는 공급자를 사용한 후 새로 고치세요."; "Refresh failures" = "새로 고침 실패"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "각 통화는 별도로 유지되며 Codex 계정 행에서는 Pi 세션 기록이 제외됩니다."; "Spend unavailable" = "지출 정보 없음"; "Model breakdown unavailable" = "모델별 내역을 사용할 수 없습니다"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index cbe4af8954..89c53057b2 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1262,6 +1262,7 @@ "No local cost history yet" = "Nog geen lokale kostengeschiedenis"; "Turn on cost tracking or refresh after using a supported provider." = "Schakel kostenregistratie in of vernieuw nadat je een ondersteunde aanbieder hebt gebruikt."; "Refresh failures" = "Mislukte vernieuwingen"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Oorspronkelijke valuta’s blijven gescheiden; rijen met Codex-accounts sluiten Pi-sessiegeschiedenis uit."; "Spend unavailable" = "Uitgaven niet beschikbaar"; "Model breakdown unavailable" = "Uitsplitsing per model niet beschikbaar"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 9958e32f9e..41c173203e 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "Brak lokalnej historii kosztów"; "Turn on cost tracking or refresh after using a supported provider." = "Włącz śledzenie kosztów lub odśwież po użyciu obsługiwanego dostawcy."; "Refresh failures" = "Błędy odświeżania"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Waluty źródłowe pozostają rozdzielone; wiersze kont Codex nie obejmują historii sesji Pi."; "Spend unavailable" = "Wydatki niedostępne"; "Model breakdown unavailable" = "Podział według modeli jest niedostępny"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 434ba9148c..d83ae85ee2 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1263,6 +1263,7 @@ "No local cost history yet" = "Ainda não há histórico local de custos"; "Turn on cost tracking or refresh after using a supported provider." = "Ative o acompanhamento de custos ou atualize após usar um provedor compatível."; "Refresh failures" = "Falhas de atualização"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "As moedas originais permanecem separadas; as linhas de contas do Codex excluem o histórico de sessões do Pi."; "Spend unavailable" = "Gastos indisponíveis"; "Model breakdown unavailable" = "Detalhamento por modelo indisponível"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index a1015d75f2..771c9ccd55 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1264,6 +1264,7 @@ "No local cost history yet" = "Локальной истории расходов пока нет"; "Turn on cost tracking or refresh after using a supported provider." = "Включите отслеживание расходов или обновите данные после использования поддерживаемого провайдера."; "Refresh failures" = "Ошибки обновления"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Исходные валюты остаются раздельными; строки учётных записей Codex не включают историю сеансов Pi."; "Spend unavailable" = "Расходы недоступны"; "Model breakdown unavailable" = "Разбивка по моделям недоступна"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 541451b974..4c547d8127 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1261,6 +1261,7 @@ "No local cost history yet" = "Ingen lokal kostnadshistorik än"; "Turn on cost tracking or refresh after using a supported provider." = "Aktivera kostnadsspårning eller uppdatera efter att ha använt en leverantör som stöds."; "Refresh failures" = "Misslyckade uppdateringar"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Ursprungliga valutor hålls åtskilda; rader för Codex-konton utesluter Pi-sessionshistorik."; "Spend unavailable" = "Utgifter ej tillgängliga"; "Model breakdown unavailable" = "Modellfördelning ej tillgänglig"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 4e0f2ff782..b7b211615a 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "ยังไม่มีประวัติค่าใช้จ่ายในเครื่อง"; "Turn on cost tracking or refresh after using a supported provider." = "เปิดการติดตามค่าใช้จ่ายหรือรีเฟรชหลังจากใช้ผู้ให้บริการที่รองรับ"; "Refresh failures" = "การรีเฟรชที่ล้มเหลว"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "สกุลเงินต้นทางแยกจากกัน แถวบัญชี Codex ไม่รวมประวัติเซสชัน Pi"; "Spend unavailable" = "ไม่มีข้อมูลค่าใช้จ่าย"; "Model breakdown unavailable" = "ไม่มีรายละเอียดแยกตามโมเดล"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 48a5096e45..543fffe482 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1264,6 +1264,7 @@ "No local cost history yet" = "Henüz yerel maliyet geçmişi yok"; "Turn on cost tracking or refresh after using a supported provider." = "Maliyet takibini açın veya desteklenen bir sağlayıcıyı kullandıktan sonra yenileyin."; "Refresh failures" = "Yenileme hataları"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Kaynak para birimleri ayrı tutulur; Codex hesap satırlarına Pi oturum geçmişi dahil edilmez."; "Spend unavailable" = "Harcama verisi kullanılamıyor"; "Model breakdown unavailable" = "Model dökümü kullanılamıyor"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 1915ea7bf9..1ced315e41 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1262,6 +1262,7 @@ "No local cost history yet" = "Локальної історії витрат ще немає"; "Turn on cost tracking or refresh after using a supported provider." = "Увімкніть відстеження витрат або оновіть дані після використання підтримуваного провайдера."; "Refresh failures" = "Помилки оновлення"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Вихідні валюти залишаються розділеними; рядки облікових записів Codex не включають історію сеансів Pi."; "Spend unavailable" = "Витрати недоступні"; "Model breakdown unavailable" = "Розподіл за моделями недоступний"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 02c9b8fdf3..c4037e105c 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1263,6 +1263,7 @@ "No local cost history yet" = "Chưa có lịch sử chi phí cục bộ"; "Turn on cost tracking or refresh after using a supported provider." = "Bật tính năng theo dõi chi phí hoặc làm mới sau khi sử dụng nhà cung cấp được hỗ trợ."; "Refresh failures" = "Lần làm mới thất bại"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Đơn vị tiền tệ gốc được giữ riêng biệt; các hàng tài khoản Codex không bao gồm lịch sử phiên Pi."; "Spend unavailable" = "Không có dữ liệu chi tiêu"; "Model breakdown unavailable" = "Phân tích theo mô hình không khả dụng"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 06fc0d4755..a7f9ff59c4 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1238,6 +1238,7 @@ "No local cost history yet" = "暂无本地费用历史"; "Turn on cost tracking or refresh after using a supported provider." = "启用费用跟踪,或在使用受支持的提供商后刷新。"; "Refresh failures" = "刷新失败"; +"Scanning history %d/%d" = "正在扫描历史 %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "各原始币种保持分开;Codex 帐户行不包含 Pi 会话历史。"; "Spend unavailable" = "支出数据不可用"; "Model breakdown unavailable" = "模型明细不可用"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index ec01ed18f3..fcf0b558e8 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1293,6 +1293,7 @@ "No local cost history yet" = "尚無本機費用歷史"; "Turn on cost tracking or refresh after using a supported provider." = "開啟費用追蹤,或在使用支援的提供者後重新整理。"; "Refresh failures" = "重新整理失敗"; +"Scanning history %d/%d" = "正在掃描歷史 %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "各原始幣別保持分開;Codex 帳號列不包含 Pi 工作階段歷史。"; "Spend unavailable" = "無法取得支出資料"; "Model breakdown unavailable" = "無法取得模型明細"; diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index 7f28ffa86f..4221c4b42e 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -333,7 +333,7 @@ extension SettingsStore { func isCostUsageEffectivelyEnabled(for provider: UsageProvider) -> Bool { let isEnabled = self.costUsageEnabled || (provider == .codex && self.codexLocalSessionCostLedgerEnabled) - return isEnabled && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost + return isEnabled && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsDashboardHistory } var resetTimeDisplayStyle: ResetTimeDisplayStyle { diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 8b7de621e3..3e063c20d1 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -115,6 +115,8 @@ struct CodexSpendSnapshotLoadContext: Sendable { let historyDays: Int let refreshPricingInBackground: Bool let includePiSessions: Bool + /// Reports Codex full-rescan progress (filesScanned, totalFiles) from the scan queue. + var progress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? } enum SpendDashboardSource { @@ -249,14 +251,22 @@ enum SpendDashboardSource { force: mode.forcesLoader) } - static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { - await self.load(request, codexSnapshotLoader: { context in - try await self.loadCodexSnapshot(context) - }) + static func load( + _ request: SpendDashboardLoadRequest, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) async + -> SpendDashboardLoadResult + { + await self.load( + request, + codexProgress: codexProgress, + codexSnapshotLoader: { context in + try await self.loadCodexSnapshot(context) + }) } static func load( _ request: SpendDashboardLoadRequest, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil, codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs @@ -280,7 +290,8 @@ enum SpendDashboardSource { force: request.force, historyDays: Self.scanDays, refreshPricingInBackground: false, - includePiSessions: false)) + includePiSessions: false, + progress: codexProgress)) try Task.checkCancellation() guard self.currentAuthFingerprint(for: account) == account.authFingerprint else { failedSourceIDs.insert(sourceID) @@ -328,13 +339,14 @@ enum SpendDashboardSource { codexHomePath: context.account.homePath, historyDays: context.historyDays, refreshPricingInBackground: context.refreshPricingInBackground, - includePiSessions: context.includePiSessions) + includePiSessions: context.includePiSessions, + codexProgress: context.progress) } @MainActor static func costCapableProviders(store: UsageStore) -> [UsageProvider] { store.enabledProvidersForDisplay().filter { - ProviderDescriptorRegistry.descriptor(for: $0).tokenCost.supportsTokenCost + ProviderDescriptorRegistry.descriptor(for: $0).tokenCost.supportsDashboardHistory } } @@ -558,6 +570,14 @@ private struct SpendDashboardSnapshotRevisionEncoder { } } +/// Main-actor store for Codex full-rescan progress. The scan queue reports file progress off the +/// main thread; the loader bounces each update here so `@Observable` views re-render live. +@MainActor +@Observable +final class CodexScanProgressStore { + var value: (scanned: Int, total: Int)? +} + @MainActor @Observable final class SpendDashboardController { @@ -653,6 +673,12 @@ final class SpendDashboardController { private(set) var model = SpendDashboardModel(requestedDays: 30, groups: []) private(set) var isRefreshing = false + /// Codex full-rescan progress during a manual refresh: (filesScanned, totalFiles). Nil when + /// no progress has been reported yet (incremental refreshes and non-Codex sources). + var codexScanProgress: (scanned: Int, total: Int)? { + self.progressStore.value + } + private(set) var failedSourceCount = 0 private(set) var generation: UInt64 = 0 private(set) var configuration: SpendDashboardConfiguration? @@ -663,6 +689,7 @@ final class SpendDashboardController { private let requestBuilder: RequestBuilder private let loader: Loader private let nowProvider: @Sendable () -> Date + private let progressStore = CodexScanProgressStore() private var loadTask: Task? private var loadedInputs: [SpendDashboardModel.ProviderInput] = [] private var loadedAt = Date() @@ -672,14 +699,25 @@ final class SpendDashboardController { init( userDefaults: UserDefaults = .standard, requestBuilder: @escaping RequestBuilder, - loader: @escaping Loader = SpendDashboardSource.load, + loader: Loader? = nil, nowProvider: @escaping @Sendable () -> Date = { Date() }) { self.userDefaults = userDefaults self.requestBuilder = requestBuilder - self.loader = loader self.nowProvider = nowProvider self.selectedDays = Self.normalizedDays(userDefaults.integer(forKey: Self.daysDefaultsKey)) + if let loader { + self.loader = loader + } else { + // Capture only the progress store (already initialized above), not `self`, so the + // default loader can bounce scan-queue progress onto the main actor. + let progressStore = self.progressStore + self.loader = { request in + await SpendDashboardSource.load(request) { scanned, total in + Task { @MainActor in progressStore.value = (scanned, total) } + } + } + } } func update(configuration: SpendDashboardConfiguration, force: Bool = false) { @@ -736,6 +774,7 @@ final class SpendDashboardController { } self.isRefreshing = true + self.progressStore.value = nil self.loadTask = Task { [weak self] in guard let self else { return } let request = await self.requestBuilder(phase.buildMode) @@ -890,6 +929,7 @@ final class SpendDashboardController { self.lastSuccessfulConfiguration = request.configuration self.failedSourceCount = result.failedSourceCount self.isRefreshing = false + self.progressStore.value = nil self.phase = .ordinary self.loadTask = nil self.rebuildModel() @@ -961,6 +1001,7 @@ final class SpendDashboardController { self.loadTask = nil self.configuration = nil self.isRefreshing = false + self.progressStore.value = nil self.phase = .ordinary } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 19c9488717..deb4531905 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1357,7 +1357,7 @@ extension UsageStore { } func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { - guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsDashboardHistory else { self.resetTokenUsageState(for: provider) return } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 226d061364..c6639a9b63 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -117,7 +117,9 @@ public struct CostUsageFetcher: Sendable { cursorCookieHeaderOverride: String? = nil, allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, - includePiSessions: Bool = true) async throws -> CostUsageTokenSnapshot + includePiSessions: Bool = true, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) async throws + -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, @@ -132,7 +134,8 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: false, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptionsOverride(), + codexProgress: codexProgress) } package func loadTokenSnapshot( @@ -147,7 +150,9 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, - bypassScannerDebounce: Bool) async throws -> CostUsageTokenSnapshot + bypassScannerDebounce: Bool, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) async throws + -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, @@ -162,7 +167,8 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: bypassScannerDebounce, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptionsOverride(), + codexProgress: codexProgress) } @available(*, deprecated, message: "Codex token-cost scans are uncapped; this limit is ignored.") @@ -194,6 +200,36 @@ public struct CostUsageFetcher: Sendable { self.scannerOptions } + /// Configures a manual full rescan: reconcile the *entire* history in one pass. The default + /// per-refresh byte budget (512MB) defers older session files to later refreshes, so a user + /// with gigabytes of history sees token counts creep up over several manual refreshes instead + /// of settling at the true total. Lifting the budget and forcing a rescan makes one manual + /// refresh converge on the full corpus. + static func configureFullRescan( + _ options: inout CostUsageScanner.Options, + progress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)?) + { + options.forceRescan = true + options.maxCodexScanBytesPerRefresh = 0 + // The aggregate budget is disabled above, so the per-file cap must go too: a session + // file larger than 256 MiB would otherwise be partially parsed on every full rescan and + // the manual result would never converge. + options.maxCodexSessionFileBytes = 0 + options.progressHandler = progress + } + + private static func applyClaudeLogFilter( + _ options: inout CostUsageScanner.Options, + provider: UsageProvider, + allowVertexClaudeFallback: Bool) + { + if provider == .vertexai { + options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly + } else if provider == .claude { + options.claudeLogProviderFilter = .excludeVertexAI + } + } + private static func resolvedScannerOptions( _ override: CostUsageScanner.Options?, provider: UsageProvider, @@ -210,84 +246,75 @@ public struct CostUsageFetcher: Sendable { return options } - static func loadTokenSnapshot( - provider: UsageProvider, - environment: [String: String] = ProcessInfo.processInfo.environment, - now: Date = Date(), - forceRefresh: Bool = false, - allowVertexClaudeFallback: Bool = false, - codexHomePath: String? = nil, - historyDays: Int = 30, - cursorCookieHeaderOverride: String? = nil, - allowPricingRefresh: Bool = true, - refreshPricingInBackground: Bool = true, - includePiSessions: Bool = true, - bypassScannerDebounce: Bool = false, - scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, - piScannerOptions overridePiScannerOptions: PiSessionCostScanner - .Options? = nil, - modelsDevClient: ModelsDevClient = ModelsDevClient(), - retryUnknownPricing: Bool = true) async throws -> CostUsageTokenSnapshot + private static func resolvedPiScannerOptions( + _ override: PiSessionCostScanner.Options?, + base: CostUsageScanner.Options, + forceRefresh: Bool, + bypassScannerDebounce: Bool) -> PiSessionCostScanner.Options { - guard self.supportsTokenSnapshot(provider) else { - throw CostUsageError.unsupportedProvider(provider) + var resolved = override ?? PiSessionCostScanner.Options() + if resolved.cacheRoot == nil { + resolved.cacheRoot = base.cacheRoot } - - let clampedHistoryDays = max(1, min(365, historyDays)) - - if let remoteSnapshot = try await self.loadRemoteTokenSnapshot( - provider: provider, - environment: environment, - now: now, - historyDays: clampedHistoryDays, - cursorCookieHeaderOverride: cursorCookieHeaderOverride) - { - return remoteSnapshot + resolved.calendar = base.calendar + if forceRefresh || bypassScannerDebounce { + resolved.refreshMinIntervalSeconds = 0 } + return resolved + } - var options = Self.resolvedScannerOptions( - overrideScannerOptions, - provider: provider, - codexHomePath: codexHomePath) - // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. - let since = options.calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now - let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) - let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false - await Self.refreshPricingIfAllowed( - options: PricingRefreshOptions( - provider: provider, - isAllowed: allowPricingRefresh, - retryUnknown: retryUnknownPricing, - inBackground: refreshPricingInBackground), - now: now, - cacheRoot: options.cacheRoot, - client: modelsDevClient) - - if provider == .vertexai { - options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly - } else if provider == .claude { - options.claudeLogProviderFilter = .excludeVertexAI - } + private static func configureScannerRefresh( + _ options: inout CostUsageScanner.Options, + forceRefresh: Bool, + bypassScannerDebounce: Bool, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)?) + { if forceRefresh || bypassScannerDebounce { options.refreshMinIntervalSeconds = 0 } - var resolvedPiOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options() - if resolvedPiOptions.cacheRoot == nil { - resolvedPiOptions.cacheRoot = options.cacheRoot - } - resolvedPiOptions.calendar = options.calendar - if forceRefresh || bypassScannerDebounce { - resolvedPiOptions.refreshMinIntervalSeconds = 0 + if forceRefresh { + self.configureFullRescan(&options, progress: codexProgress) } - let piOptions = resolvedPiOptions + } - try Task.checkCancellation() - // The corpus scans below are synchronous and can run for minutes on large session - // archives. They execute on the dedicated scan queue so they never occupy a cooperative - // pool thread; CostUsageScanExecutor bridges this task's cancellation into the - // scanner-level checks. - let scanOptions = options - let scanResult = try await CostUsageScanExecutor.run { checkCancellation in + private static func pricingRefreshOptions( + provider: UsageProvider, + allowPricingRefresh: Bool, + retryUnknownPricing: Bool, + refreshPricingInBackground: Bool) -> PricingRefreshOptions + { + PricingRefreshOptions( + provider: provider, + isAllowed: allowPricingRefresh, + retryUnknown: retryUnknownPricing, + inBackground: refreshPricingInBackground) + } + + /// Inputs for the synchronous Codex/Claude corpus scan, grouped to keep `runCorpusScan` + /// readable. + private struct CorpusScanInput { + let provider: UsageProvider + let since: Date + let now: Date + let scanOptions: CostUsageScanner.Options + let piOptions: PiSessionCostScanner.Options + let allowVertexClaudeFallback: Bool + let includePiSessions: Bool + let shouldMergePiUsage: Bool + } + + /// Runs the synchronous corpus scans on the dedicated scan queue so they never occupy a + /// cooperative pool thread; `CostUsageScanExecutor` bridges task cancellation into the + /// scanner-level checks. + private static func runCorpusScan(_ input: CorpusScanInput) async throws + -> (daily: CostUsageDailyReport, projects: [CostUsageProjectBreakdown], sessions: [CostUsageSessionBreakdown]) + { + let provider = input.provider + let since = input.since + let now = input.now + let scanOptions = input.scanOptions + let piOptions = input.piOptions + return try await CostUsageScanExecutor.run { checkCancellation in var daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, @@ -298,7 +325,7 @@ public struct CostUsageFetcher: Sendable { try checkCancellation() if provider == .vertexai, - !allowVertexClaudeFallback, + !input.allowVertexClaudeFallback, scanOptions.claudeLogProviderFilter == .vertexAIOnly, daily.data.isEmpty { @@ -334,7 +361,7 @@ public struct CostUsageFetcher: Sendable { modelsDevCacheRoot: scanOptions.cacheRoot, sessionRoots: roots) } - if includePiSessions, provider == .claude || (provider == .codex && shouldMergePiUsage) { + if input.includePiSessions, provider == .claude || (provider == .codex && input.shouldMergePiUsage) { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, since: since, @@ -357,6 +384,99 @@ public struct CostUsageFetcher: Sendable { } return (daily: daily, projects: projects, sessions: sessions) } + } + + static func loadTokenSnapshot( + provider: UsageProvider, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date(), + forceRefresh: Bool = false, + allowVertexClaudeFallback: Bool = false, + codexHomePath: String? = nil, + historyDays: Int = 30, + cursorCookieHeaderOverride: String? = nil, + allowPricingRefresh: Bool = true, + refreshPricingInBackground: Bool = true, + includePiSessions: Bool = true, + bypassScannerDebounce: Bool = false, + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, + piScannerOptions overridePiScannerOptions: PiSessionCostScanner + .Options? = nil, + modelsDevClient: ModelsDevClient = ModelsDevClient(), + retryUnknownPricing: Bool = true, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) async throws + -> CostUsageTokenSnapshot + { + guard self.supportsTokenSnapshot(provider) else { + throw CostUsageError.unsupportedProvider(provider) + } + + let clampedHistoryDays = max(1, min(365, historyDays)) + + // Local-history providers short-circuit here: their snapshot comes from the registered + // scanner, not a remote path or the Codex corpus scan below. + if Self.usesLocalHistorySnapshot(provider) { + return try await self.loadLocalHistorySnapshotOrEmpty( + provider: provider, + environment: environment, + now: now, + historyDays: clampedHistoryDays) + } + + if let remoteSnapshot = try await self.loadRemoteTokenSnapshot( + provider: provider, + environment: environment, + now: now, + historyDays: clampedHistoryDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride) + { + return remoteSnapshot + } + + var options = Self.resolvedScannerOptions( + overrideScannerOptions, + provider: provider, + codexHomePath: codexHomePath) + // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. + let since = options.calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now + let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false + await Self.refreshPricingIfAllowed( + options: Self.pricingRefreshOptions( + provider: provider, + allowPricingRefresh: allowPricingRefresh, + retryUnknownPricing: retryUnknownPricing, + refreshPricingInBackground: refreshPricingInBackground), + now: now, + cacheRoot: options.cacheRoot, + client: modelsDevClient) + Self.applyClaudeLogFilter(&options, provider: provider, allowVertexClaudeFallback: allowVertexClaudeFallback) + Self.configureScannerRefresh( + &options, + forceRefresh: forceRefresh, + bypassScannerDebounce: bypassScannerDebounce, + codexProgress: codexProgress) + let piOptions = Self.resolvedPiScannerOptions( + overridePiScannerOptions, + base: options, + forceRefresh: forceRefresh, + bypassScannerDebounce: bypassScannerDebounce) + + try Task.checkCancellation() + // The corpus scans below are synchronous and can run for minutes on large session + // archives. They execute on the dedicated scan queue so they never occupy a cooperative + // pool thread; CostUsageScanExecutor bridges this task's cancellation into the + // scanner-level checks. + let scanInput = CorpusScanInput( + provider: provider, + since: since, + now: now, + scanOptions: options, + piOptions: piOptions, + allowVertexClaudeFallback: allowVertexClaudeFallback, + includePiSessions: includePiSessions, + shouldMergePiUsage: shouldMergePiUsage) + let scanResult = try await self.runCorpusScan(scanInput) if allowPricingRefresh, retryUnknownPricing, @@ -390,7 +510,7 @@ public struct CostUsageFetcher: Sendable { from: scanResult.daily, now: now, historyDays: clampedHistoryDays, - calendar: scanOptions.calendar, + calendar: options.calendar, projects: scanResult.projects, sessions: scanResult.sessions) } @@ -606,7 +726,9 @@ public struct CostUsageFetcher: Sendable { } /// Providers whose token-cost snapshot `loadTokenSnapshot` can produce. Cursor is - /// macOS-only because it reuses the macOS Cursor session resolution. + /// macOS-only because it reuses the macOS Cursor session resolution. A provider that opts + /// into `localHistorySources` is also supported: its snapshot comes from the registered local + /// history scanner rather than a remote/web path. static func supportsTokenSnapshot(_ provider: UsageProvider) -> Bool { switch provider { case .codex, .claude, .vertexai, .bedrock: @@ -618,7 +740,8 @@ public struct CostUsageFetcher: Sendable { return false #endif default: - return false + return !ProviderDescriptorRegistry.descriptor(for: provider) + .tokenCost.localHistorySources.isEmpty } } @@ -740,6 +863,8 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, useCurrentLocalDayForSession: true, meteredCostUSD: report.meteredCostUSD, + // The Cursor dashboard API returns the account's actual billed usage events. + costSource: .providerReported, credentialScopeFingerprint: report.credentialScopeFingerprint) } #endif @@ -751,6 +876,7 @@ public struct CostUsageFetcher: Sendable { useCurrentLocalDayForSession: Bool = true, calendar: Calendar = .current, meteredCostUSD: Double? = nil, + costSource: CostUsageCostSource = .estimated, credentialScopeFingerprint: String? = nil, historyLabel: String? = nil, projects: [CostUsageProjectBreakdown] = [], @@ -791,6 +917,7 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, historyLabel: historyLabel, meteredCostUSD: meteredCostUSD, + costSource: costSource, credentialScopeFingerprint: credentialScopeFingerprint, daily: daily.data, projects: projects, @@ -1030,7 +1157,9 @@ extension CostUsageFetcher { from: daily, now: now, historyDays: historyDays, - useCurrentLocalDayForSession: false) + useCurrentLocalDayForSession: false, + // Bedrock Cost Explorer reports actual billed spend, not a rate-card estimate. + costSource: .providerReported) } #if os(macOS) @@ -1044,4 +1173,75 @@ extension CostUsageFetcher { #endif return nil } + + /// Produces a snapshot from a provider's registered local history scanners, when the provider + /// opted into `localHistorySources`. Returns nil when no scanner yields usable history on this + /// machine (tool not installed, or no usage in the window). + fileprivate static func loadLocalHistoryTokenSnapshot( + provider: UsageProvider, + environment: [String: String], + now: Date, + historyDays: Int) async throws -> CostUsageTokenSnapshot? + { + let sources = ProviderDescriptorRegistry.descriptor(for: provider) + .tokenCost.localHistorySources + guard !sources.isEmpty else { return nil } + // Scanners are synchronous and can walk large file trees, so they run on the dedicated + // scan queue with the awaiting task's cancellation bridged into the context. + return try await CostUsageScanExecutor.run { checkCancellation in + let context = LocalHistoryScanContext( + environment: environment, + historyDays: historyDays, + now: now, + checkCancellation: checkCancellation) + for source in sources { + guard let scanner = LocalHistoryScannerRegistry.shared.scanner(for: source) else { + continue + } + if let snapshot = try scanner.scan(context: context) { + return snapshot + } + } + return nil + } + } + + /// Whether the provider's token snapshot comes from local history scanners rather than a + /// remote/web path or the Codex corpus scanner. Such providers short-circuit `loadTokenSnapshot` + /// before the Codex scan path. + fileprivate static func usesLocalHistorySnapshot(_ provider: UsageProvider) -> Bool { + !ProviderDescriptorRegistry.descriptor(for: provider) + .tokenCost.localHistorySources.isEmpty + } + + /// Loads the local-history snapshot, or an empty snapshot when the tool is not installed or + /// has no usage in the window. `historyCoverageIsEstablished` is false so the dashboard can + /// distinguish "no data" from a real zero. + fileprivate static func loadLocalHistorySnapshotOrEmpty( + provider: UsageProvider, + environment: [String: String], + now: Date, + historyDays: Int) async throws -> CostUsageTokenSnapshot + { + if let snapshot = try await self.loadLocalHistoryTokenSnapshot( + provider: provider, + environment: environment, + now: now, + historyDays: historyDays) + { + return snapshot + } + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "XXX", + historyDays: historyDays, + historyCoverageIsEstablished: false, + historyLabel: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName, + costSource: .estimated, + daily: [], + updatedAt: now) + } } diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 88f2760bbb..7e0ef31184 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -1,5 +1,53 @@ import Foundation +/// Extracts billing ownership only from an explicit provider namespace retained in a model id. +/// +/// A plain family label such as `MiniMax-M3` is not evidence because a subscription can bundle +/// that model. A namespaced id such as `minimax/MiniMax-M3` is routing evidence and can safely be +/// carried into the dashboard without tool-specific heuristics. +public enum CostUsageBillingProvider { + public static func providerID(fromNamespacedModel model: String) -> String? { + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + let components = trimmed + .split(separator: "/", omittingEmptySubsequences: true) + .dropLast() + .map { $0.lowercased() } + guard !components.isEmpty else { + return nil + } + let aliases: [String: String] = [ + "alibaba": UsageProvider.qwencloud.rawValue, + "alibabacloud": UsageProvider.qwencloud.rawValue, + "anthropic": UsageProvider.claude.rawValue, + "google": UsageProvider.gemini.rawValue, + "minimax-cn": UsageProvider.minimax.rawValue, + "moonshot": UsageProvider.moonshot.rawValue, + "moonshotai": UsageProvider.moonshot.rawValue, + "openai": UsageProvider.openai.rawValue, + "qwen": UsageProvider.qwencloud.rawValue, + "z.ai": UsageProvider.zai.rawValue, + ] + // Nested routes bill through the outermost explicit provider (e.g. + // `openrouter/anthropic/claude-*` is owned by OpenRouter, not Anthropic), so walk the + // chain from the outside in and stop at the first recognizable route. + for namespace in components { + if let alias = aliases[namespace] { return alias } + if let provider = UsageProvider(rawValue: namespace) { return provider.rawValue } + } + return nil + } +} + +/// Where a snapshot's `costUSD` figures come from. Local scanners price token counts against +/// models.dev rate cards, so their cost is an API-rate *estimate* of the real bill; provider +/// dashboards/APIs (Cursor usage events, Bedrock Cost Explorer) report the actual billed amount. +public enum CostUsageCostSource: String, Sendable, Equatable { + /// Billed spend as reported by the provider itself. + case providerReported + /// Locally estimated spend (token counts priced against public rate cards). + case estimated +} + public struct CostUsageWindowSummary: Sendable, Equatable { public let days: Int public let totalTokens: Int? @@ -77,6 +125,9 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { /// actually deducts, as opposed to the API-rate estimate. Only some providers (e.g. Cursor) /// report this; `nil` when unknown. public let meteredCostUSD: Double? + /// Origin of the cost figures in this snapshot. Defaults to `.estimated` because most + /// snapshots are priced locally; provider-billed sources opt into `.providerReported`. + public let costSource: CostUsageCostSource /// Internal credential scope used to prevent cross-account cache publication. This is a /// non-reversible fingerprint, not account identity, and is not emitted by CLI payloads. public let credentialScopeFingerprint: String? @@ -97,6 +148,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyCoverageIsEstablished: Bool = true, historyLabel: String? = nil, meteredCostUSD: Double? = nil, + costSource: CostUsageCostSource = .estimated, credentialScopeFingerprint: String? = nil, daily: [CostUsageDailyReport.Entry], projects: [CostUsageProjectBreakdown] = [], @@ -115,6 +167,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.historyCoverageIsEstablished = historyCoverageIsEstablished self.historyLabel = historyLabel self.meteredCostUSD = meteredCostUSD + self.costSource = costSource self.credentialScopeFingerprint = credentialScopeFingerprint self.daily = daily self.projects = projects @@ -139,10 +192,13 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { let costs = entries.compactMap(\.costUSD) let tokens = entries.compactMap(\.totalTokens) let requests = entries.compactMap(\.requestCount) + // A token-bearing day without a price must withhold the whole window total: summing the + // priced remainder would present a partial estimate as the complete comparison figure. + let hasUnpricedTokenDay = entries.contains { $0.totalTokens != nil && $0.costUSD == nil } return CostUsageWindowSummary( days: days, totalTokens: tokens.isEmpty ? nil : tokens.reduce(0, +), - totalCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), + totalCostUSD: hasUnpricedTokenDay ? nil : (costs.isEmpty ? nil : costs.reduce(0, +)), totalRequests: requests.isEmpty ? nil : requests.reduce(0, +), entryCount: entries.count) } @@ -275,8 +331,21 @@ public struct CostUsageProjectSourceBreakdown: Sendable, Equatable { public struct CostUsageDailyReport: Sendable, Decodable { public struct ModelBreakdown: Sendable, Decodable, Equatable { public let modelName: String + /// Provider/endpoint identity reported by the source record. This is + /// billing evidence, unlike a model-name guess. It is intentionally + /// optional because many historical formats do not retain routing. + public let billingProviderID: String? public let costUSD: Double? public let totalTokens: Int? + public let inputTokens: Int? + public let cacheReadTokens: Int? + public let cacheCreationTokens: Int? + public let outputTokens: Int? + /// Reasoning ("thinking") tokens, when the source format reports them separately + /// (Codex `reasoning_output_tokens`, Gemini `thoughts`, OpenCode `reasoning`). + /// Reasoning is always a sub-bucket of `outputTokens` — output stays billing-inclusive, + /// so reasoning must never be added on top of output when summing buckets. + public let reasoningTokens: Int? public let requestCount: Int? public let standardCostUSD: Double? public let priorityCostUSD: Double? @@ -285,9 +354,19 @@ public struct CostUsageDailyReport: Sendable, Decodable { private enum CodingKeys: String, CodingKey { case modelName + case billingProviderID + case providerID case costUSD case cost case totalTokens + case inputTokens + case cacheReadTokens + case cacheCreationTokens + case cacheReadInputTokens + case cacheCreationInputTokens + case outputTokens + case reasoningTokens + case reasoningOutputTokens = "reasoning_output_tokens" case requestCount case requests case standardCostUSD @@ -299,10 +378,24 @@ public struct CostUsageDailyReport: Sendable, Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.modelName = try container.decode(String.self, forKey: .modelName) + self.billingProviderID = + try container.decodeIfPresent(String.self, forKey: .billingProviderID) + ?? container.decodeIfPresent(String.self, forKey: .providerID) self.costUSD = try container.decodeIfPresent(Double.self, forKey: .costUSD) ?? container.decodeIfPresent(Double.self, forKey: .cost) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) + self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens) + self.cacheReadTokens = + try container.decodeIfPresent(Int.self, forKey: .cacheReadTokens) + ?? container.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens) + self.cacheCreationTokens = + try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens) + ?? container.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens) + self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) + self.reasoningTokens = + try container.decodeIfPresent(Int.self, forKey: .reasoningTokens) + ?? container.decodeIfPresent(Int.self, forKey: .reasoningOutputTokens) self.requestCount = try container.decodeIfPresent(Int.self, forKey: .requestCount) ?? container.decodeIfPresent(Int.self, forKey: .requests) @@ -314,8 +407,14 @@ public struct CostUsageDailyReport: Sendable, Decodable { public init( modelName: String, + billingProviderID: String? = nil, costUSD: Double?, totalTokens: Int? = nil, + inputTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + outputTokens: Int? = nil, + reasoningTokens: Int? = nil, requestCount: Int? = nil, standardCostUSD: Double? = nil, priorityCostUSD: Double? = nil, @@ -323,8 +422,18 @@ public struct CostUsageDailyReport: Sendable, Decodable { priorityTokens: Int? = nil) { self.modelName = modelName + let normalizedProviderID = billingProviderID? + .trimmingCharacters(in: .whitespacesAndNewlines) + self.billingProviderID = normalizedProviderID?.isEmpty == false + ? normalizedProviderID + : nil self.costUSD = costUSD self.totalTokens = totalTokens + self.inputTokens = inputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.outputTokens = outputTokens + self.reasoningTokens = reasoningTokens self.requestCount = requestCount self.standardCostUSD = standardCostUSD self.priorityCostUSD = priorityCostUSD @@ -530,8 +639,27 @@ extension CostUsageDailyReport { private struct BreakdownAccumulator { var totalTokens: Int = 0 var sawTotalTokens = false + var inputTokens: Int = 0 + var sawInputTokens = false + var missingInputTokens = false + var cacheReadTokens: Int = 0 + var sawCacheReadTokens = false + var missingCacheReadTokens = false + var cacheCreationTokens: Int = 0 + var sawCacheCreationTokens = false + var missingCacheCreationTokens = false + var outputTokens: Int = 0 + var sawOutputTokens = false + var missingOutputTokens = false + var reasoningTokens: Int = 0 + var sawReasoningTokens = false + var missingReasoningTokens = false var costUSD: Double = 0 var sawCost = false + /// Billing ownership evidence retained across merged sources. Dropped when sources + /// disagree: a merged breakdown must never claim an ownership the sources do not share. + var billingProviderID: String? + var sawConflictingBillingProviderID = false var standardCostUSD: Double = 0 var sawStandardCost = false var priorityCostUSD: Double = 0 @@ -546,10 +674,47 @@ extension CostUsageDailyReport { self.totalTokens += totalTokens self.sawTotalTokens = true } + if let inputTokens = breakdown.inputTokens { + self.inputTokens += inputTokens + self.sawInputTokens = true + } else { + self.missingInputTokens = true + } + if let cacheReadTokens = breakdown.cacheReadTokens { + self.cacheReadTokens += cacheReadTokens + self.sawCacheReadTokens = true + } else { + self.missingCacheReadTokens = true + } + if let cacheCreationTokens = breakdown.cacheCreationTokens { + self.cacheCreationTokens += cacheCreationTokens + self.sawCacheCreationTokens = true + } else { + self.missingCacheCreationTokens = true + } + if let outputTokens = breakdown.outputTokens { + self.outputTokens += outputTokens + self.sawOutputTokens = true + } else { + self.missingOutputTokens = true + } + if let reasoningTokens = breakdown.reasoningTokens { + self.reasoningTokens += reasoningTokens + self.sawReasoningTokens = true + } else { + self.missingReasoningTokens = true + } if let costUSD = breakdown.costUSD { self.costUSD += costUSD self.sawCost = true } + if let billingProviderID = breakdown.billingProviderID { + if let existing = self.billingProviderID, existing != billingProviderID { + self.sawConflictingBillingProviderID = true + } else if self.billingProviderID == nil { + self.billingProviderID = billingProviderID + } + } if let standardCostUSD = breakdown.standardCostUSD { self.standardCostUSD += standardCostUSD self.sawStandardCost = true @@ -571,8 +736,16 @@ extension CostUsageDailyReport { func build(modelName: String) -> ModelBreakdown { ModelBreakdown( modelName: modelName, + billingProviderID: self.sawConflictingBillingProviderID ? nil : self.billingProviderID, costUSD: self.sawCost ? self.costUSD : nil, totalTokens: self.sawTotalTokens ? self.totalTokens : nil, + inputTokens: self.sawInputTokens && !self.missingInputTokens ? self.inputTokens : nil, + cacheReadTokens: self.sawCacheReadTokens && !self.missingCacheReadTokens ? self.cacheReadTokens : nil, + cacheCreationTokens: self.sawCacheCreationTokens && !self.missingCacheCreationTokens + ? self.cacheCreationTokens + : nil, + outputTokens: self.sawOutputTokens && !self.missingOutputTokens ? self.outputTokens : nil, + reasoningTokens: self.sawReasoningTokens && !self.missingReasoningTokens ? self.reasoningTokens : nil, standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, standardTokens: self.sawStandardTokens ? self.standardTokens : nil, @@ -1064,6 +1237,13 @@ enum CostUsageBucketInterval { } enum CostUsageLocalDay { + static func gregorianCalendar(preserving calendar: Calendar) -> Calendar { + var normalized = Calendar(identifier: .gregorian) + normalized.timeZone = calendar.timeZone + normalized.locale = calendar.locale + return normalized + } + static func gregorianCalendar(matching calendar: Calendar = .current) -> Calendar { var gregorian = Calendar(identifier: .gregorian) gregorian.timeZone = calendar.timeZone diff --git a/Sources/CodexBarCore/CostUsageScanExecutor.swift b/Sources/CodexBarCore/CostUsageScanExecutor.swift index aef8b2b78d..3aec3ada70 100644 --- a/Sources/CodexBarCore/CostUsageScanExecutor.swift +++ b/Sources/CodexBarCore/CostUsageScanExecutor.swift @@ -120,7 +120,14 @@ public enum CostUsageScanExecutor { guard state.install(continuation) else { return } queue.async { guard state.begin() else { return } - state.complete(with: Result { try work(checkCancellation) }) + #if canImport(ObjectiveC) + let result = autoreleasepool { + Result { try work(checkCancellation) } + } + #else + let result = Result { try work(checkCancellation) } + #endif + state.complete(with: result) } } } onCancel: { diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index ca353e24bb..ef78e6be7c 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "3aa49b47f4b78e13" + static let value = "5ff079ec70f99f5b" } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 4ecdbd3d63..30217f1a59 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -254,8 +254,7 @@ enum PiSessionCostScanner { pricingKey: CostUsagePricingKey.codex( modelsDevArtifact: modelsDevArtifact, formulaVersion: Self.costFormulaVersion, - parserHash: CodexParserHash.value, - modelsDevProviderIDs: ["anthropic", "openai"])) + parserHash: CodexParserHash.value)) } private static func requestedWindowExpandsCache( @@ -942,7 +941,11 @@ extension PiSessionCostScanner { breakdown.append(CostUsageDailyReport.ModelBreakdown( modelName: modelName, costUSD: costNanos.map { Double($0) / Self.costScale }, - totalTokens: modelTotalTokens > 0 ? modelTotalTokens : nil)) + totalTokens: modelTotalTokens > 0 ? modelTotalTokens : nil, + inputTokens: packed.inputTokens, + cacheReadTokens: packed.cacheReadTokens, + cacheCreationTokens: packed.cacheWriteTokens, + outputTokens: packed.outputTokens)) dayInput += packed.inputTokens dayOutput += packed.outputTokens dayCacheRead += packed.cacheReadTokens diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index db210b6069..d8929f27ba 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -35,6 +35,7 @@ public enum AntigravityProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.antigravity], noDataMessage: { "Antigravity cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli, .oauth], diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift new file mode 100644 index 0000000000..08820254f5 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift @@ -0,0 +1,470 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +// Reads Antigravity (Google) local assistant-turn token usage from the conversation databases +// (`~/.gemini/antigravity/conversations/*.db`) and folds it into a `CostUsageTokenSnapshot` for the +// Usage & Spend dashboard. Same output shape as `MiniMaxSessionScanner`. +// +// Each `gen_metadata` row is one generation encoded as a `GeneratorMetadata` protobuf (the same +// message the IDE returns over `GetCascadeTrajectoryGeneratorMetadata`). There is no shipped +// `.proto`, so — mirroring tokscale's antigravity-cli parser — this scanner ships a tiny +// dependency-free protobuf wire-format reader and pulls only the fields it needs: +// +// gen_metadata.#1 → chatModel message +// #19 (string) → responseModel (e.g. `gemini-3.6-flash`) +// #9.#4 = {#1 sec, #2 ns} → per-generation wall-clock Timestamp +// #4 → usage message +// #1 (varint, const) → fixed system-prompt tokens (≈1132), billable input +// #2 (varint) → newly-processed (non-cached) input tokens +// #5 (varint) → cacheRead tokens +// #9 (varint) → output (text) tokens +// #10 (varint) → thinking / reasoning tokens +// #11 (string) → responseId (dedup key) +// trajectory_metadata_blob.#2 = {#1 sec, #2 ns} → session-created fallback Timestamp +// +// `reasoning` is part of billing output, so it stays folded into `outputTokens` and is additionally +// surfaced as the `reasoningTokens` sub-bucket (never added on top). Costs are priced from the +// token buckets at the vendor's models.dev rate (Google provider), matching how Codex/Claude spend +// is estimated; when no official rate is known the day stays token-only (`costUSD: nil`). +#if canImport(SQLite3) || canImport(CSQLite3) +public enum AntigravitySessionScanner { + public static let defaultHistoryDays = 30 + + /// Environment override for the Antigravity conversations directory, resolved directly here so + /// this scanner stays self-contained (mirrors `MiniMaxSessionScanner.MINIMAX_HOME`). + public static let homeEnvironmentKey = "ANTIGRAVITY_HOME" + + private static let claudePricingAliases: [String: String] = [ + "claude-opus-4-6-thinking": "claude-opus-4-6", + "claude-sonnet-4-6-thinking": "claude-sonnet-4-6", + "claude-haiku-4-6-thinking": "claude-haiku-4-6", + ] + + private struct UsageRow { + let model: String + let createdMs: Int64 + let input: Int + let cacheRead: Int + let output: Int + let reasoning: Int + let responseID: String? + let costUSD: Double? + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let conversationsURL = self.conversationsURL(environment: environment) + let databaseURLs = self.conversationDatabases(under: conversationsURL) + guard !databaseURLs.isEmpty else { return nil } + + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + var events: [UnifiedUsageEvent] = [] + var seenResponseIDs: Set = [] + for databaseURL in databaseURLs { + try checkCancellation() + try self.readRows( + databaseURL: databaseURL, + pricing: (catalog: modelsDevCatalog, cacheRoot: modelsDevCacheRoot), + seenResponseIDs: &seenResponseIDs, + checkCancellation: checkCancellation) + { row in + let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { return } + // Antigravity's `input` is newly-processed (non-cached), and its consumption total + // is input+cacheRead+output — the same shape the shared engine produces — so the + // event carries that total verbatim. The per-row estimated cost (Google models.dev + // rate, resolved during the DB read) is carried as `providerCostUSD`; an + // unresolvable cost surfaces as an unpriced day rather than a partial subtotal. + events.append(UnifiedUsageEvent( + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: row.model, + billingProviderID: UsageProvider.antigravity.rawValue, + inputTokens: row.input, + outputTokens: row.output, + totalTokens: row.input + row.cacheRead + row.output, + cacheReadTokens: row.cacheRead, + reasoningTokens: row.reasoning > 0 ? row.reasoning : nil, + providerCostUSD: row.costUSD)) + } + } + + return UsageEventAggregator.aggregate( + events: events, + historyDays: days, + now: now, + options: .init( + historyLabel: "Antigravity", + defaultBillingProviderID: UsageProvider.antigravity.rawValue, + modelsDevCacheRoot: modelsDevCacheRoot)) + } + + // MARK: - Paths + + public static func antigravityHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + .appendingPathComponent("antigravity", isDirectory: true) + } + + public static func conversationsURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.antigravityHomeURL(environment: environment) + .appendingPathComponent("conversations", isDirectory: true) + } + + private static func conversationDatabases(under directory: URL) -> [URL] { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + else { + return [] + } + return contents + .filter { $0.pathExtension == "db" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + // MARK: - SQLite + + private static func readRows( + databaseURL: URL, + pricing: (catalog: ModelsDevCatalog?, cacheRoot: URL?), + seenResponseIDs: inout Set, + checkCancellation: () throws -> Void, + onRow: (UsageRow) -> Void) throws + { + var db: OpaquePointer? + // Observe committed WAL frames. `immutable=1` is unsafe for a live provider database + // because SQLite may assume the WAL can never change and return stale history. + let encodedPath = databaseURL.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) + ?? databaseURL.path + let uri = "file:\(encodedPath)?mode=ro" + guard sqlite3_open_v2( + uri, + &db, + SQLITE_OPEN_READONLY | SQLITE_OPEN_URI | SQLITE_OPEN_FULLMUTEX, + nil) == SQLITE_OK + else { + sqlite3_close(db) + return + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + guard self.hasTable(named: "gen_metadata", db: db) else { return } + let sessionTimestampMs = self.sessionCreatedMs(db: db) ?? self.fileModifiedMs(databaseURL) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT data FROM gen_metadata ORDER BY idx", -1, &stmt, nil) == SQLITE_OK + else { return } + defer { sqlite3_finalize(stmt) } + + while true { + try checkCancellation() + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { return } + guard let blob = self.columnBlob(stmt, 0) else { continue } + if let row = self.parseGeneration( + blob, + sessionTimestampMs: sessionTimestampMs, + catalog: pricing.catalog, + cacheRoot: pricing.cacheRoot, + seenResponseIDs: &seenResponseIDs) + { + onRow(row) + } + } + } + + private static func parseGeneration( + _ blob: [UInt8], + sessionTimestampMs: Int64, + catalog: ModelsDevCatalog?, + cacheRoot: URL?, + seenResponseIDs: inout Set) -> UsageRow? + { + guard let chatModel = WireReader.messageField(blob, 1), + let usage = WireReader.messageField(chatModel, 4) + else { + return nil + } + + // Per-generation wall-clock time; fall back to the session-created stamp. + let timestampMs = WireReader.messageField(chatModel, 9) + .flatMap { WireReader.messageField($0, 4) } + .flatMap { WireReader.timestampMs($0) } + .flatMap { $0 > 0 ? $0 : nil } + ?? sessionTimestampMs + + let inputPart1 = Self.clampedInt(WireReader.varintField(usage, 1)) + let inputPart2 = Self.clampedInt(WireReader.varintField(usage, 2)) + let inputAddition = inputPart1.addingReportingOverflow(inputPart2) + guard !inputAddition.overflow else { return nil } + let input = inputAddition.partialValue + let cacheRead = Self.clampedInt(WireReader.varintField(usage, 5)) + let visibleOutput = Self.clampedInt(WireReader.varintField(usage, 9)) + let reasoning = Self.clampedInt(WireReader.varintField(usage, 10)) + let outputAddition = visibleOutput.addingReportingOverflow(reasoning) + guard !outputAddition.overflow else { return nil } + let output = outputAddition.partialValue + guard input > 0 || output > 0 || cacheRead > 0 || reasoning > 0 else { return nil } + + if let responseID = WireReader.stringField(usage, 11)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !responseID.isEmpty + { + guard seenResponseIDs.insert(responseID).inserted else { return nil } + } + + let modelRaw = WireReader.stringField(chatModel, 19)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let model = modelRaw.isEmpty ? "gemini" : modelRaw + + let costUSD = self.costUSD( + model: model, + tokens: TokenBuckets(input: input, cacheRead: cacheRead, output: output), + catalog: catalog, + cacheRoot: cacheRoot) + + return UsageRow( + model: model, + createdMs: timestampMs, + input: input, + cacheRead: cacheRead, + output: output, + reasoning: reasoning, + responseID: nil, + costUSD: costUSD) + } + + // MARK: - Pricing + + private struct TokenBuckets { + let input: Int + let cacheRead: Int + let output: Int + } + + /// Prices a generation against its actual model provider. Known Gemini models use CodexBar's + /// official Google pricing snapshot first and models.dev for newly released ids; Claude uses + /// the same catalog-plus-bundled fallback as Claude Code. The raw display name is never changed. + private static func costUSD( + model: String, + tokens: TokenBuckets, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> Double? + { + let lowered = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if lowered.hasPrefix("claude-") { + let pricingModel = self.claudePricingAliases[lowered] ?? lowered + return CostUsagePricing.claudeCostUSD( + model: pricingModel, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: 0, + outputTokens: tokens.output, + modelsDevCatalog: catalog, + modelsDevCacheRoot: cacheRoot) + } + return CostUsagePricing.googleCostUSD( + model: lowered, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + outputTokens: tokens.output, + modelsDevCatalog: catalog, + modelsDevCacheRoot: cacheRoot) + } + + // MARK: - SQLite helpers + + private static func sessionCreatedMs(db: OpaquePointer?) -> Int64? { + guard self.hasTable(named: "trajectory_metadata_blob", db: db) else { return nil } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT data FROM trajectory_metadata_blob LIMIT 1", -1, &stmt, nil) == SQLITE_OK + else { return nil } + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW, + let blob = self.columnBlob(stmt, 0), + let tsMessage = WireReader.messageField(blob, 2) + else { + return nil + } + return WireReader.timestampMs(tsMessage).flatMap { $0 > 0 ? $0 : nil } + } + + private static func fileModifiedMs(_ url: URL) -> Int64 { + guard let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]), + let modified = values.contentModificationDate + else { + return 0 + } + return Int64(modified.timeIntervalSince1970 * 1000) + } + + private static func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static func columnBlob(_ stmt: OpaquePointer?, _ index: Int32) -> [UInt8]? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } + let count = Int(sqlite3_column_bytes(stmt, index)) + guard count > 0, let pointer = sqlite3_column_blob(stmt, index) else { return nil } + let buffer = UnsafeRawBufferPointer(start: pointer, count: count) + return Array(buffer) + } + + private static func clampedInt(_ value: UInt64?) -> Int { + guard let value else { return 0 } + return Int(clamping: value) + } +} + +// MARK: - Protobuf wire-format reader + +/// Minimal dependency-free protobuf wire-format reader, mirroring tokscale's antigravity-cli +/// parser. Malformed data degrades to `nil`, never traps. Group wire types (3/4) are deprecated and +/// unsupported; encountering one stops the scan rather than risking desync. +private enum WireReader { + enum Value { + case varint(UInt64) + case length([UInt8]) + } + + static func messageField(_ buffer: [UInt8], _ field: UInt64) -> [UInt8]? { + for (number, value) in self.fields(buffer) where number == field { + if case let .length(bytes) = value { return bytes } + } + return nil + } + + static func varintField(_ buffer: [UInt8], _ field: UInt64) -> UInt64? { + for (number, value) in self.fields(buffer) where number == field { + if case let .varint(raw) = value { return raw } + } + return nil + } + + static func stringField(_ buffer: [UInt8], _ field: UInt64) -> String? { + guard let bytes = self.messageField(buffer, field) else { return nil } + return String(bytes: bytes, encoding: .utf8) + } + + /// Decode a `{#1: seconds, #2: nanos}` Timestamp to epoch milliseconds. Out-of-range nanos mark + /// the stamp malformed; checked arithmetic keeps corrupt seconds from overflowing. + static func timestampMs(_ buffer: [UInt8]) -> Int64? { + guard let secondsRaw = self.varintField(buffer, 1), + let seconds = Int64(exactly: secondsRaw) + else { + return nil + } + let nanosRaw = self.varintField(buffer, 2) ?? 0 + guard let nanos = Int64(exactly: nanosRaw), (0...999_999_999).contains(nanos) else { return nil } + let millis = seconds.multipliedReportingOverflow(by: 1000) + guard !millis.overflow else { return nil } + let total = millis.partialValue.addingReportingOverflow(nanos / 1_000_000) + return total.overflow ? nil : total.partialValue + } + + private static func fields(_ buffer: [UInt8]) -> [(UInt64, Value)] { + var result: [(UInt64, Value)] = [] + var position = 0 + while position < buffer.count { + guard let tag = self.readVarint(buffer, &position) else { break } + let field = tag >> 3 + switch tag & 0x7 { + case 0: + guard let value = self.readVarint(buffer, &position) else { return result } + result.append((field, .varint(value))) + case 1: + guard position + 8 <= buffer.count else { return result } + position += 8 + case 2: + guard let length = self.readVarint(buffer, &position), + let count = Int(exactly: length), + position + count <= buffer.count + else { + return result + } + result.append((field, .length(Array(buffer[position..<(position + count)])))) + position += count + case 5: + guard position + 4 <= buffer.count else { return result } + position += 4 + default: + return result + } + } + return result + } + + private static func readVarint(_ buffer: [UInt8], _ position: inout Int) -> UInt64? { + var result: UInt64 = 0 + var shift: UInt64 = 0 + while position < buffer.count { + let byte = buffer[position] + position += 1 + result |= UInt64(byte & 0x7F) << shift + if byte & 0x80 == 0 { return result } + shift += 7 + if shift >= 64 { return nil } + } + return nil + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 13db4ed67c..912f6c539b 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -34,6 +34,7 @@ public enum CopilotProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.copilot], noDataMessage: { "Copilot cost summary is not supported." }), pace: ProviderPaceCapability( resetWindowPace: .resetDatePresent, diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift new file mode 100644 index 0000000000..3bdd1036a1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift @@ -0,0 +1,224 @@ +import Foundation + +/// Reads GitHub Copilot CLI's local session history as a thin parser over the shared aggregation +/// engine. +/// +/// Copilot stores one directory per session under `~/.copilot/session-state//events.jsonl`. +/// Unlike most tools it does not log per-request token rows; instead the terminal +/// `session.shutdown` event carries a per-model rollup under `data.modelMetrics..usage` +/// with `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens`, and +/// `reasoningTokens`. This scanner reads that rollup (falling back to the last event that carries +/// `modelMetrics` for sessions still in progress) and maps each model bucket to a +/// `UnifiedUsageEvent`, anchoring the day on the event `timestamp`. +/// +/// Cached-prefix handling (Copilot's `inputTokens` includes the cached prefix, and there is no +/// separate total, so the engine subtracts it), models.dev pricing, day bucketing, and snapshot +/// construction are all handled once by `UsageEventAggregator`. +public enum CopilotSessionScanner { + public static let homeEnvironmentKey = "COPILOT_HOME" + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + + private struct WireEvent: Decodable { + struct ModelUsage: Decodable { + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheWriteTokens: Int? + let reasoningTokens: Int? + } + + struct ModelBucket: Decodable { + struct Requests: Decodable { + let count: Int? + } + + let usage: ModelUsage? + let requests: Requests? + } + + struct Data: Decodable { + let modelMetrics: [String: ModelBucket]? + } + + let type: String? + let timestamp: String? + let data: Data? + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let root = self.homeURL(environment: environment) + .appendingPathComponent("session-state", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + let decoder = JSONDecoder() + let iso8601Fractional = ISO8601DateFormatter() + iso8601Fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let iso8601 = ISO8601DateFormatter() + let parseTimestamp: (String) -> Date? = { raw in + iso8601Fractional.date(from: raw) ?? iso8601.date(from: raw) + } + + var events: [UnifiedUsageEvent] = [] + var visitedFiles = 0 + var visitedBytes = 0 + + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + guard url.pathExtension.lowercased() == "jsonl", + url.lastPathComponent == "events.jsonl" + else { + continue + } + guard visitedFiles < self.maximumFiles else { break } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + if let modified = resourceValues?.contentModificationDate, modified < start { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumBytes - visitedBytes else { break } + visitedFiles += 1 + visitedBytes += size + + do { + // Keep only the latest event carrying a modelMetrics rollup (the shutdown summary, + // or the most recent in-progress snapshot), then fold each model bucket once. + var latest: WireEvent? + try CostUsageJsonl.scan( + fileURL: url, + maxLineBytes: 1024 * 1024, + prefixBytes: 1024 * 1024, + checkCancellation: checkCancellation) + { line in + guard !line.wasTruncated, + let event = try? decoder.decode(WireEvent.self, from: line.bytes), + let metrics = event.data?.modelMetrics, + !metrics.isEmpty + else { + return + } + latest = event + } + + guard let event = latest, let metrics = event.data?.modelMetrics else { continue } + let eventDate = event.timestamp.flatMap(parseTimestamp) + ?? resourceValues?.contentModificationDate + ?? now + let eventDay = calendar.startOfDay(for: eventDate) + guard eventDay >= start, eventDay <= end else { continue } + let dayKey = CostUsageLocalDay.key(from: eventDay, calendar: calendar) + for (rawModel, bucket) in metrics { + guard let usage = bucket.usage else { continue } + let model = Self.normalizeModel(rawModel) + events.append(UnifiedUsageEvent( + day: dayKey, + model: model, + billingProviderID: Self.billingProvider(for: model), + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: nil, + cacheReadTokens: usage.cacheReadTokens, + cacheCreationTokens: usage.cacheWriteTokens, + reasoningTokens: usage.reasoningTokens, + requestCount: bucket.requests?.count, + pricingProviderIDs: Self.pricingProviderIDs(for: model))) + } + } catch is CancellationError { + throw CancellationError() + } catch { + continue + } + } + + return UsageEventAggregator.aggregate( + events: events, + historyDays: days, + now: now, + options: .init(historyLabel: "GitHub Copilot", modelsDevCacheRoot: modelsDevCacheRoot)) + } + + public static func homeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".copilot", isDirectory: true) + } + + /// Vendor used *only* to look up the rate for a model. Copilot is a harness with no explicit + /// billing-provider field on its local events, so the model name is the only rate evidence: + /// a Claude model is priced at Anthropic's rate, a GPT model at OpenAI's, and so on. This + /// never changes ownership — the usage stays attributed to Copilot (see `billingProvider`). + private static func pricingProviderIDs(for model: String) -> [String] { + if model.hasPrefix("claude-") { return ["anthropic"] } + if model.hasPrefix("gpt-") || model.hasPrefix("o1") || model.hasPrefix("o3") || model.hasPrefix("o4") { + return ["openai"] + } + if model.hasPrefix("gemini-") { return ["google"] } + return [] + } + + /// Billing ownership always stays with Copilot. The local Copilot event carries no explicit + /// routing or billing-provider field, so attributing a `claude-*`/`gpt-*`/`gemini-*` model to + /// another provider would mix Copilot activity into that provider's rows based only on a name. + /// The vendor is used solely as the rate-lookup key in `pricingProviderIDs`. + private static func billingProvider(for _: String) -> String? { + UsageProvider.copilot.rawValue + } + + /// Normalizes Copilot's model ids to the pricing keys used by the built-in tables and + /// models.dev (e.g. `claude-haiku-4.5` -> `claude-haiku-4-5`). + private static func normalizeModel(_ raw: String) -> String { + var model = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if model.isEmpty { return "unknown" } + // Version separators in Claude ids are dots in Copilot but dashes in the pricing tables. + if model.hasPrefix("claude-") { + model = model.replacingOccurrences(of: ".", with: "-") + } + return model + } +} diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorLocalActivityScanner.swift b/Sources/CodexBarCore/Providers/Cursor/CursorLocalActivityScanner.swift new file mode 100644 index 0000000000..d4f03fec99 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Cursor/CursorLocalActivityScanner.swift @@ -0,0 +1,134 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +// Reads Cursor's local AI activity database to surface which models were used and on which +// days, without reporting token counts. +// +// Cursor stores code-generation records in `~/.cursor/ai-tracking/ai-code-tracking.db`. The +// `ai_code_hashes` table has one row per recorded AI edit with a `model` column and an +// epoch-millisecond `timestamp`, but — unlike the CLI scanners — it stores no per-request token +// usage: token billing for Cursor happens server-side and is not mirrored locally. We therefore +// treat this as a *degraded* source: the scanner aggregates the per-day set of models that were +// active and emits a snapshot whose token and cost fields are all `nil`, so the dashboard can +// show "this tool was used with these models" without fabricating token numbers. `requestCount` +// is also withheld because a row is a code-hash record, not an LLM request. +#if canImport(SQLite3) || canImport(CSQLite3) +public enum CursorLocalActivityScanner { + public static let defaultHistoryDays = 30 + + /// Environment override for the Cursor home directory (the folder that contains + /// `ai-tracking/ai-code-tracking.db`), so tests can point at a fixture. + public static let homeEnvironmentKey = "CURSOR_HOME" + + private static let maximumRows = 500_000 + + public static func databaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + let home: URL = if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + URL(fileURLWithPath: override, isDirectory: true) + } else { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cursor", isDirectory: true) + } + return home + .appendingPathComponent("ai-tracking", isDirectory: true) + .appendingPathComponent("ai-code-tracking.db", isDirectory: false) + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + historyDays: historyDays, + now: now, + calendar: calendar) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let databaseURL = self.databaseURL(environment: environment) + guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + let startMs = Int64(start.timeIntervalSince1970 * 1000) + let endMs = Int64(end.addingTimeInterval(24 * 60 * 60).timeIntervalSince1970 * 1000) + + var db: OpaquePointer? + guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + if let db { sqlite3_close(db) } + return nil + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + // One row per (day, model) with the number of activity records. We bound the scan and + // ignore rows with no usable model name. + let query = """ + SELECT (timestamp / 1000) AS seconds, model + FROM ai_code_hashes + WHERE timestamp >= ? AND timestamp < ? AND model IS NOT NULL AND model != '' + LIMIT \(self.maximumRows); + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int64(stmt, 1, startMs) + sqlite3_bind_int64(stmt, 2, endMs) + + // Degraded source: emit one model-only event per distinct (day, model). The aggregator + // surfaces these as token-less entries; we de-duplicate here so a busy day does not turn + // into tens of thousands of identical events. + var seen: Set = [] + var events: [UnifiedUsageEvent] = [] + while true { + try checkCancellation() + let stepResult = sqlite3_step(stmt) + if stepResult == SQLITE_DONE { break } + guard stepResult == SQLITE_ROW else { break } + let seconds = sqlite3_column_int64(stmt, 0) + guard let modelC = sqlite3_column_text(stmt, 1) else { continue } + let model = String(cString: modelC) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !model.isEmpty else { continue } + let day = calendar.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(seconds))) + let dayKey = CostUsageLocalDay.key(from: day, calendar: calendar) + let dedupKey = "\(dayKey)\u{1f}\(model)" + guard seen.insert(dedupKey).inserted else { continue } + events.append(UnifiedUsageEvent( + day: dayKey, + model: model, + billingProviderID: UsageProvider.cursor.rawValue)) + } + + return UsageEventAggregator.aggregate( + events: events, + historyDays: days, + now: now, + options: .init( + historyLabel: "Cursor", + defaultBillingProviderID: UsageProvider.cursor.rawValue)) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift index 4cb101eb3f..68cd067d40 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift @@ -36,6 +36,7 @@ public enum GeminiProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.geminiCLI], noDataMessage: { "Gemini cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift new file mode 100644 index 0000000000..184f66f5a0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift @@ -0,0 +1,524 @@ +import Foundation + +/// Scans local Gemini CLI session transcripts and folds their per-turn token usage into a +/// token-only `CostUsageTokenSnapshot` for the Usage & Spend dashboard. Same shape as +/// `KimiCodeSessionScanner`; the on-disk semantics mirror tokscale's `sessions/gemini.rs`: +/// +/// - Root: `$GEMINI_CLI_HOME/tmp` (default `~/.gemini/tmp`), one `/` dir per project. +/// - Chat recordings: `/chats/session-*.json` (plus legacy `session-*.json` anywhere +/// under `tmp`) holding a `messages` array. Model turns carry `model` and a `tokens` object with +/// `input`/`prompt`, `output`/`candidates`, `cached`, `thoughts`, `tool`, `total` (plus the +/// snake_case/camelCase aliases below) and an RFC 3339 `timestamp` (file mtime is the fallback). +/// - Chat streams: any `*.jsonl` under `tmp`, one message object per line; `init` lines seed the +/// current model for later token lines, and a line whose `id` repeats replaces the earlier one. +/// +/// Normalization (mirrors tokscale): when `total` equals input+output+thoughts+tool but not that +/// sum plus cached, the prompt count was cache-inclusive, so the cached overlap is subtracted from +/// input. `tool` tokens fold into input because `CostUsageDailyReport.ModelBreakdown` has no tool +/// bucket. `thoughts` stays folded into billing `outputTokens` (reasoning is part of output for +/// billing) and is additionally surfaced as the `reasoningTokens` sub-bucket. Deliberate deviations: +/// headless `stats` blobs are not parsed, string-typed numbers are rejected, and negative values +/// mark the record corrupt (skipped) rather than clamped — both matching this repo's stricter +/// `KimiCodeSessionScanner` robustness style. +public enum GeminiSessionScanner { + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + public static let maximumFileBytes = 16 * 1024 * 1024 + + /// Environment override for the Gemini CLI home directory, resolved directly here (the same + /// way `KimiSettingsReader` honors `KIMI_CODE_HOME`) so this scanner stays self-contained. + public static let cliHomeEnvironmentKey = "GEMINI_CLI_HOME" + + // MARK: - Wire models + + private struct WireTokens: Decodable { + let input: Int + let output: Int + let cached: Int + let thoughts: Int + let tool: Int + let total: Int? + + private enum CodingKeys: String, CodingKey { + case input + case prompt + case inputTokens = "input_tokens" + case promptTokens = "prompt_tokens" + case promptTokenCount + case output + case candidates + case outputTokens = "output_tokens" + case completionTokens = "completion_tokens" + case candidatesTokenCount + case cached + case cachedTokens = "cached_tokens" + case cachedContentTokenCount + case thoughts + case reasoning + case thoughtsTokens = "thoughts_tokens" + case tool + case toolTokens = "tool_tokens" + case total + case totalTokenCount + case totalTokens = "total_tokens" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.input = Self.first(container, [.input, .prompt, .inputTokens, .promptTokens, .promptTokenCount]) ?? 0 + self.output = Self + .first(container, [.output, .candidates, .outputTokens, .completionTokens, .candidatesTokenCount]) ?? 0 + self.cached = Self.first(container, [.cached, .cachedTokens, .cachedContentTokenCount]) ?? 0 + self.thoughts = Self.first(container, [.thoughts, .reasoning, .thoughtsTokens]) ?? 0 + self.tool = Self.first(container, [.tool, .toolTokens]) ?? 0 + self.total = Self.first(container, [.total, .totalTokenCount, .totalTokens]) + } + + /// First present, integer-typed alias wins; a missing or mistyped alias falls through. + private static func first( + _ container: KeyedDecodingContainer, + _ keys: [CodingKeys]) -> Int? + { + for key in keys { + if let value = try? container.decode(Int.self, forKey: key) { + return value + } + } + return nil + } + } + + private struct WireMessage: Decodable { + let id: String? + let type: String? + let model: String? + let tokens: WireTokens? + let timestamp: Date? + + private enum CodingKeys: String, CodingKey { + case id + case type + case model + case tokens + case timestamp + case createdAt = "created_at" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try? container.decode(String.self, forKey: .id) + self.type = try? container.decode(String.self, forKey: .type) + self.model = try? container.decode(String.self, forKey: .model) + self.tokens = try? container.decode(WireTokens.self, forKey: .tokens) + self.timestamp = Self.timestamp(from: container) + } + + /// Mirrors tokscale: the first *present* key wins; if it is present but unparseable the + /// caller falls back to the file mtime instead of trying the next key. + private static func timestamp(from container: KeyedDecodingContainer) -> Date? { + for key in [CodingKeys.timestamp, .createdAt] { + guard container.contains(key) else { continue } + if let text = try? container.decode(String.self, forKey: key) { + return GeminiSessionScanner.timestampTextDate(text) + } + if let number = try? container.decode(Double.self, forKey: key), + number.isFinite, number > 0 + { + // Milliseconds when the magnitude says so, otherwise seconds (tokscale rule). + return Date(timeIntervalSince1970: number >= 1_000_000_000_000 ? number / 1000 : number) + } + return nil + } + return nil + } + } + + private struct WireChatRecording: Decodable { + let messages: [WireMessage] + } + + // MARK: - Aggregation + + private struct NormalizedUsage { + let input: Int + let output: Int + let cacheRead: Int + /// `thoughts` tokens; already included in `output` (billing-inclusive), tracked separately. + let reasoning: Int + } + + private struct ResolvedTurn { + let date: Date + let model: String + let usage: NormalizedUsage + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + private struct TokenAccumulator { + var input = 0 + var output = 0 + var cacheRead = 0 + var reasoning = 0 + var requests = 0 + + mutating func add(_ usage: NormalizedUsage) -> Bool { + guard let nextInput = Self.adding(self.input, usage.input), + let nextOutput = Self.adding(self.output, usage.output), + let nextCacheRead = Self.adding(self.cacheRead, usage.cacheRead), + let nextReasoning = Self.adding(self.reasoning, usage.reasoning), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.output = nextOutput + self.cacheRead = nextCacheRead + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextOutput = Self.adding(self.output, other.output), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextReasoning = Self.adding(self.reasoning, other.reasoning), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.output = nextOutput + self.cacheRead = nextCacheRead + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + var total: Int? { + guard let inputAndOutput = Self.adding(self.input, self.output) else { return nil } + return Self.adding(inputAndOutput, self.cacheRead) + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + // MARK: - Scanning + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let tmp = self.geminiTmpURL(environment: environment) + guard let enumerator = fileManager.enumerator( + at: tmp, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + var values: [DayModelKey: TokenAccumulator] = [:] + let decoder = JSONDecoder() + var visitedFiles = 0 + var visitedBytes = 0 + + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + let pathExtension = url.pathExtension.lowercased() + guard pathExtension == "json" || pathExtension == "jsonl", + self.isChatTranscript(url, under: tmp) + else { + continue + } + guard visitedFiles < self.maximumFiles else { break } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + let modificationDate = resourceValues?.contentModificationDate + if let modificationDate, modificationDate < start { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumFileBytes, + size <= self.maximumBytes - visitedBytes + else { + continue + } + visitedFiles += 1 + visitedBytes += size + guard let data = try? Data(contentsOf: url) else { continue } + // Messages without a usable timestamp fall back to the file mtime (tokscale rule); + // epoch zero simply lands outside the window when even the mtime is unavailable. + let fallbackDate = modificationDate ?? Date(timeIntervalSince1970: 0) + let turns = pathExtension == "jsonl" + ? self.parseStream(data: data, fallbackDate: fallbackDate, decoder: decoder) + : self.parseChatRecording(data: data, fallbackDate: fallbackDate, decoder: decoder) + for turn in turns { + try checkCancellation() + let day = calendar.startOfDay(for: turn.date) + guard day >= start, day <= end else { continue } + let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: turn.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(turn.usage) else { continue } + values[key] = value + } + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + costUSD: nil, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: nil, + outputTokens: value.output, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, + requestCount: value.requests)) + } + guard let totalTokens = total.total else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: nil, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: nil, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + guard let totalTokens, let totalRequests else { return nil } + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + sessionRequests: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: nil, + last30DaysRequests: totalRequests, + currencyCode: "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "Gemini CLI", + daily: daily, + updatedAt: now) + } + + // MARK: - Parsing + + private static func parseChatRecording( + data: Data, + fallbackDate: Date, + decoder: JSONDecoder) -> [ResolvedTurn] + { + guard let recording = try? decoder.decode(WireChatRecording.self, from: data) else { return [] } + return recording.messages.compactMap { message in + guard let model = self.cleaned(message.model), + let tokens = message.tokens, + let usage = self.normalize(tokens) + else { + return nil + } + return ResolvedTurn(date: message.timestamp ?? fallbackDate, model: model, usage: usage) + } + } + + private static func parseStream( + data: Data, + fallbackDate: Date, + decoder: JSONDecoder) -> [ResolvedTurn] + { + var turns: [ResolvedTurn] = [] + var indicesByID: [String: Int] = [:] + var currentModel: String? + for line in data.split(separator: 0x0A, omittingEmptySubsequences: true) { + guard let message = try? decoder.decode(WireMessage.self, from: Data(line)) else { continue } + if message.type == "init" { + if let model = self.cleaned(message.model) { currentModel = model } + continue + } + guard message.type == "gemini" || message.tokens != nil else { continue } + if let model = self.cleaned(message.model) { currentModel = model } + guard let model = currentModel, + let tokens = message.tokens, + let usage = self.normalize(tokens) + else { + continue + } + // A repeated `id` is a streaming rewrite of the same turn: the later line wins. + let turn = ResolvedTurn(date: message.timestamp ?? fallbackDate, model: model, usage: usage) + if let id = self.cleaned(message.id) { + if let index = indicesByID[id] { + turns[index] = turn + } else { + indicesByID[id] = turns.count + turns.append(turn) + } + } else { + turns.append(turn) + } + } + return turns + } + + private static func normalize(_ tokens: WireTokens) -> NormalizedUsage? { + guard tokens.input >= 0, tokens.output >= 0, tokens.cached >= 0, + tokens.thoughts >= 0, tokens.tool >= 0, tokens.total ?? 0 >= 0 + else { + return nil + } + + var input = tokens.input + if let total = tokens.total, tokens.cached > 0, + let inclusive = self.sum([tokens.input, tokens.output, tokens.thoughts, tokens.tool]), + inclusive == total + { + // `total` leaving out the cached count proves the input count was cache-inclusive. + let excludesCache = self.adding(inclusive, tokens.cached).map { $0 != total } ?? true + if excludesCache { + input -= min(tokens.cached, input) + } + } + + guard let finalInput = self.adding(input, tokens.tool), + let finalOutput = self.adding(tokens.output, tokens.thoughts) + else { + return nil + } + return NormalizedUsage( + input: finalInput, + output: finalOutput, + cacheRead: tokens.cached, + reasoning: tokens.thoughts) + } + + private static func timestampTextDate(_ text: String) -> Date? { + guard let trimmed = self.cleaned(text) else { return nil } + if let date = CostUsageDateParser.parse(trimmed) { return date } + // Timezone-less ISO-8601 datetimes carry no offset; interpret them as UTC (tokscale rule). + for format in [ + "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd HH:mm:ss", + ] { + if let date = self.utcFormatter(format: format).date(from: trimmed) { return date } + } + return nil + } + + private static func utcFormatter(format: String) -> DateFormatter { + let key = "GeminiSessionScanner.utcFormatter.\(format)" + let threadDictionary = Thread.current.threadDictionary + if let cached = threadDictionary[key] as? DateFormatter { return cached } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = format + formatter.isLenient = false + threadDictionary[key] = formatter + return formatter + } + + // MARK: - Paths + + public static func geminiTmpURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = self.cleaned(environment[self.cliHomeEnvironmentKey]) { + return URL(fileURLWithPath: override, isDirectory: true) + .appendingPathComponent("tmp", isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + .appendingPathComponent("tmp", isDirectory: true) + } + + /// Mirrors tokscale's layout filter: every `.jsonl` under `tmp` is a candidate chat stream, + /// while `.json` files need the legacy `session-` prefix or the `tmp//chats/` layout. + private static func isChatTranscript(_ url: URL, under tmp: URL) -> Bool { + if url.pathExtension.lowercased() == "jsonl" { return true } + if url.lastPathComponent.hasPrefix("session-") { return true } + guard let components = self.relativeComponents(of: url, under: tmp) else { return false } + return components.count == 3 && components[1] == "chats" + } + + private static func relativeComponents(of url: URL, under base: URL) -> [String]? { + let baseComponents = base.standardizedFileURL.pathComponents + let urlComponents = url.standardizedFileURL.pathComponents + guard urlComponents.count > baseComponents.count, + urlComponents.prefix(baseComponents.count).elementsEqual(baseComponents) + else { + return nil + } + return Array(urlComponents.dropFirst(baseComponents.count)) + } + + private static func cleaned(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} diff --git a/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift index 8581becc5c..44ad21d426 100644 --- a/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift @@ -161,6 +161,7 @@ public struct GroqConsoleUsageSnapshot: Codable, Equatable, Sendable { last30DaysCostUSD: total.costUSD, last30DaysRequests: total.requests, historyDays: self.historyDays, + costSource: .providerReported, daily: daily, updatedAt: self.updatedAt) } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift new file mode 100644 index 0000000000..eaf95f5ff0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift @@ -0,0 +1,200 @@ +import Foundation + +public enum KimiCodeSessionScanner { + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + + private struct WireEvent: Decodable { + struct Usage: Decodable { + let inputOther: Int? + let inputCacheRead: Int? + let inputCacheCreation: Int? + let output: Int? + } + + let type: String + let time: Double? + let model: String? + let usage: Usage? + let usageScope: String? + } + + /// Returns the value when it is a valid non-negative token count, or nil otherwise. A + /// malformed (negative) count rejects the whole record, matching the pre-migration scanner. + private static func validOrNil(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return nil } + return value + } + + private static func estimatedCost( + model: String, + usage: WireEvent.Usage, + pricingDate: Date, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + guard let input = self.validOrNil(usage.inputOther), + let cacheRead = self.validOrNil(usage.inputCacheRead), + let cacheCreation = self.validOrNil(usage.inputCacheCreation), + let output = self.validOrNil(usage.output) + else { + return nil + } + return CostUsagePricing.claudeCostUSD( + model: self.pricingModelID(model), + inputTokens: input, + cacheReadInputTokens: cacheRead, + cacheCreationInputTokens: cacheCreation, + outputTokens: output, + pricingDate: pricingDate, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + private static func pricingModelID(_ model: String) -> String { + let bare = model.split(separator: "/", omittingEmptySubsequences: true).last + .map(String.init) ?? model + switch bare.lowercased() { + case "k3", "k3-256k": + return "kimi-k3" + default: + return bare + } + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let home = KimiSettingsReader.kimiCodeHomeURL(environment: environment) + let sessions = home.appendingPathComponent("sessions", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: sessions, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + var events: [UnifiedUsageEvent] = [] + let decoder = JSONDecoder() + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) + var visitedFiles = 0 + var visitedBytes = 0 + + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + guard url.lastPathComponent == "wire.jsonl", + url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent == "agents" + else { + continue + } + guard visitedFiles < self.maximumFiles else { break } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + if let modificationDate = resourceValues?.contentModificationDate, + modificationDate < start + { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumBytes - visitedBytes else { break } + visitedFiles += 1 + visitedBytes += size + do { + try CostUsageJsonl.scan( + fileURL: url, + maxLineBytes: 1024 * 1024, + prefixBytes: 1024 * 1024, + checkCancellation: checkCancellation) + { line in + guard !line.wasTruncated, + let event = try? decoder.decode(WireEvent.self, from: line.bytes), + event.type == "usage.record", + event.usageScope == nil || event.usageScope == "turn", + let time = event.time, + time.isFinite, + let rawModel = event.model?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawModel.isEmpty, + let usage = event.usage + else { + return + } + let date = Date(timeIntervalSince1970: time / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { return } + // Kimi's `inputOther` is already uncached. Passing totalTokens as + // input+cacheRead+output (cache-write excluded) hits the engine's + // "input is already uncached" branch so it is priced as-is. The estimated cost + // is resolved here (official Moonshot rate via the third-party lookup, with a + // kimi-k3 fallback) and carried as `providerCostUSD` so the engine trusts it; + // an unresolvable cost surfaces as an unpriced day, not a partial subtotal. + guard let input = Self.validOrNil(usage.inputOther), + let cacheRead = Self.validOrNil(usage.inputCacheRead), + let cacheCreation = Self.validOrNil(usage.inputCacheCreation), + let output = Self.validOrNil(usage.output) + else { + return + } + events.append(UnifiedUsageEvent( + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: rawModel, + billingProviderID: UsageProvider.kimi.rawValue, + inputTokens: input, + outputTokens: output, + totalTokens: input + cacheRead + output, + cacheReadTokens: cacheRead, + cacheCreationTokens: cacheCreation, + providerCostUSD: Self.estimatedCost( + model: rawModel, + usage: usage, + pricingDate: date, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot))) + } + } catch is CancellationError { + throw CancellationError() + } catch { + continue + } + } + + return UsageEventAggregator.aggregate( + events: events, + historyDays: days, + now: now, + options: .init( + historyLabel: "Kimi Code CLI", + defaultBillingProviderID: UsageProvider.kimi.rawValue, + modelsDevCacheRoot: modelsDevCacheRoot)) + } +} diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index c2c82758b7..a7cef89717 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -36,6 +36,7 @@ public enum KimiProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.kimiCode], noDataMessage: { "Kimi cost summary is not supported." }), pace: ProviderPaceCapability( resetWindowPace: .windowDuration(minutes: self.weeklyWindowMinutes)), diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift b/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift index e4c676f16e..24014545a9 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift @@ -120,7 +120,9 @@ public enum KimiSettingsReader { return deviceID } - private static func kimiCodeHomeURL(environment: [String: String]) -> URL { + public static func kimiCodeHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { if let override = self.cleaned(environment[self.codeHomeEnvironmentKey]) { return URL(fileURLWithPath: override, isDirectory: true) } diff --git a/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryBuiltInScanners.swift b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryBuiltInScanners.swift new file mode 100644 index 0000000000..a4bafccfb2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryBuiltInScanners.swift @@ -0,0 +1,244 @@ +import Foundation + +/// Built-in `LocalHistoryScanning` conformances that wrap the existing per-tool +/// `…SessionScanner.scanCancellable` entry points. Each wrapper is a stateless value; registering +/// one is all that is required to make a tool discoverable through +/// `LocalHistoryScannerRegistry.shared`. +/// +/// When adding a new mainstream tool, define a wrapper here (or in its own provider folder) and +/// append it to `LocalHistoryBuiltInScanners.all` — no controller or switch edits needed. +public enum LocalHistoryBuiltInScanners { + /// All built-in scanners, in stable registration order. + public static let all: [any LocalHistoryScanning] = { + var scanners: [any LocalHistoryScanning] = [ + KimiCodeLocalHistoryScanner(), + GeminiCLILocalHistoryScanner(), + OpenCodeLocalHistoryScanner(), + MiniMaxLocalHistoryScanner(), + AntigravityLocalHistoryScanner(), + QwenCodeLocalHistoryScanner(), + ZcodeLocalHistoryScanner(), + CopilotLocalHistoryScanner(), + ] + #if canImport(SQLite3) || canImport(CSQLite3) + scanners.append(CursorLocalHistoryScanner()) + scanners.append(TraeLocalHistoryScanner()) + #endif + return scanners + }() +} + +public struct KimiCodeLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .kimiCode + public let displayName = "Kimi Code CLI" + public let homeEnvironmentKey: String? = "KIMI_CODE_HOME" + + public func homeURL(environment: [String: String]) -> URL? { + KimiSettingsReader.kimiCodeHomeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try KimiCodeSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + +public struct GeminiCLILocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .geminiCLI + public let displayName = "Gemini CLI" + public let homeEnvironmentKey: String? = GeminiSessionScanner.cliHomeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + GeminiSessionScanner.geminiTmpURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try GeminiSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + checkCancellation: context.checkCancellation) + } +} + +public struct OpenCodeLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .openCode + public let displayName = "OpenCode" + public let homeEnvironmentKey: String? = OpenCodeSessionScanner.dataHomeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + OpenCodeSessionScanner.opencodeDatabaseURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try OpenCodeSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + checkCancellation: context.checkCancellation) + } +} + +public struct MiniMaxLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .miniMax + public let displayName = "MiniMax Code" + public let homeEnvironmentKey: String? = MiniMaxSessionScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + MiniMaxSessionScanner.minimaxHomeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try MiniMaxSessionScanner.scanCancellable( + environment: context.environment, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + +public struct AntigravityLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .antigravity + public let displayName = "Antigravity" + public let homeEnvironmentKey: String? = AntigravitySessionScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + AntigravitySessionScanner.antigravityHomeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try AntigravitySessionScanner.scanCancellable( + environment: context.environment, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + +public struct QwenCodeLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .qwenCode + public let displayName = "Qwen Code CLI" + public let homeEnvironmentKey: String? = QwenCodeSessionScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + QwenCodeSessionScanner.homeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try QwenCodeSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + +public struct ZcodeLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .zcode + public let displayName = "ZCode" + public let homeEnvironmentKey: String? = ZcodeSessionScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + ZcodeSessionScanner.homeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try ZcodeSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + +public struct CopilotLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .copilot + public let displayName = "GitHub Copilot" + public let homeEnvironmentKey: String? = CopilotSessionScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + CopilotSessionScanner.homeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try CopilotSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + +#if canImport(SQLite3) || canImport(CSQLite3) +public struct CursorLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .cursorLocal + public let displayName = "Cursor" + public let homeEnvironmentKey: String? = CursorLocalActivityScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + CursorLocalActivityScanner.databaseURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try CursorLocalActivityScanner.scanCancellable( + environment: context.environment, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + checkCancellation: context.checkCancellation) + } +} + +public struct TraeLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .traeLocal + public let displayName = "Trae" + public let homeEnvironmentKey: String? = TraeLocalActivityScanner.databaseEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + TraeLocalActivityScanner.databaseURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try TraeLocalActivityScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + checkCancellation: context.checkCancellation) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScannerRegistry.swift b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScannerRegistry.swift new file mode 100644 index 0000000000..f87e746c00 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScannerRegistry.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Registry of local usage-history scanners, keyed by their stable source identifier. +/// +/// This replaces per-call-site hardcoding of "which scanner handles which tool". Provider +/// descriptors opt into a source ID via `localHistorySources`; the dashboard resolves that ID to +/// a concrete scanner through this registry. Third-party or future built-in tools register an +/// additional conformance instead of editing the controller. +/// +/// The registry is a value type holding type-erased scanners. The process-wide `shared` instance +/// is pre-populated with the built-in scanners; tests can build isolated registries to register +/// fakes. +public struct LocalHistoryScannerRegistry: Sendable { + private var scannersBySource: [ProviderLocalHistorySource: any LocalHistoryScanning] + private var insertionOrder: [ProviderLocalHistorySource] + + public init(scanners: [any LocalHistoryScanning] = []) { + self.scannersBySource = [:] + self.insertionOrder = [] + for scanner in scanners { + self.register(scanner) + } + } + + /// Registers (or replaces) the scanner for its `source`. Registration order is preserved for + /// deterministic iteration. + public mutating func register(_ scanner: any LocalHistoryScanning) { + if self.scannersBySource[scanner.source] == nil { + self.insertionOrder.append(scanner.source) + } + self.scannersBySource[scanner.source] = scanner + } + + /// Returns the registered scanner for a source, or `nil` when none is registered. + public func scanner(for source: ProviderLocalHistorySource) -> (any LocalHistoryScanning)? { + self.scannersBySource[source] + } + + /// All registered scanners in registration order. + public var all: [any LocalHistoryScanning] { + self.insertionOrder.compactMap { self.scannersBySource[$0] } + } + + /// Registered source identifiers in registration order. + public var registeredSources: [ProviderLocalHistorySource] { + self.insertionOrder + } +} + +extension LocalHistoryScannerRegistry { + /// Process-wide registry pre-populated with every built-in scanner. The dashboard reads from + /// this; tests should construct isolated registries instead of mutating shared state. + public static let shared = LocalHistoryScannerRegistry( + scanners: LocalHistoryBuiltInScanners.all) +} diff --git a/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScanning.swift b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScanning.swift new file mode 100644 index 0000000000..c4eb03d215 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScanning.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Shared parameters for a local history scan, bundled so scanner signatures stay small and so +/// new scan-wide options can be added without changing every conformance. +/// +/// `FileManager` is a reference type that Foundation documents as safe to share across threads +/// for the operations these scanners perform, so the bundle is marked `@unchecked Sendable`. +public struct LocalHistoryScanContext: @unchecked Sendable { + public var environment: [String: String] + public var fileManager: FileManager + public var historyDays: Int + public var now: Date + public var calendar: Calendar + public var modelsDevCacheRoot: URL? + public var checkCancellation: @Sendable () throws -> Void + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = 30, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping @Sendable () throws -> Void = {}) + { + self.environment = environment + self.fileManager = fileManager + self.historyDays = historyDays + self.now = now + self.calendar = calendar + self.modelsDevCacheRoot = modelsDevCacheRoot + self.checkCancellation = checkCancellation + } +} + +/// A local usage-history scanner for a single tool (CLI/desktop client). +/// +/// This is the registerable extension point for the Usage & Spend data layer, modeled on +/// tokscale's `define_clients!` macro: adding support for a new mainstream tool means +/// implementing one `LocalHistoryScanning` conformance and registering it, without editing any +/// central switch or the dashboard controller. A scanner knows how to locate its tool's home +/// directory and how to fold the local files it finds there into a `CostUsageTokenSnapshot`. +/// +/// Scanners are value types (usually enums with no cases) that wrap an existing +/// `…SessionScanner.scanCancellable` entry point. They are `Sendable` so the registry can vend +/// them across actor boundaries. +public protocol LocalHistoryScanning: Sendable { + /// Stable identifier matching the provider descriptor's `localHistorySources` entry. + var source: ProviderLocalHistorySource { get } + + /// Human-facing tool name shown in the dashboard (e.g. "Kimi Code CLI"). + var displayName: String { get } + + /// Environment variable that overrides the tool's home directory (e.g. `KIMI_CODE_HOME`). + /// `nil` when the tool has a fixed, non-overridable home. + var homeEnvironmentKey: String? { get } + + /// Resolves the tool's home directory from the environment, or `nil` when the tool is not + /// installed / has no local history on this machine. + func homeURL(environment: [String: String]) -> URL? + + /// Scans the tool's local history and returns a dashboard-ready snapshot, or `nil` when no + /// usable history exists. Implementations must honor cancellation via the context's + /// `checkCancellation`. + func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? +} + +extension LocalHistoryScanning { + /// Convenience for the common case where a scanner simply forwards to an existing + /// `…SessionScanner.scanCancellable` static method. + public func scan(environment: [String: String]) throws -> CostUsageTokenSnapshot? { + try self.scan(context: LocalHistoryScanContext(environment: environment)) + } +} diff --git a/Sources/CodexBarCore/Providers/LocalHistory/UnifiedUsageEvent.swift b/Sources/CodexBarCore/Providers/LocalHistory/UnifiedUsageEvent.swift new file mode 100644 index 0000000000..dce901744a --- /dev/null +++ b/Sources/CodexBarCore/Providers/LocalHistory/UnifiedUsageEvent.swift @@ -0,0 +1,86 @@ +import Foundation + +/// A single normalized usage record produced by a thin per-tool parser. +/// +/// This is the seam that makes the local-history framework cheap to extend, modeled on +/// tokscale's `UnifiedMessage`: a tool adapter's only job is to turn its own files into a stream +/// of `UnifiedUsageEvent`s. Aggregation, cached-prefix normalization, pricing, and snapshot +/// construction all live in one shared engine (`UsageEventAggregator`), so a new tool never +/// re-implements that logic — and can't forget it. +/// +/// An event represents one billable unit (usually one request/message) on a single local day for +/// a single model. Token fields are optional because some tools (Cursor, Trae) do not mirror +/// token usage locally; a parser for such a tool emits *degraded* events with only a model and a +/// day, and the engine surfaces them as model-only entries without fabricating numbers. +public struct UnifiedUsageEvent: Sendable, Equatable { + /// Local day the usage occurred on, in `yyyy-MM-dd` form (see `CostUsageLocalDay`). + public var day: String + /// Model identifier as recorded by the tool (already normalized to the pricing key, e.g. + /// lowercase for models.dev). `"unknown"` when the tool did not record one. + public var model: String + /// Billing ownership evidence reported by the source record (e.g. Z.ai, `bigmodel`). Optional + /// because many formats do not retain routing. Never guessed from the model name. + public var billingProviderID: String? + + /// Raw input tokens as the tool reports them. May or may not include the cached prefix — + /// see `totalTokens` for how the engine disambiguates. + public var inputTokens: Int? + public var outputTokens: Int? + /// Total tokens as the tool reports them, used to detect whether `inputTokens` already + /// includes the cached prefix (input + output == total) or excludes it. + public var totalTokens: Int? + public var cacheReadTokens: Int? + public var cacheCreationTokens: Int? + /// Reasoning ("thinking") tokens. Always a sub-bucket of `outputTokens`, never added on top. + public var reasoningTokens: Int? + + /// Cost already reported by the provider for this unit, when the source retains it. Most + /// local formats do not, so pricing normally happens in the engine against models.dev. + public var providerCostUSD: Double? + + /// Number of requests this event represents. Rollup sources (e.g. Copilot's shutdown + /// metrics) summarize several requests in one record; nil means one request. + public var requestCount: Int? + + /// Ordered models.dev provider IDs to price this event against, from structured source + /// evidence (never inferred from the model name). Empty means "do not price" (e.g. degraded + /// sources or tools whose plan is flat and reported elsewhere). + public var pricingProviderIDs: [String] + + public init( + day: String, + model: String, + billingProviderID: String? = nil, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + totalTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, + providerCostUSD: Double? = nil, + requestCount: Int? = nil, + pricingProviderIDs: [String] = []) + { + self.day = day + self.model = model + self.billingProviderID = billingProviderID + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens + self.providerCostUSD = providerCostUSD + self.requestCount = requestCount + self.pricingProviderIDs = pricingProviderIDs + } + + /// Whether this event carries any billable token information. Degraded (model-only) events + /// return false and are surfaced without token totals. + public var hasTokenUsage: Bool { + (self.inputTokens ?? 0) > 0 + || (self.outputTokens ?? 0) > 0 + || (self.cacheReadTokens ?? 0) > 0 + || (self.totalTokens ?? 0) > 0 + } +} diff --git a/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift b/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift new file mode 100644 index 0000000000..96f63069fc --- /dev/null +++ b/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift @@ -0,0 +1,312 @@ +import Foundation + +/// Shared aggregation engine for the local-history framework. +/// +/// Every thin tool parser emits `UnifiedUsageEvent`s; this engine is the single place that turns +/// them into a dashboard-ready `CostUsageTokenSnapshot`. Centralizing the logic here is what +/// keeps per-tool adapters small and correct — the cached-prefix normalization, day/model +/// bucketing, models.dev pricing, and partial-pricing handling each exist exactly once instead of +/// being copied (and occasionally dropped) across scanners. +public enum UsageEventAggregator { + /// Accumulates one (day, model) bucket. + private struct Bucket { + var input = 0 + var output = 0 + var cacheRead = 0 + var cacheCreation = 0 + var reasoning = 0 + var requests = 0 + var cost = 0.0 + var sawCost = false + /// Billing ownership evidence from the source record (per-model). Optional; falls back to + /// the tool-level default when the source did not retain one. + var billingProviderID: String? + /// Set when a token-carrying event in this bucket could not be priced, so the merged day + /// cost stays nil rather than presenting a partial subtotal as the complete total. + var sawUnpricedUsage = false + /// Set when at least one event in this bucket carried token usage. Buckets built only + /// from degraded (model-only) events stay token-less. + var sawTokenUsage = false + + var total: Int? { + guard self.sawTokenUsage else { return nil } + guard let inputAndCache = Self.adding(self.input, self.cacheRead) else { return nil } + return Self.adding(inputAndCache, self.output) + } + + fileprivate static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + /// Options that vary per tool rather than per event. + public struct Options: Sendable { + /// Display label for the snapshot (`historyLabel`), e.g. "ZCode". + public var historyLabel: String + /// Fallback billing owner written onto each model breakdown when an event did not carry + /// its own `billingProviderID`. + public var defaultBillingProviderID: String? + /// `costSource` for the produced snapshot. + public var costSource: CostUsageCostSource + /// Root used to resolve the models.dev pricing catalog. Nil disables pricing. + public var modelsDevCacheRoot: URL? + + public init( + historyLabel: String, + defaultBillingProviderID: String? = nil, + costSource: CostUsageCostSource = .estimated, + modelsDevCacheRoot: URL? = nil) + { + self.historyLabel = historyLabel + self.defaultBillingProviderID = defaultBillingProviderID + self.costSource = costSource + self.modelsDevCacheRoot = modelsDevCacheRoot + } + } + + /// Aggregates a stream of events into a snapshot, or returns nil when there is nothing to + /// report. `historyDays` is recorded on the snapshot; day-window filtering is the parser's + /// responsibility (it knows each file's timestamps). + /// + /// Internal because it prices against the module-internal `ModelsDevCatalog`; tool scanners + /// live in the same module, so this is the intended call surface. + static func aggregate( + events: [UnifiedUsageEvent], + historyDays: Int, + now: Date, + options: Options) -> CostUsageTokenSnapshot? + { + let modelsDevCacheRoot = options.modelsDevCacheRoot + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) + var buckets: [DayModelKey: Bucket] = [:] + var order: [DayModelKey] = [] + for event in events { + let key = DayModelKey(day: event.day, model: event.model) + if buckets[key] == nil { + buckets[key] = Bucket() + order.append(key) + } + guard var bucket = buckets[key] else { continue } + Self.add(event, to: &bucket, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) + buckets[key] = bucket + } + guard !buckets.isEmpty else { return nil } + + let daily = Self.makeDaily(buckets: buckets, order: order, options: options) + guard !daily.isEmpty else { return nil } + + let tokenEntries = daily.filter { $0.totalTokens != nil } + let totalTokens = tokenEntries.isEmpty ? nil : Self.sum(tokenEntries.compactMap(\.totalTokens)) + let requests = tokenEntries.isEmpty ? nil : Self.sum(tokenEntries.compactMap(\.requestCount)) + // Publish the headline cost only when every token-bearing day is fully priced. A day whose + // cost was withheld (mixed priced/unpriced usage) must not be silently dropped via + // compactMap, or the remaining priced subtotal would masquerade as the complete total. + let hasUnpricedTokenDay = daily.contains { $0.totalTokens != nil && $0.costUSD == nil } + let costs = daily.compactMap(\.costUSD) + let totalCost = (costs.isEmpty || hasUnpricedTokenDay) ? nil : costs.reduce(0, +) + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: totalCost, + last30DaysRequests: requests, + currencyCode: totalCost == nil ? "XXX" : "USD", + historyDays: historyDays, + historyCoverageIsEstablished: true, + historyLabel: options.historyLabel, + costSource: options.costSource, + daily: daily, + updatedAt: now) + } + + /// Folds one event into its bucket, applying cached-prefix normalization and pricing. + private static func add( + _ event: UnifiedUsageEvent, + to bucket: inout Bucket, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) + { + guard event.hasTokenUsage else { return } + bucket.sawTokenUsage = true + if bucket.billingProviderID == nil, let evidence = event.billingProviderID { + bucket.billingProviderID = evidence + } + + let rawInput = Self.valid(event.inputTokens) + let output = Self.valid(event.outputTokens) + let cacheRead = Self.valid(event.cacheReadTokens) + let cacheCreation = Self.valid(event.cacheCreationTokens) + let reasoning = Self.valid(event.reasoningTokens) + // Reasoning is billed as output but is a sub-bucket of it; only add it when the source + // reports output and reasoning separately (e.g. Gemini thoughts) rather than folded in. + let billingOutput = output + let uncachedInput = Self.uncachedInput( + rawInput: rawInput, + output: billingOutput, + cacheRead: cacheRead, + total: event.totalTokens) + + guard let nextInput = Bucket.adding(bucket.input, uncachedInput), + let nextOutput = Bucket.adding(bucket.output, billingOutput), + let nextCacheRead = Bucket.adding(bucket.cacheRead, cacheRead), + let nextCacheCreation = Bucket.adding(bucket.cacheCreation, cacheCreation), + let nextReasoning = Bucket.adding(bucket.reasoning, reasoning), + let nextRequests = Bucket.adding(bucket.requests, max(1, event.requestCount ?? 1)) + else { + return + } + bucket.input = nextInput + bucket.output = nextOutput + bucket.cacheRead = nextCacheRead + bucket.cacheCreation = nextCacheCreation + bucket.reasoning = nextReasoning + bucket.requests = nextRequests + + if let providerCost = event.providerCostUSD, providerCost.isFinite { + // The provider already priced this unit; trust it and do not re-price. + let next = bucket.cost + providerCost + if next.isFinite { + bucket.cost = next + bucket.sawCost = true + } + return + } + + guard !event.pricingProviderIDs.isEmpty else { + bucket.sawUnpricedUsage = true + return + } + if let cost = CostUsagePricing.modelsDevCostUSD( + request: .init( + providerIDs: event.pricingProviderIDs, + model: event.model, + inputTokens: uncachedInput, + cacheReadInputTokens: cacheRead, + outputTokens: billingOutput, + cacheCreationInputTokens: cacheCreation), + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot), + cost.isFinite + { + let next = bucket.cost + cost + if next.isFinite { + bucket.cost = next + bucket.sawCost = true + } + } else { + bucket.sawUnpricedUsage = true + } + } + + /// Splits a tool's reported input into its uncached remainder, cross-checking against the + /// reported total the same way tokscale does: when `input + output == total` the input already + /// includes the cached prefix; when `input + cacheRead + output == total` it is already + /// uncached. Falls back to subtracting the cache when the total is missing or inconsistent. + private static func uncachedInput(rawInput: Int, output: Int, cacheRead: Int, total: Int?) -> Int { + guard let total else { return max(0, rawInput - cacheRead) } + let inputPlusOutput = rawInput.addingReportingOverflow(output) + let inputPlusCache = rawInput.addingReportingOverflow(cacheRead) + let inputCachePlusOutput = inputPlusCache.overflow + ? nil + : inputPlusCache.partialValue.addingReportingOverflow(output) + // Malformed records can carry counts near Int.max; overflow-reporting arithmetic lets the + // aggregator reject the record instead of trapping inside these comparisons. + if !inputPlusOutput.overflow, inputPlusOutput.partialValue == total { + return max(0, rawInput - cacheRead) + } + if let inputCachePlusOutput, !inputCachePlusOutput.overflow, + inputCachePlusOutput.partialValue == total + { + return rawInput + } + return max(0, rawInput - cacheRead) + } + + private static func makeDaily( + buckets: [DayModelKey: Bucket], + order: [DayModelKey], + options: Options) -> [CostUsageDailyReport.Entry] + { + let byDay = Dictionary(grouping: order, by: \.day) + return byDay.keys.sorted().map { day in + let dayKeys = (byDay[day] ?? []).sorted { + $0.model.localizedCaseInsensitiveCompare($1.model) == .orderedAscending + } + var total = Bucket() + var breakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + var modelsUsed: [String] = [] + for key in dayKeys { + guard let value = buckets[key] else { continue } + modelsUsed.append(key.model) + if value.sawTokenUsage { + breakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + billingProviderID: value.billingProviderID ?? options.defaultBillingProviderID, + costUSD: value.sawCost ? value.cost : nil, + totalTokens: value.total, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: value.cacheCreation, + outputTokens: value.output, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, + requestCount: value.requests)) + } + Self.merge(value, into: &total) + } + let dayCost = total.sawCost && !total.sawUnpricedUsage ? total.cost : nil + let hasTokens = total.sawTokenUsage + return CostUsageDailyReport.Entry( + date: day, + inputTokens: hasTokens ? total.input : nil, + outputTokens: hasTokens ? total.output : nil, + cacheReadTokens: hasTokens ? total.cacheRead : nil, + cacheCreationTokens: hasTokens ? total.cacheCreation : nil, + totalTokens: hasTokens ? total.total : nil, + requestCount: hasTokens ? total.requests : nil, + costUSD: dayCost, + modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, + modelBreakdowns: breakdowns.isEmpty ? nil : breakdowns) + } + } + + private static func merge(_ other: Bucket, into bucket: inout Bucket) { + guard other.sawTokenUsage else { return } + bucket.sawTokenUsage = true + if let v = Bucket.adding(bucket.input, other.input) { bucket.input = v } + if let v = Bucket.adding(bucket.output, other.output) { bucket.output = v } + if let v = Bucket.adding(bucket.cacheRead, other.cacheRead) { bucket.cacheRead = v } + if let v = Bucket.adding(bucket.cacheCreation, other.cacheCreation) { bucket.cacheCreation = v } + if let v = Bucket.adding(bucket.reasoning, other.reasoning) { bucket.reasoning = v } + if let v = Bucket.adding(bucket.requests, other.requests) { bucket.requests = v } + if other.sawCost, other.cost.isFinite { + let next = bucket.cost + other.cost + if next.isFinite { + bucket.cost = next + bucket.sawCost = true + } + } + bucket.sawUnpricedUsage = bucket.sawUnpricedUsage || other.sawUnpricedUsage + } + + private static func valid(_ value: Int?) -> Int { + guard let value, value >= 0 else { return 0 } + return value + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 4d02782100..e085b332a3 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -34,6 +34,7 @@ public enum MiniMaxProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.miniMax], noDataMessage: { "MiniMax cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web, .api], diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift new file mode 100644 index 0000000000..1990535239 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift @@ -0,0 +1,294 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +// Reads MiniMax local assistant-turn token usage from the MiniMax desktop runtime database +// (`~/.minimax/v2/sqlite/runtime-state.sqlite`, table `local_runtime_token_usage`) and folds it +// into a token-only `CostUsageTokenSnapshot` for the Usage & Spend dashboard. Same output shape as +// `KimiCodeSessionScanner`, but sourced from SQLite rather than JSONL. +// +// The runtime table already stores one row per billable turn with explicit buckets: +// `input_tokens`, `output_tokens`, `reasoning_tokens`, `cache_read_tokens`, `cache_write_tokens`, +// plus `model` (e.g. `minimax/MiniMax-M3`) and `ts` (epoch milliseconds). `reasoning` is part of +// billing output, so it stays folded into `outputTokens` and is additionally surfaced as the +// `reasoningTokens` sub-bucket (never added on top). `cache_write` maps to `cacheCreationTokens`. +// `cost_usd` is reported by the provider, but MiniMax coding plans run on a flat subscription and +// report `0` there. To stay consistent with how Codex/Claude spend is estimated, we price each turn +// at the vendor's official models.dev rate (via `CostUsagePricing.claudeCostUSD`, which routes +// MiniMax through the third-party lookup) instead of trusting the zeroed provider figure. A turn is +// priced from its token buckets; only when no official rate is known does the snapshot fall back to +// the provider's `cost_usd`, and failing that stays token-only (`costUSD: nil`). +#if canImport(SQLite3) || canImport(CSQLite3) +public enum MiniMaxSessionScanner { + public static let defaultHistoryDays = 30 + + /// Environment override for the MiniMax home directory, resolved directly here so this scanner + /// stays self-contained (mirrors how `KimiCodeSessionScanner` honors `KIMI_CODE_HOME`). + public static let homeEnvironmentKey = "MINIMAX_HOME" + + private struct UsageRow { + let model: String + let createdMs: Int64 + let input: Int + let output: Int + let reasoning: Int + let cacheRead: Int + let cacheCreation: Int + let cost: Double? + + /// Prices the turn at the vendor's official models.dev rate (mirroring how Codex/Claude + /// spend is estimated). The stored `model` is namespaced as `minimax/MiniMax-M3`, but the + /// third-party lookup keys on the bare model id, so the provider prefix is stripped first. + /// `reasoning` is already folded into `output`, so it is not priced twice. Falls back to the + /// provider-reported `cost_usd` only when it carries a real (non-zero) figure and no + /// official rate is known. + func estimatedCost( + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + let bareModel = Self.bareModelID(self.model) + if let priced = CostUsagePricing.claudeCostUSD( + model: bareModel, + inputTokens: self.input, + cacheReadInputTokens: self.cacheRead, + cacheCreationInputTokens: self.cacheCreation, + outputTokens: self.output, + pricingDate: Date(timeIntervalSince1970: TimeInterval(self.createdMs) / 1000), + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + return priced + } + if let cost = self.cost, cost > 0 { return cost } + return nil + } + + private static func bareModelID(_ model: String) -> String { + guard let slash = model.lastIndex(of: "/") else { return model } + return String(model[model.index(after: slash)...]) + } + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let databaseURL = self.runtimeDatabaseURL(environment: environment) + guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil } + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + let until = calendar.date(byAdding: .day, value: 1, to: end) ?? now + guard let rows = try self.readRows( + databaseURL: databaseURL, + sinceMs: Int64(start.timeIntervalSince1970 * 1000), + untilMs: Int64(until.timeIntervalSince1970 * 1000), + checkCancellation: checkCancellation), + !rows.isEmpty + else { + return nil + } + + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) + var events: [UnifiedUsageEvent] = [] + + for row in rows { + try checkCancellation() + let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { continue } + // MiniMax reports `input` already uncached. The engine's uncached-input disambiguation + // keys off `totalTokens`: passing input+cacheRead+output (cache-write excluded) hits the + // "input is already uncached" branch so it is priced as-is, while cache-write is priced + // separately via `cacheCreationTokens`. The estimated cost is resolved here (official + // models.dev rate, falling back to a real provider-reported `cost_usd`) and carried as + // `providerCostUSD` so the engine trusts it and never re-prices; an unresolvable cost + // surfaces as an unpriced day instead of a partial subtotal. + events.append(UnifiedUsageEvent( + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: row.model, + billingProviderID: UsageProvider.minimax.rawValue, + inputTokens: row.input, + outputTokens: row.output, + totalTokens: row.input + row.cacheRead + row.output, + cacheReadTokens: row.cacheRead, + cacheCreationTokens: row.cacheCreation, + reasoningTokens: row.reasoning > 0 ? row.reasoning : nil, + providerCostUSD: row.estimatedCost( + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot))) + } + + return UsageEventAggregator.aggregate( + events: events, + historyDays: days, + now: now, + options: .init( + historyLabel: "MiniMax", + defaultBillingProviderID: UsageProvider.minimax.rawValue, + modelsDevCacheRoot: modelsDevCacheRoot)) + } + + // MARK: - Paths + + public static func minimaxHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".minimax", isDirectory: true) + } + + public static func runtimeDatabaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.minimaxHomeURL(environment: environment) + .appendingPathComponent("v2", isDirectory: true) + .appendingPathComponent("sqlite", isDirectory: true) + .appendingPathComponent("runtime-state.sqlite", isDirectory: false) + } + + // MARK: - SQLite + + private static func readRows( + databaseURL: URL, + sinceMs: Int64, + untilMs: Int64, + checkCancellation: () throws -> Void) throws -> [UsageRow]? + { + var db: OpaquePointer? + // The runtime database is WAL-journaled. `immutable=1` must not be used here: it tells + // SQLite the database cannot change and can therefore ignore a live WAL, which made recent + // MiniMax turns disappear until a checkpoint. A normal URI read-only connection observes + // committed WAL frames while still preventing CodexBar from modifying the provider DB. + let encodedPath = databaseURL.path.addingPercentEncoding( + withAllowedCharacters: .urlPathAllowed) ?? databaseURL.path + let uri = "file:\(encodedPath)?mode=ro" + guard sqlite3_open_v2( + uri, + &db, + SQLITE_OPEN_READONLY | SQLITE_OPEN_URI | SQLITE_OPEN_FULLMUTEX, + nil) == SQLITE_OK + else { + sqlite3_close(db) + return nil + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + guard self.hasTable(named: "local_runtime_token_usage", db: db) else { return nil } + + let sql = """ + SELECT + COALESCE(model, ''), + ts, + input_tokens, + output_tokens, + reasoning_tokens, + cache_read_tokens, + cache_write_tokens, + cost_usd + FROM local_runtime_token_usage + WHERE ts >= ? AND ts < ? + ORDER BY ts + """ + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int64(stmt, 1, sinceMs) + sqlite3_bind_int64(stmt, 2, untilMs) + + var rows: [UsageRow] = [] + while true { + try checkCancellation() + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { return nil } + + let model = self.columnText(stmt, 0) ?? "" + let createdMs = sqlite3_column_int64(stmt, 1) + let input = Int(sqlite3_column_int64(stmt, 2)) + let output = Int(sqlite3_column_int64(stmt, 3)) + let reasoning = Int(sqlite3_column_int64(stmt, 4)) + let cacheRead = Int(sqlite3_column_int64(stmt, 5)) + let cacheCreation = Int(sqlite3_column_int64(stmt, 6)) + let cost: Double? = sqlite3_column_type(stmt, 7) == SQLITE_NULL + ? nil + : sqlite3_column_double(stmt, 7) + + guard createdMs > 0, + input >= 0, output >= 0, reasoning >= 0, cacheRead >= 0, cacheCreation >= 0 + else { + continue + } + rows.append(UsageRow( + model: model.isEmpty ? "minimax" : model, + createdMs: createdMs, + input: input, + output: output, + reasoning: reasoning, + cacheRead: cacheRead, + cacheCreation: cacheCreation, + cost: cost)) + } + return rows + } + + private static func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static func columnText(_ stmt: OpaquePointer?, _ index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let cString = sqlite3_column_text(stmt, index) + else { + return nil + } + return String(cString: cString) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index db653f1a84..ea9e0b8df4 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -291,6 +291,7 @@ public struct MistralUsageSnapshot: Codable, Sendable { historyDays: window.coveredDays, historyCoverageIsEstablished: window.coverageIsEstablished, historyLabel: window.isMonthToDate ? "This month" : nil, + costSource: .providerReported, daily: entries, updatedAt: window.observationEnd) } diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift index 5b4030eca8..f1bf02bcb2 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -268,6 +268,7 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { last30DaysCostUSD: total.costUSD, last30DaysRequests: total.requests, historyDays: self.historyDays, + costSource: .providerReported, daily: daily, updatedAt: self.updatedAt) } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift index 54883af5d4..ec5f1f219f 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift @@ -34,6 +34,7 @@ public enum OpenCodeProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.openCode], noDataMessage: { "OpenCode cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift new file mode 100644 index 0000000000..5ade76f943 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift @@ -0,0 +1,678 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +/// Scans local OpenCode message storage and folds assistant-turn token usage into a token-only +/// `CostUsageTokenSnapshot` for the Usage & Spend dashboard. Same shape as +/// `KimiCodeSessionScanner`; the on-disk semantics mirror tokscale's `sessions/opencode.rs`: +/// +/// - Root: `$XDG_DATA_HOME/opencode` (default `~/.local/share/opencode`). +/// - Messages: `storage/message//*.json`, one message per file, with `role`, +/// `modelID`/`providerID` (v2 payloads may nest the model under `model.id`), a required +/// `tokens {input, output, reasoning?, cache {read, write}}` object, and `time.created` / +/// `time.completed` as epoch milliseconds (float allowed). +/// +/// Only `role == "assistant"` files carry billable usage; role-less files are skipped on purpose +/// (the missing-role shortcut in tokscale applies solely to its type-filtered SQLite query). +/// `cache.write` maps to `cacheCreationTokens`. `reasoning` stays folded into billing +/// `outputTokens` (reasoning is part of output for billing) and is additionally surfaced as the +/// `reasoningTokens` sub-bucket. Negative values mark the record corrupt (skipped) +/// rather than clamped, matching this repo's `KimiCodeSessionScanner` robustness style. +/// +/// Deduplication mirrors tokscale: a message's dedup key is its embedded `id`, falling back to +/// the file stem, and repeated keys collapse into one record — this absorbs forked-session +/// copies, which keep the same message id (and usually the same filename) in a second session +/// directory. +/// +/// Future work — SQLite storage: OpenCode 1.2+ also keeps messages in `opencode.db` (`message` +/// table, role inside the JSON `data` column) and newer channel builds in +/// `opencode-.db` (`session_message` table, model nested under `$.model`). The package +/// already links the SQLite3 C module (see `OpenCodeGoLocalUsageReader`), so support can be +/// added without new dependencies. When it lands, the same dedup keys must be shared with this +/// JSON pass so messages present in both stores collapse (tokscale dedups JSON vs DB the same +/// way); DB rows additionally collapse on a full-field fingerprint — created/completed +/// timestamps, model/provider, token counts, cost, agent — whenever the embedded ids do not +/// conflict. +public enum OpenCodeSessionScanner { + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + public static let maximumFileBytes = 16 * 1024 * 1024 + + /// Environment override for the XDG data home, resolved directly here (the same way + /// `KimiSettingsReader` honors `KIMI_CODE_HOME`) so this scanner stays self-contained. + public static let dataHomeEnvironmentKey = "XDG_DATA_HOME" + + // MARK: - Wire models + + private struct WireMessage: Decodable { + struct Tokens: Decodable { + struct Cache: Decodable { + let read: Int + let write: Int + } + + let input: Int + let output: Int + let reasoning: Int? + let cache: Cache + } + + struct Model: Decodable { + let id: String? + let provider: String? + } + + struct Time: Decodable { + let created: Double + } + + let id: String? + let role: String? + let modelID: String? + let model: Model? + /// Billing ownership evidence recorded by OpenCode (e.g. `openai`, `anthropic`). + /// Optional because legacy JSON files predate it; never guessed from the model name. + let providerID: String? + let tokens: Tokens? + let time: Time + } + + // MARK: - Aggregation + + private struct NormalizedUsage { + let input: Int + let output: Int + let cacheRead: Int + let cacheCreation: Int + /// `reasoning` tokens; already included in `output` (billing-inclusive), tracked separately. + let reasoning: Int + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + /// One assistant-turn usage record, normalized from either a JSON message file or an + /// `opencode.db` row so both storage passes share the aggregation path. + private struct UsageRecord { + let dedupKey: String + let day: String + let model: String + /// Billing ownership evidence retained from the source record (JSON `providerID` / + /// `model.provider`, or the DB row's equivalents). Optional because legacy records predate + /// it; never inferred from the model name. + let billingProviderID: String? + let usage: NormalizedUsage + /// Provider-reported cost (only present for `opencode.db` rows; JSON files carry none). + let cost: Double? + /// Full-field fingerprint used to collapse JSON-vs-DB duplicates when ids don't conflict. + let fingerprint: String + } + + private struct TokenAccumulator { + var input = 0 + var cacheRead = 0 + var cacheCreation = 0 + var output = 0 + var reasoning = 0 + var requests = 0 + + mutating func add(_ usage: NormalizedUsage) -> Bool { + guard let nextInput = Self.adding(self.input, usage.input), + let nextCacheRead = Self.adding(self.cacheRead, usage.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, usage.cacheCreation), + let nextOutput = Self.adding(self.output, usage.output), + let nextReasoning = Self.adding(self.reasoning, usage.reasoning), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, other.cacheCreation), + let nextOutput = Self.adding(self.output, other.output), + let nextReasoning = Self.adding(self.reasoning, other.reasoning), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + var total: Int? { + guard let inputAndCacheRead = Self.adding(self.input, self.cacheRead), + let withCacheCreation = Self.adding(inputAndCacheRead, self.cacheCreation) + else { + return nil + } + return Self.adding(withCacheCreation, self.output) + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + // MARK: - Scanning + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + checkCancellation: @escaping () throws -> Void = {}, + fileLimit: Int? = nil) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + + var context = ScanContext(start: start, end: end, calendar: calendar) + var records: [UsageRecord] = [] + // The SQLite row is the richer source after an OpenCode migration because it carries + // provider-reported cost. Register it before legacy JSON so duplicates retain pricing. + try records.append(contentsOf: self.scanDatabaseMessages( + environment: environment, + context: &context, + checkCancellation: checkCancellation)) + try records.append(contentsOf: self.scanJSONMessages( + environment: environment, + fileManager: fileManager, + context: &context, + checkCancellation: checkCancellation, + fileLimit: fileLimit ?? self.maximumFiles)) + + var values: [DayModelKey: TokenAccumulator] = [:] + var costs: [DayModelKey: Double] = [:] + // First retained ownership evidence per (day, model); records that lack one do not + // overwrite an earlier, sourced value. + var billingProviderIDs: [DayModelKey: String] = [:] + // Keys that include at least one billable record with no provider-reported cost (legacy JSON + // rows). Their day's cost must be withheld so a priced DB subtotal is not read as complete. + var partiallyPricedKeys: Set = [] + for record in records { + try checkCancellation() + let key = DayModelKey(day: record.day, model: record.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(record.usage) else { continue } + values[key] = value + if billingProviderIDs[key] == nil, let billingProviderID = record.billingProviderID { + billingProviderIDs[key] = billingProviderID + } + if let cost = record.cost, cost.isFinite, cost >= 0 { + costs[key] = (costs[key] ?? 0) + cost + } else { + partiallyPricedKeys.insert(key) + } + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var dayCost = 0.0 + var dayCostSeen = false + var dayHasUnpricedUsage = false + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + // A model whose records are only partially priced reports no subtotal: pairing the + // combined token count with a priced-only cost would read as a complete figure. + let modelPriced = costs[key] != nil && !partiallyPricedKeys.contains(key) + let modelCost = modelPriced ? costs[key] : nil + if let modelCost { dayCost += modelCost; dayCostSeen = true } + if !modelPriced { dayHasUnpricedUsage = true } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + billingProviderID: billingProviderIDs[key], + costUSD: modelCost, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: value.cacheCreation, + outputTokens: value.output, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, + requestCount: value.requests)) + } + guard let totalTokens = total.total else { return nil } + let dayCostUSD = dayCostSeen && !dayHasUnpricedUsage ? dayCost : nil + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: total.cacheCreation, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: dayCostUSD, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + guard let totalTokens, let totalRequests else { return nil } + // Cost only exists for `opencode.db` rows (JSON files carry none). A day with any + // unpriced usage withholds the headline cost: publishing the priced subtotal alone would + // read as the complete history total. + let hasUnpricedTokenDay = daily.contains { $0.totalTokens != nil && $0.costUSD == nil } + let totalCost = hasUnpricedTokenDay ? nil : self.sum(daily.compactMap(\.costUSD)) + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + sessionRequests: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: totalCost, + last30DaysRequests: totalRequests, + currencyCode: totalCost != nil ? "USD" : "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "OpenCode", + costSource: totalCost != nil ? .providerReported : .estimated, + daily: daily, + updatedAt: now) + } + + /// JSON message files (OpenCode < 1.2). Only assistant turns bill tokens. + /// Shared scan window and cross-source dedup state threaded through the JSON and SQLite scanners. + private struct ScanContext { + let start: Date + let end: Date + let calendar: Calendar + var seenMessageIDs: Set = [] + var seenFingerprints: Set = [] + } + + private static func scanJSONMessages( + environment: [String: String], + fileManager: FileManager, + context: inout ScanContext, + checkCancellation: () throws -> Void, + fileLimit: Int) throws -> [UsageRecord] + { + let start = context.start + let end = context.end + let calendar = context.calendar + let storage = self.opencodeMessageStorageURL(environment: environment) + guard let enumerator = fileManager.enumerator( + at: storage, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return [] + } + + let decoder = JSONDecoder() + var records: [UsageRecord] = [] + var candidates: [(url: URL, modificationDate: Date?, size: Int)] = [] + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + guard url.pathExtension.lowercased() == "json" else { continue } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + if let modificationDate = resourceValues?.contentModificationDate, + modificationDate < start + { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumFileBytes else { continue } + candidates.append((url, resourceValues?.contentModificationDate, size)) + } + // The cap must prefer the most recent messages: session directories are enumerated in + // arbitrary order, so an unsorted first-N pass can fill the cap with old sessions and + // omit the recent usage the dashboard actually needs. + let cappedCandidates = candidates + .sorted { ($0.modificationDate ?? .distantPast) > ($1.modificationDate ?? .distantPast) } + .prefix(fileLimit) + var visitedBytes = 0 + for candidate in cappedCandidates { + try checkCancellation() + let url = candidate.url + let size = candidate.size + guard size <= self.maximumBytes - visitedBytes else { continue } + visitedBytes += size + guard let data = try? Data(contentsOf: url), + let message = try? decoder.decode(WireMessage.self, from: data) + else { + continue + } + guard message.role == "assistant", + let model = self.cleaned(message.modelID ?? message.model?.id), + let tokens = message.tokens, + let usage = self.normalize(tokens), + message.time.created.isFinite + else { + continue + } + let date = Date(timeIntervalSince1970: message.time.created / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { continue } + let dedupKey = self.cleaned(message.id) ?? url.deletingPathExtension().lastPathComponent + let billingProviderID = self.cleaned(message.providerID ?? message.model?.provider) + let fingerprint = self.fingerprint( + createdMs: Int64(message.time.created.rounded()), + model: model, + billingProviderID: billingProviderID, + usage: usage, + cost: nil) + guard context.seenMessageIDs.insert(dedupKey).inserted, + context.seenFingerprints.insert(fingerprint).inserted + else { continue } + records.append(UsageRecord( + dedupKey: dedupKey, + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: model, + billingProviderID: billingProviderID, + usage: usage, + cost: nil, + fingerprint: fingerprint)) + } + return records + } + + /// `opencode.db` (OpenCode 1.2+). The `message` table keeps each message as a JSON `data` + /// blob; assistant rows carry `modelID`/`model.id`, `tokens{input,output,reasoning,cache{read,write}}`, + /// `cost` (provider-reported USD), and `time.created` (epoch ms). `json_extract` reads them in + /// SQL so we never materialize the blob. + private static func scanDatabaseMessages( + environment: [String: String], + context: inout ScanContext, + checkCancellation: () throws -> Void) throws -> [UsageRecord] + { + let start = context.start + let end = context.end + let calendar = context.calendar + let dbURL = self.opencodeDatabaseURL(environment: environment) + guard FileManager.default.fileExists(atPath: dbURL.path) else { return [] } + + var db: OpaquePointer? + // Open the live database read-only without `immutable=1`; immutable mode can ignore + // committed WAL frames and publish stale history. + let encodedPath = dbURL.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) + ?? dbURL.path + let uri = "file:\(encodedPath)?mode=ro" + guard sqlite3_open_v2( + uri, + &db, + SQLITE_OPEN_READONLY | SQLITE_OPEN_URI | SQLITE_OPEN_FULLMUTEX, + nil) == SQLITE_OK + else { + sqlite3_close(db) + return [] + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + guard self.hasTable(named: "message", db: db) else { return [] } + + let sql = """ + SELECT + COALESCE(NULLIF(json_extract(data, '$.id'), ''), ''), + COALESCE( + NULLIF(json_extract(data, '$.modelID'), ''), + json_extract(data, '$.model.id')), + COALESCE(json_extract(data, '$.time.created'), time_created), + json_extract(data, '$.tokens.input'), + json_extract(data, '$.tokens.output'), + json_extract(data, '$.tokens.reasoning'), + json_extract(data, '$.tokens.cache.read'), + json_extract(data, '$.tokens.cache.write'), + json_extract(data, '$.cost'), + COALESCE( + NULLIF(json_extract(data, '$.providerID'), ''), + json_extract(data, '$.model.provider')) + FROM message + WHERE json_extract(data, '$.role') = 'assistant' + AND COALESCE(json_extract(data, '$.time.created'), time_created) >= ? + AND COALESCE(json_extract(data, '$.time.created'), time_created) < ? + """ + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + let until = calendar.date(byAdding: .day, value: 1, to: end) ?? end + sqlite3_bind_int64(stmt, 1, Int64(start.timeIntervalSince1970 * 1000)) + sqlite3_bind_int64(stmt, 2, Int64(until.timeIntervalSince1970 * 1000)) + + var records: [UsageRecord] = [] + while true { + try checkCancellation() + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { return [] } + + let messageID = self.columnText(stmt, 0) + guard let model = self.cleaned(self.columnText(stmt, 1)) else { continue } + let createdMs = sqlite3_column_type(stmt, 2) == SQLITE_NULL + ? 0 + : Int64(sqlite3_column_double(stmt, 2)) + guard createdMs > 0 else { continue } + let input = Int(sqlite3_column_int64(stmt, 3)) + let output = Int(sqlite3_column_int64(stmt, 4)) + let reasoning = sqlite3_column_type(stmt, 5) == SQLITE_NULL ? 0 : Int(sqlite3_column_int64(stmt, 5)) + let cacheRead = sqlite3_column_type(stmt, 6) == SQLITE_NULL ? 0 : Int(sqlite3_column_int64(stmt, 6)) + let cacheCreation = sqlite3_column_type(stmt, 7) == SQLITE_NULL ? 0 : Int(sqlite3_column_int64(stmt, 7)) + let cost: Double? = sqlite3_column_type(stmt, 8) == SQLITE_NULL + ? nil + : sqlite3_column_double(stmt, 8) + let billingProviderID = self.cleaned(self.columnText(stmt, 9)) + + guard input >= 0, output >= 0, reasoning >= 0, cacheRead >= 0, cacheCreation >= 0, + let foldedOutput = self.adding(output, reasoning) + else { + continue + } + let usage = NormalizedUsage( + input: input, + output: foldedOutput, + cacheRead: cacheRead, + cacheCreation: cacheCreation, + reasoning: reasoning) + + let date = Date(timeIntervalSince1970: Double(createdMs) / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { continue } + + let fingerprint = self.fingerprint( + createdMs: createdMs, + model: model, + billingProviderID: billingProviderID, + usage: usage, + cost: cost) + // tokscale dedups JSON vs DB by embedded id, then by full-field fingerprint when ids + // don't conflict — so a message present in both stores collapses to one record. + if let messageID, !messageID.isEmpty { + guard context.seenMessageIDs.insert(messageID).inserted else { continue } + } + guard context.seenFingerprints.insert(fingerprint).inserted else { continue } + + records.append(UsageRecord( + dedupKey: messageID ?? fingerprint, + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: model, + billingProviderID: billingProviderID, + usage: usage, + cost: cost, + fingerprint: fingerprint)) + } + return records + } + + // MARK: - Paths + + public static func opencodeMessageStorageURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.opencodeDataRootURL(environment: environment) + .appendingPathComponent("storage", isDirectory: true) + .appendingPathComponent("message", isDirectory: true) + } + + public static func opencodeDatabaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.opencodeDataRootURL(environment: environment) + .appendingPathComponent("opencode.db", isDirectory: false) + } + + private static func opencodeDataRootURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + let dataHome = if let override = self.cleaned(environment[self.dataHomeEnvironmentKey]) { + URL(fileURLWithPath: override, isDirectory: true) + } else { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } + return dataHome.appendingPathComponent("opencode", isDirectory: true) + } + + // MARK: - Helpers + + private static func normalize(_ tokens: WireMessage.Tokens) -> NormalizedUsage? { + let reasoning = tokens.reasoning ?? 0 + guard tokens.input >= 0, tokens.output >= 0, reasoning >= 0, + tokens.cache.read >= 0, tokens.cache.write >= 0 + else { + return nil + } + // Reasoning is part of billing output, so it stays folded into `output`; the + // `reasoning` bucket surfaces the same count separately (never add it on top). + guard let output = self.adding(tokens.output, reasoning) else { return nil } + return NormalizedUsage( + input: tokens.input, + output: output, + cacheRead: tokens.cache.read, + cacheCreation: tokens.cache.write, + reasoning: reasoning) + } + + private static func cleaned(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } + + private static func sum(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + var result = 0.0 + for value in values { + result += value + guard result.isFinite else { return nil } + } + return result + } + + /// Cross-store fingerprint for one logical message. Cost is deliberately excluded because + /// migrated database rows can enrich an otherwise identical legacy JSON record with pricing. + /// Ownership evidence is included so records whose routing differs are not collapsed. + private static func fingerprint( + createdMs: Int64, + model: String, + billingProviderID: String?, + usage: NormalizedUsage, + cost _: Double?) -> String + { + [ + String(createdMs), + model, + billingProviderID ?? "", + String(usage.input), + String(usage.output), + String(usage.cacheRead), + String(usage.cacheCreation), + String(usage.reasoning), + ].joined(separator: "|") + } + + // MARK: - SQLite helpers + + private static func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static func columnText(_ stmt: OpaquePointer?, _ index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let cString = sqlite3_column_text(stmt, index) + else { + return nil + } + return String(cString: cString) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift index 6f0adc4ebe..90dae68ff1 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift @@ -175,6 +175,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { CostUsageFetcher.tokenSnapshot( from: CostUsageDailyReport(data: self.daily, summary: nil), now: self.updatedAt, - historyDays: historyDays) + historyDays: historyDays, + costSource: .providerReported) } } diff --git a/Sources/CodexBarCore/Providers/ProviderBranding.swift b/Sources/CodexBarCore/Providers/ProviderBranding.swift index 9d76dafedf..4ad5ba4e72 100644 --- a/Sources/CodexBarCore/Providers/ProviderBranding.swift +++ b/Sources/CodexBarCore/Providers/ProviderBranding.swift @@ -19,9 +19,17 @@ public struct ProviderColor: Sendable, Equatable { } } +public enum ProviderIconRenderingMode: Sendable, Equatable { + /// A monochrome official mark that follows the surrounding foreground color. + case template + /// An official asset whose embedded colors must be preserved. + case original +} + public struct ProviderBranding: Sendable { public let iconStyle: IconStyle public let iconResourceName: String + public let iconRenderingMode: ProviderIconRenderingMode public let color: ProviderColor public let confettiPalette: [ProviderColor] @@ -39,12 +47,14 @@ public struct ProviderBranding: Sendable { public init( iconStyle: IconStyle, iconResourceName: String, + iconRenderingMode: ProviderIconRenderingMode = .template, color: ProviderColor, confettiPalette: [ProviderColor]) { precondition((2...3).contains(confettiPalette.count), "Provider confetti palettes require 2–3 colors.") self.iconStyle = iconStyle self.iconResourceName = iconResourceName + self.iconRenderingMode = iconRenderingMode self.color = color self.confettiPalette = confettiPalette } diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 6e24825e55..3c1fdd3f54 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -1,13 +1,69 @@ import Foundation +/// Stable identifier for a local usage-history adapter. +/// +/// This is intentionally an open value type instead of an enum. Provider +/// descriptors can opt into new adapters without expanding a central switch, +/// which keeps the dashboard extensible as tools add or change local formats. +public struct ProviderLocalHistorySource: RawRepresentable, Hashable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + precondition(!rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + self.rawValue = rawValue + } + + public static let antigravity = Self(rawValue: "antigravity") + public static let geminiCLI = Self(rawValue: "geminiCLI") + public static let kimiCode = Self(rawValue: "kimiCode") + public static let miniMax = Self(rawValue: "miniMax") + public static let openCode = Self(rawValue: "openCode") + public static let qwenCode = Self(rawValue: "qwenCode") + public static let zcode = Self(rawValue: "zcode") + public static let copilot = Self(rawValue: "copilot") + /// Degraded sources: the tool is recognized and model activity is reported, but no per-request + /// token usage is available locally (billing is server-side). + public static let cursorLocal = Self(rawValue: "cursorLocal") + public static let traeLocal = Self(rawValue: "traeLocal") + + public static let builtIn: [Self] = [ + .antigravity, + .geminiCLI, + .kimiCode, + .miniMax, + .openCode, + .qwenCode, + .zcode, + .copilot, + .cursorLocal, + .traeLocal, + ] + + /// Built-in adapter identifiers shipped by this build. Custom identifiers + /// remain valid even though they are not present in this inventory. + public static var allCases: [Self] { + self.builtIn + } +} + public struct ProviderTokenCostConfig: Sendable { public let supportsTokenCost: Bool + public let localHistorySources: [ProviderLocalHistorySource] public let noDataMessage: @Sendable () -> String - public init(supportsTokenCost: Bool, noDataMessage: @escaping @Sendable () -> String) { + public init( + supportsTokenCost: Bool, + localHistorySources: [ProviderLocalHistorySource] = [], + noDataMessage: @escaping @Sendable () -> String) + { self.supportsTokenCost = supportsTokenCost + self.localHistorySources = localHistorySources self.noDataMessage = noDataMessage } + + public var supportsDashboardHistory: Bool { + self.supportsTokenCost || !self.localHistorySources.isEmpty + } } public enum ProviderPaceWindowRule: Sendable { @@ -207,7 +263,9 @@ public enum ProviderDescriptorRegistry { public static func register(_ descriptor: ProviderDescriptor) -> ProviderDescriptor { self.lock.lock() defer { self.lock.unlock() } - if self.store.byID[descriptor.id] == nil { + if let index = self.store.ordered.firstIndex(where: { $0.id == descriptor.id }) { + self.store.ordered[index] = descriptor + } else { self.store.ordered.append(descriptor) } self.store.byID[descriptor.id] = descriptor @@ -227,8 +285,9 @@ public enum ProviderDescriptorRegistry { public static func descriptor(for id: UsageProvider) -> ProviderDescriptor { self.ensureBootstrapped() + self.lock.lock() + defer { self.lock.unlock() } if let found = self.store.byID[id] { return found } - if let found = self.all.first(where: { $0.id == id }) { return found } fatalError("Missing ProviderDescriptor for \(id.rawValue)") } diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift index ffd88045b4..55e4e61f37 100644 --- a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift @@ -45,6 +45,7 @@ public enum QwenCloudProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.qwenCode], noDataMessage: { "Qwen Cloud cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCodeSessionScanner.swift new file mode 100644 index 0000000000..7a1e0ce827 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCodeSessionScanner.swift @@ -0,0 +1,177 @@ +import Foundation + +/// Reads Qwen Code's local JSONL history as a thin parser over the shared aggregation engine. +/// +/// Qwen stores assistant messages under `~/.qwen/projects/*/chats/*.jsonl`. The format is also +/// consumed by tokscale. The scanner is deliberately bounded and cancellable so enabling a long +/// history cannot turn dashboard refresh into an unbounded filesystem crawl. +/// +/// This scanner only walks files and maps each assistant message to a `UnifiedUsageEvent`. The +/// cached-prefix split (Qwen's `promptTokenCount` already includes `cachedContentTokenCount`), +/// models.dev pricing, reasoning/output accounting, day bucketing, and snapshot construction are +/// all handled once by `UsageEventAggregator`. +public enum QwenCodeSessionScanner { + public static let homeEnvironmentKey = "QWEN_HOME" + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + + private struct WireMessage: Decodable { + struct Usage: Decodable { + let promptTokenCount: Int? + let candidatesTokenCount: Int? + let thoughtsTokenCount: Int? + let cachedContentTokenCount: Int? + } + + let type: String? + let model: String? + let timestamp: String? + let usageMetadata: Usage? + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let home = self.homeURL(environment: environment) + let root = home.appendingPathComponent("projects", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + let decoder = JSONDecoder() + // Qwen emits both whole-second and fractional-second RFC 3339 timestamps. The default + // formatter rejects fractional seconds and would silently collapse those messages onto the + // file's modification day, so try a fractional-seconds formatter first. + let iso8601Fractional = ISO8601DateFormatter() + iso8601Fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let iso8601 = ISO8601DateFormatter() + let parseTimestamp: (String) -> Date? = { raw in + iso8601Fractional.date(from: raw) ?? iso8601.date(from: raw) + } + + var events: [UnifiedUsageEvent] = [] + var visitedFiles = 0 + var visitedBytes = 0 + + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + guard url.pathExtension.lowercased() == "jsonl", + url.deletingLastPathComponent().lastPathComponent == "chats" + else { + continue + } + guard visitedFiles < self.maximumFiles else { break } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + if let modified = resourceValues?.contentModificationDate, modified < start { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumBytes - visitedBytes else { break } + visitedFiles += 1 + visitedBytes += size + + do { + try CostUsageJsonl.scan( + fileURL: url, + maxLineBytes: 1024 * 1024, + prefixBytes: 1024 * 1024, + checkCancellation: checkCancellation) + { line in + guard !line.wasTruncated, + let message = try? decoder.decode(WireMessage.self, from: line.bytes), + message.type == "assistant", + let usage = message.usageMetadata + else { + return + } + let eventDate = message.timestamp.flatMap(parseTimestamp) + ?? resourceValues?.contentModificationDate + ?? now + let eventDay = calendar.startOfDay(for: eventDate) + guard eventDay >= start, eventDay <= end else { return } + let model = message.model? + .trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedModel = model?.isEmpty == false ? model! : "unknown" + // Reasoning ("thoughts") is billed as output. Fold it into the event's output so + // the engine prices output correctly, and also pass it through as the reasoning + // sub-bucket for display. `promptTokenCount` carries no separate total, so the + // engine falls back to subtracting the cached prefix. + let candidates = max(0, usage.candidatesTokenCount ?? 0) + let thoughts = max(0, usage.thoughtsTokenCount ?? 0) + events.append(UnifiedUsageEvent( + day: CostUsageLocalDay.key(from: eventDay, calendar: calendar), + model: normalizedModel, + billingProviderID: UsageProvider.qwencloud.rawValue, + inputTokens: usage.promptTokenCount, + outputTokens: candidates + thoughts, + totalTokens: nil, + cacheReadTokens: usage.cachedContentTokenCount, + cacheCreationTokens: nil, + reasoningTokens: thoughts, + pricingProviderIDs: ["alibaba", "alibaba-cn"])) + } + } catch is CancellationError { + throw CancellationError() + } catch { + continue + } + } + + return UsageEventAggregator.aggregate( + events: events, + historyDays: days, + now: now, + options: .init( + historyLabel: "Qwen Code CLI", + defaultBillingProviderID: UsageProvider.qwencloud.rawValue, + modelsDevCacheRoot: modelsDevCacheRoot)) + } + + public static func homeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".qwen", isDirectory: true) + } +} diff --git a/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift b/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift new file mode 100644 index 0000000000..334b3eb8dd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift @@ -0,0 +1,185 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +// Reads Trae's local state to surface which model the agent was last configured to use, without +// reporting token counts. +// +// Trae (a VS Code fork) keeps its state in +// `~/Library/Application Support/Trae*/User/globalStorage/state.vscdb`, a VS Code `ItemTable` +// key-value store. The keys relevant here are the per-agent selected-model map +// (`…_ai-chat:sessionRelation:globalModelMap`, values like `1_-_glm-5.2`) and the telemetry +// last-session date. Trae stores no per-request token usage locally — billing is server-side — +// so this is a *degraded* source: the scanner reports the models the user actually selected and +// the last active day, with every token and cost field left `nil` so nothing is fabricated. +#if canImport(SQLite3) || canImport(CSQLite3) +public enum TraeLocalActivityScanner { + public static let defaultHistoryDays = 30 + + /// Environment override pointing at the Trae `state.vscdb` file, so tests can use a fixture. + public static let databaseEnvironmentKey = "TRAE_STATE_VSCDB" + + /// Candidate `Application Support` bundle folder names, newest branding first. + private static let applicationSupportFolders = ["Trae CN", "Trae"] + + public static func databaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> URL? + { + if let override = environment[self.databaseEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: false) + } + let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + guard let appSupport else { return nil } + for folder in self.applicationSupportFolders { + let candidate = appSupport + .appendingPathComponent(folder, isDirectory: true) + .appendingPathComponent("User/globalStorage/state.vscdb", isDirectory: false) + if fileManager.fileExists(atPath: candidate.path) { + return candidate + } + } + return nil + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + guard let databaseURL = self.databaseURL(environment: environment, fileManager: fileManager), + fileManager.fileExists(atPath: databaseURL.path) + else { + return nil + } + + var db: OpaquePointer? + guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + if let db { sqlite3_close(db) } + return nil + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let models = self.selectedModels(db: db) + let lastActive = self.lastSessionDate(db: db) + guard !models.isEmpty || lastActive != nil else { return nil } + + // Anchor the (single) record on the last active day when known, otherwise today. Trae + // exposes no per-day token history, so there is exactly one degraded entry per model. + let anchor = lastActive ?? now + let anchorDay = calendar.startOfDay(for: anchor) + // A last-session date older than the requested window must not surface as recent + // activity; the scanner reports nothing rather than an out-of-window record. + if let lastActive, anchorDay < start || anchorDay > end { + return nil + } + let dayKey = CostUsageLocalDay.key(from: anchorDay, calendar: calendar) + let events = models.map { model in + UnifiedUsageEvent(day: dayKey, model: model) + } + return UsageEventAggregator.aggregate( + events: events, + historyDays: days, + now: now, + options: .init(historyLabel: "Trae")) + } + + /// Reads the per-agent selected-model map and returns the de-duplicated, cleaned model names + /// the user actually chose (e.g. `glm-5.2` from `1_-_glm-5.2`). + private static func selectedModels(db: OpaquePointer?) -> [String] { + guard let raw = self.value(db: db, keySuffix: "ai-chat:sessionRelation:globalModelMap"), + let data = raw.data(using: .utf8), + let map = try? JSONDecoder().decode([String: String].self, from: data) + else { + return [] + } + var seen: Set = [] + var models: [String] = [] + for value in map.values { + let cleaned = self.cleanModelName(value) + guard !cleaned.isEmpty, !seen.contains(cleaned.lowercased()) else { continue } + seen.insert(cleaned.lowercased()) + models.append(cleaned) + } + return models.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + /// Strips Trae's internal `N_-_` selection-id prefix from a stored model reference. + private static func cleanModelName(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if let range = value.range(of: "_-_") { + value = String(value[range.upperBound...]) + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Parses the HTTP-date telemetry last-session timestamp (`Sat, 27 Jun 2026 15:51:03 GMT`). + private static func lastSessionDate(db: OpaquePointer?) -> Date? { + guard let raw = self.value(db: db, exactKey: "telemetry.lastSessionDate") else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + return formatter.date(from: raw.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + /// Looks up a value whose key either matches exactly or ends with the given suffix (Trae + /// prefixes many keys with a numeric install id). + private static func value(db: OpaquePointer?, exactKey: String? = nil, keySuffix: String? = nil) -> String? { + let query = if exactKey != nil { + "SELECT value FROM ItemTable WHERE key = ? LIMIT 1;" + } else { + "SELECT value FROM ItemTable WHERE key LIKE ? LIMIT 1;" + } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(stmt) } + let bind = exactKey ?? "%\(keySuffix ?? "")" + sqlite3_bind_text(stmt, 1, bind, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } + switch sqlite3_column_type(stmt, 0) { + case SQLITE_TEXT: + guard let c = sqlite3_column_text(stmt, 0) else { return nil } + return String(cString: c) + case SQLITE_BLOB: + guard let bytes = sqlite3_column_blob(stmt, 0) else { return nil } + let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, 0))) + return String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .utf16LittleEndian) + default: + return nil + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Zcode/ZcodeSessionScanner.swift b/Sources/CodexBarCore/Providers/Zcode/ZcodeSessionScanner.swift new file mode 100644 index 0000000000..cb3a60b87d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Zcode/ZcodeSessionScanner.swift @@ -0,0 +1,185 @@ +import Foundation + +/// Reads ZCode's local JSONL rollout history as a thin parser over the shared aggregation engine. +/// +/// ZCode stores one file per session under `~/.zcode/cli/rollout/model-io-sess_*.jsonl`. Each +/// line is a single billable request with `completedAt` (RFC 3339), `model.modelId` (e.g. +/// `GLM-5.2`), `model.providerId` (e.g. `builtin:bigmodel-start-plan`, the Zhipu/BigModel coding +/// plan), and `response.usage` carrying `inputTokens`, `outputTokens`, `totalTokens`, +/// `cacheReadTokens`, and `cacheWriteTokens`. +/// +/// This scanner only walks files and maps each request to a `UnifiedUsageEvent`. The cached-prefix +/// double-count (ZCode's `inputTokens` already includes the cached prefix), models.dev pricing at +/// the official Z.ai rate, day bucketing, and snapshot construction are all handled once by +/// `UsageEventAggregator` — nothing here re-implements them. +public enum ZcodeSessionScanner { + public static let homeEnvironmentKey = "ZCODE_HOME" + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + + private struct WireMessage: Decodable { + struct Model: Decodable { + let modelId: String? + let providerId: String? + } + + struct Usage: Decodable { + let inputTokens: Int? + let outputTokens: Int? + let totalTokens: Int? + let cacheReadTokens: Int? + let cacheWriteTokens: Int? + } + + struct Response: Decodable { + let usage: Usage? + } + + let completedAt: String? + let model: Model? + let response: Response? + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let root = self.homeURL(environment: environment) + .appendingPathComponent("cli", isDirectory: true) + .appendingPathComponent("rollout", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + let decoder = JSONDecoder() + // ZCode emits RFC 3339 timestamps with fractional seconds (`2026-06-14T17:23:26.382Z`). + let iso8601Fractional = ISO8601DateFormatter() + iso8601Fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let iso8601 = ISO8601DateFormatter() + let parseTimestamp: (String) -> Date? = { raw in + iso8601Fractional.date(from: raw) ?? iso8601.date(from: raw) + } + + var events: [UnifiedUsageEvent] = [] + var visitedFiles = 0 + var visitedBytes = 0 + + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + guard url.pathExtension.lowercased() == "jsonl", + url.lastPathComponent.hasPrefix("model-io-sess") + else { + continue + } + guard visitedFiles < self.maximumFiles else { break } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + if let modified = resourceValues?.contentModificationDate, modified < start { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumBytes - visitedBytes else { break } + visitedFiles += 1 + visitedBytes += size + + do { + try CostUsageJsonl.scan( + fileURL: url, + maxLineBytes: 1024 * 1024, + prefixBytes: 1024 * 1024, + checkCancellation: checkCancellation) + { line in + guard !line.wasTruncated, + let message = try? decoder.decode(WireMessage.self, from: line.bytes), + let usage = message.response?.usage + else { + return + } + let eventDate = message.completedAt.flatMap(parseTimestamp) + ?? resourceValues?.contentModificationDate + ?? now + let eventDay = calendar.startOfDay(for: eventDate) + guard eventDay >= start, eventDay <= end else { return } + // models.dev keys the Z.ai catalog by the lowercase model id (`glm-5.2`), while + // ZCode records the display casing (`GLM-5.2`). + let model = message.model?.modelId? + .trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedModel = model?.isEmpty == false + ? model!.lowercased() + : "unknown" + events.append(UnifiedUsageEvent( + day: CostUsageLocalDay.key(from: eventDay, calendar: calendar), + model: normalizedModel, + billingProviderID: UsageProvider.zai.rawValue, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + cacheReadTokens: usage.cacheReadTokens, + cacheCreationTokens: usage.cacheWriteTokens, + // Price at the official Z.ai API rate; the zero-priced coding-plan catalogs + // are intentionally excluded so we report the API-equivalent value. + pricingProviderIDs: ["zai", "zhipuai"])) + } + } catch is CancellationError { + throw CancellationError() + } catch { + continue + } + } + + return UsageEventAggregator.aggregate( + events: events, + historyDays: days, + now: now, + options: .init( + historyLabel: "ZCode", + defaultBillingProviderID: UsageProvider.zai.rawValue, + modelsDevCacheRoot: modelsDevCacheRoot)) + } + + public static func homeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".zcode", isDirectory: true) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift b/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift index 56f5608a56..bccb49327a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift @@ -187,14 +187,16 @@ extension CostUsageScanner { private static func totalsEqual(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { lhs.input == rhs.input && lhs.cached == rhs.cached && lhs.output == rhs.output + && lhs.reasoning == rhs.reasoning } private static func totalsAtLeast(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { lhs.input >= rhs.input && lhs.cached >= rhs.cached && lhs.output >= rhs.output + && (lhs.reasoning ?? 0) >= (rhs.reasoning ?? 0) } private static func totalsContainUsage(_ totals: CostUsageCodexTotals) -> Bool { - totals.input > 0 || totals.cached > 0 || totals.output > 0 + totals.input > 0 || totals.cached > 0 || totals.output > 0 || (totals.reasoning ?? 0) > 0 } private static func normalizedSessionID(_ value: String?) -> String? { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index e13e0bcd3b..85a56219e0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -137,6 +137,9 @@ struct CostUsageCache: Codable { struct CostUsageFileUsage: Codable { var mtimeUnixMs: Int64 var size: Int64 + /// Sampling fingerprint recorded at scan time; nil on cache entries written before + /// bounded content-fingerprint validation was introduced. + var fingerprint: CostUsageSourceFingerprint? var days: [String: [String: [Int]]] var parsedBytes: Int64? var lastModel: String? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift new file mode 100644 index 0000000000..d8606ad147 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift @@ -0,0 +1,165 @@ +import Foundation + +extension CostUsagePricing { + private struct GooglePricing { + let inputCostPerToken: Double + let outputCostPerToken: Double + let cacheReadInputCostPerToken: Double + let thresholdTokens: Int? + let inputCostPerTokenAboveThreshold: Double? + let outputCostPerTokenAboveThreshold: Double? + let cacheReadInputCostPerTokenAboveThreshold: Double? + } + + /// Official Google Gemini Standard rates, kept as an offline fallback for model ids that can + /// appear in Antigravity before the cached models.dev catalog catches up. + /// + /// Source (accessed 2026-07-28): + /// https://ai.google.dev/gemini-api/docs/pricing + private static let google: [String: GooglePricing] = [ + "gemini-3.6-flash": GooglePricing( + inputCostPerToken: 1.50 / 1_000_000, + outputCostPerToken: 7.50 / 1_000_000, + cacheReadInputCostPerToken: 0.15 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + "gemini-3.5-flash": GooglePricing( + inputCostPerToken: 1.50 / 1_000_000, + outputCostPerToken: 9.00 / 1_000_000, + cacheReadInputCostPerToken: 0.15 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + "gemini-3.1-pro-preview": GooglePricing( + inputCostPerToken: 2.00 / 1_000_000, + outputCostPerToken: 12.00 / 1_000_000, + cacheReadInputCostPerToken: 0.20 / 1_000_000, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 4.00 / 1_000_000, + outputCostPerTokenAboveThreshold: 18.00 / 1_000_000, + cacheReadInputCostPerTokenAboveThreshold: 0.40 / 1_000_000), + "gemini-3-flash-preview": GooglePricing( + inputCostPerToken: 0.50 / 1_000_000, + outputCostPerToken: 3.00 / 1_000_000, + cacheReadInputCostPerToken: 0.05 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + ] + + private static let googleAliases: [String: String] = [ + "gemini-pro-default": "gemini-3.1-pro-preview", + "gemini-pro-agent": "gemini-3.1-pro-preview", + "gemini-3.1-pro": "gemini-3.1-pro-preview", + "gemini-3.1-pro-high": "gemini-3.1-pro-preview", + "gemini-3.1-pro-low": "gemini-3.1-pro-preview", + "gemini-3-flash": "gemini-3-flash-preview", + "gemini-3-flash-c": "gemini-3-flash-preview", + "gemini-default": "gemini-3-flash-preview", + "gemini-3-flash-a": "gemini-3.5-flash", + "gemini-3-flash-agent": "gemini-3.5-flash", + "gemini-3-flash-b": "gemini-3.5-flash", + "gemini-3.5-flash-high": "gemini-3.5-flash", + "gemini-3.5-flash-medium": "gemini-3.5-flash", + "gemini-3.5-flash-low": "gemini-3.5-flash", + "gemini-3.5-flash-extra-low": "gemini-3.5-flash", + ] + + static func googleCostUSD( + model: String, + inputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? + { + let canonicalModel = self.googlePricingModelID(model) + // Prefer the refreshed models.dev catalog so a corrected or updated rate is actually used; + // the embedded table below is the offline fallback for ids the catalog has not caught up + // with yet, not the first source consulted. + if let lookup = self.modelsDevLookup( + providerID: "google", + model: canonicalModel, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.googleCostUSD( + pricing: lookup.pricing, + inputTokens: inputTokens, + cacheReadInputTokens: cacheReadInputTokens, + outputTokens: outputTokens) + } + + guard let pricing = self.google[canonicalModel] else { return nil } + return self.googleCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cacheReadInputTokens: cacheReadInputTokens, + outputTokens: outputTokens) + } + + static func googlePricingModelID(_ model: String) -> String { + let normalized = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.googleAliases[normalized] ?? normalized + } + + private static func googleCostUSD( + pricing: ModelsDevPricingInfo, + inputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int) -> Double + { + let input = max(0, inputTokens) + let cacheRead = max(0, cacheReadInputTokens) + let output = max(0, outputTokens) + let totalInput = input.addingReportingOverflow(cacheRead) + let usesLongContextRates = pricing.thresholdTokens.map { + totalInput.overflow || totalInput.partialValue > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold + ?? pricing.cacheReadInputCostPerToken + ?? inputRate + : pricing.cacheReadInputCostPerToken ?? inputRate + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + return Double(input) * inputRate + + Double(cacheRead) * cacheReadRate + + Double(output) * outputRate + } + + private static func googleCostUSD( + pricing: GooglePricing, + inputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int) -> Double + { + let input = max(0, inputTokens) + let cacheRead = max(0, cacheReadInputTokens) + let output = max(0, outputTokens) + let totalInput = input.addingReportingOverflow(cacheRead) + let usesLongContextRates = pricing.thresholdTokens.map { + totalInput.overflow || totalInput.partialValue > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken + : pricing.cacheReadInputCostPerToken + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + return Double(input) * inputRate + + Double(cacheRead) * cacheReadRate + + Double(output) * outputRate + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift new file mode 100644 index 0000000000..235e1841a3 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift @@ -0,0 +1,180 @@ +import Foundation + +extension CostUsagePricing { + struct ModelsDevCostRequest { + let providerIDs: [String] + let model: String + let inputTokens: Int + let cacheReadInputTokens: Int + let outputTokens: Int + /// Cache-write (cache-creation) tokens. Defaults to 0 so callers that do not track them + /// price unchanged; catalogs without a distinct cache-write rate fall back to the input + /// rate, matching how cache reads are handled. + var cacheCreationInputTokens: Int = 0 + } + + /// Prices a model against an explicit ordered list of models.dev provider IDs. + /// + /// Callers must supply provider ownership from structured source evidence. This helper never + /// guesses a vendor from the model name, so a harness cannot silently bill usage to the wrong + /// subscription. + static func modelsDevCostUSD( + request: ModelsDevCostRequest, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> Double? + { + let lookup = request.providerIDs.lazy.compactMap { + self.modelsDevLookup( + providerID: $0, + model: request.model, + catalog: catalog, + cacheRoot: cacheRoot) + }.first + guard let pricing = lookup?.pricing else { return nil } + + let input = max(0, request.inputTokens) + let cacheRead = max(0, request.cacheReadInputTokens) + let cacheCreation = max(0, request.cacheCreationInputTokens) + let output = max(0, request.outputTokens) + let context = input.addingReportingOverflow(cacheRead) + let usesLongContextRates = pricing.thresholdTokens.map { + context.overflow || context.partialValue > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold + ?? pricing.cacheReadInputCostPerToken + ?? inputRate + : pricing.cacheReadInputCostPerToken ?? inputRate + let cacheCreationRate = usesLongContextRates + ? pricing.cacheCreationInputCostPerTokenAboveThreshold + ?? pricing.cacheCreationInputCostPerToken + ?? inputRate + : pricing.cacheCreationInputCostPerToken ?? inputRate + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + let cost = Double(input) * inputRate + + Double(cacheRead) * cacheReadRate + + Double(cacheCreation) * cacheCreationRate + + Double(output) * outputRate + return cost.isFinite ? cost : nil + } + + /// Resolves pricing for third-party models that are routed through the Claude-compatible + /// endpoint (DeepSeek, Kimi/Moonshot, MiniMax) via the models.dev catalog. + static func thirdPartyClaudeLookup( + model: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> ClaudePricing? + { + let routedModel = model + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "/", omittingEmptySubsequences: true) + .last + .map(String.init) ?? model + let trimmed = routedModel.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + + let candidates: [(providerID: String, modelID: String)] + if lower.hasPrefix("deepseek-") { + candidates = [("deepseek", trimmed)] + } else if lower == "k3" || lower == "k3-256k" || lower == "kimi-k3" { + candidates = [ + ("moonshotai", "kimi-k3"), + ("moonshotai-cn", "kimi-k3"), + ] + } else if lower == "kimi-for-coding" { + candidates = [ + ("kimi-for-coding", trimmed), + ("moonshotai", "kimi-k2.6"), + ("moonshotai-cn", "kimi-k2.6"), + ] + } else if lower.hasPrefix("kimi-") { + candidates = [("moonshotai", trimmed), ("moonshotai-cn", trimmed)] + } else if lower.hasPrefix("minimax-") { + candidates = [("minimax", trimmed), ("minimax-cn", trimmed)] + } else { + return nil + } + + for candidate in candidates { + if let lookup = self.modelsDevLookup( + providerID: candidate.providerID, + model: candidate.modelID, + catalog: catalog, + cacheRoot: cacheRoot) + { + return ClaudePricing( + inputCostPerToken: lookup.pricing.inputCostPerToken, + outputCostPerToken: lookup.pricing.outputCostPerToken, + cacheCreationInputCostPerToken: lookup.pricing.cacheCreationInputCostPerToken + ?? lookup.pricing.inputCostPerToken, + cacheReadInputCostPerToken: lookup.pricing.cacheReadInputCostPerToken + ?? lookup.pricing.inputCostPerToken, + thresholdTokens: lookup.pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: lookup.pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: lookup.pricing.outputCostPerTokenAboveThreshold, + cacheCreationInputCostPerTokenAboveThreshold: lookup.pricing + .cacheCreationInputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: lookup.pricing + .cacheReadInputCostPerTokenAboveThreshold) + } + } + + // Kimi's official API price on 2026-07-26 is $3/M uncached input, $0.30/M cached + // input, and $15/M output for kimi-k3. Keep a fallback because newly released models can + // precede the models.dev catalog; the catalog remains authoritative as soon as it contains + // the model. Source: https://www.kimi.com/help/kimi-api/api-pricing + if lower == "k3" || lower == "k3-256k" || lower == "kimi-k3" { + return ClaudePricing( + inputCostPerToken: 3 / 1_000_000, + outputCostPerToken: 15 / 1_000_000, + cacheCreationInputCostPerToken: 3 / 1_000_000, + cacheReadInputCostPerToken: 0.30 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil) + } + + // MiniMax-M3 launched before every cached pricing catalog carried its pay-as-you-go row. + // Keep the official standard-tier API price as a fallback so local Token Plan usage stays + // priceable while offline or during catalog refresh. For requests whose input context + // (including cache hits) exceeds 512K, MiniMax doubles input, output, and cache-read rates. + // Source (accessed 2026-07-26): + // https://platform.minimax.io/subscribe/token-plan?tab=api-enterprise + if lower == "minimax-m3" { + return ClaudePricing( + inputCostPerToken: 0.30 / 1_000_000, + outputCostPerToken: 1.20 / 1_000_000, + cacheCreationInputCostPerToken: 0.30 / 1_000_000, + cacheReadInputCostPerToken: 0.06 / 1_000_000, + thresholdTokens: 512_000, + inputCostPerTokenAboveThreshold: 0.60 / 1_000_000, + outputCostPerTokenAboveThreshold: 2.40 / 1_000_000, + cacheCreationInputCostPerTokenAboveThreshold: 0.60 / 1_000_000, + cacheReadInputCostPerTokenAboveThreshold: 0.12 / 1_000_000) + } + return nil + } + + static func modelsDevLookup( + providerID: String, + model: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> ModelsDevPricingLookup? + { + if let catalog { + return catalog.pricing(providerID: providerID, modelID: model) + } + + return ModelsDevPricingPipeline.lookup( + providerID: providerID, + modelID: model, + cacheRoot: cacheRoot) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 3727bf2ce2..ed2df0007f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -148,6 +148,15 @@ enum CostUsagePricing { outputCostPerToken: 0, cacheReadInputCostPerToken: 0, displayLabel: "Research Preview"), + // Auto-review turns are an internal Codex feature billed within the subscription, not per + // token, so they carry no price. Pricing them at zero (like spark above) keeps those days + // "proven zero-cost" instead of unpriced, which would otherwise void the whole provider's + // cost history via the all-or-nothing completeness check. + "codex-auto-review": CodexPricing( + inputCostPerToken: 0, + outputCostPerToken: 0, + cacheReadInputCostPerToken: 0, + displayLabel: "Included"), "gpt-5.4": CodexPricing( inputCostPerToken: 2.5e-6, outputCostPerToken: 1.5e-5, @@ -576,13 +585,34 @@ enum CostUsagePricing { outputTokens: outputTokens) } - guard let pricing = self.codex[key] else { return nil } - return self.codexCostUSD( - pricing: pricing, - inputTokens: inputTokens, - cachedInputTokens: cachedInputTokens, - cacheWriteInputTokens: cacheWriteInputTokens, - outputTokens: outputTokens) + if let pricing = self.codex[key] { + return self.codexCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, + outputTokens: outputTokens) + } + + // Non-OpenAI models routed through a Codex-compatible endpoint (e.g. MiniMax / DeepSeek / + // Kimi). Price them at the vendor's official per-token rates so a single third-party day + // does not void the provider's whole cost history via the all-or-nothing check. + if let thirdParty = self.thirdPartyClaudeLookup( + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.claudeCostUSD( + pricing: thirdParty, + tokens: ClaudeCostTokens( + input: inputTokens, + cacheRead: cachedInputTokens, + cacheCreation: cacheWriteInputTokens, + cacheCreation1h: 0, + output: outputTokens)) + } + + return nil } static func codexPriorityCostUSD( @@ -733,12 +763,26 @@ enum CostUsagePricing { tokens: tokens) } + // Non-Anthropic models routed through a Claude-compatible endpoint (Kimi / DeepSeek / + // MiniMax coding plans): price them at the vendor's official per-token rates so the spend + // dashboard shows an equivalent-cost estimate instead of "unavailable". + if let thirdParty = self.thirdPartyClaudeLookup( + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.claudeCostUSD(pricing: thirdParty, tokens: tokens) + } + guard let pricing = self.claude[key] else { return nil } return self.claudeCostUSD( pricing: pricing, tokens: tokens) } + /// Maps a non-Anthropic model seen on the Claude endpoint to its vendor's official models.dev + /// pricing. `kimi-for-coding` is a subscription alias; it is priced as Moonshot's kimi-k2.6, + /// the current default coding model. Returns nil when no official rate is known. private static func claudeCostUSD( pricing: ClaudePricing, tokens: ClaudeCostTokens) -> Double @@ -792,20 +836,4 @@ enum CostUsagePricing { static func modelsDevCatalog(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCatalog? { ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog } - - private static func modelsDevLookup( - providerID: String, - model: String, - catalog: ModelsDevCatalog?, - cacheRoot: URL?) -> ModelsDevPricingLookup? - { - if let catalog { - return catalog.pricing(providerID: providerID, modelID: model) - } - - return ModelsDevPricingPipeline.lookup( - providerID: providerID, - modelID: model, - cacheRoot: cacheRoot) - } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift index 1e3e8d565e..b35867b03a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift @@ -10,7 +10,18 @@ enum CostUsagePricingKey { modelsDevArtifact: ModelsDevCacheArtifact?, formulaVersion: Int, parserHash: String? = nil, - modelsDevProviderIDs: Set = ["openai"]) -> String + modelsDevProviderIDs: Set = [ + "anthropic", + "openai", + // Third-party models routed through a Codex-compatible endpoint are priced via + // `thirdPartyClaudeLookup`, so their catalog entries must also bust the cost cache. + "deepseek", + "minimax", + "minimax-cn", + "moonshotai", + "moonshotai-cn", + "kimi-for-coding", + ]) -> String { var parts = [ "costFormulaVersion=\(formulaVersion)", diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index d6c5882db9..2a1ae5b4ad 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -276,6 +276,7 @@ extension CostUsageScanner { static func makeFileUsage( mtimeUnixMs: Int64, size: Int64, + fingerprint: CostUsageSourceFingerprint? = nil, days: [String: [String: [Int]]], parsedBytes: Int64?, lastModel: String? = nil, @@ -312,6 +313,7 @@ extension CostUsageScanner { CostUsageFileUsage( mtimeUnixMs: mtimeUnixMs, size: size, + fingerprint: fingerprint, days: days, parsedBytes: parsedBytes, lastModel: lastModel, @@ -730,9 +732,13 @@ extension CostUsageScanner { var days: [String: [String: [Int]]] = [:] for row in rows { let packed = days[row.day]?[row.model] ?? [] + var delta = [row.input, row.cached, row.output] + if let reasoning = row.reasoning { + delta.append(reasoning) + } days[row.day, default: [:]][row.model] = Self.addPacked( a: packed, - b: [row.input, row.cached, row.output], + b: delta, sign: 1) } return days @@ -916,6 +922,20 @@ extension CostUsageScanner { fileId: "\(info.st_dev):\(info.st_ino)") } + /// Builds the bounded sampling fingerprint for a Codex session file. `mtimeUnixNs` is derived + /// from the metadata's millisecond mtime so it round-trips with the value used at freshness + /// check time. Returns nil when the file is unreadable; the caller then stores no fingerprint + /// and the next scan fails safe to a reparse. + static func codexContentFingerprint( + metadata: CodexFileMetadata, + fileURL: URL) -> CostUsageSourceFingerprint? + { + CostUsageSourceFingerprint.make( + fileURL: fileURL, + size: metadata.size, + mtimeUnixNs: metadata.mtimeUnixMs * 1_000_000) + } + static func dropCachedCodexFile( path: String, cached: CostUsageFileUsage?, @@ -965,6 +985,24 @@ extension CostUsageScanner { !context.forceFullScan else { return false } + // Sampling-fingerprint freshness (tokscale-style): an exact size+mtime match is NOT + // sufficient on its own — a rewrite that restores the same byte length and mtime would + // otherwise be invisible (see the in-place same-size rewrite regression test). Recompute + // the bounded content samples and only treat the entry as fresh when they still match. + // A nil cached fingerprint (entries written before bounded validation) fails safe to a + // reparse so the entry is upgraded. + switch CostUsageSourceFingerprint.check( + fileURL: input.fileURL, + size: input.metadata.size, + mtimeUnixNs: input.metadata.mtimeUnixMs * 1_000_000, + cached: cached.fingerprint) + { + case .unchanged, .touched: + break + case .changed: + return false + } + guard !Self.cachedCodexFileNeedsPriorityRescan(cached, context: context) else { return false } let sessionAlreadyContributed = cached.sessionId.map { state.contributingSessionIds.contains($0) } ?? false @@ -1172,6 +1210,7 @@ extension CostUsageScanner { cache.files[input.metadata.path] = Self.makeFileUsage( mtimeUnixMs: input.metadata.mtimeUnixMs, size: input.metadata.size, + fingerprint: Self.codexContentFingerprint(metadata: input.metadata, fileURL: input.fileURL), days: mergedDays, parsedBytes: delta.parsedBytes, lastModel: delta.lastModel, @@ -1296,6 +1335,7 @@ extension CostUsageScanner { cache.files[input.metadata.path] = Self.makeFileUsage( mtimeUnixMs: input.metadata.mtimeUnixMs, size: input.metadata.size, + fingerprint: Self.codexContentFingerprint(metadata: input.metadata, fileURL: input.fileURL), days: usageDays, parsedBytes: parsed.parsedBytes, lastModel: parsed.lastModel, @@ -1536,6 +1576,7 @@ extension CostUsageScanner { let input = packed[safe: 0] ?? 0 let cached = packed[safe: 1] ?? 0 let output = packed[safe: 2] ?? 0 + let reasoning = packed[safe: 3] let totalTokens = input + output dayInput += input @@ -1577,8 +1618,13 @@ extension CostUsageScanner { breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, + billingProviderID: CostUsageBillingProvider.providerID(fromNamespacedModel: model), costUSD: cost, totalTokens: totalTokens, + inputTokens: input, + cacheReadTokens: cached, + outputTokens: output, + reasoningTokens: reasoning, standardCostUSD: hasModeSplit ? standardCost : nil, priorityCostUSD: hasModeSplit ? priorityCost : nil, standardTokens: hasModeSplit ? cachedStandardTokens : nil, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index e862bb349e..8e8006c511 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -370,12 +370,14 @@ extension CostUsageScanner { private static func makeClaudeFileUsage( mtimeMs: Int64, size: Int64, + fingerprint: CostUsageSourceFingerprint? = nil, rows: [ClaudeUsageRow], parsedBytes: Int64?) -> CostUsageFileUsage { makeFileUsage( mtimeUnixMs: mtimeMs, size: size, + fingerprint: fingerprint, days: [:], parsedBytes: parsedBytes, claudeRows: rows) @@ -537,25 +539,47 @@ extension CostUsageScanner { url: URL, size: Int64, mtimeMs: Int64, + mtimeUnixNs: Int64, state: ClaudeScanState) throws { try state.checkCancellation?() let path = url.path state.touched.insert(path) - if let cached = state.cache.files[path], - cached.mtimeUnixMs == mtimeMs, - cached.size == size, - !state.forceFullScan - { - return - } - + // Reused by the incremental/full parse paths so a changed file is hashed only once. + var freshFingerprint: CostUsageSourceFingerprint? if let cached = state.cache.files[path], !state.forceFullScan { + // Sampling fingerprint freshness (tokscale-style): an exact size+mtime+samples match + // is a hit; a metadata-only touch (same whole-file hash) is a hit with a refreshed + // fingerprint; anything else falls through to incremental/full parsing. + switch CostUsageSourceFingerprint.check( + fileURL: url, + size: size, + mtimeUnixNs: mtimeUnixNs, + cached: cached.fingerprint) + { + case .unchanged: + return + case let .touched(fresh): + var updated = cached + updated.fingerprint = fresh + updated.mtimeUnixMs = mtimeMs + state.cache.files[path] = updated + return + case let .changed(fresh): + freshFingerprint = fresh + } + let startOffset = cached.parsedBytes ?? cached.size let canIncremental = size > cached.size && startOffset > 0 && startOffset <= size && cached.claudeRows != nil if canIncremental { + // Fingerprint the post-append content before parsing the delta so a concurrent + // rewrite invalidates the entry on the next scan. + let fingerprint = freshFingerprint ?? CostUsageSourceFingerprint.make( + fileURL: url, + size: size, + mtimeUnixNs: mtimeUnixNs) let delta = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, @@ -568,12 +592,19 @@ extension CostUsageScanner { state.cache.files[path] = Self.makeClaudeFileUsage( mtimeMs: mtimeMs, size: size, + fingerprint: fingerprint, rows: mergedRows, parsedBytes: delta.parsedBytes) return } } + // Fingerprint before the full parse: any concurrent rewrite then invalidates the entry + // on the next scan instead of leaving a stale fingerprint behind. + let fingerprint = freshFingerprint ?? CostUsageSourceFingerprint.make( + fileURL: url, + size: size, + mtimeUnixNs: mtimeUnixNs) let parsed = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, @@ -584,6 +615,7 @@ extension CostUsageScanner { let usage = Self.makeClaudeFileUsage( mtimeMs: mtimeMs, size: size, + fingerprint: fingerprint, rows: parsed.rows, parsedBytes: parsed.parsedBytes) state.cache.files[path] = usage @@ -640,10 +672,14 @@ extension CostUsageScanner { let mtime = values.contentModificationDate?.timeIntervalSince1970 ?? 0 let mtimeMs = Int64(mtime * 1000) + // Derived from the same resource value on every scan, so the conversion is + // deterministic for unchanged files even though Date carries sub-ms precision. + let mtimeNs = Int64((mtime * 1_000_000_000).rounded()) try Self.processClaudeFile( url: url, size: size, mtimeMs: mtimeMs, + mtimeUnixNs: mtimeNs, state: state) } @@ -823,8 +859,13 @@ extension CostUsageScanner { breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, + billingProviderID: CostUsageBillingProvider.providerID(fromNamespacedModel: model), costUSD: cost, - totalTokens: totalTokens)) + totalTokens: totalTokens, + inputTokens: input, + cacheReadTokens: cacheRead, + cacheCreationTokens: cacheCreate, + outputTokens: output)) if let cost { dayCost += cost dayCostSeen = true diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift index 5e79e86c07..67b10000bf 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift @@ -202,37 +202,57 @@ extension CostUsageScanner { var sawStandardTokens = false var priorityTokens = 0 var sawPriorityTokens = false + var billingProviderIDs: Set = [] mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) { if let totalTokens = breakdown.totalTokens { - self.totalTokens += totalTokens - self.sawTotalTokens = true + let addition = self.totalTokens.addingReportingOverflow(totalTokens) + if !addition.overflow { + self.totalTokens = addition.partialValue + self.sawTotalTokens = true + } } - if let costUSD = breakdown.costUSD { + if let costUSD = breakdown.costUSD, costUSD.isFinite { self.costUSD += costUSD self.sawCost = true } - if let standardCostUSD = breakdown.standardCostUSD { + if let standardCostUSD = breakdown.standardCostUSD, standardCostUSD.isFinite { self.standardCostUSD += standardCostUSD self.sawStandardCost = true } - if let priorityCostUSD = breakdown.priorityCostUSD { + if let priorityCostUSD = breakdown.priorityCostUSD, priorityCostUSD.isFinite { self.priorityCostUSD += priorityCostUSD self.sawPriorityCost = true } if let standardTokens = breakdown.standardTokens { - self.standardTokens += standardTokens - self.sawStandardTokens = true + let addition = self.standardTokens.addingReportingOverflow(standardTokens) + if !addition.overflow { + self.standardTokens = addition.partialValue + self.sawStandardTokens = true + } } if let priorityTokens = breakdown.priorityTokens { - self.priorityTokens += priorityTokens - self.sawPriorityTokens = true + let addition = self.priorityTokens.addingReportingOverflow(priorityTokens) + if !addition.overflow { + self.priorityTokens = addition.partialValue + self.sawPriorityTokens = true + } + } + if let billingProviderID = breakdown.billingProviderID? + .trimmingCharacters(in: .whitespacesAndNewlines), + !billingProviderID.isEmpty + { + self.billingProviderIDs.insert(billingProviderID) } } func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown { - CostUsageDailyReport.ModelBreakdown( + let billingProviderID = self.billingProviderIDs.count == 1 + ? self.billingProviderIDs.first + : CostUsageBillingProvider.providerID(fromNamespacedModel: modelName) + return CostUsageDailyReport.ModelBreakdown( modelName: modelName, + billingProviderID: billingProviderID, costUSD: self.sawCost ? self.costUSD : nil, totalTokens: self.sawTotalTokens ? self.totalTokens : nil, standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index fe2677734a..e801efc3fc 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -42,6 +42,9 @@ enum CostUsageScanner { var maxCodexScanBytesPerRefresh: Int64 = 512 * 1024 * 1024 /// Prefer newest session files first so recent usage lands before catch-up work. var preferNewestCodexSessionsFirst: Bool = true + /// Called on the scan queue with (filesScanned, totalFiles) as each Codex session file + /// is parsed. Lets a manual full rescan surface progress instead of appearing stalled. + var progressHandler: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? init( codexSessionsRoot: URL? = nil, @@ -53,7 +56,8 @@ enum CostUsageScanner { forceRescan: Bool = false, maxCodexSessionFileBytes: Int64 = 256 * 1024 * 1024, maxCodexScanBytesPerRefresh: Int64 = 512 * 1024 * 1024, - preferNewestCodexSessionsFirst: Bool = true) + preferNewestCodexSessionsFirst: Bool = true, + progressHandler: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots @@ -65,6 +69,7 @@ enum CostUsageScanner { self.maxCodexSessionFileBytes = max(0, maxCodexSessionFileBytes) self.maxCodexScanBytesPerRefresh = max(0, maxCodexScanBytesPerRefresh) self.preferNewestCodexSessionsFirst = preferNewestCodexSessionsFirst + self.progressHandler = progressHandler } } @@ -2352,6 +2357,27 @@ enum CostUsageScanner { bufferedSubagentLines: nil) } + /// Test-only parser instrumentation. It remains nil in production, so normal scans do not + /// retain file paths or allocate a tracking set. + private(set) nonisolated(unsafe) static var _test_codexParsedFilePaths: Set? + private static let testParsedFilePathsLock = NSLock() + + static func _test_resetCodexParsedFilePaths() { + self.testParsedFilePathsLock.lock() + defer { self.testParsedFilePathsLock.unlock() } + self._test_codexParsedFilePaths = [] + } + + private static func recordTestParsedFilePath(_ path: String) { + self.testParsedFilePathsLock.lock() + defer { self.testParsedFilePathsLock.unlock() } + var normalized = URL(fileURLWithPath: path).standardizedFileURL.path + if normalized.hasPrefix("/private/var/") { + normalized = String(normalized.dropFirst("/private".count)) + } + self._test_codexParsedFilePaths?.insert(normalized) + } + // swiftlint:disable:next cyclomatic_complexity function_body_length static func parseCodexFileCancellable( fileURL: URL, @@ -2372,6 +2398,7 @@ enum CostUsageScanner { inheritedTotalsResolver: ((String, String) throws -> CodexForkBaseline)? = nil, checkCancellation: CancellationCheck? = nil) throws -> CodexParseResult { + self.recordTestParsedFilePath(fileURL.path) var currentModel = initialModel var previousTotals = initialTotals var sessionId: String? @@ -2410,16 +2437,26 @@ enum CostUsageScanner { var days: [String: [String: [Int]]] = [:] var rows: [CodexUsageRow] = [] - func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) { + func add( + dayKey: String, + model: String, + usage: (input: Int, cached: Int, output: Int, reasoning: Int?)) + { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) else { return } let normModel = CostUsagePricing.normalizeCodexModel(model) var dayModels = days[dayKey] ?? [:] var packed = dayModels[normModel] ?? [0, 0, 0] - packed[0] = (packed[safe: 0] ?? 0) + input - packed[1] = (packed[safe: 1] ?? 0) + cached - packed[2] = (packed[safe: 2] ?? 0) + output + packed[0] = (packed[safe: 0] ?? 0) + usage.input + packed[1] = (packed[safe: 1] ?? 0) + usage.cached + packed[2] = (packed[safe: 2] ?? 0) + usage.output + if let reasoning = usage.reasoning { + while packed.count < 4 { + packed.append(0) + } + packed[3] = (packed[safe: 3] ?? 0) + reasoning + } dayModels[normModel] = packed days[dayKey] = dayModels } @@ -2735,9 +2772,11 @@ enum CostUsageScanner { add( dayKey: dayKey, model: normModel, - input: deltaInput, - cached: deltaCached, - output: deltaOutput) + usage: ( + input: deltaInput, + cached: deltaCached, + output: deltaOutput, + reasoning: deltaReasoning)) if CostUsageDayRange.isInRange( dayKey: dayKey, since: range.scanSinceKey, @@ -3482,12 +3521,15 @@ enum CostUsageScanner { resources: resources, checkCancellation: checkCancellation, scanBudget: scanBudget) - for fileURL in files { + let totalFiles = files.count + options.progressHandler?(0, totalFiles) + for (index, fileURL) in files.enumerated() { try Self.scanCodexFile( fileURL: fileURL, context: scanContext, cache: &cache, state: &scanState) + options.progressHandler?(index + 1, totalFiles) } if scanBudget.resumedPartialFileCount > 0 || scanBudget.deferredByBudgetFileCount > 0 { Self.log.info( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageSourceFingerprint.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageSourceFingerprint.swift new file mode 100644 index 0000000000..24eeb04990 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageSourceFingerprint.swift @@ -0,0 +1,141 @@ +import Foundation + +/// One bounded content sample: FNV-1a (64-bit) over up to `sampleBytes` read at a fixed offset. +struct CostUsageFileSampleHash: Codable, Equatable { + var offset: Int64 + var length: Int64 + var fnv1a: UInt64 +} + +/// tokscale-style sampling fingerprint for per-file cache freshness (mirrors `SourceFingerprint` +/// in tokscale-core's message_cache.rs): size + nanosecond mtime + FNV-1a over 5 fixed sample +/// points x 4 KiB. Metadata changes are treated as content changes instead of hashing the whole +/// file: active JSONL logs grow frequently, and an extra full read before parsing doubles disk I/O. +struct CostUsageSourceFingerprint: Codable, Equatable { + static let sampleBytes: Int64 = 4096 + static let samplePoints = 5 + + var size: Int64 + var mtimeUnixNs: Int64 + var samples: [CostUsageFileSampleHash] + /// Legacy cache field retained for decoding caches written by older builds. New fingerprints + /// intentionally leave it nil; bounded samples are the only content reads. + var contentSHA256: String? + + enum Freshness: Equatable { + /// Size, mtime and all bounded samples match; validation read at most + /// `samplePoints x sampleBytes` and no full-file hash was computed. + case unchanged + /// Legacy state retained for callers that can read older cache entries. + /// Carries the refreshed fingerprint the caller should store. + case touched(CostUsageSourceFingerprint) + /// Real content change, an unreadable file, or no usable cached fingerprint. + /// Carries the fresh fingerprint describing the current file. + case changed(CostUsageSourceFingerprint) + + /// The freshly computed fingerprint carried by touched/changed results, reusable by + /// rescan paths so a changed file is hashed only once per scan. + var freshFingerprint: CostUsageSourceFingerprint? { + switch self { + case .unchanged: + nil + case let .touched(fresh), let .changed(fresh): + fresh + } + } + } + + // MARK: - Validation + + /// Cheap metadata first: when size + mtime match, only the bounded samples are recomputed + /// (<=20 KiB). When metadata moved, return changed immediately with a new bounded fingerprint. + static func check( + fileURL: URL, + size: Int64, + mtimeUnixNs: Int64, + cached: CostUsageSourceFingerprint?) -> Freshness + { + if let cached, + cached.size == size, + cached.mtimeUnixNs == mtimeUnixNs, + let samples = self.sampleHashes(fileURL: fileURL, size: size), + samples == cached.samples + { + return .unchanged + } + + guard let fresh = self.make(fileURL: fileURL, size: size, mtimeUnixNs: mtimeUnixNs) else { + return .changed(CostUsageSourceFingerprint( + size: size, + mtimeUnixNs: mtimeUnixNs, + samples: [], + contentSHA256: nil)) + } + return .changed(fresh) + } + + /// Builds a bounded fingerprint for the current file content. + static func make( + fileURL: URL, + size: Int64, + mtimeUnixNs: Int64) -> CostUsageSourceFingerprint? + { + guard let samples = self.sampleHashes(fileURL: fileURL, size: size) else { return nil } + return CostUsageSourceFingerprint( + size: size, + mtimeUnixNs: mtimeUnixNs, + samples: samples, + contentSHA256: nil) + } + + // MARK: - Sampling + + /// Fixed sample offsets matching tokscale: start / quarter / half / three-quarter / final + /// window, sorted and deduplicated, capped at `samplePoints`. + static func sampleOffsets(size: Int64) -> [(offset: Int64, length: Int64)] { + let sampleLength = min(size, self.sampleBytes) + guard sampleLength > 0 else { return [] } + let maxOffset = size - sampleLength + let raw: [Int64] = maxOffset == 0 + ? [0] + : [0, maxOffset / 4, maxOffset / 2, maxOffset / 4 * 3, maxOffset] + var seen: Set = [] + var offsets: [Int64] = [] + for offset in raw.sorted() where !seen.contains(offset) { + seen.insert(offset) + offsets.append(offset) + } + return offsets.prefix(self.samplePoints).map { (offset: $0, length: sampleLength) } + } + + static func sampleHashes(fileURL: URL, size: Int64) -> [CostUsageFileSampleHash]? { + let offsets = self.sampleOffsets(size: size) + guard !offsets.isEmpty else { return [] } + guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return nil } + defer { try? handle.close() } + var samples: [CostUsageFileSampleHash] = [] + for (offset, length) in offsets { + do { + try handle.seek(toOffset: UInt64(offset)) + guard let data = try handle.read(upToCount: Int(length)), data.count == length + else { return nil } + samples.append(CostUsageFileSampleHash( + offset: offset, + length: length, + fnv1a: self.fnv1a(data))) + } catch { + return nil + } + } + return samples + } + + static func fnv1a(_ data: Data) -> UInt64 { + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + for byte in data { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01B3 + } + return hash + } +} diff --git a/Tests/CodexBarTests/AntigravitySessionScannerTests.swift b/Tests/CodexBarTests/AntigravitySessionScannerTests.swift new file mode 100644 index 0000000000..05e02743bc --- /dev/null +++ b/Tests/CodexBarTests/AntigravitySessionScannerTests.swift @@ -0,0 +1,244 @@ +import Foundation +import SQLite3 +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct AntigravitySessionScannerTests { + enum SQLiteTestError: Error { + case open + case exec(String) + } + + @Test + func `scans token usage from conversation database`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.createDatabase(at: env.databaseURL) + try Self.insertGeneration( + databaseURL: env.databaseURL, + meta: GenerationMeta( + index: 0, + model: "gemini-3.6-flash", + responseID: "resp-1", + timestampMs: Self.ms("2026-07-20T10:00:00.000Z")), + tokens: Tokens(newlyProcessed: 500, cacheRead: 16000, output: 300, reasoning: 40)) + try Self.insertGeneration( + databaseURL: env.databaseURL, + meta: GenerationMeta( + index: 1, + model: "gemini-3.6-flash", + responseID: "resp-2", + timestampMs: Self.ms("2026-07-20T11:00:00.000Z")), + tokens: Tokens(newlyProcessed: 100, cacheRead: 0, output: 50, reasoning: 0)) + + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-07-20T12:00:00.000Z")) / 1000) + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: now) + + let report = try #require(snapshot) + #expect(report.last30DaysTokens == 1132 + 500 + 16000 + 300 + 40 + 1132 + 100 + 50) + #expect(report.last30DaysRequests == 2) + #expect(report.daily.count == 1) + let entry = try #require(report.daily.first) + #expect(entry.requestCount == 2) + #expect(entry.inputTokens == 1132 + 500 + 1132 + 100) + #expect(entry.cacheReadTokens == 16000) + #expect(entry.outputTokens == 390) + #expect(entry.modelsUsed == ["gemini-3.6-flash"]) + #expect(entry.modelBreakdowns?.first?.reasoningTokens == 40) + let expectedCost = (2864.0 * 1.5e-6) + (16000.0 * 0.15e-6) + (390.0 * 7.5e-6) + #expect(abs((entry.costUSD ?? 0) - expectedCost) < 1e-12) + } + + @Test + func `dedupes generations by response id`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.createDatabase(at: env.databaseURL) + for index in 0..<3 { + try Self.insertGeneration( + databaseURL: env.databaseURL, + meta: GenerationMeta( + index: index, + model: "gemini-3.6-flash", + responseID: "same-response", + timestampMs: Self.ms("2026-07-20T10:00:00.000Z")), + tokens: Tokens(newlyProcessed: 500, cacheRead: 0, output: 300, reasoning: 0)) + } + + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-07-20T12:00:00.000Z")) / 1000) + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: now) + + #expect(snapshot?.last30DaysRequests == 1) + } + + @Test + func `empty conversations directory yields no snapshot`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: Date()) + + #expect(snapshot == nil) + } + + @Test + func `database without gen_metadata table is skipped`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + var db: OpaquePointer? + guard sqlite3_open(env.databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + sqlite3_close(db) + + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: Date()) + + #expect(snapshot == nil) + } + + // MARK: - Environment + + private static func makeEnvironment() throws -> (root: URL, homeURL: URL, databaseURL: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("AntigravitySessionScannerTests-\(UUID().uuidString)", isDirectory: true) + let homeURL = root.appendingPathComponent("antigravity", isDirectory: true) + let conversationsURL = homeURL.appendingPathComponent("conversations", isDirectory: true) + try FileManager.default.createDirectory(at: conversationsURL, withIntermediateDirectories: true) + return (root, homeURL, conversationsURL.appendingPathComponent("session-1.db", isDirectory: false)) + } + + private static func createDatabase(at url: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try self.exec(db: db, sql: "CREATE TABLE gen_metadata (idx INTEGER PRIMARY KEY, data BLOB);") + } + + struct Tokens { + /// Fixed system-prompt tokens the scanner adds on top of `newlyProcessed` for billable input. + var systemPrompt: UInt64 = 1132 + var newlyProcessed: UInt64 + var cacheRead: UInt64 + var output: UInt64 + var reasoning: UInt64 + } + + struct GenerationMeta { + var index: Int + var model: String + var responseID: String? + var timestampMs: Int64 + } + + private static func insertGeneration( + databaseURL: URL, + meta: GenerationMeta, + tokens: Tokens) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let blob = Self.buildGeneration( + model: meta.model, + tokens: tokens, + responseID: meta.responseID, + timestampMs: meta.timestampMs) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "INSERT INTO gen_metadata (idx, data) VALUES (?, ?)", -1, &stmt, nil) == SQLITE_OK + else { throw SQLiteTestError.exec("prepare") } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int(stmt, 1, Int32(meta.index)) + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + _ = blob.withUnsafeBytes { buffer in + sqlite3_bind_blob(stmt, 2, buffer.baseAddress, Int32(buffer.count), transient) + } + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.exec("insert") } + } + + private static func exec(db: OpaquePointer?, sql: String) throws { + var error: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &error) == SQLITE_OK else { + let message = error.map { String(cString: $0) } ?? "unknown" + sqlite3_free(error) + throw SQLiteTestError.exec(message) + } + } + + private static func ms(_ iso: String) -> Int64 { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = formatter.date(from: iso) ?? Date(timeIntervalSince1970: 0) + return Int64(date.timeIntervalSince1970 * 1000) + } + + // MARK: - Protobuf encoding + + /// Builds a `gen_metadata` blob: `{#1: chatModel}` where chatModel carries `#4: usage`, + /// `#9: {#4: Timestamp}` and `#19: responseModel`. + private static func buildGeneration( + model: String, + tokens: Tokens, + responseID: String?, + timestampMs: Int64) -> [UInt8] + { + var usage = self.encVarint(field: 1, value: tokens.systemPrompt) + usage += self.encVarint(field: 2, value: tokens.newlyProcessed) + usage += self.encVarint(field: 5, value: tokens.cacheRead) + usage += self.encVarint(field: 9, value: tokens.output) + usage += self.encVarint(field: 10, value: tokens.reasoning) + if let responseID { + usage += self.encLen(field: 11, payload: Array(responseID.utf8)) + } + + var generation: [UInt8] = [] + generation += self.encLen(field: 4, payload: self.encTimestamp(seconds: timestampMs / 1000, nanos: 0)) + + var chatModel = self.encLen(field: 4, payload: usage) + chatModel += self.encLen(field: 9, payload: generation) + chatModel += self.encLen(field: 19, payload: Array(model.utf8)) + + return self.encLen(field: 1, payload: chatModel) + } + + private static func encTimestamp(seconds: Int64, nanos: Int64) -> [UInt8] { + var out = self.encVarint(field: 1, value: UInt64(bitPattern: seconds)) + out += self.encVarint(field: 2, value: UInt64(bitPattern: nanos)) + return out + } + + private static func encVarint(field: UInt64, value: UInt64) -> [UInt8] { + self.encodeVarint(field << 3) + self.encodeVarint(value) + } + + private static func encLen(field: UInt64, payload: [UInt8]) -> [UInt8] { + self.encodeVarint((field << 3) | 2) + self.encodeVarint(UInt64(payload.count)) + payload + } + + private static func encodeVarint(_ value: UInt64) -> [UInt8] { + var value = value + var out: [UInt8] = [] + while true { + var byte = UInt8(value & 0x7F) + value >>= 7 + if value != 0 { byte |= 0x80 } + out.append(byte) + if value == 0 { return out } + } + } +} diff --git a/Tests/CodexBarTests/CopilotSessionScannerTests.swift b/Tests/CodexBarTests/CopilotSessionScannerTests.swift new file mode 100644 index 0000000000..3dc4cfdd60 --- /dev/null +++ b/Tests/CodexBarTests/CopilotSessionScannerTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CopilotSessionScannerTests { + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + return calendar + }() + + @Test + func `scanner reads the session.shutdown model metrics rollup`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let sessionDir = root + .appendingPathComponent("session-state", isDirectory: true) + .appendingPathComponent("sess-1", isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + + // Mirrors the real Copilot layout: per-model usage lives only on the terminal + // session.shutdown event's data.modelMetrics map. + let shutdownUsage = #""usage":{"inputTokens":73595,"outputTokens":1196,"# + + #""cacheReadTokens":48228,"cacheWriteTokens":0,"reasoningTokens":0}"# + let shutdown = """ + {"type":"session.shutdown","timestamp":"2026-07-28T10:00:00.000Z",\ + "data":{"modelMetrics":{"claude-haiku-4.5":{"requests":{"count":3},\(shutdownUsage)}}}} + """ + let lines = [ + #"{"type":"session.start","timestamp":"2026-07-28T09:00:00.000Z"}"#, + shutdown, + ] + try lines.joined(separator: "\n").write( + to: sessionDir.appendingPathComponent("events.jsonl"), + atomically: true, + encoding: .utf8) + + let snapshot = try #require(CopilotSessionScanner.scan( + environment: [CopilotSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true))) + + #expect(snapshot.historyLabel == "GitHub Copilot") + let entry = try #require(snapshot.daily.first) + #expect(entry.totalTokens == 73595 + 1196) + #expect(entry.inputTokens == 73595 - 48228) + #expect(entry.cacheReadTokens == 48228) + // The shutdown rollup carries requests.count: 3, and all three must be reported. + #expect(entry.requestCount == 3) + let model = try #require(entry.modelBreakdowns?.first) + // Model name is normalized to the pricing key, but ownership stays with Copilot: the + // local event has no explicit billing-provider field, so the vendor (Anthropic) is used + // only as the rate-lookup key, never to re-attribute the usage. + #expect(model.modelName == "claude-haiku-4-5") + #expect(model.billingProviderID == UsageProvider.copilot.rawValue) + #expect(model.requestCount == 3) + } + + @Test + func `scanner falls back to the latest in-progress metrics event`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let sessionDir = root + .appendingPathComponent("session-state", isDirectory: true) + .appendingPathComponent("sess-2", isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + + // No shutdown event: a still-running session. The most recent modelMetrics snapshot is + // used instead. + let metricsEvent: (String, Int, Int, Int) -> String = { ts, input, output, cache in + """ + {"type":"session.model_metrics","timestamp":"\(ts)",\ + "data":{"modelMetrics":{"gpt-5":{"usage":{"inputTokens":\(input),\ + "outputTokens":\(output),"cacheReadTokens":\(cache),"cacheWriteTokens":0,"reasoningTokens":0}}}}} + """ + } + let lines = [ + metricsEvent("2026-07-28T09:00:00.000Z", 100, 50, 20), + metricsEvent("2026-07-28T09:30:00.000Z", 300, 120, 60), + ] + try lines.joined(separator: "\n").write( + to: sessionDir.appendingPathComponent("events.jsonl"), + atomically: true, + encoding: .utf8) + + let snapshot = try #require(CopilotSessionScanner.scan( + environment: [CopilotSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true))) + + let entry = try #require(snapshot.daily.first) + // Latest snapshot wins (300+120), not the sum of both. + #expect(entry.totalTokens == 300 + 120) + #expect(entry.inputTokens == 300 - 60) + let model = try #require(entry.modelBreakdowns?.first) + #expect(model.billingProviderID == UsageProvider.copilot.rawValue) + } + + @Test + func `scanner returns nil when no metrics exist`() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let snapshot = CopilotSessionScanner.scan( + environment: [CopilotSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true)) + #expect(snapshot == nil) + } +} diff --git a/Tests/CodexBarTests/CostUsageBillingProviderTests.swift b/Tests/CodexBarTests/CostUsageBillingProviderTests.swift new file mode 100644 index 0000000000..975a88b79e --- /dev/null +++ b/Tests/CodexBarTests/CostUsageBillingProviderTests.swift @@ -0,0 +1,22 @@ +import CodexBarCore +import Testing + +struct CostUsageBillingProviderTests { + @Test + func `outer explicit route owns nested billing chains`() { + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "openrouter/anthropic/claude-sonnet-4") + == UsageProvider.openrouter.rawValue) + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "gateway/team/moonshot/kimi-k2") + == UsageProvider.moonshot.rawValue) + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "gateway/team/moonshotai/kimi-k2") + == UsageProvider.moonshot.rawValue) + } + + @Test + func `plain family labels are not billing evidence`() { + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "MiniMax-M3") == nil) + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "claude-sonnet-4") == nil) + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "minimax/MiniMax-M3") + == UsageProvider.minimax.rawValue) + } +} diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index e7a42293c9..86f7bbd0cf 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -21,6 +21,9 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 1.25, totalTokens: 130, + inputTokens: 100, + cacheReadTokens: 10, + outputTokens: 20, standardCostUSD: 0.75, priorityCostUSD: 0.50, standardTokens: 80, @@ -50,6 +53,10 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 0.75, totalTokens: 67, + inputTokens: 50, + cacheReadTokens: 5, + cacheCreationTokens: 2, + outputTokens: 10, standardCostUSD: 0.25, priorityCostUSD: 0.50, standardTokens: 20, @@ -77,6 +84,9 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 2.0, totalTokens: 197, + inputTokens: 150, + cacheReadTokens: 15, + outputTokens: 30, standardCostUSD: 1.0, priorityCostUSD: 1.0, standardTokens: 100, @@ -163,4 +173,100 @@ struct CostUsageDailyReportMergeTests { #expect(merged.summary?.totalTokens == 120) #expect(abs((merged.data.first?.costUSD ?? 0) - 1.25) < 0.000001) } + + @Test + func `merged report sums reasoning tokens and drops the bucket when any source misses it`() { + func report(day: String, reasoning: Int?) -> CostUsageDailyReport { + CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: day, + inputTokens: 100, + outputTokens: 30, + totalTokens: 130, + costUSD: 1.0, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 1.0, + totalTokens: 130, + inputTokens: 100, + outputTokens: 30, + reasoningTokens: reasoning), + ]), + ], + summary: nil) + } + + let both = report(day: "2026-04-04", reasoning: 12) + .merged(with: report(day: "2026-04-04", reasoning: 8)) + #expect(both.data.first?.modelBreakdowns?.first?.outputTokens == 60) + #expect(both.data.first?.modelBreakdowns?.first?.reasoningTokens == 20) + + // Reasoning is a sub-bucket of output: merging must never change the output total. + let missing = report(day: "2026-04-04", reasoning: 12) + .merged(with: report(day: "2026-04-04", reasoning: nil)) + #expect(missing.data.first?.modelBreakdowns?.first?.outputTokens == 60) + #expect(missing.data.first?.modelBreakdowns?.first?.reasoningTokens == nil) + } + + @Test + func `merged report preserves billing ownership when sources agree and drops it on conflict`() { + func report(billingProviderID: String?) -> CostUsageDailyReport { + CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-04-04", + inputTokens: 100, + outputTokens: 30, + totalTokens: 130, + costUSD: 1.0, + modelsUsed: ["claude-haiku-4-5"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "claude-haiku-4-5", + billingProviderID: billingProviderID, + costUSD: 1.0, + totalTokens: 130, + inputTokens: 100, + outputTokens: 30), + ]), + ], + summary: nil) + } + + let agreed = report(billingProviderID: "anthropic") + .merged(with: report(billingProviderID: "anthropic")) + #expect(agreed.data.first?.modelBreakdowns?.first?.billingProviderID == "anthropic") + + // Two sources disagree about who owns the usage; the merged breakdown must not claim + // either side's ownership as the merged truth. + let conflicted = report(billingProviderID: "anthropic") + .merged(with: report(billingProviderID: "openai")) + #expect(conflicted.data.first?.modelBreakdowns?.first?.billingProviderID == nil) + + // A source without evidence does not erase evidence another source retained. + let oneSided = report(billingProviderID: "anthropic") + .merged(with: report(billingProviderID: nil)) + #expect(oneSided.data.first?.modelBreakdowns?.first?.billingProviderID == "anthropic") + } + + @Test + func `model breakdown decodes reasoning tokens from camel and snake case keys`() throws { + let camel = try JSONDecoder().decode( + CostUsageDailyReport.ModelBreakdown.self, + from: Data(#"{"modelName":"gpt-5.4","reasoningTokens":7}"#.utf8)) + #expect(camel.reasoningTokens == 7) + + let snake = try JSONDecoder().decode( + CostUsageDailyReport.ModelBreakdown.self, + from: Data(#"{"modelName":"gpt-5.4","reasoning_output_tokens":9}"#.utf8)) + #expect(snake.reasoningTokens == 9) + + let absent = try JSONDecoder().decode( + CostUsageDailyReport.ModelBreakdown.self, + from: Data(#"{"modelName":"gpt-5.4"}"#.utf8)) + #expect(absent.reasoningTokens == nil) + } } diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 5db8108693..24e6893d5a 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -4,6 +4,32 @@ import Testing @Suite(.serialized) struct CostUsageFetcherTests { + @Test + func `local history providers support token snapshot`() { + // Providers that opt into `localHistorySources` (Copilot, Antigravity, Kimi, MiniMax, + // OpenCode, Qwen) get their snapshot from a registered local scanner, so the fetcher must + // report them as snapshot-capable even though `supportsTokenCost` is false. + #expect(CostUsageFetcher.supportsTokenSnapshot(.copilot)) + #expect(CostUsageFetcher.supportsTokenSnapshot(.antigravity)) + #expect(CostUsageFetcher.supportsTokenSnapshot(.kimi)) + #expect(CostUsageFetcher.supportsTokenSnapshot(.minimax)) + #expect(CostUsageFetcher.supportsTokenSnapshot(.opencode)) + #expect(CostUsageFetcher.supportsTokenSnapshot(.qwencloud)) + // A provider with neither a remote path nor local history sources stays unsupported. + #expect(!CostUsageFetcher.supportsTokenSnapshot(.grok)) + } + + @Test + func `full rescan also lifts the per-file byte cap`() { + var options = CostUsageScanner.Options() + CostUsageFetcher.configureFullRescan(&options, progress: nil) + #expect(options.forceRescan) + #expect(options.maxCodexScanBytesPerRefresh == 0) + // A session file larger than the 256 MiB default cap must not be partially parsed on a + // full rescan, or the manual result can never converge for that file. + #expect(options.maxCodexSessionFileBytes == 0) + } + @Test func `fetcher scopes codex history to selected codex home`() async throws { let env = try CostUsageTestEnvironment() @@ -705,7 +731,11 @@ extension CostUsageFetcherTests { CostUsageDailyReport.ModelBreakdown( modelName: "claude-sonnet-4-6", costUSD: nativeCost + piCost, - totalTokens: 205), + totalTokens: 205, + inputTokens: 150, + cacheReadTokens: 9, + cacheCreationTokens: 16, + outputTokens: 30), ]) } diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift index c202d20157..c7d6e02e35 100644 --- a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift +++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift @@ -29,20 +29,57 @@ struct CostUsagePerformanceGateTests { until: day, now: day, options: options) + CostUsageScanner._test_resetCodexParsedFilePaths() + let warm = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + // Other suites may parse their own fixtures concurrently, so assert on this corpus. + let corpusPaths = fileURLs.map(\.standardizedFileURL.path) + #expect((CostUsageScanner._test_codexParsedFilePaths ?? []).isDisjoint(with: corpusPaths)) + #expect(cold.data.count == 1) + #expect(warm.data.first?.totalTokens == cold.data.first?.totalTokens) + } + + @Test + func `in-place same-size session rewrite invalidates the sampling fingerprint cache`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let fileURLs = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 2, turnsPerFile: 4) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let cold = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + // Rewrite one file in place: identical byte length, original mtime restored. Only the + // sampling fingerprint can see this edit, and it must invalidate exactly that file. let changedFile = try #require(fileURLs.first) - let originalAttributes = try FileManager.default.attributesOfItem(atPath: changedFile.path) - let originalModificationDate = try #require(originalAttributes[.modificationDate] as? Date) + let originalModificationDateNs = Self.statModificationUnixNs(of: changedFile) let original = try String(contentsOf: changedFile, encoding: .utf8) let modified = original.replacingOccurrences( - of: #""input_tokens":100,"#, + of: #""input_tokens":400,"#, with: #""input_tokens":900,"#) #expect(modified != original) #expect(modified.utf8.count == original.utf8.count) try modified.write(to: changedFile, atomically: false, encoding: .utf8) - try FileManager.default.setAttributes( - [.modificationDate: originalModificationDate], - ofItemAtPath: changedFile.path) + try Self.setModificationUnixNs(originalModificationDateNs, of: changedFile) + #expect(Self.statModificationUnixNs(of: changedFile) == originalModificationDateNs) + CostUsageScanner._test_resetCodexParsedFilePaths() let warm = CostUsageScanner.loadDailyReport( provider: .codex, @@ -51,8 +88,14 @@ struct CostUsagePerformanceGateTests { now: day, options: options) - #expect(cold.data.count == 1) - #expect(warm.data.first?.totalTokens == cold.data.first?.totalTokens) + // Only the rewritten file is re-parsed; the edited final turn (input 400 -> 900) lifts + // that file's cumulative total from 440 to 940 tokens. Other suites may parse their own + // fixtures concurrently, so assert on this corpus only. + let parsedCorpusPaths = (CostUsageScanner._test_codexParsedFilePaths ?? []) + .intersection(fileURLs.map(\.standardizedFileURL.path)) + #expect(parsedCorpusPaths == [changedFile.standardizedFileURL.path]) + #expect(cold.data.first?.totalTokens == 880) + #expect(warm.data.first?.totalTokens == 1380) } @Test @@ -693,6 +736,30 @@ struct CostUsagePerformanceGateTests { return fileURLs } + /// stat-level mtime in nanoseconds. `FileManager.attributesOfItem` round-trips through + /// `Date` (Double seconds, µs granularity beyond ~±128 years), silently truncating the + /// nanosecond tail — which would make an in-place rewrite fixture look mtime-changed even + /// after a "restore". These helpers keep the whole round-trip in integer nanoseconds. + private static func statModificationUnixNs(of fileURL: URL) -> Int64 { + var info = stat() + guard fileURL.path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { return 0 } + #if os(Linux) + return Int64(info.st_mtim.tv_sec) * 1_000_000_000 + Int64(info.st_mtim.tv_nsec) + #else + return Int64(info.st_mtimespec.tv_sec) * 1_000_000_000 + Int64(info.st_mtimespec.tv_nsec) + #endif + } + + private static func setModificationUnixNs(_ ns: Int64, of fileURL: URL) throws { + var times = [ + timespec(tv_sec: 0, tv_nsec: Int(UTIME_OMIT)), + timespec(tv_sec: Int(ns / 1_000_000_000), tv_nsec: Int(ns % 1_000_000_000)), + ] + guard fileURL.path.withCString({ utimensat(AT_FDCWD, $0, ×, 0) }) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + } + private static func replaceTraceBody(dbURL: URL, rowID: Int64, body: String) throws { var db: OpaquePointer? guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 7c5340418c..87ea4f5f5d 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -3,6 +3,53 @@ import Testing @testable import CodexBarCore struct CostUsagePricingTests { + @Test + func `generic catalog pricing stays scoped to explicit provider ownership`() throws { + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + { + "alibaba": { + "id": "alibaba", + "models": { + "qwen3-coder-plus": { + "id": "qwen3-coder-plus", + "cost": { "input": 1, "output": 4, "cache_read": 0.2 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "qwen3-coder-plus": { + "id": "qwen3-coder-plus", + "cost": { "input": 99, "output": 199 } + } + } + } + } + """.utf8)) + + let cost = CostUsagePricing.modelsDevCostUSD( + request: .init( + providerIDs: ["alibaba", "alibaba-cn"], + model: "qwen3-coder-plus", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000), + catalog: catalog, + cacheRoot: nil) + + #expect(cost == 5.2) + #expect(CostUsagePricing.modelsDevCostUSD( + request: .init( + providerIDs: ["missing-provider"], + model: "qwen3-coder-plus", + inputTokens: 1_000_000, + cacheReadInputTokens: 0, + outputTokens: 0), + catalog: catalog, + cacheRoot: nil) == nil) + } + @Test func `normalizes codex model variants exactly`() { #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5-codex") == "gpt-5-codex") @@ -24,6 +71,60 @@ struct CostUsagePricingTests { #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5.6-terra-2099-01-01") == "gpt-5.6-terra") } + @Test + func `google pricing normalizes antigravity aliases and stays available offline`() throws { + let emptyCacheRoot = try Self.cacheRoot() + + let flash36 = CostUsagePricing.googleCostUSD( + model: "gemini-3.6-flash", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + modelsDevCacheRoot: emptyCacheRoot) + let flash35Alias = CostUsagePricing.googleCostUSD( + model: "gemini-3-flash-agent", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + modelsDevCacheRoot: emptyCacheRoot) + let flash3Alias = CostUsagePricing.googleCostUSD( + model: "gemini-default", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + modelsDevCacheRoot: emptyCacheRoot) + + #expect(flash36 == 1.50 + 0.15 + 7.50) + #expect(flash35Alias == 1.50 + 0.15 + 9.00) + #expect(flash3Alias == 0.50 + 0.05 + 3.00) + for alias in [ + "gemini-3.5-flash-medium", + "gemini-3.5-flash-low", + "gemini-3.5-flash-extra-low", + ] { + let value = CostUsagePricing.googleCostUSD( + model: alias, + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + modelsDevCacheRoot: emptyCacheRoot) + #expect(value == flash35Alias) + } + } + + @Test + func `google pricing applies gemini31 pro long context rates`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.googleCostUSD( + model: "gemini-3.1-pro-high", + inputTokens: 200_001, + cacheReadInputTokens: 10, + outputTokens: 20, + modelsDevCacheRoot: emptyCacheRoot) + + #expect(cost == (200_001.0 * 4e-6) + (10.0 * 0.4e-6) + (20.0 * 18e-6)) + } + @Test func `unattributed codex usage stays unpriced despite a catalog collision`() throws { let root = try Self.seedModelsDevCache(""" diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 547cf7fc0e..d9d042f12f 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -204,7 +204,10 @@ struct CostUsageScannerBreakdownTests { CostUsageDailyReport.ModelBreakdown( modelName: "gpt-5.2-codex", costUSD: first.data[0].costUSD, - totalTokens: 110), + totalTokens: 110, + inputTokens: 100, + cacheReadTokens: 20, + outputTokens: 10), ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) @@ -241,6 +244,101 @@ struct CostUsageScannerBreakdownTests { #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } + @Test + func `codex daily report surfaces reasoning output tokens without changing billing output`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 11) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "openai/gpt-5.2-codex" + + func tokenCount( + timestamp: String, + total: (input: Int, cached: Int, output: Int, reasoning: Int), + last: (input: Int, cached: Int, output: Int, reasoning: Int)) -> [String: Any] + { + func usage(_ value: (input: Int, cached: Int, output: Int, reasoning: Int)) -> [String: Any] { + [ + "input_tokens": value.input, + "cached_input_tokens": value.cached, + "output_tokens": value.output, + "reasoning_output_tokens": value.reasoning, + ] + } + return [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "model": model, + "total_token_usage": usage(total), + "last_token_usage": usage(last), + ], + ], + ] + } + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: iso0, model: model), + tokenCount( + timestamp: iso1, + total: (input: 100, cached: 20, output: 10, reasoning: 6), + last: (input: 100, cached: 20, output: 10, reasoning: 6)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let firstBreakdown = try #require(first.data.first?.modelBreakdowns?.first) + // Reasoning is a sub-bucket of billing output: output and totals stay as before. + #expect(firstBreakdown.outputTokens == 10) + #expect(firstBreakdown.reasoningTokens == 6) + #expect(firstBreakdown.totalTokens == 110) + #expect(first.data.first?.totalTokens == 110) + + // The incremental rescan rides the cached row path; reasoning must survive the roundtrip. + try env.jsonl([ + self.codexTurnContext(timestamp: iso0, model: model), + tokenCount( + timestamp: iso1, + total: (input: 100, cached: 20, output: 10, reasoning: 6), + last: (input: 100, cached: 20, output: 10, reasoning: 6)), + tokenCount( + timestamp: iso2, + total: (input: 160, cached: 40, output: 16, reasoning: 9), + last: (input: 60, cached: 20, output: 6, reasoning: 3)), + ]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let secondBreakdown = try #require(second.data.first?.modelBreakdowns?.first) + #expect(secondBreakdown.outputTokens == 16) + #expect(secondBreakdown.reasoningTokens == 9) + #expect(secondBreakdown.totalTokens == 176) + } + @Test func `codex project breakdowns group by cwd and preserve daily totals`() throws { let env = try CostUsageTestEnvironment() @@ -6540,7 +6638,11 @@ struct CostUsageScannerBreakdownTests { CostUsageDailyReport.ModelBreakdown( modelName: "claude-sonnet-4-20250514", costUSD: report.data[0].costUSD, - totalTokens: 355), + totalTokens: 355, + inputTokens: 200, + cacheReadTokens: 25, + cacheCreationTokens: 50, + outputTokens: 80), ]) #expect((report.data[0].costUSD ?? 0) > 0) } diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index 2db80641cd..8e7db813c3 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -406,7 +406,9 @@ struct CostUsageScannerPriorityTests { until: day, now: day, options: options) - #expect(first.summary?.totalCostUSD == nil) + // The auto-review harness is intentionally zero-priced until the completed response + // identifies the real model. Preserve that known zero instead of reporting unavailable. + #expect(first.summary?.totalCostUSD == 0) try CostUsageScannerCodexPriorityTests.insertTestLog( dbURL: dbURL, diff --git a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift index e7b150ec9b..756671a635 100644 --- a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift +++ b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift @@ -42,6 +42,27 @@ struct CostUsageWindowSummaryTests { #expect(summary.totalRequests == nil) } + @Test + func `window with an unpriced token day withholds the cost total`() { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyDays: 30, + daily: [ + Self.entry(day: "2026-06-29", cost: 5, tokens: 500, requests: 5), + Self.entry(day: "2026-07-01", cost: nil, tokens: 100, requests: 1), + ], + updatedAt: Self.now) + + let summary = snapshot.summary(forLastDays: 7, calendar: Self.utcCalendar) + #expect(summary.entryCount == 2) + #expect(summary.totalTokens == 600) + #expect(summary.totalRequests == 6) + #expect(summary.totalCostUSD == nil) + } + @Test func `comparison summaries keep Gregorian entries under a Buddhist calendar`() throws { let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok")) diff --git a/Tests/CodexBarTests/CursorLocalActivityScannerTests.swift b/Tests/CodexBarTests/CursorLocalActivityScannerTests.swift new file mode 100644 index 0000000000..6552e34a92 --- /dev/null +++ b/Tests/CodexBarTests/CursorLocalActivityScannerTests.swift @@ -0,0 +1,98 @@ +import CodexBarCore +import Foundation +import Testing + +#if canImport(SQLite3) || canImport(CSQLite3) +#if canImport(SQLite3) +import SQLite3 +#else +import CSQLite3 +#endif + +struct CursorLocalActivityScannerTests { + @Test + func `scanner aggregates per-day model activity without token counts`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CursorLocalActivityScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let tracking = root.appendingPathComponent("ai-tracking", isDirectory: true) + try FileManager.default.createDirectory(at: tracking, withIntermediateDirectories: true) + let database = tracking.appendingPathComponent("ai-code-tracking.db") + // 2026-07-28 and 2026-07-29 (epoch ms), each with two models and a blank model to skip. + try Self.createDatabase(at: database, rows: [ + (1_785_283_200_000, "Kimi K3"), // 2026-07-28T00:00:00Z + (1_785_283_800_000, "default"), + (1_785_284_000_000, ""), // skipped: empty model + (1_785_369_600_000, "MiniMax-M3"), // 2026-07-29T00:00:00Z + (1_785_369_900_000, "Kimi K3"), + ]) + + let snapshot = try #require(CursorLocalActivityScanner.scan( + environment: [CursorLocalActivityScanner.homeEnvironmentKey: root.path], + historyDays: 60, + now: Date(timeIntervalSince1970: 1_785_456_000), // 2026-07-30T00:00:00Z + calendar: Self.calendar)) + + #expect(snapshot.historyLabel == "Cursor") + #expect(snapshot.last30DaysTokens == nil) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.last30DaysRequests == nil) + #expect(snapshot.daily.count == 2) + let first = try #require(snapshot.daily.first) + #expect(first.totalTokens == nil) + #expect(first.costUSD == nil) + #expect(first.requestCount == nil) + #expect(first.modelsUsed == ["default", "Kimi K3"]) + let second = snapshot.daily[1] + #expect(second.modelsUsed == ["Kimi K3", "MiniMax-M3"]) + } + + @Test + func `scanner returns nil when the database is missing`() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CursorLocalActivityScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let snapshot = CursorLocalActivityScanner.scan( + environment: [CursorLocalActivityScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_456_000), + calendar: Self.calendar) + #expect(snapshot == nil) + } + + private static func createDatabase(at url: URL, rows: [(Int64, String)]) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK, let db else { + throw TestFailure.dbOpen + } + defer { sqlite3_close(db) } + let schema = """ + CREATE TABLE ai_code_hashes ( + hash TEXT, source TEXT, fileExtension TEXT, fileName TEXT, + requestId TEXT, conversationId TEXT, timestamp INTEGER, + model TEXT, createdAt INTEGER + ); + """ + guard sqlite3_exec(db, schema, nil, nil, nil) == SQLITE_OK else { + throw TestFailure.dbWrite + } + for (timestamp, model) in rows { + let insert = "INSERT INTO ai_code_hashes (timestamp, model) VALUES (\(timestamp), '\(model)');" + guard sqlite3_exec(db, insert, nil, nil, nil) == SQLITE_OK else { + throw TestFailure.dbWrite + } + } + } + + private enum TestFailure: Error { + case dbOpen + case dbWrite + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() +} +#endif diff --git a/Tests/CodexBarTests/GeminiSessionScannerTests.swift b/Tests/CodexBarTests/GeminiSessionScannerTests.swift new file mode 100644 index 0000000000..6a760e56be --- /dev/null +++ b/Tests/CodexBarTests/GeminiSessionScannerTests.swift @@ -0,0 +1,330 @@ +import CodexBarCore +import Foundation +import Testing + +struct GeminiSessionScannerTests { + @Test + func `scanner aggregates chat recordings across projects layouts and models`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chatsA = try Self.makeChatsDir(root, "hash-a") + let chatsB = try Self.makeChatsDir(root, "hash-b") + + try Self.write(Self.chatRecording(messages: [ + #"{"id":"m1","timestamp":"2026-07-10T09:00:00Z","type":"user"}"#, + Self.modelTurn( + id: "m2", + timestamp: "2026-07-10T09:01:00Z", + tokens: #"{"input":100,"output":20,"cached":10,"thoughts":5,"tool":3,"total":138}"#), + Self.modelTurn( + id: "m3", + timestamp: "2026-07-10T09:02:00Z", + tokens: #"{"input":15,"output":20,"cached":5,"thoughts":2,"tool":0,"total":37}"#), + ]), to: chatsA.appendingPathComponent("session-one.json")) + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-11T10:00:00Z", + model: "gemini-2.0-flash", + tokens: #"{"input":8,"output":9,"total":17}"#), + ]), to: chatsB.appendingPathComponent("session-two.json")) + // Legacy layout: `session-` prefix accepted anywhere under `tmp`. + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-11T11:00:00Z", + model: "gemini-2.0-flash", + tokens: #"{"input":1,"output":2,"total":3}"#), + ]), to: root.appendingPathComponent("tmp/session-legacy.json")) + // Decoys: wrong extension, and a `.json` outside the expected layouts. + try Self.write("not json", to: chatsB.appendingPathComponent("notes.txt")) + try Self.write( + #"{"messages":[{"id":"x","model":"gemini-2.0-flash","tokens":{"input":99,"output":99}}]}"#, + to: root.appendingPathComponent("tmp/hash-b/other.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.currencyCode == "XXX") + #expect(snapshot.historyLabel == "Gemini CLI") + #expect(snapshot.historyDays == 30) + #expect(snapshot.last30DaysTokens == 195) + #expect(snapshot.last30DaysRequests == 4) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.sessionTokens == nil) + #expect(snapshot.daily.map(\.date) == ["2026-07-10", "2026-07-11"]) + + let first = snapshot.daily[0] + #expect(first.inputTokens == 113) + #expect(first.outputTokens == 47) + #expect(first.cacheReadTokens == 15) + #expect(first.cacheCreationTokens == nil) + #expect(first.totalTokens == 175) + #expect(first.requestCount == 2) + #expect(first.costUSD == nil) + #expect(first.modelsUsed == ["gemini-2.5-pro"]) + let firstBreakdown = try #require(first.modelBreakdowns?.first) + #expect(firstBreakdown.modelName == "gemini-2.5-pro") + #expect(firstBreakdown.inputTokens == 113) + #expect(firstBreakdown.outputTokens == 47) + #expect(firstBreakdown.cacheReadTokens == 15) + #expect(firstBreakdown.cacheCreationTokens == nil) + // `thoughts` stays folded into billing output and is also surfaced as the reasoning bucket. + #expect(firstBreakdown.reasoningTokens == 7) + #expect(firstBreakdown.totalTokens == 175) + #expect(firstBreakdown.requestCount == 2) + #expect(firstBreakdown.costUSD == nil) + + let second = snapshot.daily[1] + #expect(second.inputTokens == 9) + #expect(second.outputTokens == 11) + #expect(second.cacheReadTokens == 0) + #expect(second.totalTokens == 20) + #expect(second.requestCount == 2) + #expect(second.modelsUsed == ["gemini-2.0-flash"]) + #expect(second.modelBreakdowns?.first?.reasoningTokens == nil) + } + + @Test + func `scanner buckets usage by local day across midnight`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + try Self.write(Self.chatRecording(messages: [ + // 23:30 at GMT+8 on 2026-07-10. + Self.modelTurn(id: "m1", timestamp: "2026-07-10T15:30:00Z", tokens: #"{"input":1,"output":2}"#), + // 00:30 at GMT+8 on 2026-07-11. + Self.modelTurn(id: "m2", timestamp: "2026-07-10T16:30:00Z", tokens: #"{"input":3,"output":4}"#), + ]), to: chats.appendingPathComponent("session-one.json")) + + let snapshot = try #require(Self.scan( + root: root, + now: Self.date("2026-07-12T12:00:00Z"), + calendar: Self.gmtPlus8Calendar)) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10", "2026-07-11"]) + #expect(snapshot.daily.map(\.requestCount) == [1, 1]) + #expect(snapshot.daily.map(\.totalTokens) == [3, 7]) + } + + @Test + func `scanner prefilters by file mtime and falls back to mtime for missing timestamps`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + + // mtime older than the window: skipped without parsing, even for in-window timestamps. + let oldFile = chats.appendingPathComponent("session-old.json") + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-12T09:00:00Z", + tokens: #"{"input":100,"output":100}"#), + ]), to: oldFile) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-06-02T12:00:00Z")], + ofItemAtPath: oldFile.path) + // Fresh mtime but a record older than the window: dropped by the day filter. + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-06-02T09:00:00Z", + tokens: #"{"input":200,"output":200}"#), + ]), to: chats.appendingPathComponent("session-stale.json")) + // No timestamp at all: bucketed by the file mtime, pinned inside the window here. + let fallbackFile = chats.appendingPathComponent("session-fallback.json") + try Self.write(Self.chatRecording(messages: [ + #"{"id":"m1","type":"gemini","model":"gemini-2.5-pro","tokens":{"input":5,"output":6}}"#, + ]), to: fallbackFile) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-07-12T09:00:00Z")], + ofItemAtPath: fallbackFile.path) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-12"]) + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 11) + #expect(snapshot.daily.first?.inputTokens == 5) + #expect(snapshot.daily.first?.outputTokens == 6) + } + + @Test + func `scanner skips corrupt files records and stream lines`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + + try Self.write("this is not json", to: chats.appendingPathComponent("session-garbage.json")) + try Self.write(#"{"foo":1}"#, to: chats.appendingPathComponent("session-shape.json")) + try Self.write(Self.chatRecording(messages: [ + #"{"id":"m1","timestamp":"2026-07-10T09:00:00Z","type":"gemini","tokens":{"input":1,"output":1}}"#, + #"{"id":"m2","timestamp":"2026-07-10T09:01:00Z","type":"gemini","model":"gemini-2.5-pro"}"#, + ]), to: chats.appendingPathComponent("session-incomplete.json")) + try Self.write("", to: chats.appendingPathComponent("session-empty.jsonl")) + try Self.write([ + "not json at all", + #"{"type":"init","model":"gemini-2.5-pro","session_id":"s1"}"#, + #"{"type":"gemini","id":"x1","timestamp":"2026-07-10T09:00:00.000Z","tokens":{"input":10,"output":5}}"#, + ].joined(separator: "\n"), to: chats.appendingPathComponent("session-mixed.jsonl")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10"]) + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 15) + #expect(snapshot.daily.first?.modelsUsed == ["gemini-2.5-pro"]) + } + + @Test + func `scanner skips negative token values and overflowing accumulations`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-10T09:00:00Z", + tokens: #"{"input":-1,"output":2,"total":1}"#), + Self.modelTurn( + id: "m2", + timestamp: "2026-07-10T09:01:00Z", + tokens: #"{"input":1,"output":2,"total":-3}"#), + Self.modelTurn( + id: "m3", + timestamp: "2026-07-10T09:02:00Z", + tokens: #"{"input":9223372036854775807,"output":0}"#), + Self.modelTurn( + id: "m4", + timestamp: "2026-07-10T09:03:00Z", + tokens: #"{"input":10,"output":0}"#), + ]), to: chats.appendingPathComponent("session-one.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + // Only the Int.max record survives; the follow-up record overflows the accumulator and + // is dropped without poisoning the bucket. + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == Int.max) + #expect(snapshot.daily.first?.inputTokens == Int.max) + } + + @Test + func `scanner parses chat streams with model hints and replaces duplicate message ids`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + try Self.write([ + // swiftlint:disable:next line_length + #"{"sessionId":"s1","projectHash":"hash-a","startTime":"2026-07-10T00:00:00.000Z","lastUpdated":"2026-07-10T00:01:00.000Z"}"#, + #"{"type":"init","model":"gemini-2.5-pro","session_id":"s1"}"#, + // swiftlint:disable:next line_length + #"{"id":"m1","timestamp":"2026-07-10T09:00:00.000Z","type":"gemini","tokens":{"input":10,"output":1,"total":11}}"#, + // swiftlint:disable:next line_length + #"{"id":"m1","timestamp":"2026-07-10T09:01:00.000Z","type":"gemini","tokens":{"input":20,"output":2,"cached":5,"thoughts":3,"total":25}}"#, + #"{"timestamp":"2026-07-10T09:02:00.000Z","type":"gemini","tokens":{"input":7,"output":8}}"#, + #"{"timestamp":"2026-07-10T09:03:00.000Z","type":"user"}"#, + ].joined(separator: "\n"), to: chats.appendingPathComponent("session-stream.jsonl")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + // The second "m1" line replaces the first (cache-inclusive input 20 normalizes to 15, + // thoughts fold into output), and the id-less line inherits the `init` model. + #expect(snapshot.daily.map(\.date) == ["2026-07-10"]) + #expect(snapshot.last30DaysRequests == 2) + #expect(snapshot.last30DaysTokens == 40) + let entry = snapshot.daily[0] + #expect(entry.inputTokens == 22) + #expect(entry.outputTokens == 13) + #expect(entry.cacheReadTokens == 5) + #expect(entry.modelsUsed == ["gemini-2.5-pro"]) + #expect(entry.modelBreakdowns?.first?.reasoningTokens == 3) + } + + @Test + func `scanner honors GEMINI_CLI_HOME override and returns nil for empty directories`() throws { + let emptyRoot = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: emptyRoot) } + + #expect(Self.scan(root: emptyRoot, now: Self.date("2026-07-12T12:00:00Z")) == nil) + + let tmp = emptyRoot.appendingPathComponent("tmp", isDirectory: true) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + #expect(Self.scan(root: emptyRoot, now: Self.date("2026-07-12T12:00:00Z")) == nil) + + let fixtureRoot = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: fixtureRoot) } + let chats = try Self.makeChatsDir(fixtureRoot, "hash-a") + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn(id: "m1", timestamp: "2026-07-10T09:00:00Z", tokens: #"{"input":1,"output":2}"#), + ]), to: chats.appendingPathComponent("session-one.json")) + + let snapshot = try #require(Self.scan(root: fixtureRoot, now: Self.date("2026-07-12T12:00:00Z"))) + #expect(snapshot.last30DaysTokens == 3) + #expect(snapshot.historyLabel == "Gemini CLI") + } + + // MARK: - Fixtures + + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static let gmtPlus8Calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 8 * 3600)! + return calendar + }() + + private static func scan( + root: URL, + now: Date, + calendar: Calendar = Self.utcCalendar) -> CostUsageTokenSnapshot? + { + GeminiSessionScanner.scan( + environment: [GeminiSessionScanner.cliHomeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: calendar) + } + + private static func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func makeChatsDir(_ root: URL, _ projectHash: String) throws -> URL { + let dir = root.appendingPathComponent("tmp/\(projectHash)/chats", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private static func chatRecording(messages: [String]) -> String { + """ + {"sessionId":"ses","projectHash":"hash","startTime":"2026-07-10T09:00:00Z",\ + "lastUpdated":"2026-07-10T09:05:00Z","messages":[\(messages.joined(separator: ","))]} + """ + } + + private static func modelTurn( + id: String, + timestamp: String, + model: String = "gemini-2.5-pro", + tokens: String) -> String + { + #"{"id":"\#(id)","timestamp":"\#(timestamp)","type":"gemini","model":"\#(model)","tokens":\#(tokens)}"# + } + + private static func date(_ iso: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: iso) ?? Date(timeIntervalSince1970: 0) + } + + private static func write(_ content: String, to url: URL) throws { + try (content + "\n").write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift new file mode 100644 index 0000000000..b0120d8066 --- /dev/null +++ b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift @@ -0,0 +1,204 @@ +import CodexBarCore +import Foundation +import Testing + +struct KimiCodeSessionScannerTests { + @Test + func `scanner aggregates turn usage across main and subagents without reading other events`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let main = root + .appendingPathComponent("sessions/workspace/session-a/agents/main", isDirectory: true) + let child = root + .appendingPathComponent("sessions/workspace/session-a/agents/agent-0", isDirectory: true) + try FileManager.default.createDirectory(at: main, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) + + let now = Date(timeIntervalSince1970: 1_784_347_200) + try Self.write([ + Self.usage(time: 1_784_257_200_000, model: "kimi-code/k3", input: 10, cacheRead: 20, output: 3), + #"{"type":"assistant.message","time":1784257200000,"content":"must not be parsed"}"#, + Self.usage( + time: 1_784_257_300_000, + model: "kimi-code/k3", + input: 4, + cacheRead: 5, + cacheCreation: 6, + output: 7), + Self.usage( + time: 1_784_257_400_000, + model: "kimi-code/k3", + scope: "session", + input: 999, + cacheRead: 999, + output: 999), + ], to: main.appendingPathComponent("wire.jsonl")) + try Self.write([ + Self.usage( + time: 1_784_343_600_000, + model: "kimi-code/kimi-for-coding", + input: 8, + cacheRead: 9, + output: 10), + ], to: child.appendingPathComponent("wire.jsonl")) + + let snapshot = try #require(KimiCodeSessionScanner.scan( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: Self.calendar)) + + #expect(snapshot.currencyCode == "USD") + // Shared-engine total is input+cacheRead+output; cache-write is priced and reported + // separately (cacheCreationTokens) but not folded into the consumption total. + #expect(snapshot.last30DaysTokens == 76) + #expect(snapshot.last30DaysRequests == 3) + #expect(snapshot.last30DaysCostUSD != nil) + #expect(snapshot.costSource == .estimated) + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.modelName) == [ + "kimi-code/k3", + "kimi-code/kimi-for-coding", + ]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.totalTokens) == [49, 27]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.inputTokens) == [14, 8]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheReadTokens) == [25, 9]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheCreationTokens) == [6, 0]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.outputTokens) == [10, 10]) + let breakdowns = snapshot.daily.flatMap { $0.modelBreakdowns ?? [] } + #expect(abs((breakdowns.first?.costUSD ?? 0) - 0.0002175) < 0.000000001) + // Kimi wire.jsonl carries no reasoning field, so the reasoning bucket stays nil. + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.reasoningTokens) == [nil, nil]) + } + + @Test + func `mixed pricing day withholds the day cost instead of a partial total`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let agent = root + .appendingPathComponent("sessions/workspace/session-a/agents/main", isDirectory: true) + try FileManager.default.createDirectory(at: agent, withIntermediateDirectories: true) + try Self.write([ + // kimi-code/k3 is priced via the third-party fallback. + Self.usage( + time: 1_784_257_200_000, + model: "kimi-code/k3", + input: 10, + cacheRead: 20, + output: 3), + // An unresolvable model (no third-party mapping, empty catalog) stays unpriced. + Self.usage( + time: 1_784_257_300_000, + model: "kimi-code/unknown-model", + input: 4, + cacheRead: 5, + output: 7), + ], to: agent.appendingPathComponent("wire.jsonl")) + + let snapshot = try #require(KimiCodeSessionScanner.scan( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_784_347_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("empty-pricing", isDirectory: true))) + + // Tokens are still counted; the mixed-priced day withholds its cost so the partial + // subtotal never masquerades as a complete total. k3(10+20+3) + unknown(4+5+7) = 49. + #expect(snapshot.last30DaysTokens == 49) + #expect(snapshot.currencyCode == "XXX") + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.daily.first?.totalTokens == 49) + #expect(snapshot.daily.first?.costUSD == nil) + } + + @Test + func `scanner ignores malformed negative and out of range records`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let agent = root + .appendingPathComponent("sessions/workspace/session-a/agents/main", isDirectory: true) + try FileManager.default.createDirectory(at: agent, withIntermediateDirectories: true) + try Self.write([ + Self.usage( + time: 1_784_257_200_000, + model: "kimi-code/k3", + input: -1, + cacheRead: 2, + output: 3), + Self.usage( + time: 1_770_000_000_000, + model: "kimi-code/k3", + input: 10, + cacheRead: 20, + output: 30), + #"{"type":"usage.record","time":"bad","model":"kimi-code/k3","usageScope":"turn","usage":{}}"#, + ], to: agent.appendingPathComponent("wire.jsonl")) + + #expect(KimiCodeSessionScanner.scan( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_784_347_200), + calendar: Self.calendar) == nil) + } + + @Test + func `streaming scanner cancels without loading the remaining transcript`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let agent = root + .appendingPathComponent("sessions/workspace/session-a/agents/main", isDirectory: true) + try FileManager.default.createDirectory(at: agent, withIntermediateDirectories: true) + let line = Self.usage( + time: 1_784_257_200_000, + model: "kimi-code/k3", + input: 10, + cacheRead: 20, + output: 3) + try Self.write(Array(repeating: line, count: 20000), to: agent.appendingPathComponent("wire.jsonl")) + + var checks = 0 + #expect(throws: CancellationError.self) { + _ = try KimiCodeSessionScanner.scanCancellable( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_784_347_200), + calendar: Self.calendar, + checkCancellation: { + checks += 1 + if checks >= 4 { + throw CancellationError() + } + }) + } + #expect(checks >= 4) + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static func usage( + time: Int64, + model: String, + scope: String = "turn", + input: Int, + cacheRead: Int, + cacheCreation: Int = 0, + output: Int) -> String + { + """ + {"type":"usage.record","time":\(time),"model":"\(model)","usageScope":"\(scope)","usage":{"inputOther":\( + input),"inputCacheRead":\(cacheRead),"inputCacheCreation":\(cacheCreation),"output":\(output)}} + """ + } + + private static func write(_ lines: [String], to url: URL) throws { + try (lines.joined(separator: "\n") + "\n").write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/LocalHistoryScannerRegistryTests.swift b/Tests/CodexBarTests/LocalHistoryScannerRegistryTests.swift new file mode 100644 index 0000000000..1dcd328f4a --- /dev/null +++ b/Tests/CodexBarTests/LocalHistoryScannerRegistryTests.swift @@ -0,0 +1,59 @@ +import CodexBarCore +import Foundation +import Testing + +struct LocalHistoryScannerRegistryTests { + private struct FakeScanner: LocalHistoryScanning { + let source: ProviderLocalHistorySource + let displayName: String + let homeEnvironmentKey: String? = nil + + func homeURL(environment: [String: String]) -> URL? { + nil + } + + func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + nil + } + } + + @Test + func `shared registry registers every built-in source`() { + let registry = LocalHistoryScannerRegistry.shared + for source in ProviderLocalHistorySource.builtIn { + #expect(registry.scanner(for: source) != nil, "missing built-in scanner for \(source.rawValue)") + } + #expect(registry.registeredSources.count == ProviderLocalHistorySource.builtIn.count) + } + + @Test + func `register adds a new scanner and preserves registration order`() { + var registry = LocalHistoryScannerRegistry() + let custom = ProviderLocalHistorySource(rawValue: "customTool") + #expect(registry.scanner(for: custom) == nil) + + registry.register(FakeScanner(source: custom, displayName: "Custom Tool")) + #expect(registry.scanner(for: custom)?.displayName == "Custom Tool") + #expect(registry.registeredSources == [custom]) + } + + @Test + func `re-registering a source replaces it without duplicating order`() { + var registry = LocalHistoryScannerRegistry() + let source = ProviderLocalHistorySource(rawValue: "replaceable") + registry.register(FakeScanner(source: source, displayName: "First")) + registry.register(FakeScanner(source: source, displayName: "Second")) + + #expect(registry.scanner(for: source)?.displayName == "Second") + #expect(registry.registeredSources == [source]) + #expect(registry.all.count == 1) + } + + @Test + func `zcode built-in scanner is discoverable with its display name`() { + let registry = LocalHistoryScannerRegistry.shared + let scanner = registry.scanner(for: .zcode) + #expect(scanner?.displayName == "ZCode") + #expect(scanner?.homeEnvironmentKey == ZcodeSessionScanner.homeEnvironmentKey) + } +} diff --git a/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift b/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift new file mode 100644 index 0000000000..ff551d7244 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift @@ -0,0 +1,185 @@ +import CodexBarCore +import Foundation +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif +import Testing + +#if canImport(SQLite3) || canImport(CSQLite3) +struct MiniMaxSessionScannerTests { + @Test + func `scanner observes committed rows still present in the live WAL`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("MiniMaxSessionScannerWALTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let databaseURL = root.appendingPathComponent("v2/sqlite/runtime-state.sqlite") + try FileManager.default.createDirectory( + at: databaseURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + + var writer: OpaquePointer? + guard sqlite3_open(databaseURL.path, &writer) == SQLITE_OK, let writer else { + throw SQLiteFixtureError.open + } + defer { sqlite3_close(writer) } + guard sqlite3_exec(writer, "PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0;", nil, nil, nil) + == SQLITE_OK + else { + throw SQLiteFixtureError.schema + } + try Self.createSchema(in: writer) + guard sqlite3_wal_checkpoint_v2(writer, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil) == SQLITE_OK else { + throw SQLiteFixtureError.schema + } + try Self.insertCurrentRow(in: writer) + + let snapshot = try #require(MiniMaxSessionScanner.scan( + environment: [MiniMaxSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_033_600), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("empty-pricing", isDirectory: true))) + + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 350) + } + + @Test + func `scanner prices MiniMax M3 even when the models catalog is empty`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("MiniMaxSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("v2/sqlite/runtime-state.sqlite") + try FileManager.default.createDirectory( + at: database.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Self.createDatabase(at: database) + + let now = Date(timeIntervalSince1970: 1_785_033_600) // 2026-07-26 UTC + let cacheRoot = root.appendingPathComponent("empty-model-pricing-cache", isDirectory: true) + let snapshot = try #require(MiniMaxSessionScanner.scan( + environment: [MiniMaxSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: Self.calendar, + modelsDevCacheRoot: cacheRoot)) + + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.costSource == .estimated) + #expect(snapshot.last30DaysTokens == 350) + #expect(snapshot.last30DaysRequests == 1) + #expect(abs((snapshot.last30DaysCostUSD ?? 0) - 0.000_102) < 0.000_000_001) + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "minimax/MiniMax-M3") + #expect(breakdown.reasoningTokens == 20) + #expect(abs((breakdown.costUSD ?? 0) - 0.000_102) < 0.000_000_001) + } + + @Test + func `scanner filters old database rows before decoding`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("MiniMaxSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("v2/sqlite/runtime-state.sqlite") + try FileManager.default.createDirectory( + at: database.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Self.createDatabase(at: database, oldRowCount: 200) + + var checks = 0 + let snapshot = try MiniMaxSessionScanner.scanCancellable( + environment: [MiniMaxSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_033_600), + calendar: Self.calendar, + checkCancellation: { + checks += 1 + if checks > 10 { + throw CancellationError() + } + }) + + #expect(snapshot?.last30DaysRequests == 1) + #expect(checks <= 10) + } + + private static func createDatabase(at url: URL, oldRowCount: Int = 0) throws { + var database: OpaquePointer? + guard sqlite3_open(url.path, &database) == SQLITE_OK, let database else { + throw SQLiteFixtureError.open + } + defer { sqlite3_close(database) } + try Self.createSchema(in: database) + try Self.insertCurrentRow(in: database) + guard oldRowCount > 0 else { return } + for index in 0.. ModelsDevCatalog { try JSONDecoder().decode(ModelsDevCatalog.self, from: self.fixtureData()) } diff --git a/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift b/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift new file mode 100644 index 0000000000..9b2f3e6c32 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift @@ -0,0 +1,614 @@ +import CodexBarCore +import Foundation +import Testing +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +struct OpenCodeSessionScannerTests { + @Test + func `scanner aggregates assistant messages across sessions and models`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let sessionA = try Self.makeSessionDir(root, "ses_a") + let sessionB = try Self.makeSessionDir(root, "ses_b") + + try Self.write(Self.assistantMessage( + id: "msg_001", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":100,"output":50,"reasoning":10,"cache":{"read":20,"write":5}}"#, + completed: true), to: sessionA.appendingPathComponent("msg_001.json")) + try Self.write( + Self.assistantMessage( + id: "msg_002", + modelID: "gpt-5", + created: "2026-07-11T10:00:00Z", + tokens: #"{"input":3,"output":4,"cache":{"read":1,"write":2}}"#), + to: sessionA.appendingPathComponent("msg_002.json")) + try Self.write( + Self.assistantMessage( + id: "msg_003", + modelID: "claude-sonnet-4", + created: "2026-07-10T11:00:00Z", + tokens: #"{"input":7,"output":8,"reasoning":1,"cache":{"read":0,"write":0}}"#), + to: sessionB.appendingPathComponent("msg_003.json")) + // Non-JSON files in the storage tree are ignored. + try Self.write("not json", to: sessionB.appendingPathComponent("notes.txt")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.currencyCode == "XXX") + #expect(snapshot.historyLabel == "OpenCode") + #expect(snapshot.last30DaysTokens == 211) + #expect(snapshot.last30DaysRequests == 3) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.daily.map(\.date) == ["2026-07-10", "2026-07-11"]) + + // Reasoning stays folded into billing output and is also surfaced as the reasoning + // bucket; cache.write maps to cacheCreationTokens. + let first = snapshot.daily[0] + #expect(first.inputTokens == 107) + #expect(first.outputTokens == 69) + #expect(first.cacheReadTokens == 20) + #expect(first.cacheCreationTokens == 5) + #expect(first.totalTokens == 201) + #expect(first.requestCount == 2) + #expect(first.costUSD == nil) + #expect(first.modelsUsed == ["claude-sonnet-4"]) + let firstBreakdown = try #require(first.modelBreakdowns?.first) + #expect(firstBreakdown.modelName == "claude-sonnet-4") + #expect(firstBreakdown.inputTokens == 107) + #expect(firstBreakdown.outputTokens == 69) + #expect(firstBreakdown.cacheReadTokens == 20) + #expect(firstBreakdown.cacheCreationTokens == 5) + #expect(firstBreakdown.reasoningTokens == 11) + #expect(firstBreakdown.totalTokens == 201) + #expect(firstBreakdown.requestCount == 2) + #expect(firstBreakdown.costUSD == nil) + + let second = snapshot.daily[1] + #expect(second.inputTokens == 3) + #expect(second.outputTokens == 4) + #expect(second.cacheReadTokens == 1) + #expect(second.cacheCreationTokens == 2) + #expect(second.totalTokens == 10) + #expect(second.requestCount == 1) + #expect(second.modelsUsed == ["gpt-5"]) + // A message without the optional `reasoning` key reports no reasoning bucket. + #expect(second.modelBreakdowns?.first?.reasoningTokens == nil) + } + + @Test + func `scanner retains billing ownership evidence from json and nested model`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let sessionA = try Self.makeSessionDir(root, "ses_a") + let sessionB = try Self.makeSessionDir(root, "ses_b") + try Self.write( + Self.assistantMessage( + id: "msg_001", + modelID: "claude-sonnet-4", + providerID: "anthropic", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":100,"output":50,"cache":{"read":20,"write":5}}"#), + to: sessionA.appendingPathComponent("msg_001.json")) + try Self.write( + Self.assistantMessage( + id: "msg_002", + modelID: "gpt-5", + nestedModelID: "gpt-5", + nestedProviderID: "openai", + created: "2026-07-11T10:00:00Z", + tokens: #"{"input":3,"output":4,"cache":{"read":1,"write":2}}"#), + to: sessionA.appendingPathComponent("msg_002.json")) + // Legacy record without ownership evidence stays nil. + try Self.write( + Self.assistantMessage( + id: "msg_003", + modelID: "gemini-2.5-pro", + created: "2026-07-10T11:00:00Z", + tokens: #"{"input":7,"output":8,"cache":{"read":0,"write":0}}"#), + to: sessionB.appendingPathComponent("msg_003.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + let breakdowns = snapshot.daily.flatMap { $0.modelBreakdowns ?? [] } + #expect(breakdowns.map(\.billingProviderID) == ["anthropic", nil, "openai"]) + } + + @Test + func `scanner buckets usage by local day across midnight`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let session = try Self.makeSessionDir(root, "ses_a") + // 23:30 at GMT+8 on 2026-07-10. + try Self.write( + Self.assistantMessage( + id: "msg_001", + modelID: "claude-sonnet-4", + created: "2026-07-10T15:30:00Z", + tokens: #"{"input":1,"output":2,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_001.json")) + // 00:30 at GMT+8 on 2026-07-11. + try Self.write( + Self.assistantMessage( + id: "msg_002", + modelID: "claude-sonnet-4", + created: "2026-07-10T16:30:00Z", + tokens: #"{"input":3,"output":4,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_002.json")) + + let snapshot = try #require(Self.scan( + root: root, + now: Self.date("2026-07-12T12:00:00Z"), + calendar: Self.gmtPlus8Calendar)) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10", "2026-07-11"]) + #expect(snapshot.daily.map(\.requestCount) == [1, 1]) + #expect(snapshot.daily.map(\.totalTokens) == [3, 7]) + } + + @Test + func `scanner prefilters by file mtime and drops records outside the window`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let session = try Self.makeSessionDir(root, "ses_a") + + // mtime older than the window: skipped without parsing, even for in-window timestamps. + let oldFile = session.appendingPathComponent("msg_old.json") + try Self.write( + Self.assistantMessage( + id: "msg_old", + modelID: "claude-sonnet-4", + created: "2026-07-12T09:00:00Z", + tokens: #"{"input":100,"output":100,"cache":{"read":0,"write":0}}"#), + to: oldFile) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-06-02T12:00:00Z")], + ofItemAtPath: oldFile.path) + // Fresh mtime but a record older than the window: dropped by the day filter. + try Self.write( + Self.assistantMessage( + id: "msg_stale", + modelID: "claude-sonnet-4", + created: "2026-06-02T09:00:00Z", + tokens: #"{"input":200,"output":200,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_stale.json")) + try Self.write( + Self.assistantMessage( + id: "msg_ok", + modelID: "claude-sonnet-4", + created: "2026-07-12T09:00:00Z", + tokens: #"{"input":5,"output":6,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_ok.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-12"]) + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 11) + } + + @Test + func `scanner skips corrupt files and non assistant or model less messages`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let session = try Self.makeSessionDir(root, "ses_a") + + try Self.write("this is not json", to: session.appendingPathComponent("msg_garbage.json")) + try Self.write(Self.assistantMessage( + id: "msg_user", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":5,"cache":{"read":0,"write":0}}"#, + role: "user"), to: session.appendingPathComponent("msg_user.json")) + // Role-less payloads are v2 SQLite rows, never valid JSON message files. + try Self.write(Self.assistantMessage( + id: "msg_norole", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":5,"cache":{"read":0,"write":0}}"#, + role: nil), to: session.appendingPathComponent("msg_norole.json")) + try Self.write( + Self.assistantMessage( + id: "msg_nomodel", + modelID: nil, + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":5,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_nomodel.json")) + // v2-style nested model object resolves when the top-level modelID is absent. + try Self.write( + Self.assistantMessage( + id: "msg_nested", + modelID: nil, + nestedModelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":5,"cache":{"read":1,"write":2}}"#), + to: session.appendingPathComponent("msg_nested.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10"]) + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 18) + #expect(snapshot.daily.first?.modelsUsed == ["claude-sonnet-4"]) + #expect(snapshot.daily.first?.cacheReadTokens == 1) + #expect(snapshot.daily.first?.cacheCreationTokens == 2) + } + + @Test + func `scanner skips negative token values and overflowing accumulations`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let session = try Self.makeSessionDir(root, "ses_a") + + try Self.write( + Self.assistantMessage( + id: "msg_neg", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":5,"cache":{"read":-5,"write":0}}"#), + to: session.appendingPathComponent("msg_neg.json")) + try Self.write( + Self.assistantMessage( + id: "msg_neg_reasoning", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:01:00Z", + tokens: #"{"input":10,"output":5,"reasoning":-1,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_neg_reasoning.json")) + try Self.write( + Self.assistantMessage( + id: "msg_huge", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:02:00Z", + tokens: #"{"input":9223372036854775807,"output":0,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_huge.json")) + try Self.write( + Self.assistantMessage( + id: "msg_small", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:03:00Z", + tokens: #"{"input":10,"output":0,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_small.json")) + for (fileName, modified) in [ + ("msg_neg.json", "2026-07-10T09:00:00Z"), + ("msg_neg_reasoning.json", "2026-07-10T09:01:00Z"), + ("msg_huge.json", "2026-07-10T09:02:00Z"), + ("msg_small.json", "2026-07-10T09:03:00Z"), + ] { + try FileManager.default.setAttributes( + [.modificationDate: Self.date(modified)], + ofItemAtPath: session.appendingPathComponent(fileName).path) + } + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + // The newest record survives; the Int.max record overflows the accumulator and is dropped + // without poisoning the bucket. + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 10) + #expect(snapshot.daily.first?.inputTokens == 10) + } + + @Test + func `file cap prefers the most recently modified messages`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let oldSession = try Self.makeSessionDir(root, "ses_old") + let midSession = try Self.makeSessionDir(root, "ses_mid") + let newSession = try Self.makeSessionDir(root, "ses_new") + let now = Self.date("2026-07-12T12:00:00Z") + + let oldURL = oldSession.appendingPathComponent("msg_old.json") + let midURL = midSession.appendingPathComponent("msg_mid.json") + let newURL = newSession.appendingPathComponent("msg_new.json") + try Self.write( + Self.assistantMessage( + id: "msg_old", + modelID: "claude-sonnet-4", + created: "2026-07-05T09:00:00Z", + tokens: #"{"input":1000,"output":0,"cache":{"read":0,"write":0}}"#), + to: oldURL) + try Self.write( + Self.assistantMessage( + id: "msg_mid", + modelID: "claude-sonnet-4", + created: "2026-07-08T09:00:00Z", + tokens: #"{"input":100,"output":0,"cache":{"read":0,"write":0}}"#), + to: midURL) + try Self.write( + Self.assistantMessage( + id: "msg_new", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":0,"cache":{"read":0,"write":0}}"#), + to: newURL) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-07-05T09:00:00Z")], + ofItemAtPath: oldURL.path) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-07-08T09:00:00Z")], + ofItemAtPath: midURL.path) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-07-10T09:00:00Z")], + ofItemAtPath: newURL.path) + + let snapshot = try #require(try OpenCodeSessionScanner.scanCancellable( + environment: [OpenCodeSessionScanner.dataHomeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: Self.utcCalendar, + fileLimit: 2)) + + // The two newest files win; the old session is omitted even though enumeration order + // could surface it first. + #expect(snapshot.last30DaysTokens == 110) + #expect(snapshot.last30DaysRequests == 2) + } + + @Test + func `scanner deduplicates forked message copies by id then file stem`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let sessionA = try Self.makeSessionDir(root, "ses_a") + let sessionB = try Self.makeSessionDir(root, "ses_b") + let sessionFork = try Self.makeSessionDir(root, "ses_fork") + + try Self.write( + Self.assistantMessage( + id: "msg_shared", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":1,"cache":{"read":0,"write":0}}"#), + to: sessionA.appendingPathComponent("msg_a.json")) + // Same embedded id under a forked session directory collapses into the first copy. + try Self.write( + Self.assistantMessage( + id: "msg_shared", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":1,"cache":{"read":0,"write":0}}"#), + to: sessionFork.appendingPathComponent("msg_copy.json")) + // Without an embedded id the file stem is the dedup key, so the fork's copy collapses too. + try Self.write( + Self.assistantMessage( + id: nil, + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":5,"output":5,"cache":{"read":0,"write":0}}"#), + to: sessionA.appendingPathComponent("msg_stem.json")) + try Self.write( + Self.assistantMessage( + id: nil, + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":5,"output":5,"cache":{"read":0,"write":0}}"#), + to: sessionB.appendingPathComponent("msg_stem.json")) + try Self.write( + Self.assistantMessage( + id: "msg_distinct", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":2,"output":2,"cache":{"read":0,"write":0}}"#), + to: sessionB.appendingPathComponent("msg_distinct.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10"]) + #expect(snapshot.last30DaysRequests == 3) + #expect(snapshot.last30DaysTokens == 25) + #expect(snapshot.daily.first?.inputTokens == 17) + #expect(snapshot.daily.first?.outputTokens == 8) + } + + @Test + func `scanner honors XDG_DATA_HOME override and returns nil for empty directories`() throws { + let emptyRoot = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: emptyRoot) } + + #expect(Self.scan(root: emptyRoot, now: Self.date("2026-07-12T12:00:00Z")) == nil) + + let dataDir = emptyRoot.appendingPathComponent("opencode/storage/message", isDirectory: true) + try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true) + #expect(Self.scan(root: emptyRoot, now: Self.date("2026-07-12T12:00:00Z")) == nil) + + let fixtureRoot = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: fixtureRoot) } + let session = try Self.makeSessionDir(fixtureRoot, "ses_a") + try Self.write( + Self.assistantMessage( + id: "msg_001", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":1,"output":2,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_001.json")) + + let snapshot = try #require(Self.scan(root: fixtureRoot, now: Self.date("2026-07-12T12:00:00Z"))) + #expect(snapshot.last30DaysTokens == 3) + #expect(snapshot.historyLabel == "OpenCode") + } + + @Test + func `db priced day plus unpriced json day withholds the headline cost`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeDatabase(root, rows: [ + ( + id: "db-1", + created: "2026-07-27T10:00:00Z", + tokens: #"{"input":100,"output":50,"reasoning":0,"cache":{"read":0,"write":0}}"#, + cost: 0.01), + ]) + let session = try Self.makeSessionDir(root, "ses_legacy") + try Self.write( + Self.assistantMessage( + id: "msg_001", + modelID: "gpt-5", + created: "2026-07-28T10:00:00Z", + tokens: #"{"input":3,"output":4,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_001.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-28T12:00:00Z"))) + // The priced DB day stays visible, but the unpriced JSON day withholds the headline: + // publishing 0.01 as the 30-day total would read as complete history. + #expect(snapshot.daily.first?.costUSD == 0.01) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.currencyCode == "XXX") + #expect(snapshot.costSource == .estimated) + } + + @Test + func `db priced snapshot reports provider-reported cost source`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeDatabase(root, rows: [ + ( + id: "db-1", + created: "2026-07-27T10:00:00Z", + tokens: #"{"input":100,"output":50,"reasoning":0,"cache":{"read":0,"write":0}}"#, + cost: 0.01), + ]) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-28T12:00:00Z"))) + #expect(snapshot.last30DaysCostUSD == 0.01) + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.costSource == .providerReported) + #expect(snapshot.daily.first?.modelBreakdowns?.first?.billingProviderID == "anthropic") + } + + // MARK: - Fixtures + + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static let gmtPlus8Calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 8 * 3600)! + return calendar + }() + + private static func scan( + root: URL, + now: Date, + calendar: Calendar = Self.utcCalendar) -> CostUsageTokenSnapshot? + { + OpenCodeSessionScanner.scan( + environment: [OpenCodeSessionScanner.dataHomeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: calendar) + } + + private static func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func makeSessionDir(_ root: URL, _ sessionID: String) throws -> URL { + let dir = root.appendingPathComponent("opencode/storage/message/\(sessionID)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private static func assistantMessage( + id: String?, + modelID: String?, + nestedModelID: String? = nil, + nestedProviderID: String? = nil, + providerID: String? = nil, + created: String, + tokens: String, + role: String?? = "assistant", + completed: Bool = false) -> String + { + var fields: [String] = [] + if let id { fields.append(#""id":"\#(id)""#) } + fields.append(#""sessionID":"ses""#) + if let role = role.flatMap(\.self) { fields.append(#""role":"\#(role)""#) } + if let modelID { fields.append(#""modelID":"\#(modelID)""#) } + if let nestedModelID { + var nested = #""model":{"id":"\#(nestedModelID)""# + if let nestedProviderID { nested += #","provider":"\#(nestedProviderID)""# } + nested += "}" + fields.append(nested) + } + if let providerID { fields.append(#""providerID":"\#(providerID)""#) } + fields.append(#""tokens":"# + tokens) + var time = #""created":"# + "\(Self.ms(created))" + if completed { time += #","completed":"# + "\(Self.ms(created) + 1000)" } + fields.append(#""time":{"# + time + "}") + return "{" + fields.joined(separator: ",") + "}" + } + + private static func ms(_ iso: String) -> Int { + Int(self.date(iso).timeIntervalSince1970 * 1000) + } + + private static func date(_ iso: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: iso) ?? Date(timeIntervalSince1970: 0) + } + + private static func write(_ content: String, to url: URL) throws { + try (content + "\n").write(to: url, atomically: true, encoding: .utf8) + } + + private static func makeDatabase( + _ root: URL, + rows: [(id: String, created: String, tokens: String, cost: Double?)]) throws + { + let opencodeDir = root.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: opencodeDir, withIntermediateDirectories: true) + var db: OpaquePointer? + guard sqlite3_open(opencodeDir.appendingPathComponent("opencode.db").path, &db) == SQLITE_OK, + let db + else { + throw TestFailure.dbOpen + } + defer { sqlite3_close(db) } + guard sqlite3_exec(db, "CREATE TABLE message (data TEXT, time_created INTEGER);", nil, nil, nil) == + SQLITE_OK + else { + throw TestFailure.dbWrite + } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + for row in rows { + var data = #"{"id":"\#(row.id)","role":"assistant","modelID":"claude-sonnet-4","tokens":\#(row.tokens),"# + if let cost = row.cost { + data += #""cost":\#(cost),"# + } + data += #""providerID":"anthropic","time":{"created":\#(Self.ms(row.created))}}"# + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO message (data, time_created) VALUES (?, ?);", + -1, + &stmt, + nil) == SQLITE_OK + else { + throw TestFailure.dbWrite + } + sqlite3_bind_text(stmt, 1, data, -1, transient) + sqlite3_bind_int64(stmt, 2, Int64(Self.ms(row.created))) + guard sqlite3_step(stmt) == SQLITE_DONE else { + sqlite3_finalize(stmt) + throw TestFailure.dbWrite + } + sqlite3_finalize(stmt) + } + } + + private enum TestFailure: Error { + case dbOpen + case dbWrite + } +} diff --git a/Tests/CodexBarTests/QwenCodeSessionScannerTests.swift b/Tests/CodexBarTests/QwenCodeSessionScannerTests.swift new file mode 100644 index 0000000000..0f9b7ab85e --- /dev/null +++ b/Tests/CodexBarTests/QwenCodeSessionScannerTests.swift @@ -0,0 +1,86 @@ +import CodexBarCore +import Foundation +import Testing + +struct QwenCodeSessionScannerTests { + @Test + func `scanner reads Qwen Code assistant usage and preserves billing owner`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("QwenCodeSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let chats = root + .appendingPathComponent("projects/project-a/chats", isDirectory: true) + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + // promptTokenCount includes the cached prefix, so prompt must be >= cachedContentTokenCount. + let assistantOne = #"{"type":"assistant","model":"qwen3-coder-plus","# + + #""timestamp":"2026-07-28T08:01:00Z","usageMetadata":{"promptTokenCount":300,"# + + #""candidatesTokenCount":30,"thoughtsTokenCount":20,"cachedContentTokenCount":200}}"# + let assistantTwo = #"{"type":"assistant","model":"qwen3-coder-plus","# + + #""timestamp":"2026-07-28T08:02:00Z","usageMetadata":{"promptTokenCount":30,"# + + #""candidatesTokenCount":5,"thoughtsTokenCount":2,"cachedContentTokenCount":20}}"# + let jsonl = [ + #"{"type":"user","timestamp":"2026-07-28T08:00:00Z"}"#, + assistantOne, + assistantTwo, + ].joined(separator: "\n") + try Data(jsonl.utf8).write(to: chats.appendingPathComponent("session.jsonl")) + + let snapshot = try #require(QwenCodeSessionScanner.scan( + environment: [QwenCodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true))) + + #expect(snapshot.historyLabel == "Qwen Code CLI") + #expect(snapshot.last30DaysRequests == 2) + #expect(snapshot.last30DaysTokens == 387) + let entry = try #require(snapshot.daily.first) + #expect(entry.inputTokens == 110) + #expect(entry.outputTokens == 57) + #expect(entry.cacheReadTokens == 220) + #expect(entry.totalTokens == 387) + let model = try #require(entry.modelBreakdowns?.first) + #expect(model.modelName == "qwen3-coder-plus") + #expect(model.billingProviderID == UsageProvider.qwencloud.rawValue) + #expect(model.reasoningTokens == 22) + } + + @Test + func `scanner ignores other layouts and cooperatively cancels`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("QwenCodeSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let chats = root.appendingPathComponent("projects/project-a/chats", isDirectory: true) + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + let realEntry = #"{"type":"assistant","model":"qwen3-coder","# + + #""timestamp":"2026-07-28T08:01:00Z","usageMetadata":{"promptTokenCount":1,"# + + #""candidatesTokenCount":1}}"# + try Data(realEntry.utf8).write(to: chats.appendingPathComponent("session.jsonl")) + let decoy = root.appendingPathComponent("projects/project-a/other", isDirectory: true) + try FileManager.default.createDirectory(at: decoy, withIntermediateDirectories: true) + let decoyEntry = #"{"type":"assistant","model":"decoy","# + + #""timestamp":"2026-07-28T08:01:00Z","usageMetadata":{"promptTokenCount":999,"# + + #""candidatesTokenCount":999}}"# + try Data(decoyEntry.utf8).write(to: decoy.appendingPathComponent("session.jsonl")) + + var checks = 0 + #expect(throws: CancellationError.self) { + _ = try QwenCodeSessionScanner.scanCancellable( + environment: [QwenCodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + checkCancellation: { + checks += 1 + if checks >= 2 { throw CancellationError() } + }) + } + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() +} diff --git a/Tests/CodexBarTests/TraeLocalActivityScannerTests.swift b/Tests/CodexBarTests/TraeLocalActivityScannerTests.swift new file mode 100644 index 0000000000..1cf46d26a7 --- /dev/null +++ b/Tests/CodexBarTests/TraeLocalActivityScannerTests.swift @@ -0,0 +1,123 @@ +import CodexBarCore +import Foundation +import Testing + +#if canImport(SQLite3) || canImport(CSQLite3) +#if canImport(SQLite3) +import SQLite3 +#else +import CSQLite3 +#endif + +struct TraeLocalActivityScannerTests { + @Test + func `scanner surfaces the selected model and last active day without token counts`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("TraeLocalActivityScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("state.vscdb") + let modelMap = #"{"solo_coder":"1_-_glm-5.2","builder":"0_-_kimi-k2.7-code"}"# + try Self.createDatabase(at: database, values: [ + "4484236971871945_ai-chat:sessionRelation:globalModelMap": modelMap, + "telemetry.lastSessionDate": "Tue, 28 Jul 2026 08:30:00 GMT", + ]) + + let snapshot = try #require(TraeLocalActivityScanner.scan( + environment: [TraeLocalActivityScanner.databaseEnvironmentKey: database.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_456_000), // 2026-07-30T00:00:00Z + calendar: Self.calendar)) + + #expect(snapshot.historyLabel == "Trae") + #expect(snapshot.last30DaysTokens == nil) + let entry = try #require(snapshot.daily.first) + #expect(entry.totalTokens == nil) + #expect(entry.costUSD == nil) + // Selection-id prefixes stripped, de-duplicated, sorted case-insensitively. + #expect(entry.modelsUsed == ["glm-5.2", "kimi-k2.7-code"]) + // Anchored on the telemetry last-session day. + #expect(entry.date == "2026-07-28") + } + + @Test + func `scanner returns nil when nothing useful is stored`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("TraeLocalActivityScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("state.vscdb") + try Self.createDatabase(at: database, values: [:]) + let snapshot = TraeLocalActivityScanner.scan( + environment: [TraeLocalActivityScanner.databaseEnvironmentKey: database.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_456_000), + calendar: Self.calendar) + #expect(snapshot == nil) + } + + @Test + func `scanner omits activity when the last session predates the history window`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("TraeLocalActivityScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("state.vscdb") + let modelMap = #"{"solo_coder":"1_-_glm-5.2"}"# + try Self.createDatabase(at: database, values: [ + "4484236971871945_ai-chat:sessionRelation:globalModelMap": modelMap, + // 2026-05-01 is months before the 30-day window ending 2026-07-30. + "telemetry.lastSessionDate": "Fri, 01 May 2026 12:00:00 GMT", + ]) + + let snapshot = TraeLocalActivityScanner.scan( + environment: [TraeLocalActivityScanner.databaseEnvironmentKey: database.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_456_000), // 2026-07-30T00:00:00Z + calendar: Self.calendar) + #expect(snapshot == nil) + } + + private static func createDatabase(at url: URL, values: [String: String]) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK, let db else { + throw TestFailure.dbOpen + } + defer { sqlite3_close(db) } + guard sqlite3_exec( + db, + "CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);", + nil, + nil, + nil) == SQLITE_OK + else { + throw TestFailure.dbWrite + } + for (key, value) in values { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "INSERT INTO ItemTable (key, value) VALUES (?, ?);", -1, &stmt, nil) == + SQLITE_OK + else { + throw TestFailure.dbWrite + } + sqlite3_bind_text(stmt, 1, key, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + sqlite3_bind_text(stmt, 2, value, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + guard sqlite3_step(stmt) == SQLITE_DONE else { + sqlite3_finalize(stmt) + throw TestFailure.dbWrite + } + sqlite3_finalize(stmt) + } + } + + private enum TestFailure: Error { + case dbOpen + case dbWrite + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() +} +#endif diff --git a/Tests/CodexBarTests/UsageEventAggregatorTests.swift b/Tests/CodexBarTests/UsageEventAggregatorTests.swift new file mode 100644 index 0000000000..18b246886a --- /dev/null +++ b/Tests/CodexBarTests/UsageEventAggregatorTests.swift @@ -0,0 +1,210 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct UsageEventAggregatorTests { + private let now = Date(timeIntervalSince1970: 1_785_283_200) + + @Test + func `cache-inclusive input is split using the total cross-check`() { + // input(1100) + output(500) == total(1600), so the cached prefix is already inside input. + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "glm-5.2", + inputTokens: 1100, + outputTokens: 500, + totalTokens: 1600, + cacheReadTokens: 900, + pricingProviderIDs: [])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + let entry = snapshot?.daily.first + #expect(entry?.inputTokens == 200) + #expect(entry?.cacheReadTokens == 900) + #expect(entry?.outputTokens == 500) + #expect(entry?.totalTokens == 1600) + } + + @Test + func `cache-exclusive input is kept without double counting`() { + // input(200) + cacheRead(900) + output(500) == total(1600): input is already uncached. + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "glm-5.2", + inputTokens: 200, + outputTokens: 500, + totalTokens: 1600, + cacheReadTokens: 900, + pricingProviderIDs: [])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot?.daily.first?.inputTokens == 200) + #expect(snapshot?.daily.first?.totalTokens == 1600) + } + + @Test + func `missing total falls back to subtracting the cached prefix`() { + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "qwen3-coder-plus", + inputTokens: 330, + outputTokens: 57, + totalTokens: nil, + cacheReadTokens: 220, + pricingProviderIDs: [])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot?.daily.first?.inputTokens == 110) + #expect(snapshot?.daily.first?.totalTokens == 387) + } + + @Test + func `provider-reported cost is trusted and not re-priced`() { + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "glm-5.2", + inputTokens: 100, + outputTokens: 50, + providerCostUSD: 0.5, + pricingProviderIDs: ["zai"])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot?.daily.first?.costUSD == 0.5) + } + + @Test + func `unpriced token usage keeps the day cost nil rather than a partial total`() { + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "unknown-model", + inputTokens: 100, + outputTokens: 50, + pricingProviderIDs: ["no-such-provider"])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + // Tokens are real but unpriced, so the cost must stay nil, not a fabricated partial sum. + #expect(snapshot?.daily.first?.totalTokens == 150) + #expect(snapshot?.daily.first?.costUSD == nil) + } + + @Test + func `headline cost is withheld when any token day is unpriced`() { + // Day 1 is fully priced via a provider-reported cost; day 2 has real tokens but no price. + // The priced day alone must not be published as the 30-day total. + let snapshot = UsageEventAggregator.aggregate( + events: [ + UnifiedUsageEvent( + day: "2026-07-27", + model: "glm-5.2", + inputTokens: 100, + outputTokens: 50, + providerCostUSD: 0.5, + pricingProviderIDs: ["zai"]), + UnifiedUsageEvent( + day: "2026-07-28", + model: "unknown-model", + inputTokens: 100, + outputTokens: 50, + pricingProviderIDs: ["no-such-provider"]), + ], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot?.daily.count == 2) + #expect(snapshot?.daily.first?.costUSD == 0.5) + #expect(snapshot?.daily.last?.costUSD == nil) + // Tokens still aggregate, but the headline cost is withheld as incomplete. + #expect(snapshot?.last30DaysTokens == 300) + #expect(snapshot?.last30DaysCostUSD == nil) + } + + @Test + func `degraded model-only events surface without token totals`() { + let snapshot = UsageEventAggregator.aggregate( + events: [ + UnifiedUsageEvent(day: "2026-07-28", model: "claude-opus-4-8"), + UnifiedUsageEvent(day: "2026-07-28", model: "gpt-5"), + ], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + let entry = snapshot?.daily.first + #expect(entry?.totalTokens == nil) + #expect(entry?.costUSD == nil) + #expect(entry?.modelsUsed == ["claude-opus-4-8", "gpt-5"]) + #expect(entry?.modelBreakdowns == nil) + // A fully degraded snapshot carries no headline token total. + #expect(snapshot?.last30DaysTokens == nil) + } + + @Test + func `per-event billing evidence wins over the tool default`() { + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "claude-haiku-4-5", + billingProviderID: "claude", + inputTokens: 100, + outputTokens: 50, + pricingProviderIDs: [])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test", defaultBillingProviderID: "copilot")) + #expect(snapshot?.daily.first?.modelBreakdowns?.first?.billingProviderID == "claude") + } + + @Test + func `rollup request counts aggregate instead of counting one event`() { + let snapshot = UsageEventAggregator.aggregate( + events: [ + UnifiedUsageEvent( + day: "2026-07-28", + model: "claude-haiku-4-5", + inputTokens: 100, + outputTokens: 50, + requestCount: 3, + pricingProviderIDs: []), + UnifiedUsageEvent( + day: "2026-07-28", + model: "claude-haiku-4-5", + inputTokens: 100, + outputTokens: 50, + pricingProviderIDs: []), + ], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot?.daily.first?.requestCount == 4) + #expect(snapshot?.daily.first?.modelBreakdowns?.first?.requestCount == 4) + } + + @Test + func `overflowing totals are rejected instead of trapping in total comparisons`() { + // Malformed local records can carry counts near Int.max. The old comparison + // `rawInput + output == total` would trap; the engine must reject the record safely. + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "qwen3-coder-plus", + inputTokens: Int.max, + outputTokens: Int.max, + totalTokens: Int.max, + pricingProviderIDs: [])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot != nil) + #expect(snapshot?.daily.first?.totalTokens == nil) + #expect(snapshot?.last30DaysTokens == nil) + } +} diff --git a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift index eb28ffd4fd..53f8d0c941 100644 --- a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift +++ b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift @@ -94,6 +94,38 @@ private actor TokenRefreshRecorder { @MainActor @Suite(.serialized) struct UsageStoreManualTokenRefreshTests { + @Test + func `local history provider refresh publishes token snapshot instead of resetting`() async { + let store = Self.makeStore(enabledProviders: [.codex, .gemini]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 150, + sessionCostUSD: 0.5, + last30DaysTokens: 150, + last30DaysCostUSD: 0.5, + currencyCode: "USD", + historyDays: 30, + historyLabel: "Gemini CLI", + costSource: .estimated, + daily: [CostUsageDailyReport.Entry( + date: "2026-07-28", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 0.5, + modelsUsed: ["gemini-2.5-pro"], + modelBreakdowns: nil)], + updatedAt: Date(timeIntervalSince1970: 1_785_283_200)) + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + snapshot + } + + await store.refreshTokenUsage(.gemini, force: true) + + // Gemini opts into local history but not supportsTokenCost; the refresh must run the + // snapshot pipeline instead of resetting the provider's token state. + #expect(store.tokenSnapshot(for: .gemini)?.historyLabel == "Gemini CLI") + } + @Test func `manual refresh waits for token-cost refresh before completing`() async { let store = Self.makeStore() diff --git a/Tests/CodexBarTests/ZcodeSessionScannerTests.swift b/Tests/CodexBarTests/ZcodeSessionScannerTests.swift new file mode 100644 index 0000000000..b19086b168 --- /dev/null +++ b/Tests/CodexBarTests/ZcodeSessionScannerTests.swift @@ -0,0 +1,121 @@ +import CodexBarCore +import Foundation +import Testing + +struct ZcodeSessionScannerTests { + @Test + func `scanner reads ZCode rollout usage and normalizes the cached prefix`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ZcodeSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let rollout = root.appendingPathComponent("cli/rollout", isDirectory: true) + try FileManager.default.createDirectory(at: rollout, withIntermediateDirectories: true) + // inputTokens includes the cached prefix: input + output == total even with cacheRead > 0. + // 300 input (of which 200 cached) + 30 output == 330 total → 100 uncached input. + let inclusive = #"{"completedAt":"2026-07-28T08:01:00.000Z","# + + #""model":{"modelId":"GLM-5.2","providerId":"builtin:bigmodel-start-plan"},"# + + #""response":{"usage":{"inputTokens":300,"outputTokens":30,"totalTokens":330,"# + + #""cacheReadTokens":200,"cacheWriteTokens":0}}}"# + // A second request on the same day, uncached: input + output == total with no cache. + let second = #"{"completedAt":"2026-07-28T08:02:00.000Z","# + + #""model":{"modelId":"GLM-5.2","providerId":"builtin:bigmodel-start-plan"},"# + + #""response":{"usage":{"inputTokens":30,"outputTokens":7,"totalTokens":37,"# + + #""cacheReadTokens":0,"cacheWriteTokens":0}}}"# + let jsonl = [inclusive, second].joined(separator: "\n") + try Data(jsonl.utf8).write(to: rollout.appendingPathComponent("model-io-sess_abc.jsonl")) + + let snapshot = try #require(ZcodeSessionScanner.scan( + environment: [ZcodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true))) + + #expect(snapshot.historyLabel == "ZCode") + #expect(snapshot.last30DaysRequests == 2) + // Uncached input 100 + 30, cache read 200, output 30 + 7 → total 367. + #expect(snapshot.last30DaysTokens == 367) + let entry = try #require(snapshot.daily.first) + #expect(entry.inputTokens == 130) + #expect(entry.outputTokens == 37) + #expect(entry.cacheReadTokens == 200) + #expect(entry.totalTokens == 367) + let model = try #require(entry.modelBreakdowns?.first) + // models.dev keys the Z.ai catalog by the lowercase model id. + #expect(model.modelName == "glm-5.2") + #expect(model.billingProviderID == UsageProvider.zai.rawValue) + } + + @Test + func `scanner treats cache-exclusive input without double counting`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ZcodeSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let rollout = root.appendingPathComponent("cli/rollout", isDirectory: true) + try FileManager.default.createDirectory(at: rollout, withIntermediateDirectories: true) + // Defensive: if a build ever reports cache-exclusive input, input + cacheRead + output + // equals total, so the input is already uncached and must not be reduced again. + // 100 input + 200 cache + 30 output == 330 total → keep 100 input. + let exclusive = #"{"completedAt":"2026-07-28T08:01:00.000Z","# + + #""model":{"modelId":"GLM-5.2","providerId":"builtin:bigmodel-start-plan"},"# + + #""response":{"usage":{"inputTokens":100,"outputTokens":30,"totalTokens":330,"# + + #""cacheReadTokens":200,"cacheWriteTokens":0}}}"# + try Data(exclusive.utf8).write(to: rollout.appendingPathComponent("model-io-sess_def.jsonl")) + + let snapshot = try #require(ZcodeSessionScanner.scan( + environment: [ZcodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true))) + + let entry = try #require(snapshot.daily.first) + #expect(entry.inputTokens == 100) + #expect(entry.cacheReadTokens == 200) + #expect(entry.totalTokens == 330) + } + + @Test + func `scanner ignores non-rollout files and cooperatively cancels`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ZcodeSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let rollout = root.appendingPathComponent("cli/rollout", isDirectory: true) + try FileManager.default.createDirectory(at: rollout, withIntermediateDirectories: true) + let real = #"{"completedAt":"2026-07-28T08:01:00.000Z","# + + #""model":{"modelId":"GLM-5.2"},"# + + #""response":{"usage":{"inputTokens":1,"outputTokens":1,"totalTokens":2}}}"# + try Data(real.utf8).write(to: rollout.appendingPathComponent("model-io-sess_abc.jsonl")) + // A decoy that does not match the model-io-sess prefix must be skipped. + let decoy = #"{"completedAt":"2026-07-28T08:01:00.000Z","# + + #""response":{"usage":{"inputTokens":999,"outputTokens":999,"totalTokens":1998}}}"# + try Data(decoy.utf8).write(to: rollout.appendingPathComponent("other.jsonl")) + + var checks = 0 + #expect(throws: CancellationError.self) { + _ = try ZcodeSessionScanner.scanCancellable( + environment: [ZcodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + checkCancellation: { + checks += 1 + if checks >= 2 { throw CancellationError() } + }) + } + + // Without cancellation, only the rollout file is read. + let snapshot = try #require(ZcodeSessionScanner.scan( + environment: [ZcodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar)) + #expect(snapshot.last30DaysRequests == 1) + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() +}