diff --git a/CHANGELOG.md b/CHANGELOG.md index 137fad9f4f4..61ccbd4bd9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Codex: add an opt-in "Include OpenCode local history (OpenAI)" source that merges machine-local OpenCode usage for `openai`-provider assistant records into the ambient Codex menu/CLI history, with read-only SQLite access, stable-ID deduplication across OpenCode's v1/v2 tables, recorded-cost-as-is semantics, and permanent exclusion from managed accounts, scoped `CODEX_HOME` scans (argument or environment), and the Spend Dashboard (refs #3273). Merged rows carry a visible `(OpenCode local)` model label, source-recorded cost provenance (`sourceRecorded`/`mixed`, never a pure list-price claim), and join the unattributed project bucket in grouped totals while session breakdowns stay native. Database selection mirrors upstream: `OPENCODE_DB` selects exactly one database (`:memory:` reads as no history, never a substitute), otherwise the release database under the XDG data root. Day buckets follow the reporting calendar with a start-of-day lower bound. - Providers: add Claude Platform for Console API credit balances, expiring subscription and purchased grants, and optional monthly spend, using its signed-in browser session (#4510). Thanks @mjaverto! - About: show bundled open-source acknowledgements and bundle SwiftPM and vendored dependency notices with the app, widget, CLI, and Linux desktop (#4483). Thanks @elijahfriedman! diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index fcc41abffdf..5c4b5aa61da 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -67,6 +67,7 @@ struct CostHistoryChartMenuView: View { } private let provider: UsageProvider + private let costProvenance: CostProvenance private let daily: [DailyEntry] private let totalCostUSD: Double? private let currencyCode: String @@ -96,9 +97,11 @@ struct CostHistoryChartMenuView: View { sessions: [CostUsageSessionBreakdown] = [], hidePersonalInfo: Bool, width: CGFloat, + costProvenance: CostProvenance = .listPriceEstimate, onMetricChanged: (@MainActor (ChartMetric) -> Void)? = nil) { self.provider = provider + self.costProvenance = costProvenance self.daily = daily self.totalCostUSD = totalCostUSD self.currencyCode = currencyCode @@ -338,7 +341,7 @@ struct CostHistoryChartMenuView: View { if self.totalCostUSD != nil || incompleteCount > 0 { VStack(alignment: .leading, spacing: 2) { Text(String( - format: incompleteCount > 0 ? L("Est. subtotal (%@): %@") : L("Est. total (%@): %@"), + format: self.costTotalLabelKey(incompleteCount: incompleteCount), self.windowLabel, self.totalCostUSD.map(self.costString) ?? "—") + UsageFormatter.incompleteUsageSuffix(incompleteCount)) @@ -348,7 +351,7 @@ struct CostHistoryChartMenuView: View { .truncationMode(.head) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) if let disclaimer = UsageFormatter.incompleteUsageNote(incompleteCount) - ?? Self.estimateDisclaimer(provider: self.provider) + ?? Self.estimateDisclaimer(provider: self.provider, provenance: self.costProvenance) { Text(disclaimer) .help(disclaimer) @@ -410,7 +413,22 @@ struct CostHistoryChartMenuView: View { } } - static func estimateDisclaimer(provider: UsageProvider) -> String? { + // Provider-specific by design: only Codex history can carry source-recorded OpenCode + // costs, so only its chart total label and disclaimer follow provenance like the + // menu-card hint does. + private func costTotalLabelKey(incompleteCount: Int) -> String { + guard self.provider == .codex, self.costProvenance == .sourceRecorded else { + return incompleteCount > 0 ? "Est. subtotal (%@): %@" : "Est. total (%@): %@" + } + return incompleteCount > 0 ? "Recorded subtotal (%@): %@" : "Recorded total (%@): %@" + } + + static func estimateDisclaimer(provider: UsageProvider, provenance: CostProvenance = .listPriceEstimate) -> String? { + if provider == .codex, provenance == .sourceRecorded || provenance == .mixed { + return provenance == .mixed + ? L("Estimated from token usage plus source-recorded OpenCode cost · not a subscription bill") + : L("Source-recorded cost from OpenCode local history · not a subscription bill") + } guard let hint = ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.chartEstimateDisclaimer else { return nil } diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 739eedf5097..4d11ab64490 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -249,7 +249,7 @@ extension UsageMenuCardView.Model { details.append("\(L("Top model")): \(Self.shortModelName(topModel))") } let tokenCost = ProviderDescriptorRegistry.descriptor(for: input.provider).tokenCost - let hintLines = Self.tokenUsageHintLines(provider: input.provider) + let hintLines = Self.tokenUsageHintLines(provider: input.provider, provenance: snapshot.costProvenance) if tokenCost.hintPlacement == .beforeRequestHistory { details.append(contentsOf: hintLines) } @@ -499,7 +499,7 @@ extension UsageMenuCardView.Model { value: L("%@ tokens", snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—"), emphasis: false)) } - var details = Self.tokenUsageHintLines(provider: provider) + var details = Self.tokenUsageHintLines(provider: provider, provenance: snapshot.costProvenance) if details.isEmpty { details.append(L("Local token history · dollar costs unavailable")) } if let coverage = Self.tokenHistoryCoverageHint(snapshot) { details.append(coverage) } if comparisonPeriodsEnabled { diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index da3b4003c9f..18187d59922 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -286,7 +286,7 @@ extension UsageMenuCardView.Model { let todayIncompleteCount = snapshot.summary(forLastDays: 1, calendar: calendar).incompleteRequestCount let err = (error?.isEmpty ?? true) ? nil : error let hints = [ - Self.tokenUsageHint(provider: provider), + Self.tokenUsageHint(provider: provider, provenance: snapshot.costProvenance), UsageFormatter.incompleteUsageNote(incompleteCount), tokensOnly ? Self.tokenHistoryCoverageHint(snapshot) : nil, ] @@ -334,8 +334,11 @@ extension UsageMenuCardView.Model { UsageFormatter.tokenCountString(totalTokens)) + suffix } - static func tokenUsageHint(provider: UsageProvider) -> String? { - let lines = Self.tokenUsageHintLines(provider: provider) + static func tokenUsageHint( + provider: UsageProvider, + provenance: CostProvenance = .listPriceEstimate) -> String? + { + let lines = Self.tokenUsageHintLines(provider: provider, provenance: provenance) return lines.isEmpty ? nil : lines.joined(separator: "\n") } @@ -356,8 +359,18 @@ extension UsageMenuCardView.Model { : L("Local token history is unavailable or incomplete.") } - static func tokenUsageHintLines(provider: UsageProvider) -> [String] { - ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.menuHintLines.map { hint in + static func tokenUsageHintLines( + provider: UsageProvider, + provenance: CostProvenance = .listPriceEstimate) -> [String] + { + // Provider-specific by design: only Codex history can mix source-recorded OpenCode + // costs into an otherwise list-price estimate, so only that hint follows provenance. + if provider == .codex, provenance == .sourceRecorded || provenance == .mixed { + return [provenance == .mixed + ? L("Estimated from token usage plus source-recorded OpenCode cost · not a subscription bill") + : L("Source-recorded cost from OpenCode local history · not a subscription bill")] + } + return ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.menuHintLines.map { hint in switch hint { case let .localized(key): L(key) case .estimate: UsageFormatter.costEstimateHint(provider: provider) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 48d1e2c0f20..0cdf0065c4f 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -160,11 +160,14 @@ struct ProviderDetailView: View { Section { ProviderTokenUsageInlineView(provider: self.provider, tokenUsage: tokenUsage) } header: { - Text(L(self.store.settings.codexLocalSessionCostLedgerEnabled ? "This Mac" : "Local usage")) + Text(L(self.store.settings.codexLocalSessionCostLedgerEnabled + || self.store.settings.codexIncludeOpenCodeLocalHistory + ? "This Mac" : "Local usage")) } footer: { SettingsSectionFooter(L(self.store.settings.codexLocalSessionCostLedgerEnabled - ? "Local usage is shared across accounts on this Mac." - : "Local usage comes from the current Codex profile.")) + || self.store.settings.codexIncludeOpenCodeLocalHistory + ? "Local usage is shared across accounts on this Mac." + : "Local usage comes from the current Codex profile.")) } } if let diagnostic = self.openAIWebDiagnostic { diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index afc06452ec1..b2d3b0ecd19 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -79,6 +79,7 @@ func spendDashboardProvenanceText(_ provenance: CostProvenance) -> String { switch provenance { case .listPriceEstimate: L("List-price equivalent") case .vendorMetered: L("Plan metered") + case .sourceRecorded: L("Source recorded") case .mixed: L("Metered and list-price") case .unknown: L("Spend unavailable") } diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index ab1b84f49ba..f8f0d4813f3 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -94,6 +94,23 @@ struct CodexProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "codex-opencode-local-history", + title: "Include OpenCode local history (OpenAI)", + subtitle: [ + "Optional, off by default.", + "Merges machine-local OpenCode usage for OpenAI-provider assistant records from ~/.local/share/opencode/opencode.db into this Mac's ambient Codex history.", + "Reads usage metadata only (read-only SQLite: model, tokens, recorded cost); prompts, responses and auth state are never read.", + "OpenCode records $0 cost for subscription usage; recorded dollars are shown as-is and never treated as billing.", + "Rows are attributed to the model only, never to an account, and are excluded from managed accounts, scoped CODEX_HOME scans and the Spend Dashboard.", + ].joined(separator: " "), + binding: context.binding(\.codexIncludeOpenCodeLocalHistory), + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-historical-tracking", title: "Historical tracking", diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 97f25023db7..9ddc99eec8a 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -31,7 +31,20 @@ struct CodexAccountScopedRefreshGuard: Equatable { @MainActor extension UsageStore { func accountScopedTokenSnapshot(for provider: UsageProvider) -> CostUsageTokenSnapshot? { - guard provider == .codex, !self.settings.codexLocalSessionCostLedgerEnabled else { + guard provider == .codex else { + return self.tokenSnapshotPublications[provider.instanceID]?.snapshot + } + if self.settings.codexIncludeOpenCodeLocalHistory { + // Ambient history is account-agnostic: an ambient publication stays valid across + // account switches (config-revision churn) as long as the ambient scope itself is + // still selected — but a pre-toggle scoped publication must not leak account rows + // into the machine-local lane, so the scope signature has to match. + guard let publication = self.tokenSnapshotPublications[provider.instanceID], + publication.scopeSignature == self.tokenSnapshotScopeSignature(for: provider) + else { return nil } + return publication.snapshot + } + guard !self.settings.codexLocalSessionCostLedgerEnabled else { return self.tokenSnapshotPublications[provider.instanceID]?.snapshot } return self.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 5b0c30efacc..cf61dde13f9 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1636,6 +1636,8 @@ "Usage snapshot unavailable" = "ملخص الاستخدام غير متاح"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "تعذر على CodexBar إعداد ملخص استخدام قابل للمشاركة. حدّث الاستخدام والإنفاق ثم حاول مرة أخرى."; "Incomplete" = "غير مكتمل"; +"Recorded total (%@): %@" = "إجمالي مسجل (%@): %@"; +"Recorded subtotal (%@): %@" = "إجمالي فرعي مسجل (%@): %@"; "Est. subtotal (%@): %@" = "الإجمالي الفرعي التقديري (%@): %@"; "Excluded requests with missing final usage: %d" = "الطلبات المستبعدة لعدم توفر بيانات الاستخدام النهائية: %d"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index f6475922a35..6c5fbad1810 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1636,6 +1636,8 @@ "Usage snapshot unavailable" = "Instantània d'ús no disponible"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar no ha pogut preparar una instantània d'ús per compartir. Actualitza Ús i despesa i torna-ho a provar."; "Incomplete" = "Incomplet"; +"Recorded total (%@): %@" = "Total registrat (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotal registrat (%@): %@"; "Est. subtotal (%@): %@" = "Subtotal estimat (%@): %@"; "Excluded requests with missing final usage: %d" = "Sol·licituds excloses sense dades d’ús finals: %d"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 57436d884e7..533ab954e4c 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1634,6 +1634,8 @@ "Usage snapshot unavailable" = "Nutzungsübersicht nicht verfügbar"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar konnte keine teilbare Nutzungsübersicht erstellen. Aktualisieren Sie „Nutzung & Ausgaben“ und versuchen Sie es erneut."; "Incomplete" = "Unvollständig"; +"Recorded total (%@): %@" = "Erfasster Gesamtbetrag (%@): %@"; +"Recorded subtotal (%@): %@" = "Erfasster Teilbetrag (%@): %@"; "Est. subtotal (%@): %@" = "Geschätzte Zwischensumme (%@): %@"; "Excluded requests with missing final usage: %d" = "Ausgeschlossene Anfragen ohne abschließende Nutzungsdaten: %d"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 8fe79601742..a6808c6648e 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1635,6 +1635,8 @@ "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again."; "Incomplete" = "Incomplete"; +"Recorded total (%@): %@" = "Recorded total (%@): %@"; +"Recorded subtotal (%@): %@" = "Recorded subtotal (%@): %@"; "Est. subtotal (%@): %@" = "Est. subtotal (%@): %@"; "Excluded requests with missing final usage: %d" = "Excluded requests with missing final usage: %d"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 16c2a5fec16..cb5b0b8a6f6 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1630,6 +1630,8 @@ "Usage snapshot unavailable" = "Instantánea de uso no disponible"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar no pudo preparar una instantánea de uso para compartir. Actualiza Uso y gasto y vuelve a intentarlo."; "Incomplete" = "Incompleto"; +"Recorded total (%@): %@" = "Total registrado (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotal registrado (%@): %@"; "Est. subtotal (%@): %@" = "Subtotal estimado (%@): %@"; "Excluded requests with missing final usage: %d" = "Solicitudes excluidas sin datos de uso finales: %d"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index b08e1b0a439..9e44a7a38c1 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1636,6 +1636,8 @@ "Usage snapshot unavailable" = "نمای کلی استفاده در دسترس نیست"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar نتوانست نمای کلی استفاده قابل اشتراک‌گذاری را آماده کند. استفاده و هزینه را تازه‌سازی کنید و دوباره امتحان کنید."; "Incomplete" = "ناقص"; +"Recorded total (%@): %@" = "مجموع ثبت‌شده (%@): %@"; +"Recorded subtotal (%@): %@" = "جمع جزئی ثبت‌شده (%@): %@"; "Est. subtotal (%@): %@" = "جمع جزئی تخمینی (%@): %@"; "Excluded requests with missing final usage: %d" = "درخواست‌های حذف‌شده به دلیل نبود داده‌های مصرف نهایی: %d"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 7b4da6b3904..c7cf2017047 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1631,6 +1631,8 @@ "Usage snapshot unavailable" = "Aperçu de l’utilisation indisponible"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar n’a pas pu préparer un aperçu de l’utilisation à partager. Actualisez Utilisation et dépenses, puis réessayez."; "Incomplete" = "Incomplet"; +"Recorded total (%@): %@" = "Total enregistré (%@) : %@"; +"Recorded subtotal (%@): %@" = "Sous-total enregistré (%@) : %@"; "Est. subtotal (%@): %@" = "Sous-total estimé (%@) : %@"; "Excluded requests with missing final usage: %d" = "Requêtes exclues sans données d’utilisation finales : %d"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 7a3b99083ca..df7a8bbf361 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1631,6 +1631,8 @@ "Usage snapshot unavailable" = "Resumo do uso non dispoñible"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar non puido preparar un resumo do uso para compartir. Actualiza Uso e gasto e téntao de novo."; "Incomplete" = "Incompleto"; +"Recorded total (%@): %@" = "Total rexistrado (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotal rexistrado (%@): %@"; "Est. subtotal (%@): %@" = "Subtotal estimado (%@): %@"; "Excluded requests with missing final usage: %d" = "Solicitudes excluídas sen datos de uso finais: %d"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index deb8f30e10d..46b23d7493a 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1635,6 +1635,8 @@ "Usage snapshot unavailable" = "Ringkasan penggunaan tidak tersedia"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar tidak dapat menyiapkan ringkasan penggunaan untuk dibagikan. Segarkan Penggunaan & pengeluaran, lalu coba lagi."; "Incomplete" = "Tidak lengkap"; +"Recorded total (%@): %@" = "Total tercatat (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotal tercatat (%@): %@"; "Est. subtotal (%@): %@" = "Subtotal perkiraan (%@): %@"; "Excluded requests with missing final usage: %d" = "Permintaan yang dikecualikan karena data penggunaan akhir tidak tersedia: %d"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index dc64735c40c..f1bed02497a 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1636,6 +1636,8 @@ "Usage snapshot unavailable" = "Riepilogo utilizzo non disponibile"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar non ha potuto preparare un riepilogo dell’utilizzo da condividere. Aggiorna Utilizzo e spesa e riprova."; "Incomplete" = "Incompleto"; +"Recorded total (%@): %@" = "Totale registrato (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotale registrato (%@): %@"; "Est. subtotal (%@): %@" = "Subtotale stimato (%@): %@"; "Excluded requests with missing final usage: %d" = "Richieste escluse senza dati di utilizzo finali: %d"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index f3398730950..3f5d96e0c5d 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1632,6 +1632,8 @@ "Usage snapshot unavailable" = "使用状況の概要を利用できません"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar で共有用の使用状況の概要を準備できませんでした。「使用状況と支出」を更新して、もう一度お試しください。"; "Incomplete" = "不完全"; +"Recorded total (%@): %@" = "記録された合計 (%@): %@"; +"Recorded subtotal (%@): %@" = "記録された小計 (%@): %@"; "Est. subtotal (%@): %@" = "推定小計 (%@): %@"; "Excluded requests with missing final usage: %d" = "最終的な使用量が未記録のため除外されたリクエスト: %d"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index d941867632b..1f3b5a6d535 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1601,6 +1601,8 @@ "Usage snapshot unavailable" = "사용량 개요를 사용할 수 없음"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar에서 공유할 사용량 개요를 준비하지 못했습니다. 사용량 및 지출을 새로 고친 후 다시 시도하세요."; "Incomplete" = "불완전"; +"Recorded total (%@): %@" = "기록된 합계 (%@): %@"; +"Recorded subtotal (%@): %@" = "기록된 소계 (%@): %@"; "Est. subtotal (%@): %@" = "예상 소계 (%@): %@"; "Excluded requests with missing final usage: %d" = "최종 사용량이 없어 제외된 요청: %d"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 653c84e3cca..c743a73586a 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1631,6 +1631,8 @@ "Usage snapshot unavailable" = "Gebruiksoverzicht niet beschikbaar"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar kon geen deelbaar gebruiksoverzicht maken. Vernieuw Gebruik en uitgaven en probeer het opnieuw."; "Incomplete" = "Onvolledig"; +"Recorded total (%@): %@" = "Vastgelegd totaal (%@): %@"; +"Recorded subtotal (%@): %@" = "Vastgelegd subtotaal (%@): %@"; "Est. subtotal (%@): %@" = "Geschat subtotaal (%@): %@"; "Excluded requests with missing final usage: %d" = "Uitgesloten verzoeken zonder definitieve gebruiksgegevens: %d"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index f02f3e0abe4..d892b5e605f 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1635,6 +1635,8 @@ "Usage snapshot unavailable" = "Podsumowanie użycia niedostępne"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar nie może przygotować podsumowania użycia do udostępnienia. Odśwież Zużycie i wydatki, a następnie spróbuj ponownie."; "Incomplete" = "Niekompletne"; +"Recorded total (%@): %@" = "Zarejestrowana łącznie (%@): %@"; +"Recorded subtotal (%@): %@" = "Zarejestrowana częściowo (%@): %@"; "Est. subtotal (%@): %@" = "Szacowana suma częściowa (%@): %@"; "Excluded requests with missing final usage: %d" = "Wykluczone żądania bez końcowych danych o użyciu: %d"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 1ce98cce38a..a76331c1435 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1632,6 +1632,8 @@ "Usage snapshot unavailable" = "Resumo de uso indisponível"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "O CodexBar não conseguiu preparar um resumo de uso para compartilhar. Atualize Uso e gastos e tente novamente."; "Incomplete" = "Incompleto"; +"Recorded total (%@): %@" = "Total registrado (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotal registrado (%@): %@"; "Est. subtotal (%@): %@" = "Subtotal estimado (%@): %@"; "Excluded requests with missing final usage: %d" = "Solicitações excluídas sem dados finais de uso: %d"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index d806607541c..6ef763af38d 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1633,6 +1633,8 @@ "Usage snapshot unavailable" = "Сводка использования недоступна"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar не удалось подготовить сводку использования для отправки. Обновите раздел «Использование и расходы» и повторите попытку."; "Incomplete" = "Неполные данные"; +"Recorded total (%@): %@" = "Записанный итог (%@): %@"; +"Recorded subtotal (%@): %@" = "Записанный подытог (%@): %@"; "Est. subtotal (%@): %@" = "Оценка подытога (%@): %@"; "Excluded requests with missing final usage: %d" = "Исключённые запросы без итоговых данных об использовании: %d"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 1c7b15ae4d5..7ae80f9dad5 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1631,6 +1631,8 @@ "Usage snapshot unavailable" = "Användningsöversikt är inte tillgänglig"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar kunde inte förbereda en delbar användningsöversikt. Uppdatera Användning och utgifter och försök igen."; "Incomplete" = "Ofullständigt"; +"Recorded total (%@): %@" = "Registrerad total (%@): %@"; +"Recorded subtotal (%@): %@" = "Registrerad delsumma (%@): %@"; "Est. subtotal (%@): %@" = "Uppskattad delsumma (%@): %@"; "Excluded requests with missing final usage: %d" = "Uteslutna förfrågningar utan slutliga användningsdata: %d"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index f17e4cf82c5..30be187b6b7 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1636,6 +1636,8 @@ "Usage snapshot unavailable" = "ภาพรวมการใช้งานไม่พร้อมใช้งาน"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar ไม่สามารถเตรียมภาพรวมการใช้งานสำหรับแชร์ได้ รีเฟรชการใช้งานและค่าใช้จ่ายแล้วลองอีกครั้ง"; "Incomplete" = "ไม่ครบถ้วน"; +"Recorded total (%@): %@" = "ยอดรวมที่บันทึก (%@): %@"; +"Recorded subtotal (%@): %@" = "ยอดรวมย่อยที่บันทึก (%@): %@"; "Est. subtotal (%@): %@" = "ยอดรวมย่อยโดยประมาณ (%@): %@"; "Excluded requests with missing final usage: %d" = "คำขอที่ไม่นับรวมเนื่องจากไม่มีข้อมูลการใช้งานขั้นสุดท้าย: %d"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index b92a5a10b19..6c1f94f2412 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1635,6 +1635,8 @@ "Usage snapshot unavailable" = "Kullanım özeti kullanılamıyor"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar paylaşılabilir bir kullanım özeti hazırlayamadı. Kullanım ve harcamayı yenileyip tekrar deneyin."; "Incomplete" = "Eksik"; +"Recorded total (%@): %@" = "Kaydedilen toplam (%@): %@"; +"Recorded subtotal (%@): %@" = "Kaydedilen ara toplam (%@): %@"; "Est. subtotal (%@): %@" = "Tahmini ara toplam (%@): %@"; "Excluded requests with missing final usage: %d" = "Nihai kullanım verileri eksik olduğu için hariç tutulan istekler: %d"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 8371d642fa7..d3943c09665 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1632,6 +1632,8 @@ "Usage snapshot unavailable" = "Огляд використання недоступний"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar не вдалося підготувати огляд використання для поширення. Оновіть розділ «Використання й витрати» та повторіть спробу."; "Incomplete" = "Неповні дані"; +"Recorded total (%@): %@" = "Записаний підсумок (%@): %@"; +"Recorded subtotal (%@): %@" = "Записаний проміжний підсумок (%@): %@"; "Est. subtotal (%@): %@" = "Орієнтовний підсумок (%@): %@"; "Excluded requests with missing final usage: %d" = "Виключені запити без остаточних даних про використання: %d"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 2bca93d393d..810b42c5ab9 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1633,6 +1633,8 @@ "Usage snapshot unavailable" = "Không có tổng quan mức sử dụng"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar không thể chuẩn bị tổng quan mức sử dụng để chia sẻ. Hãy làm mới Mức sử dụng và chi phí rồi thử lại."; "Incomplete" = "Chưa đầy đủ"; +"Recorded total (%@): %@" = "Tổng đã ghi nhận (%@): %@"; +"Recorded subtotal (%@): %@" = "Tổng phụ đã ghi nhận (%@): %@"; "Est. subtotal (%@): %@" = "Tổng phụ ước tính (%@): %@"; "Excluded requests with missing final usage: %d" = "Yêu cầu bị loại trừ do thiếu dữ liệu sử dụng cuối cùng: %d"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index e7398608c0b..59e90023603 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1647,6 +1647,8 @@ "Usage snapshot unavailable" = "用量概览不可用"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar 无法准备可分享的用量概览。请刷新“用量和支出”后重试。"; "Incomplete" = "不完整"; +"Recorded total (%@): %@" = "记录总额 (%@): %@"; +"Recorded subtotal (%@): %@" = "记录小计 (%@): %@"; "Est. subtotal (%@): %@" = "估算小计(%@):%@"; "Excluded requests with missing final usage: %d" = "因缺少最终用量而排除的请求:%d"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index e8cb7ff2eca..59ea764f59a 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1663,6 +1663,8 @@ "Usage snapshot unavailable" = "用量總覽無法使用"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar 無法準備可分享的用量總覽。請重新整理「使用量與支出」後再試一次。"; "Incomplete" = "不完整"; +"Recorded total (%@): %@" = "記錄總額 (%@): %@"; +"Recorded subtotal (%@): %@" = "記錄小計 (%@): %@"; "Est. subtotal (%@): %@" = "估算小計(%@):%@"; "Excluded requests with missing final usage: %d" = "因缺少最終用量而排除的請求:%d"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 5d045293041..5ab605370d3 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -548,6 +548,17 @@ extension SettingsStore { } } + /// Opt-in, default-off: merge machine-local OpenCode history for OpenAI-provider assistant + /// records into the ambient Codex menu/CLI history. Never affects managed Usage & Spend rows + /// or scoped CODEX_HOME scans (#3273 contract). + var codexIncludeOpenCodeLocalHistory: Bool { + get { self.defaultsState.codexIncludeOpenCodeLocalHistory } + set { + self.setDefault(\.codexIncludeOpenCodeLocalHistory, newValue, key: "codexIncludeOpenCodeLocalHistory") + self.noteBackgroundWorkSettingsChanged() + } + } + var costUsageHistoryDays: Int { get { self.costReportingPeriod.days(now: Date(), calendar: self.costUsageBucketCalendar) } set { self.costReportingPeriod = .rolling(days: max(1, min(365, newValue))) } diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 4d5ee41bebc..76e853ad2bd 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -53,6 +53,7 @@ extension SettingsStore { _ = self.copilotIconSecondaryWindowIDRaw _ = self.costUsageEnabled _ = self.codexLocalSessionCostLedgerEnabled + _ = self.codexIncludeOpenCodeLocalHistory _ = self.costUsageHistoryDays _ = self.costUsageBucketTimeZoneIdentifier _ = self.openCodexUsageLogsEnabled diff --git a/Sources/CodexBar/SettingsStore+TokenCost.swift b/Sources/CodexBar/SettingsStore+TokenCost.swift index 8aece924ffe..2976277867c 100644 --- a/Sources/CodexBar/SettingsStore+TokenCost.swift +++ b/Sources/CodexBar/SettingsStore+TokenCost.swift @@ -3,18 +3,29 @@ import Foundation extension SettingsStore { func costSummaryShowsInline(for provider: UsageProvider) -> Bool { - // Provider-specific by design: Codex's local ledger can enable its summary without the global scanner. + // Provider-specific by design: Codex's local ledger and OpenCode local history can + // enable its summary without the global scanner. let isEnabled = self.costUsageEnabled || - (provider == .codex && self.codexLocalSessionCostLedgerEnabled) + (provider == .codex && + (self.codexLocalSessionCostLedgerEnabled || self.codexIncludeOpenCodeLocalHistory)) return isEnabled && self.costSummaryDisplayStyle.showsInlineSummary } func costSummaryShowsSubmenu(for provider: UsageProvider) -> Bool { - self.isCostUsageEffectivelyEnabled(for: provider) && + self.isHistoryLaneEffectivelyEnabled(for: provider) && self.costSummaryDisplayStyle.showsCostSubmenu } + /// Opt-in OpenCode local history activates the ambient Codex menu-history lane on its + /// own, so users whose only source is OpenCode need no second toggle to see the merged + /// history. Spend Dashboard gating never consults this lane (#3273 contract). + func isHistoryLaneEffectivelyEnabled(for provider: UsageProvider) -> Bool { + // Provider-specific by design: the OpenCode opt-in enables Codex's ambient history lane alone. + self.isCostUsageEffectivelyEnabled(for: provider) || + (provider == .codex && self.codexIncludeOpenCodeLocalHistory) + } + func applyTokenCostDefaultIfNeeded() { // Tests cover detection directly; skip filesystem-driven auto-enablement to keep startup deterministic. guard !Self.isRunningTests else { return } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 71c05dd572c..df1e675c201 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -619,6 +619,8 @@ extension SettingsStore { costUsageEnabled: costUsageEnabled, codexLocalSessionCostLedgerEnabled: userDefaults.object( forKey: "codexLocalSessionCostLedgerEnabled") as? Bool ?? false, + codexIncludeOpenCodeLocalHistory: userDefaults.object( + forKey: "codexIncludeOpenCodeLocalHistory") as? Bool ?? false, costReportingPeriod: costReportingPeriod, costUsageBucketTimeZoneIdentifier: costUsageBucketTimeZoneIdentifier, openCodexUsageLogsEnabled: userDefaults.object(forKey: "openCodexUsageLogsEnabled") as? Bool ?? false, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index e04f587be9b..979dfd933f0 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -59,6 +59,7 @@ struct SettingsDefaultsState { var copilotSeatCreditEntitlementRaw: String var costUsageEnabled: Bool var codexLocalSessionCostLedgerEnabled: Bool + var codexIncludeOpenCodeLocalHistory: Bool var costReportingPeriod: CostReportingPeriod var costUsageBucketTimeZoneIdentifier: String var openCodexUsageLogsEnabled: Bool diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index b694fad2c04..a52133adc2b 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -652,6 +652,10 @@ struct SpendDashboardModel: Equatable, Sendable { sawVendorMeteredProvenance = true case .listPriceEstimate: sawEstimate = true + case .sourceRecorded: + // OpenCode local history never reaches the Spend Dashboard; treat any + // such snapshot conservatively like legacy unknown provenance. + sawEstimate = true case .mixed: sawVendorMeteredProvenance = true sawEstimate = true diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index e69c8fbee3b..22f1566e9f0 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -450,7 +450,8 @@ extension StatusItemController { projects: provider == .codex ? tokenSnapshot.projects : [], sessions: provider == .codex ? tokenSnapshot.sessions : [], hidePersonalInfo: self.settings.hidePersonalInfo, - width: width) + width: width, + costProvenance: tokenSnapshot.costProvenance) let hosting = MenuHostingView(rootView: chartView.codexBarLocalized()) let scrollView = CostHistoryMenuScrollView( hosting: hosting, diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index d2192a6c815..1fa7c2e192c 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -111,6 +111,7 @@ extension StatusItemController { var parts = [ "costEnabled=\(self.settings.costUsageEnabled ? "1" : "0")", "codexLocalCost=\(self.settings.codexLocalSessionCostLedgerEnabled ? "1" : "0")", + "opencodeLocal=\(self.settings.codexIncludeOpenCodeLocalHistory ? "1" : "0")", "costStyle=\(self.settings.costSummaryDisplayStyle.rawValue)", "openAIAttached=\(self.store.openAIDashboardAttachmentAuthorized ? "1" : "0")", "openAILogin=\(self.store.openAIDashboardRequiresLogin ? "1" : "0")", diff --git a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift index 1a0a289f2fa..9ab0d8350f0 100644 --- a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift @@ -9,6 +9,7 @@ private struct CodexCostCatchUpContext { let providerConfigRevision: UInt64 let costUsageSettingsRevision: UInt64 let includePiSessions: Bool + let includeOpenCodeLocalHistory: Bool @ProcessEnvironment private(set) var environment: [String: String] let piHistoryScopeGeneration: UInt64 } @@ -54,6 +55,8 @@ extension UsageStore { providerConfigRevision: self.settings.providerConfigRevision(for: .codex), costUsageSettingsRevision: self.settings.costUsageSettingsRevision, includePiSessions: self.shouldIncludePiSessionsInTokenSnapshot(for: .codex), + // Provider-specific by design: only the ambient Codex ledger merges machine-local OpenCode history. + includeOpenCodeLocalHistory: self.shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for: .codex), environment: self.environmentBase, piHistoryScopeGeneration: self.piHistoryScopeGeneration) self.codexCostCatchUpToken = token @@ -254,6 +257,7 @@ extension UsageStore { codexHomePath: context.codexHomePath, historyDays: context.historyDays, includePiSessions: context.includePiSessions, + includeOpenCodeLocalHistory: context.includeOpenCodeLocalHistory, calendar: self.settings.costUsageBucketCalendar, requireCompleteHistory: true, environment: context.environment) @@ -305,7 +309,7 @@ extension UsageStore { && self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision && self.settings.costUsageSettingsRevision == context.costUsageSettingsRevision && self.settings.costUsageHistoryDays == context.historyDays - && self.settings.isCostUsageEffectivelyEnabled(for: .codex) + && self.settings.isHistoryLaneEffectivelyEnabled(for: .codex) && self.isEnabled(.codex) && self.tokenCostScope(for: .codex).codexHomePath == context.codexHomePath && self.tokenSnapshotScopeSignature(for: .codex) == context.scopeSignature diff --git a/Sources/CodexBar/UsageStore+MenuCardModel.swift b/Sources/CodexBar/UsageStore+MenuCardModel.swift index 65a2e20bf0e..07ef1f52b71 100644 --- a/Sources/CodexBar/UsageStore+MenuCardModel.swift +++ b/Sources/CodexBar/UsageStore+MenuCardModel.swift @@ -139,13 +139,13 @@ extension UsageStore { limitsAvailability: isSettings && !isLive ? nil : self.knownLimitsAvailability(for: provider), usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, - tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), + tokenCostUsageEnabled: self.settings.isHistoryLaneEffectivelyEnabled(for: provider), tokenCostIsRefreshing: !isSettings && self.tokenCostRefreshIsActive(for: provider), codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, // Settings exposes available costs regardless of the menu's display style. costSummaryInlineEnabled: isSettings || self.settings.costSummaryShowsInline(for: provider), tokenCostMenuSectionEnabled: isSettings - ? self.settings.isCostUsageEffectivelyEnabled(for: provider) + ? self.settings.isHistoryLaneEffectivelyEnabled(for: provider) : descriptor.tokenCost.showsCostMenuSection && self.settings.costSummaryShowsSubmenu(for: provider), costComparisonPeriodsEnabled: !isSettings && self.settings.costComparisonPeriodsEnabled, showOptionalCreditsAndExtraUsage: optionalUsageOverride ?? self.settings.showOptionalCreditsAndExtraUsage, diff --git a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift index ceb1ecc1d97..3314821477e 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift @@ -128,6 +128,7 @@ extension UsageStore { historyDays: historyDays, cursorCookieHeaderOverride: cursorCookieHeaderOverride, includePiSessions: self.shouldIncludePiSessionsInTokenSnapshot(for: provider), + includeOpenCodeLocalHistory: false, reportContext: .spendDashboard) let snapshot = result.snapshot try Task.checkCancellation() diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 4f5b2d27ccf..c00f321c72e 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -133,6 +133,15 @@ extension UsageStore { return !piIsCostSource } + /// Provider-specific by design: only the ambient Codex ledger may merge machine-local + /// OpenCode history; scoped homes and every other provider stay excluded, and the source + /// is opt-in and default-off (#3273 contract). + func shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for provider: UsageProvider) -> Bool { + guard provider == .codex else { return false } + if self.tokenCostScope(for: provider).codexHomePath != nil { return false } + return self.settings.codexIncludeOpenCodeLocalHistory + } + func piRowsScopeSignature(for provider: UsageProvider) -> String? { guard provider == .claude || (provider == .codex && self.tokenCostScope(for: provider).codexHomePath == nil) @@ -148,6 +157,7 @@ extension UsageStore { historyDays: Int, cursorCookieHeaderOverride: String? = nil, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, reportContext: CostUsageReportContext = .regular) async throws -> CostUsageTokenResult { if let override = self._test_tokenUsageResultLoaderOverride { @@ -196,6 +206,7 @@ extension UsageStore { cursorCookieHeaderOverride: cursorCookieHeaderOverride, allowPricingRefresh: allowPricingRefresh, includePiSessions: effectiveIncludePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, piSessionProcessContexts: piSessionProcessContexts, bypassScannerDebounce: true, calendar: self.settings.costUsageBucketCalendar, @@ -376,7 +387,7 @@ extension UsageStore { @discardableResult func hydrateCachedTokenSnapshots(now: Date = Date()) -> Task? { // Provider-specific by design: only the Codex local ledger hydrates a cached snapshot before the first scan. - guard self.settings.isCostUsageEffectivelyEnabled(for: .codex) else { return nil } + guard self.settings.isHistoryLaneEffectivelyEnabled(for: .codex) else { return nil } guard self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata).contains(.codex) else { return nil } @@ -411,6 +422,7 @@ extension UsageStore { codexHomePath: scope.codexHomePath, historyDays: historyDays, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: self.shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for: .codex), calendar: self.settings.costUsageBucketCalendar, environment: self.environmentBase) .map { @@ -431,7 +443,7 @@ extension UsageStore { self.tokenAccountingScopeIsCurrent(result.accounting, for: .codex), self.settings.providerConfigRevision(for: .codex) == providerConfigRevision, self.settings.costUsageSettingsRevision == costUsageSettingsRevision, - self.settings.isCostUsageEffectivelyEnabled(for: .codex), + self.settings.isHistoryLaneEffectivelyEnabled(for: .codex), self.isEnabled(.codex), self.tokenCostScope(for: .codex).signature == scope.signature, self.settings.costUsageHistoryDays == historyDays, @@ -481,7 +493,11 @@ extension UsageStore { guard provider == .codex else { return (nil, provider.rawValue) } - if self.settings.codexLocalSessionCostLedgerEnabled { + // The local ledger and the OpenCode local history opt-in both describe machine-local + // ambient history, so either selects the ambient home like the ledger always has; + // without this the opt-in would scan a managed home whose scoped rows can never + // include OpenCode history, leaving the toggle ineffective (#3273 contract). + if self.settings.codexLocalSessionCostLedgerEnabled || self.settings.codexIncludeOpenCodeLocalHistory { return (nil, "codex:ambient") } let activeSource = self.settings.codexActiveSource @@ -533,6 +549,10 @@ extension UsageStore { { let scope = self.tokenCostScope(for: provider) var base = "\(scope.signature)|historyDays=\(historyDays)" + // Provider-specific by design: only Codex's ambient ledger carries the opt-in OpenCode local source. + if provider == .codex { + base += "|openCodeLocal=\(self.shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for: provider) ? "1" : "0")" + } if self.usesPiHistoryScope(provider) { base += "|piHistoryGeneration=\(self.piHistoryScopeGeneration)" } @@ -650,7 +670,7 @@ extension UsageStore { guard self.providerPublicationRevisionIsCurrent(scope.publicationRevision, for: provider), self.settings.providerConfigRevision(for: provider) == scope.providerConfigRevision, self.settings.costUsageSettingsRevision == scope.costSettingsRevision, - self.settings.isCostUsageEffectivelyEnabled(for: provider), + self.settings.isHistoryLaneEffectivelyEnabled(for: provider), self.isEnabled(provider), self.settings.costUsageHistoryDays == scope.historyDays else { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 3d032f6f1aa..fc1578d8afc 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1454,7 +1454,7 @@ extension UsageStore { return } - guard self.settings.isCostUsageEffectivelyEnabled(for: provider), self.isEnabled(provider) else { + guard self.settings.isHistoryLaneEffectivelyEnabled(for: provider), self.isEnabled(provider) else { self.resetTokenUsageState(for: provider) return } @@ -1530,7 +1530,8 @@ extension UsageStore { codexHomePath: costScope.codexHomePath, historyDays: historyDays, cursorCookieHeaderOverride: cursorCookieHeaderOverride, - includePiSessions: self.shouldIncludePiSessionsInTokenSnapshot(for: provider)) + includePiSessions: self.shouldIncludePiSessionsInTokenSnapshot(for: provider), + includeOpenCodeLocalHistory: self.shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for: provider)) try self.commitTokenUsageResult(result, context: refreshContext) } catch { guard self.tokenRefreshPublicationDisposition( diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index a670a0b0264..61ed977e765 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -81,6 +81,17 @@ extension CodexBarCLI { { Self.writeStderr("Warning: \(warning)\n") } + // Provider-specific by design: OpenCode rows cannot be attributed to Codex sessions. + if providers.contains(.codex), + !output.jsonOnly, + let warning = Self.sessionGroupingOpenCodeOmissionWarning( + provider: .codex, + groupBy: groupBy, + format: format, + includeOpenCodeLocalHistory: Self.openCodeLocalHistorySettingEnabled()) + { + Self.writeStderr("Warning: \(warning)\n") + } let fetcher = CostUsageFetcher(calendar: bucketCalendar) let outputProviders = Self.costProviders(providers, groupBy: groupBy, format: format) @@ -124,6 +135,10 @@ extension CodexBarCLI { groupBy: groupBy, format: format, includePiSessions: includePiSessions), + includeOpenCodeLocalHistory: Self.costIncludeOpenCodeLocalHistory( + provider: provider, + groupBy: groupBy, + format: format), piSessionProcessContexts: piSessionProcessContexts).reporting(period) switch format { case .text: @@ -206,12 +221,24 @@ extension CodexBarCLI { if descriptor.tokenCost.presentation == .tokensOnly || costIsEntirelyUnknown { return Self.renderLocalTokenHistoryText(name: name, snapshot: snapshot, useColor: useColor) } - // Provider-specific by design: Codex cost is explicitly an API-equivalent local-session estimate. - let title = provider == .codex - ? "\(name) API-equivalent estimate (not billed)" - : "\(name) Cost (API-rate estimate)" + let title: String + // Provider-specific by design: Codex titles state the estimate contract; recorded + // OpenCode rows change that claim instead of hiding under a list-price label. + if provider == .codex { + switch snapshot.costProvenance { + case .sourceRecorded: + title = "\(name) local history · source-recorded cost (not billed)" + case .mixed: + title = "\(name) API-equivalent estimate + source-recorded cost (not billed)" + default: + title = "\(name) API-equivalent estimate (not billed)" + } + } else { + title = "\(name) Cost (API-rate estimate)" + } let header = Self.costHeaderLine(title, useColor: useColor) let historyNotice = snapshot.historyIsFullyScanned ? nil : "Partial local history · recorded token subtotal" + // Provider-specific by design: only Codex local sessions carry project grouping. if groupBy == .project, provider == .codex { return Self.renderProjectCostText( header: [header, historyNotice].compactMap(\.self).joined(separator: "\n"), @@ -265,7 +292,7 @@ extension CodexBarCLI { calendar: calendar, useColor: useColor)) } - lines.append(Self.costEstimateHint(provider: provider)) + lines.append(Self.costEstimateHint(provider: provider, provenance: snapshot.costProvenance)) return lines.compactMap(\.self).joined(separator: "\n") } @@ -484,7 +511,7 @@ extension CodexBarCLI { var lines = [header, "Projects (\(historyLabel)):"] guard !snapshot.projects.isEmpty else { lines.append("—") - lines.append(Self.costEstimateHint(provider: .codex)) + lines.append(Self.costEstimateHint(provider: .codex, provenance: snapshot.costProvenance)) return lines.joined(separator: "\n") } for project in snapshot.projects { @@ -507,7 +534,7 @@ extension CodexBarCLI { } } } - lines.append(Self.costEstimateHint(provider: .codex)) + lines.append(Self.costEstimateHint(provider: .codex, provenance: snapshot.costProvenance)) return lines.joined(separator: "\n") } @@ -523,7 +550,7 @@ extension CodexBarCLI { lines.append("—") } // Provider-specific by design: session output uses Codex's local estimate hint. - lines.append(Self.costEstimateHint(provider: .codex)) + lines.append(Self.costEstimateHint(provider: .codex, provenance: snapshot.costProvenance)) return lines.joined(separator: "\n") } for session in snapshot.sessions { @@ -541,7 +568,7 @@ extension CodexBarCLI { lines.append("\(modelLabel) · \(Self.sessionTimestampString(session.lastActivity))") } // Provider-specific by design: session output uses Codex's local estimate hint. - lines.append(Self.costEstimateHint(provider: .codex)) + lines.append(Self.costEstimateHint(provider: .codex, provenance: snapshot.costProvenance)) return lines.joined(separator: "\n") } @@ -571,10 +598,23 @@ extension CodexBarCLI { return formatter.string(from: date) } - private static func costEstimateHint(provider: UsageProvider) -> String { - provider == .codex - ? "Not a subscription bill or plan value · local usage × public API prices" - : UsageFormatter.costEstimateHint(provider: provider) + private static func costEstimateHint( + provider: UsageProvider, + provenance: CostProvenance = .listPriceEstimate) -> String + { + // Provider-specific by design: only Codex history mixes source-recorded OpenCode + // costs into an otherwise list-price estimate, so only it needs provenance copy. + guard provider == .codex else { + return UsageFormatter.costEstimateHint(provider: provider) + } + switch provenance { + case .sourceRecorded: + return "Not a subscription bill or plan value · local usage · OpenCode rows carry source-recorded cost" + case .mixed: + return "Not a subscription bill or plan value · local usage × public API prices + source-recorded OpenCode cost" + default: + return "Not a subscription bill or plan value · local usage × public API prices" + } } private static func costHeaderLine(_ header: String, useColor: Bool) -> String { @@ -643,6 +683,22 @@ extension CodexBarCLI { + "Use the default cost view for merged totals." } + static func sessionGroupingOpenCodeOmissionWarning( + provider: UsageProvider, + groupBy: CostGroupBy, + format: OutputFormat, + includeOpenCodeLocalHistory: Bool) -> String? + { + // Provider-specific by design: only Codex local session text warns about omitted sources. + guard provider == .codex, + groupBy == .session, + format == .text, + includeOpenCodeLocalHistory + else { return nil } + return "Session grouping shows native Codex conversations only; OpenCode local history is omitted from this view. " + + "Use the default cost view for merged totals." + } + /// Provider-specific by design: only Codex JSONL sessions carry the local project/session indexes, /// so text-mode grouping skips other providers with a concise stderr notice. static func warnSkippedGroupingProviders( @@ -866,6 +922,27 @@ extension CodexBarCLI { !values.flags.contains("providerNativeOnly") } + /// The raw state of the "Include OpenCode local history" setting, used by warnings that + /// must fire even for views that exclude the merge. + static func openCodeLocalHistorySettingEnabled() -> Bool { + boolFromAppDefaults("codexIncludeOpenCodeLocalHistory") ?? false + } + + /// Provider-specific by design: machine-local OpenCode history joins only the ambient Codex + /// cost report, behind the same default-off app setting the menu uses (#3273 contract). + /// Session text reports rely on native Codex session attribution, so the merge stays out + /// of that view, exactly like Pi; JSON output keeps it. + static func costIncludeOpenCodeLocalHistory( + provider: UsageProvider, + groupBy: CostGroupBy, + format: OutputFormat) -> Bool + { + if provider == .codex, groupBy == .session, format == .text { + return false + } + return provider == .codex && Self.openCodeLocalHistorySettingEnabled() + } + static func decodeCostGroupBy(from values: ParsedValues) -> CostGroupBy { guard let raw = values.options["groupBy"]?.last?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 8f807f4c0c0..6d9e452ae4e 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -596,6 +596,7 @@ enum CLIRenderer { let provenance: String? = switch history.costProvenance { case .vendorMetered: "reported" case .listPriceEstimate: "estimated" + case .sourceRecorded: "recorded" case .mixed: "includes estimates" case .unknown: nil } diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index ac7ab559772..d5f83e329c1 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -1488,6 +1488,10 @@ extension CodexBarCLI { groupBy: .none, format: .json, includePiSessions: true), + includeOpenCodeLocalHistory: Self.costIncludeOpenCodeLocalHistory( + provider: provider, + groupBy: .none, + format: .json), piSessionProcessContexts: piSessionProcessContexts).reporting(context.period) return Self.makeCostPayload( provider: provider, diff --git a/Sources/CodexBarCore/CostProvenance.swift b/Sources/CodexBarCore/CostProvenance.swift index e4c6a88c90c..18336eaf363 100644 --- a/Sources/CodexBarCore/CostProvenance.swift +++ b/Sources/CodexBarCore/CostProvenance.swift @@ -6,7 +6,11 @@ public enum CostProvenance: String, Sendable, Equatable, Codable { case listPriceEstimate /// Vendor-reported metered spend (for example Cursor plan deductions). case vendorMetered - /// Window mixes list-price rows with vendor-metered rows. + /// Cost recorded by the source client (for example OpenCode's local history) and + /// surfaced as-is; no list-price estimation was applied to these rows. + case sourceRecorded + /// Window mixes list-price rows with rows that carry non-estimated costs + /// (vendor-metered or source-recorded). case mixed case unknown @@ -16,29 +20,29 @@ public enum CostProvenance: String, Sendable, Equatable, Codable { /// Narrow a snapshot-level provenance to the costs actually present in a window. /// Daily vendor-reported rows stay vendor-metered even when `meteredCostUSD` is absent. + /// `hasListPricedCosts` distinguishes list-priced cost from source-recorded cost inside + /// the window; when absent the window-cost bit is used as a legacy approximation. public static func forWindow( snapshot: CostProvenance, hasWindowCosts: Bool, - includesMetered: Bool) -> CostProvenance + includesMetered: Bool, + includesSourceRecorded: Bool = false, + hasListPricedCosts: Bool? = nil) -> CostProvenance { + let windowHasListPricedCosts = hasListPricedCosts ?? hasWindowCosts switch snapshot { case .vendorMetered: - includesMetered || hasWindowCosts ? .vendorMetered : .unknown + return includesMetered || hasWindowCosts ? .vendorMetered : .unknown case .mixed: - switch (includesMetered, hasWindowCosts) { - case (true, true): - .mixed - case (true, false): - .vendorMetered - case (false, true): - .listPriceEstimate - case (false, false): - .unknown - } + if includesMetered { return hasWindowCosts ? .mixed : .vendorMetered } + if includesSourceRecorded { return windowHasListPricedCosts ? .mixed : .sourceRecorded } + return hasWindowCosts ? .listPriceEstimate : .unknown case .listPriceEstimate: - hasWindowCosts ? .listPriceEstimate : .unknown + return hasWindowCosts ? .listPriceEstimate : .unknown + case .sourceRecorded: + return hasWindowCosts ? .sourceRecorded : .unknown case .unknown: - .unknown + return .unknown } } } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index b860fc02422..ba527c4376c 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -129,6 +129,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String? = nil, historyDays: Int = 30, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, calendar: Calendar? = nil, requireCompleteHistory: Bool = false, environment: [String: String] = ProcessInfo.processInfo.environment) async -> CachedCodexTokenSnapshotResult? @@ -139,6 +140,7 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, allowScopedCodexHome: requireCompleteHistory, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, requireCompleteHistory: requireCompleteHistory, scannerOptions: self.scannerOptions(calendar: calendar), environment: environment) @@ -214,6 +216,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, piWorkingDirectories: [URL] = [], piSessionProcessContexts: [PiSessionProcessContext] = [], bypassScannerDebounce: Bool = false, @@ -232,6 +235,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, bypassScannerDebounce: bypassScannerDebounce, piWorkingDirectories: piWorkingDirectories, piSessionProcessContexts: piSessionProcessContexts, @@ -251,6 +255,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, piWorkingDirectories: [URL] = [], piSessionProcessContexts: [PiSessionProcessContext] = [], bypassScannerDebounce: Bool, @@ -270,6 +275,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, bypassScannerDebounce: bypassScannerDebounce, piWorkingDirectories: piWorkingDirectories, piSessionProcessContexts: piSessionProcessContexts, @@ -419,6 +425,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, bypassScannerDebounce: Bool = false, piWorkingDirectories: [URL] = [], piSessionProcessContexts: [PiSessionProcessContext] = [], @@ -441,6 +448,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, bypassScannerDebounce: bypassScannerDebounce, piWorkingDirectories: piWorkingDirectories, piSessionProcessContexts: piSessionProcessContexts, @@ -464,6 +472,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, bypassScannerDebounce: Bool = false, piWorkingDirectories: [URL] = [], piSessionProcessContexts: [PiSessionProcessContext] = [], @@ -664,6 +673,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: false, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, piWorkingDirectories: piWorkingDirectories, piSessionProcessContexts: piSessionProcessContexts, scannerOptions: overrideScannerOptions, @@ -699,6 +709,10 @@ public struct CostUsageFetcher: Sendable { let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) // Provider-specific by design: scoped Codex homes exclude ambient Pi sessions from managed-profile totals. let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false + // Provider-specific by design: machine-local OpenCode history only joins the ambient + // Codex ledger; scoped homes and non-Codex providers never merge it (#3273 contract). + let shouldMergeOpenCodeLocalUsage = includeOpenCodeLocalHistory && + provider == .codex && Self.codexHistoryIsAmbient(codexHomePath: codexHomePath, environment: environment) await Self.refreshPricingIfAllowed( options: PricingRefreshOptions( provider: provider, @@ -740,6 +754,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, includePiSessions: includePiSessions, shouldMergePiUsage: shouldMergePiUsage, + shouldMergeOpenCodeLocalUsage: shouldMergeOpenCodeLocalUsage, scanOptions: scanOptions, environment: environment, piOptions: piOptions, @@ -772,6 +787,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: false, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, piWorkingDirectories: piWorkingDirectories, piSessionProcessContexts: piSessionProcessContexts, scannerOptions: options, @@ -789,7 +805,7 @@ public struct CostUsageFetcher: Sendable { historyCoverageIsEstablished: scanResult.inclusive.historyCoverageIsEstablished, historyScanIsPartial: scanResult.native.historyCoverageIsEstablished && !scanResult.inclusive.historyCoverageIsEstablished, - costProvenance: .listPriceEstimate, + costProvenance: scanResult.costProvenance, projects: scanResult.inclusive.projects, sessions: scanResult.inclusive.sessions, detailsAreComplete: scanResult.inclusive.detailsAreComplete, @@ -828,12 +844,26 @@ public struct CostUsageFetcher: Sendable { let inclusive: LocalTokenScanReport let native: LocalTokenScanReport let piScope: String? + /// Provenance of the inclusive report's costs; the native projection stays list-price. + let costProvenance: CostProvenance + } + + /// Provider-specific by design: machine-local OpenCode history joins only scans whose + /// Codex home was not explicitly selected, whether through the `codexHomePath` argument + /// or the process `CODEX_HOME` the native scanner honors. Managed profiles, scoped CLI + /// homes, and other providers never see machine-wide OpenCode rows (#3273 contract). + static func codexHistoryIsAmbient(codexHomePath: String?, environment: [String: String]) -> Bool { + if let path = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty { + return false + } + return environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false } private struct LocalTokenScanOptions: Sendable { let allowVertexClaudeFallback: Bool let includePiSessions: Bool let shouldMergePiUsage: Bool + let shouldMergeOpenCodeLocalUsage: Bool let scanOptions: CostUsageScanner.Options @ProcessEnvironment private(set) var environment: [String: String] let piOptions: PiSessionCostScanner.Options @@ -966,9 +996,41 @@ public struct CostUsageFetcher: Sendable { [daily, piScanResult.report], calendar: options.scanOptions.calendar) } + var openCodeLocalReport: CostUsageDailyReport? + // Mixed provenance claims a list-price estimate is included, so it must rest on an + // actual native cost — unpriced native rows (unknown model) contribute tokens only. + let listPriceHistoryPresent = daily.data.contains { $0.costUSD != nil } + if options.shouldMergeOpenCodeLocalUsage { + // Provider-specific by design: this optional merge reads machine-local OpenCode + // history for OpenAI-provider assistant records into the ambient Codex ledger. + // Scoped homes never reach this branch, and read failures skip the merge so a + // damaged OpenCode database cannot break the native Codex scan. + if let openCode = try? OpenCodeLocalUsageReader(environment: options.environment) + .loadDailyReport(since: since, until: now, calendar: options.scanOptions.calendar) + { + daily = CostUsageDailyReport.merged( + [daily, openCode], + calendar: options.scanOptions.calendar) + openCodeLocalReport = openCode + } + } + if let openCodeLocalReport { + // Provider-specific by design: OpenCode rows carry no Codex project or session + // attribution, so they join the unattributed bucket the Pi merge established. + // Session breakdowns stay native (they cannot attribute these rows). + if let project = Self.unknownProjectBreakdown(from: openCodeLocalReport) { + projects.append(project) + sessions = [] + } + } if provider == .codex { projects = Self.mergedProjectBreakdowns(projects) } + // Recorded OpenCode costs must not surface under a pure list-price claim: + // mixed when estimated rows coexist, sourceRecorded when only OpenCode rows exist. + let inclusiveCostProvenance: CostProvenance = openCodeLocalReport != nil + ? (listPriceHistoryPresent ? .mixed : .sourceRecorded) + : .listPriceEstimate return LocalTokenScanResult( inclusive: LocalTokenScanReport( daily: daily, @@ -978,7 +1040,8 @@ public struct CostUsageFetcher: Sendable { historyCoverageIsEstablished: native.historyCoverageIsEstablished && piScanIsComplete, detailsAreComplete: native.detailsAreComplete && piScanIsComplete && piScope == nil), native: native, - piScope: piScope) + piScope: piScope, + costProvenance: inclusiveCostProvenance) } } @@ -1349,6 +1412,7 @@ public struct CostUsageFetcher: Sendable { historyDays: Int = 30, allowScopedCodexHome: Bool = false, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, includeProjectAndSessionBreakdowns: Bool = true, requireCompleteHistory: Bool = false, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, @@ -1359,6 +1423,11 @@ public struct CostUsageFetcher: Sendable { let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) guard scopedCodexHomePath?.isEmpty != false || allowScopedCodexHome else { return nil } let piHistoryRequested = includePiSessions && scopedCodexHomePath?.isEmpty != false + // Provider-specific by design: machine-local OpenCode history only joins the ambient + // Codex cache publication; explicitly scoped homes (argument or CODEX_HOME) never + // request it. + let openCodeLocalHistoryRequested = includeOpenCodeLocalHistory && + Self.codexHistoryIsAmbient(codexHomePath: codexHomePath, environment: environment) let processContexts: [PiSessionProcessContext] = if piHistoryRequested, piScannerOptions == nil { await LocalAgentSessionScanner().piSessionProcessContexts(environment: environment) } else { @@ -1502,6 +1571,24 @@ public struct CostUsageFetcher: Sendable { } } + // Mirror the foreground scan: only a native row with an actual cost carries the + // list-price half of a mixed provenance claim. + let listPriceReportsPresent = reports.contains { report in + report.data.contains { $0.costUSD != nil } + } + var openCodeLocalReport: CostUsageDailyReport? + if openCodeLocalHistoryRequested { + // Provider-specific by design: this optional merge reads machine-local OpenCode + // history for OpenAI-provider assistant records into the cached ambient Codex + // publication so catch-up converges on the same rows the foreground scan shows. + if let openCode = try? OpenCodeLocalUsageReader(environment: environment) + .loadDailyReport(since: since, until: now, calendar: options.calendar) + { + reports.append(openCode) + openCodeLocalReport = openCode + } + } + try check() guard !reports.isEmpty else { return nil } // `previous` is an exact report captured before the current bounded refresh became @@ -1513,6 +1600,21 @@ public struct CostUsageFetcher: Sendable { // would let stale token rows inherit app-start freshness (#1964). lastRefreshAt // drives TTL suppression and stays native-only: a merged load must never delay a // rescan on the strength of another source's scan. + if let openCodeLocalReport { + // Provider-specific by design: OpenCode rows carry no Codex project or session + // attribution, so they join the unattributed bucket the Pi merge established. + if let project = Self.unknownProjectBreakdown(from: openCodeLocalReport) { + projects.append(project) + sessions = [] + } + } + // Recorded OpenCode costs must not surface under a pure list-price claim; + // mirror the foreground scan's provenance so cache and live views agree. + let cachedCostProvenance: CostProvenance = if openCodeLocalReport != nil { + listPriceReportsPresent ? .mixed : .sourceRecorded + } else { + .listPriceEstimate + } return CachedCodexTokenSnapshotResult( snapshot: Self.tokenSnapshot( from: CostUsageDailyReport.merged(reports, calendar: options.calendar), @@ -1520,7 +1622,7 @@ public struct CostUsageFetcher: Sendable { historyDays: clampedHistoryDays, calendar: options.calendar, historyCoverageIsEstablished: displayedHistoryCoverageIsEstablished, - costProvenance: .listPriceEstimate, + costProvenance: cachedCostProvenance, projects: Self.mergedProjectBreakdowns(projects), sessions: sessions, detailsAreComplete: nativeDetailsAreComplete && !piHistoryRequested, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 3ebbd239304..8ee3f9ce7c9 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -287,7 +287,11 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { provenance: CostProvenance.forWindow( snapshot: self.costProvenance, hasWindowCosts: !costs.isEmpty, - includesMetered: windowMetered != nil), + includesMetered: windowMetered != nil, + includesSourceRecorded: entries.contains { $0.costIsSourceRecorded }, + hasListPricedCosts: entries.contains { + $0.containsListPricedCost || ($0.costUSD != nil && !$0.costIsSourceRecorded) + }), meteredCostUSD: windowMetered, incompleteRequestCount: CostUsageIncompleteRequests.sum(entries.map(\.incompleteRequestCount))) } @@ -549,6 +553,17 @@ public struct CostUsageDailyReport: Sendable, Codable { public let pricedRequestCount: Int? public let unmeteredRequestCount: Int? public let estimatedRequestCount: Int? + /// True when this day's cost was recorded by the source client (for example OpenCode + /// local history) rather than estimated from token counts. Persists through caches so + /// window provenance stays truthful; older caches decode as plain estimated rows. + public let costIsSourceRecorded: Bool + /// True when this day mixes source-recorded cost with non-recorded (list-price + /// estimated or vendor-metered) cost — the one case where the recorded flag alone + /// would mislabel the whole day's cost as recorded. Pure days stay false: their cost + /// kind is already conveyed by `costIsSourceRecorded` and `costUSD`, which also keeps + /// single-report merges identity-preserving. Older caches decode as false and + /// refresh on the next scan. + public let containsListPricedCost: Bool package var hasOnlyIncompleteRequests: Bool { self.incompleteRequestCount > 0 && self.totalTokens == nil && self.costUSD == nil @@ -624,6 +639,8 @@ public struct CostUsageDailyReport: Sendable, Codable { case pricedRequestCount case unmeteredRequestCount case estimatedRequestCount + case costIsSourceRecorded + case containsListPricedCost } public init(from decoder: Decoder) throws { @@ -653,6 +670,10 @@ public struct CostUsageDailyReport: Sendable, Codable { self.pricedRequestCount = try container.decodeIfPresent(Int.self, forKey: .pricedRequestCount) self.unmeteredRequestCount = try container.decodeIfPresent(Int.self, forKey: .unmeteredRequestCount) self.estimatedRequestCount = try container.decodeIfPresent(Int.self, forKey: .estimatedRequestCount) + self.costIsSourceRecorded = + try container.decodeIfPresent(Bool.self, forKey: .costIsSourceRecorded) ?? false + self.containsListPricedCost = + try container.decodeIfPresent(Bool.self, forKey: .containsListPricedCost) ?? false } public init( @@ -670,7 +691,9 @@ public struct CostUsageDailyReport: Sendable, Codable { unpricedRequestCount: Int? = nil, unmeteredRequestCount: Int? = nil, estimatedRequestCount: Int? = nil, - pricedRequestCount: Int? = nil) + pricedRequestCount: Int? = nil, + costIsSourceRecorded: Bool = false, + containsListPricedCost: Bool = false) { self.date = date self.inputTokens = inputTokens @@ -687,6 +710,8 @@ public struct CostUsageDailyReport: Sendable, Codable { self.unmeteredRequestCount = unmeteredRequestCount self.estimatedRequestCount = estimatedRequestCount self.pricedRequestCount = pricedRequestCount + self.costIsSourceRecorded = costIsSourceRecorded + self.containsListPricedCost = containsListPricedCost } public func encode(to encoder: Encoder) throws { @@ -706,6 +731,12 @@ public struct CostUsageDailyReport: Sendable, Codable { try container.encodeIfPresent(self.pricedRequestCount, forKey: .pricedRequestCount) try container.encodeIfPresent(self.unmeteredRequestCount, forKey: .unmeteredRequestCount) try container.encodeIfPresent(self.estimatedRequestCount, forKey: .estimatedRequestCount) + if self.costIsSourceRecorded { + try container.encode(true, forKey: .costIsSourceRecorded) + } + if self.containsListPricedCost { + try container.encode(true, forKey: .containsListPricedCost) + } } private static func decodeModelsUsed(from container: KeyedDecodingContainer) -> [String]? { @@ -986,6 +1017,8 @@ extension CostUsageDailyReport { var costUSD: Double = 0 var sawCost = false var costIsComplete = true + var anyCostIsSourceRecorded = false + var anyCostIsListPriced = false var modelsUsed: Set = [] var breakdowns: [String: BreakdownAccumulator] = [:] @@ -1036,6 +1069,12 @@ extension CostUsageDailyReport { } } } + self.anyCostIsSourceRecorded = self.anyCostIsSourceRecorded || entry.costIsSourceRecorded + // Re-merges of already-merged reports must keep the mixed-day bit: a merged day + // folds recorded and priced rows into one entry whose own flag propagates here. + self.anyCostIsListPriced = self.anyCostIsListPriced + || entry.containsListPricedCost + || (entry.costUSD != nil && !entry.costIsSourceRecorded) if let modelsUsed = entry.modelsUsed { self.modelsUsed.formUnion(modelsUsed) } @@ -1079,7 +1118,9 @@ extension CostUsageDailyReport { unpricedRequestCount: includeCoverage ? self.coverage.exact?.unpriced : nil, unmeteredRequestCount: includeCoverage ? self.coverage.exact?.unmetered : nil, estimatedRequestCount: includeCoverage ? self.coverage.exact?.estimated : nil, - pricedRequestCount: includeCoverage ? self.coverage.exact?.priced : nil) + pricedRequestCount: includeCoverage ? self.coverage.exact?.priced : nil, + costIsSourceRecorded: self.anyCostIsSourceRecorded, + containsListPricedCost: self.anyCostIsListPriced && self.anyCostIsSourceRecorded) } func resolvedCostUSD() -> Double? { diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeLocalUsageReader.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeLocalUsageReader.swift new file mode 100644 index 00000000000..262f25e341c --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeLocalUsageReader.swift @@ -0,0 +1,414 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +#if canImport(SQLite3) || canImport(CSQLite3) + +/// Reads machine-local OpenCode usage metadata for assistant records whose provider is +/// exactly `openai`, so Codex-origin traffic generated through OpenCode can appear in the +/// ambient Codex menu/CLI history. +/// +/// Contract (see issue #3273): +/// - Reads usage metadata only, with `SQLITE_OPEN_READONLY`. Prompts, responses, tool output +/// and `auth.json` are never read; rows are never attributed to an account. +/// - Selects only assistant records with an exact `providerID == "openai"` match. +/// - Counts valid `step-finish` parts when present and falls back to message-level totals +/// only when no valid parts exist, mirroring the OpenCode Go reader's precedence. +/// - Deduplicates by stable message IDs: OpenCode v2 copies legacy `message` rows into +/// `session_message` under the same IDs, so v2 rows only contribute IDs the legacy table +/// does not already hold. No fuzzy timestamp or token matching is attempted. +/// - Preserves recorded token dimensions. Recorded cost is surfaced as-is (OpenCode records +/// `$0` for subscription usage); dollars are never recomputed or reinterpreted here. +public struct OpenCodeLocalUsageReader: Sendable { + private let databaseURLs: [URL] + + public init( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + environment: [String: String] = ProcessInfo.processInfo.environment) + { + let dataRoot = Self.xdgDataRoot(homeDirectory: homeDirectory, environment: environment) + let openCodeDirectory = dataRoot.appendingPathComponent("opencode", isDirectory: true) + self.databaseURLs = Self.candidateDatabaseURLs(dataDirectory: openCodeDirectory, environment: environment) + } + + public init(databaseURLs: [URL]) { + self.databaseURLs = databaseURLs + } + + /// Resolves the XDG data root the way OpenCode's `xdg-basedir` dependency does: an + /// absolute `XDG_DATA_HOME` wins; relative or missing values fall back to `~/.local/share`. + static func xdgDataRoot(homeDirectory: URL, environment: [String: String]) -> URL { + if let raw = Self.cleaned(environment["XDG_DATA_HOME"]), + raw.hasPrefix("/") + { + return URL(fileURLWithPath: raw, isDirectory: true) + } + return homeDirectory + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } + + /// Resolves the single database OpenCode itself would use, mirroring upstream + /// `database.path()`: `OPENCODE_DB` selects exactly one database (`:memory:` is in-memory + /// history an on-disk reader cannot see; absolute paths win as-is; relative names resolve + /// inside the data directory) and is never substituted with another database. Without an + /// explicit selection, upstream derives the channel from a build-time constant + /// (`InstallationChannel`), which an external reader cannot observe — release channels + /// (`latest`/`beta`/`prod`) and the `OPENCODE_DISABLE_CHANNEL_DB` opt-out both select the + /// plain database, so that is the contract's selection here. Non-release channel + /// databases stay reachable through `OPENCODE_DB`, exactly like pointing OpenCode itself + /// at them. + public static func candidateDatabaseURLs( + dataDirectory: URL, + environment: [String: String]) -> [URL] + { + if let override = Self.cleaned(environment["OPENCODE_DB"]) { + if override == ":memory:" { + return [] + } + if override.hasPrefix("/") { + return [URL(fileURLWithPath: override)] + } + return [dataDirectory.appendingPathComponent(override, isDirectory: false)] + } + return [dataDirectory.appendingPathComponent("opencode.db", isDirectory: false)] + } + + private static func cleaned(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// A daily report built from the matched rows. The report is `nil` when no candidate + /// database exists or it holds no matching rows; only genuine SQLite read failures throw, + /// so a missing or empty OpenCode installation never disrupts the native Codex scan. + public func loadDailyReport(since: Date, until: Date, calendar: Calendar) throws -> CostUsageDailyReport? { + for url in self.databaseURLs { + guard FileManager.default.fileExists(atPath: url.path) else { continue } + guard let rows = try self.readRows(databaseURL: url), !rows.isEmpty else { continue } + let entries = Self.dailyEntries(rows: rows, since: since, until: until, calendar: calendar) + guard !entries.isEmpty else { continue } + // Merge same-day entries into one entry per day, matching how native Codex + // history is keyed, so downstream aggregation sees a single day row. + return CostUsageDailyReport.merged( + [CostUsageDailyReport(data: entries, summary: nil)], + calendar: calendar) + } + return nil + } + + // MARK: - SQLite + + private struct SQLiteReadFailure: Error { + let code: Int32 + let message: String + } + + private func readRows(databaseURL: URL) throws -> [UsageRow]? { + do { + return try self.readRows(databaseURL: databaseURL, immutable: false) + } catch let failure as SQLiteReadFailure { + // A clean WAL shutdown can remove both sidecars while leaving the database header + // in WAL mode. Immutable mode reads that idle main file without recreating the + // sidecars; active WALs stay on the normal path. + guard failure.code == SQLITE_CANTOPEN, Self.walSidecarsAreMissing(at: databaseURL) else { + throw failure + } + return try self.readRows(databaseURL: databaseURL, immutable: true) + } + } + + private func readRows(databaseURL: URL, immutable: Bool) throws -> [UsageRow]? { + var db: OpaquePointer? + let filename = immutable + ? "\(databaseURL.absoluteURL.absoluteString)?immutable=1" + : databaseURL.path + let flags = immutable ? SQLITE_OPEN_READONLY | SQLITE_OPEN_URI : SQLITE_OPEN_READONLY + let openResult = sqlite3_open_v2(filename, &db, flags, nil) + guard openResult == SQLITE_OK, db != nil else { + // sqlite3_open_v2 can return a non-null connection handle even on failure. + // Capture the failure (it reads sqlite3_errcode/errmsg through the handle) + // before closing, then release the handle on every failure path. + let failure = Self.sqliteFailure(db: db, resultCode: openResult) + if let db { + _ = sqlite3_close(db) + } + throw failure + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let hasMessage = Self.tableExists(db, "message") + let hasParts = Self.tableExists(db, "part") + let hasSessionMessages = Self.tableExists(db, "session_message") + guard hasMessage || hasSessionMessages else { + // An idle WAL database with removed sidecars fails table detection with CANTOPEN + // before any query runs. Probe readability so that case surfaces as a failure the + // immutable fallback can handle, while a readable database without our tables + // reads as empty history rather than an error. + try Self.assertReadable(db) + return nil + } + + var statements: [String] = [] + if hasMessage { + statements.append(hasParts ? Self.messageAndPartUsageSQL : Self.messageOnlyUsageSQL) + } + if hasSessionMessages { + statements.append(Self.sessionMessageUsageSQL(hasLegacyMessages: hasMessage)) + } + let sql = statements.joined(separator: "\nUNION ALL\n") + + var stmt: OpaquePointer? + let prepareResult = sqlite3_prepare_v2(db, sql, -1, &stmt, nil) + guard prepareResult == SQLITE_OK else { + throw Self.sqliteFailure(db: db, resultCode: prepareResult) + } + defer { sqlite3_finalize(stmt) } + + var rows: [UsageRow] = [] + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { + break + } + guard step == SQLITE_ROW else { + throw Self.sqliteFailure(db: db, resultCode: step) + } + + let createdMs = sqlite3_column_int64(stmt, 0) + let cost = sqlite3_column_double(stmt, 1) + guard createdMs > 0, cost >= 0, cost.isFinite else { continue } + let model = sqlite3_column_text(stmt, 2).map { String(cString: $0) } ?? "" + let tokens = sqlite3_column_text(stmt, 3).flatMap { json in + try? JSONDecoder().decode(TokenCounts.self, from: Data(String(cString: json).utf8)) + } + rows.append(UsageRow( + createdMs: createdMs, + cost: cost, + model: model, + tokens: tokens.flatMap { $0.isValid ? $0 : nil })) + } + return rows + } + + private static func tableExists(_ db: OpaquePointer?, _ table: String) -> Bool { + sqlite3_table_column_metadata(db, nil, table, nil, nil, nil, nil, nil, nil) == SQLITE_OK + } + + /// Prepares a schema-touching statement so an unreadable database fails here with its + /// real SQLite result code instead of silently reading as "no tables". + private static func assertReadable(_ db: OpaquePointer?) throws { + var stmt: OpaquePointer? + let result = sqlite3_prepare_v2(db, "SELECT 1 FROM sqlite_master LIMIT 1", -1, &stmt, nil) + guard result == SQLITE_OK else { + throw Self.sqliteFailure(db: db, resultCode: result) + } + sqlite3_finalize(stmt) + } + + private static func walSidecarsAreMissing(at url: URL) -> Bool { + !FileManager.default.fileExists(atPath: url.path + "-wal") && + !FileManager.default.fileExists(atPath: url.path + "-shm") + } + + private static func sqliteFailure(db: OpaquePointer?, resultCode: Int32) -> SQLiteReadFailure { + SQLiteReadFailure( + code: db.map { sqlite3_errcode($0) } ?? resultCode, + message: db.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown error") + } + + // MARK: - SQL + + private static let providerMessagesSQL = """ + SELECT + id AS messageID, + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost, + json_type(data, '$.cost') IN ('integer', 'real') AS hasCost, + COALESCE(json_extract(data, '$.modelID'), '') AS modelID, + CASE WHEN json_type(data, '$.tokens') = 'object' + THEN json_extract(data, '$.tokens') END AS tokens + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'openai' + AND json_extract(data, '$.role') = 'assistant' + """ + + /// Message-level totals only, for databases without a `part` table. + private static let messageOnlyUsageSQL = """ + SELECT createdMs, cost, modelID, tokens + FROM (\(Self.providerMessagesSQL)) + WHERE hasCost + """ + + /// Valid `step-finish` parts win; the message fallback contributes only when a message + /// has no valid part, so a message is never counted twice. + private static let messageAndPartUsageSQL = """ + WITH provider_messages AS (\(Self.providerMessagesSQL)) + SELECT + CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.createdMs) AS INTEGER) + AS createdMs, + CAST(json_extract(p.data, '$.cost') AS REAL) AS cost, + m.modelID AS modelID, + CASE WHEN json_type(p.data, '$.tokens') = 'object' + THEN json_extract(p.data, '$.tokens') END AS tokens + FROM part p + JOIN provider_messages m ON m.messageID = p.message_id + WHERE json_valid(p.data) + AND json_extract(p.data, '$.type') = 'step-finish' + AND json_type(p.data, '$.cost') IN ('integer', 'real') + UNION ALL + SELECT createdMs, cost, modelID, tokens + FROM provider_messages m + WHERE hasCost + AND NOT EXISTS ( + SELECT 1 + FROM part p + WHERE p.message_id = m.messageID + AND json_valid(p.data) + AND json_extract(p.data, '$.type') = 'step-finish' + AND json_type(p.data, '$.cost') IN ('integer', 'real') + ) + """ + + /// OpenCode v2 writes new turns to `session_message` and copies older turns there under + /// the same IDs, so v2 rows only add IDs the legacy `message` table does not already hold. + private static func sessionMessageUsageSQL(hasLegacyMessages: Bool) -> String { + let legacyExclusion = hasLegacyMessages + ? "\n AND NOT EXISTS (SELECT 1 FROM message m WHERE m.id = s.id)" + : "" + return """ + SELECT + CAST(COALESCE(json_extract(s.data, '$.time.created'), s.time_created) AS INTEGER) AS createdMs, + CAST(json_extract(s.data, '$.cost') AS REAL) AS cost, + COALESCE(json_extract(s.data, '$.model.id'), '') AS modelID, + CASE WHEN json_type(s.data, '$.tokens') = 'object' + THEN json_extract(s.data, '$.tokens') END AS tokens + FROM session_message s + WHERE s.type = 'assistant' + AND json_valid(s.data) + AND json_extract(s.data, '$.model.providerID') = 'openai' + AND json_type(s.data, '$.cost') IN ('integer', 'real')\(legacyExclusion) + """ + } + + // MARK: - Rows + + private struct UsageRow { + let createdMs: Int64 + let cost: Double + /// The model OpenCode recorded for the turn; empty when unattributed. + let model: String + let tokens: TokenCounts? + } + + private struct TokenCounts: Decodable { + struct Cache: Decodable { + let read: Int? + let write: Int? + } + + let total: Int? + let input: Int? + let output: Int? + let reasoning: Int? + let cache: Cache? + + private var components: [Int?] { + [self.input, self.output, self.reasoning, self.cache?.read, self.cache?.write] + } + + var resolvedTotal: Int? { + // OpenCode separates output from reasoning and input from cache counts. + self.total ?? (self.components.allSatisfy { $0 != nil } + ? Self.checkedSum(self.components.compactMap(\.self)) : nil) + } + + var isValid: Bool { + (self.components + [self.total]).allSatisfy { ($0 ?? 0) >= 0 } && self.resolvedTotal != nil + } + + private static func checkedSum(_ values: [Int]) -> Int? { + var total = 0 + for value in values { + let (partial, overflow) = total.addingReportingOverflow(value) + guard !overflow else { return nil } + total = partial + } + return total + } + } + + /// Buckets rows into calendar-day entries keyed by the supplied reporting calendar, + /// matching how native Codex history is keyed, so they merge consistently with it. + private static func dailyEntries( + rows: [UsageRow], + since: Date, + until: Date, + calendar: Calendar) -> [CostUsageDailyReport.Entry] + { + // Normalize the lower bound to the reporting day's start: the foreground scan passes + // a `now`-anchored window, and a clock-time bound would drop earlier-today turns. + let sinceStartMs = Int64(calendar.startOfDay(for: since).timeIntervalSince1970 * 1000) + let untilMs = Int64(until.timeIntervalSince1970 * 1000) + return rows.compactMap { row in + guard row.createdMs >= sinceStartMs, row.createdMs <= untilMs else { return nil } + let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) + let key = CostUsageScanner.CostUsageDayRange.dayKey(from: date, calendar: calendar) + let trimmedModel = row.model.trimmingCharacters(in: .whitespacesAndNewlines) + let baseModel = trimmedModel.isEmpty ? "unknown" : trimmedModel + // Keep the source visible (#3273 contract): rows stay identifiable as OpenCode + // local history in UI model breakdowns and CLI JSON instead of silently merging + // into native model totals. + let model = "\(baseModel) (OpenCode local)" + let tokens = row.tokens + let breakdown = CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: row.cost, + totalTokens: tokens?.resolvedTotal, + requestCount: 1, + inputTokens: tokens?.input, + outputTokens: tokens?.output, + cacheReadTokens: tokens?.cache?.read, + cacheCreationTokens: tokens?.cache?.write, + reasoningTokens: tokens?.reasoning) + return CostUsageDailyReport.Entry( + date: key, + inputTokens: tokens?.input, + outputTokens: tokens?.output, + cacheReadTokens: tokens?.cache?.read, + cacheCreationTokens: tokens?.cache?.write, + reasoningTokens: tokens?.reasoning, + totalTokens: tokens?.resolvedTotal, + requestCount: 1, + costUSD: row.cost, + modelsUsed: [model], + modelBreakdowns: [breakdown], + // The cost comes from OpenCode's own records, not list-price estimation. + costIsSourceRecorded: true) + } + } +} + +#else + +public struct OpenCodeLocalUsageReader: Sendable { + public init( + homeDirectory _: URL = FileManager.default.homeDirectoryForCurrentUser, + environment _: [String: String] = ProcessInfo.processInfo.environment) + {} + + public init(databaseURLs _: [URL]) {} + + public func loadDailyReport(since _: Date, until _: Date, calendar _: Calendar) throws -> CostUsageDailyReport? { + nil + } +} + +#endif diff --git a/Tests/CodexBarTests/OpenCodeLocalHistoryMergeTests.swift b/Tests/CodexBarTests/OpenCodeLocalHistoryMergeTests.swift new file mode 100644 index 00000000000..ec0838f7591 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeLocalHistoryMergeTests.swift @@ -0,0 +1,591 @@ +import CodexBarCore +import Foundation +import SQLite3 +import Testing + +@testable import CodexBarCore + +/// Gating tests for the opt-in machine-local OpenCode history source (#3273 contract): +/// it may only join the ambient Codex ledger, never scoped homes, other providers, or a +/// default-off configuration. +@Suite(.serialized) +struct OpenCodeLocalHistoryMergeTests { + @Test + func `ambient codex scan merges opt-in opencode local history`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + + let scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let merged = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: ["OPENCODE_DB": databaseURL.path], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + #expect(merged.snapshot.last30DaysTokens == 35) + // Recorded $0 cost (subscription usage) is preserved as-is, never reinterpreted. + #expect(merged.snapshot.last30DaysCostUSD == 0) + // Rows keep a visible source label instead of silently merging into native totals. + let model = try #require(merged.snapshot.daily.first?.modelsUsed?.first) + #expect(model.hasSuffix("(OpenCode local)")) + // Grouped accounting carries the rows: the unattributed bucket holds the OpenCode + // daily data, and session breakdowns stay native (empty here, no native sessions). + let unattributed = try #require( + merged.snapshot.projects.first { $0.name == CostUsageProjectBreakdown.unknownProjectName }) + #expect(unattributed.daily.first?.totalTokens == 35) + #expect(merged.snapshot.sessions.isEmpty) + // Only recorded rows are present, so the snapshot must not claim list-price estimation. + #expect(merged.snapshot.costProvenance == .sourceRecorded) + } + + @Test + func `mixed history separates recorded costs from the list-price claim`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + try Self.writeNativeCodexSession(env: env, day: day, filename: "mixed-native.jsonl", tokens: 100) + + var scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + scannerOptions.refreshMinIntervalSeconds = 0 + let merged = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: [ + "OPENCODE_DB": databaseURL.path, + "HOME": env.root.path, + ], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + // Native (estimated) and OpenCode (recorded) rows both contribute. + #expect(merged.snapshot.last30DaysTokens == 135) + // Grouped totals keep both: the native project and the unattributed bucket. + let nativeProject = try #require(merged.snapshot.projects.first { + $0.name != CostUsageProjectBreakdown.unknownProjectName + }) + #expect(nativeProject.totalTokens == 100) + let unattributed = try #require( + merged.snapshot.projects.first { $0.name == CostUsageProjectBreakdown.unknownProjectName }) + #expect(unattributed.totalTokens == 35) + // Session breakdowns cannot attribute OpenCode rows and stay native. + #expect(merged.snapshot.sessions.isEmpty) + // The window mixes estimated and recorded costs; a pure list-price claim would be false. + #expect(merged.snapshot.costProvenance == .mixed) + let window = merged.snapshot.summary(forLastDays: 1, calendar: scannerOptions.calendar) + #expect(window.provenance == .mixed) + // Per-day entries keep the recorded marker so cached windows stay truthful too. + #expect(merged.snapshot.daily.contains { $0.costIsSourceRecorded }) + } + + @Test + func `narrow window of a mixed snapshot stays source recorded`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let olderDay = try env.makeLocalNoon(year: 2026, month: 4, day: 5) + // OpenCode recorded cost lands on the recent day; the priced native session is + // two days older, so the snapshot is mixed but today's window is recorded-only. + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + try Self.writeNativeCodexSession( + env: env, + day: olderDay, + filename: "older-priced-native.jsonl", + tokens: 100) + + var scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + scannerOptions.refreshMinIntervalSeconds = 0 + let merged = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: [ + "OPENCODE_DB": databaseURL.path, + "HOME": env.root.path, + ], + now: day, + historyDays: 7, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + // The full snapshot is mixed: list-priced native rows and recorded OpenCode rows. + #expect(merged.snapshot.costProvenance == .mixed) + // A window covering both days is mixed too. + #expect(merged.snapshot.summary(forLastDays: 7, calendar: scannerOptions.calendar).provenance == .mixed) + // But the one-day window holds only the recorded row: no list-price amount exists + // in it, so claiming a mixed estimate would be false. + let today = merged.snapshot.summary(forLastDays: 1, calendar: scannerOptions.calendar) + #expect(today.provenance == .sourceRecorded) + #expect(today.totalCostUSD != nil) + } + + @Test + func `unpriced native history keeps provenance source recorded`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + // A native model with no list price contributes tokens only; its rows carry no cost, + // so a mixed provenance would claim an estimate that is not actually included. + try Self.writeNativeCodexSession( + env: env, + day: day, + filename: "unpriced-native.jsonl", + tokens: 100, + model: "unknown-unpriced-model") + + var scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + scannerOptions.refreshMinIntervalSeconds = 0 + let merged = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: [ + "OPENCODE_DB": databaseURL.path, + "HOME": env.root.path, + ], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + // Tokens still merge from both sources. + #expect(merged.snapshot.last30DaysTokens == 135) + // But the cost claim stays recorded-only: no native list-price amount exists. + #expect(merged.snapshot.costProvenance == .sourceRecorded) + let window = merged.snapshot.summary(forLastDays: 1, calendar: scannerOptions.calendar) + #expect(window.provenance == .sourceRecorded) + } + + @Test + func `ambient codex scan stays native when the source is off`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + + let scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let native = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: ["OPENCODE_DB": databaseURL.path], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: false, + scannerOptions: scannerOptions) + + #expect((native.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `scoped codex home never merges opencode local history`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + let scopedHome = env.root.appendingPathComponent("scoped-codex-home", isDirectory: true) + try FileManager.default.createDirectory(at: scopedHome, withIntermediateDirectories: true) + + let scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: scopedHome.appendingPathComponent("sessions", isDirectory: true), + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let scoped = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: ["OPENCODE_DB": databaseURL.path], + now: day, + codexHomePath: scopedHome.path, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + #expect((scoped.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `environment selected codex home never merges opencode local history`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + let selectedHome = env.root.appendingPathComponent("selected-codex-home", isDirectory: true) + try FileManager.default.createDirectory(at: selectedHome, withIntermediateDirectories: true) + + let scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: selectedHome.appendingPathComponent("sessions", isDirectory: true), + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let scoped = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: [ + "OPENCODE_DB": databaseURL.path, + "CODEX_HOME": selectedHome.path, + ], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + #expect((scoped.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `claude scans never merge opencode local history`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + + let scannerOptions = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let claude = try await CostUsageFetcher.loadTokenResult( + provider: .claude, + environment: ["OPENCODE_DB": databaseURL.path], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + #expect((claude.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `cached codex publication merges opt-in opencode local history`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let scanTime = day.addingTimeInterval(-60) + var cache = CostUsageCache() + cache.lastScanUnixMs = Int64(scanTime.timeIntervalSince1970 * 1000) + cache.scanSinceKey = "2026-04-07" + cache.scanUntilKey = "2026-04-09" + cache.timeZoneIdentifier = options.calendar.timeZone.identifier + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + CostUsageStoreAccess.replace( + cacheRoot: env.cacheRoot, + cache: cache, + calendar: options.calendar) + + let merged = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + historyDays: 1, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: options, + environment: ["OPENCODE_DB": databaseURL.path]) + #expect(merged?.snapshot.last30DaysTokens == 35) + // Catch-up publication converges on the same grouped accounting and provenance. + #expect(merged?.snapshot.costProvenance == .sourceRecorded) + #expect(merged?.snapshot.projects.contains { + $0.name == CostUsageProjectBreakdown.unknownProjectName + } == true) + #expect(merged?.snapshot.sessions.isEmpty == true) + + let native = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + historyDays: 1, + includePiSessions: false, + scannerOptions: options, + environment: ["OPENCODE_DB": databaseURL.path]) + #expect(native?.snapshot.last30DaysTokens == 0) + #expect(native?.snapshot.costProvenance == .listPriceEstimate) + } + + @Test + func `cached scoped codex publication stays native`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + let scopedHome = env.root.appendingPathComponent("scoped-codex-home", isDirectory: true) + try FileManager.default.createDirectory(at: scopedHome, withIntermediateDirectories: true) + + let options = CostUsageScanner.Options( + codexSessionsRoot: scopedHome.appendingPathComponent("sessions", isDirectory: true), + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + var cache = CostUsageCache() + cache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) + cache.scanSinceKey = "2026-04-07" + cache.scanUntilKey = "2026-04-09" + cache.timeZoneIdentifier = options.calendar.timeZone.identifier + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + CostUsageStoreAccess.replace( + cacheRoot: env.cacheRoot, + cache: cache, + calendar: options.calendar) + + let scoped = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + codexHomePath: scopedHome.path, + historyDays: 1, + allowScopedCodexHome: true, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: options, + environment: ["OPENCODE_DB": databaseURL.path]) + #expect((scoped?.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `cached publication honors environment selected codex homes`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + let selectedHome = env.root.appendingPathComponent("selected-codex-home", isDirectory: true) + try FileManager.default.createDirectory(at: selectedHome, withIntermediateDirectories: true) + + let options = CostUsageScanner.Options( + codexSessionsRoot: selectedHome.appendingPathComponent("sessions", isDirectory: true), + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + var cache = CostUsageCache() + cache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) + cache.scanSinceKey = "2026-04-07" + cache.scanUntilKey = "2026-04-09" + cache.timeZoneIdentifier = options.calendar.timeZone.identifier + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + CostUsageStoreAccess.replace( + cacheRoot: env.cacheRoot, + cache: cache, + calendar: options.calendar) + + let scoped = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + historyDays: 1, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: options, + environment: [ + "OPENCODE_DB": databaseURL.path, + "CODEX_HOME": selectedHome.path, + ]) + #expect((scoped?.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `entries persisted before this feature decode without the recorded marker`() throws { + // A pre-feature cache entry carries every field this feature reads, plus none of the + // new ones. Upgrading must keep that stored data loading instead of discarding it. + let legacyJSON = """ + { + "date": "2026-04-08", + "inputTokens": 30, + "outputTokens": 5, + "totalTokens": 35, + "requestCount": 1, + "costUSD": 0.5, + "modelsUsed": ["gpt-5.1-codex"], + "modelBreakdowns": [ + {"modelName": "gpt-5.1-codex", "costUSD": 0.5, "totalTokens": 35, "requestCount": 1} + ] + } + """ + let entry = try JSONDecoder().decode( + CostUsageDailyReport.Entry.self, from: Data(legacyJSON.utf8)) + #expect(entry.totalTokens == 35) + #expect(entry.costUSD == 0.5) + #expect(entry.costIsSourceRecorded == false) + // Re-encoding round-trips the new field so refreshed caches stay self-describing. + let reencoded = try JSONDecoder().decode( + CostUsageDailyReport.Entry.self, from: JSONEncoder().encode(entry)) + #expect(reencoded == entry) + // A recorded entry must survive its own encode/decode round trip. + let recorded = CostUsageDailyReport.Entry( + date: "2026-04-08", + inputTokens: 30, + outputTokens: 5, + totalTokens: 35, + requestCount: 1, + costUSD: 0, + modelsUsed: ["gpt-5.1-codex (OpenCode local)"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.1-codex (OpenCode local)", + costUSD: 0, + totalTokens: 35, + requestCount: 1), + ], + costIsSourceRecorded: true) + let recordedData = try JSONEncoder().encode(recorded) + let recordedJSON = try #require(String(data: recordedData, encoding: .utf8)) + #expect(recordedJSON.contains("\"costIsSourceRecorded\":true")) + let decodedRecorded = try JSONDecoder().decode(CostUsageDailyReport.Entry.self, from: recordedData) + #expect(decodedRecorded.costIsSourceRecorded == true) + #expect(decodedRecorded == recorded) + } + + /// Writes one native Codex JSONL session with the given token count, so mixed-history + /// tests can assert how estimated and recorded rows combine. + private static func writeNativeCodexSession( + env: CostUsageTestEnvironment, + day: Date, + filename: String, + tokens: Int, + model: String = "gpt-5.4") throws + { + var partition = Calendar(identifier: .gregorian) + partition.timeZone = TimeZone(identifier: "UTC") ?? .current + let components = partition.dateComponents([.year, .month, .day], from: day) + let directory = env.codexSessionsRoot + .appendingPathComponent(String(format: "%04d", components.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", components.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", components.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day.addingTimeInterval(-2)), + "payload": [ + "id": "opencode-merge-\(filename)", + "cwd": env.root.appendingPathComponent("merge-project", isDirectory: true).path, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: day.addingTimeInterval(-1)), + "payload": ["model": model, "turn_id": "opencode-merge-turn"], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day), + "payload": [ + "type": "token_count", + "info": [ + "model": model, + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + ], + ], + ], + ]).write( + to: directory.appendingPathComponent(filename, isDirectory: false), + atomically: true, + encoding: .utf8) + } + + /// Builds an OpenCode SQLite database with one `openai` assistant record (35 tokens, + /// recorded $0 cost) plus one `nchc` record that must never be selected. + private static func makeOpenCodeDatabase(root: URL, day: Date) throws -> URL { + let databaseURL = root.appendingPathComponent("opencode-local.db", isDirectory: false) + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try Self.exec( + db: db, + sql: """ + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + """) + let createdMs = Int64(day.timeIntervalSince1970 * 1000) + try Self.insertRow( + db: db, + json: Self.json([ + "providerID": "openai", + "role": "assistant", + "modelID": "gpt-5.1-codex", + "cost": 0, + "time": ["created": createdMs], + "tokens": ["input": 30, "output": 5, "total": 35]]), + createdMs: createdMs) + try Self.insertRow( + db: db, + json: Self.json([ + "providerID": "nchc", + "role": "assistant", + "modelID": "glm-5.3-max", + "cost": 9, + "time": ["created": createdMs], + "tokens": ["input": 900, "output": 90, "total": 990]]), + createdMs: createdMs) + return databaseURL + } + + private static func insertRow(db: OpaquePointer?, json: String, createdMs: Int64) throws { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, UUID().uuidString, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_text(stmt, 3, json, -1, transient) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_int64(stmt, 5, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func json(_ payload: [String: Any]) -> String { + let data = try? JSONSerialization.data(withJSONObject: payload) + return String(data: data ?? Data(), encoding: .utf8) ?? "{}" + } + + private static func exec(db: OpaquePointer?, sql: String) throws { + var message: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &message) == SQLITE_OK else { + sqlite3_free(message) + throw SQLiteTestError.exec + } + } + + private enum SQLiteTestError: Error { + case open + case prepare + case step + case exec + } +} diff --git a/Tests/CodexBarTests/OpenCodeLocalHistorySettingsTests.swift b/Tests/CodexBarTests/OpenCodeLocalHistorySettingsTests.swift new file mode 100644 index 00000000000..d3317da1c18 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeLocalHistorySettingsTests.swift @@ -0,0 +1,203 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +/// Lane-activation tests for the opt-in OpenCode local history source (#3273 contract): +/// enabling it must surface the ambient Codex menu-history lane on its own, while the +/// shared cost-usage gate — and every Spend Dashboard path that consults it — stays +/// untouched for users who never opted in. +@MainActor +struct OpenCodeLocalHistorySettingsTests { + @Test + func `opencode opt-in activates the ambient codex history lane`() throws { + let settings = testSettingsStore(suiteName: "OpenCodeLocalHistorySettingsTests-lane") + // Default-off: the ambient lane follows the shared cost-usage gate. + #expect(!settings.costUsageEnabled) + #expect(!settings.codexLocalSessionCostLedgerEnabled) + #expect(!settings.codexIncludeOpenCodeLocalHistory) + #expect(!settings.isCostUsageEffectivelyEnabled(for: .codex)) + #expect(!settings.isHistoryLaneEffectivelyEnabled(for: .codex)) + + // Opting into OpenCode local history alone activates the ambient Codex + // menu-history lane, so a user whose only source is OpenCode needs no second + // toggle to see the merged history. + settings.codexIncludeOpenCodeLocalHistory = true + #expect(settings.isHistoryLaneEffectivelyEnabled(for: .codex)) + // The shared gate stays off — other providers and the Spend Dashboard paths + // that consult it never activate through this opt-in. + #expect(!settings.isCostUsageEffectivelyEnabled(for: .codex)) + #expect(!settings.isCostUsageEffectivelyEnabled(for: .claude)) + + // The classic toggles keep working on their own. + settings.codexIncludeOpenCodeLocalHistory = false + settings.codexLocalSessionCostLedgerEnabled = true + #expect(settings.isHistoryLaneEffectivelyEnabled(for: .codex)) + settings.codexLocalSessionCostLedgerEnabled = false + #expect(!settings.isHistoryLaneEffectivelyEnabled(for: .codex)) + } + + @Test + func `codex settings expose the opencode opt-in toggle default off`() throws { + let fixture = try self.makeSettingsFixture(suite: "OpenCodeLocalHistorySettingsTests-toggle") + let context = fixture.settingsContext(provider: .codex) + let toggle = try #require(CodexProviderImplementation().settingsToggles(context: context) + .first(where: { $0.id == "codex-opencode-local-history" })) + #expect(!toggle.binding.wrappedValue) + toggle.binding.wrappedValue = true + #expect(fixture.settings.codexIncludeOpenCodeLocalHistory) + #expect(fixture.settings.isHistoryLaneEffectivelyEnabled(for: .codex)) + } + + @Test + func `menu card exposes the codex cost section from the opencode opt-in alone`() throws { + let fixture = try self.makeSettingsFixture(suite: "OpenCodeLocalHistorySettingsTests-menu") + // Default-off with no other cost source: the ambient history lane stays hidden. + #expect(!fixture.settings.costUsageEnabled) + #expect(!fixture.settings.codexLocalSessionCostLedgerEnabled) + #expect(!fixture.store.menuCardInput(for: .codex, context: .menu).tokenCostUsageEnabled) + #expect(!fixture.store.menuCardInput(for: .codex, context: .menu).tokenCostMenuSectionEnabled) + + // The opt-in alone surfaces the menu-history lane for a user whose only source is + // OpenCode — no second cost toggle required. + fixture.settings.codexIncludeOpenCodeLocalHistory = true + #expect(fixture.store.menuCardInput(for: .codex, context: .menu).tokenCostUsageEnabled) + #expect(fixture.store.menuCardInput(for: .codex, context: .menu).tokenCostMenuSectionEnabled) + #expect(fixture.store.menuCardInput(for: .codex, context: .settings).tokenCostMenuSectionEnabled) + + // Other providers never activate through this opt-in. + #expect(!fixture.store.menuCardInput(for: .claude, context: .menu).tokenCostUsageEnabled) + #expect(!fixture.store.menuCardInput(for: .claude, context: .menu).tokenCostMenuSectionEnabled) + } + + @Test + func `inline summary style renders from the opencode opt-in alone`() throws { + let fixture = try self.makeSettingsFixture(suite: "OpenCodeLocalHistorySettingsTests-inline") + fixture.settings.costSummaryDisplayStyle = .inlineSummary + // Default-off with no other cost source: the inline lane stays hidden. + #expect(!fixture.settings.costSummaryShowsInline(for: .codex)) + + // The opt-in alone renders the inline summary — the only presentation under this + // style, since .inlineSummary disables the submenu. + fixture.settings.codexIncludeOpenCodeLocalHistory = true + #expect(fixture.settings.costSummaryShowsInline(for: .codex)) + #expect(!fixture.settings.costSummaryShowsSubmenu(for: .codex)) + #expect(fixture.store.menuCardInput(for: .codex, context: .menu).costSummaryInlineEnabled) + + // Other providers never activate through this opt-in. + #expect(!fixture.settings.costSummaryShowsInline(for: .claude)) + } + + @Test + func `opencode opt-in selects the ambient scope over the managed account home`() throws { + let fixture = try self.makeSettingsFixture(suite: "OpenCodeLocalHistorySettingsTests-scope") + fixture.settings._test_activeManagedCodexRemoteHomePath = "/tmp/managed-codex-home" + fixture.settings.codexActiveSource = .managedAccount(id: UUID()) + defer { fixture.settings._test_activeManagedCodexRemoteHomePath = nil } + + // Default-off: the managed home stays selected and the merge stays gated off. + let managedScope = fixture.store.tokenCostScope(for: .codex) + #expect(managedScope.codexHomePath == "/tmp/managed-codex-home") + #expect(!fixture.store.shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for: .codex)) + + // The opt-in selects the ambient home — the machine-local scope its rows live in — + // mirroring the local ledger, so the merge gate opens instead of scanning a home + // whose scoped rows can never include OpenCode history. + fixture.settings.codexIncludeOpenCodeLocalHistory = true + let ambientScope = fixture.store.tokenCostScope(for: .codex) + #expect(ambientScope.codexHomePath == nil) + #expect(ambientScope.signature == "codex:ambient") + #expect(fixture.store.shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for: .codex)) + } + + @Test + func `ambient opencode publication survives codex account switches`() throws { + let fixture = try self.makeSettingsFixture(suite: "OpenCodeLocalHistorySettingsTests-account") + let store = fixture.store + + // The opt-in selects the ambient scope; a publication under that scope installs. + store.settings.codexIncludeOpenCodeLocalHistory = true + store.installCachedTokenSnapshot(Self.snapshot(cost: 1), for: .codex) + #expect(store.accountScopedTokenSnapshot(for: .codex) != nil) + + // An account switch bumps the provider-config revision, but ambient history is + // account-agnostic: the still-current ambient publication must not be discarded + // or the This Mac section would vanish behind a rescan. + let revisionBefore = store.settings.providerConfigRevision(for: .codex) + store.settings.codexActiveSource = .managedAccount(id: UUID()) + #expect(store.settings.providerConfigRevision(for: .codex) != revisionBefore) + #expect(store.accountScopedTokenSnapshot(for: .codex) != nil) + + // A pre-toggle scoped publication must not leak account rows into the ambient lane. + store.settings.codexIncludeOpenCodeLocalHistory = false + store.settings._test_activeManagedCodexRemoteHomePath = "/tmp/managed-codex-home" + store.settings.codexActiveSource = .managedAccount(id: UUID()) + store.installCachedTokenSnapshot(Self.snapshot(cost: 2), for: .codex) + store.settings.codexIncludeOpenCodeLocalHistory = true + #expect(store.accountScopedTokenSnapshot(for: .codex) == nil) + } + + private static func snapshot(cost: Double) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: cost, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [], + updatedAt: Date()) + } + + private func makeSettingsFixture(suite: String) throws -> Fixture { + let settings = testSettingsStore(suiteName: suite) + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + settings._test_managedCodexAccountStoreURL = root.appendingPathComponent("accounts.json") + let environment = ["HOME": root.path, "CODEX_HOME": root.appendingPathComponent("codex").path] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(homeDirectory: root.path, cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + return Fixture(settings: settings, store: store) + } + + private struct Fixture { + let settings: SettingsStore + let store: UsageStore + private let state = ProviderSettingsContextState() + + @MainActor + func settingsContext(provider: UsageProvider) -> ProviderSettingsContext { + let settings = self.settings + let store = self.store + let state = self.state + return ProviderSettingsContext( + provider: provider, + settings: settings, + store: store, + statusText: { id in state.statusByID[id] }, + setStatusText: { id, text in + if let text { + state.statusByID[id] = text + } else { + state.statusByID.removeValue(forKey: id) + } + }, + lastAppActiveRunAt: { id in state.lastRunAtByID[id] }, + setLastAppActiveRunAt: { id, date in + if let date { + state.lastRunAtByID[id] = date + } else { + state.lastRunAtByID.removeValue(forKey: id) + } + }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } + } + + private final class ProviderSettingsContextState { + var statusByID: [String: String] = [:] + var lastRunAtByID: [String: Date] = [:] + } +} diff --git a/Tests/CodexBarTests/OpenCodeLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeLocalUsageReaderTests.swift new file mode 100644 index 00000000000..f1f84f7c384 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeLocalUsageReaderTests.swift @@ -0,0 +1,732 @@ +import Foundation +import Testing + +@testable import CodexBarCore + +#if canImport(SQLite3) +import SQLite3 + +struct OpenCodeLocalUsageReaderTests { + @Test + func `selects only openai provider records`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1.5, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 9, + model: "glm-5.3-max", providerID: "nchc") + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 9, + model: "claude-sonnet-4-5", providerID: "opencode-go") + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 9, + model: "gpt-5.1", providerID: "openrouter") + + let loaded = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(result.data.count == 1) + #expect(entry.costUSD == 1.5) + #expect(entry.requestCount == 1) + #expect(entry.modelsUsed == ["gpt-5.1-codex (OpenCode local)"]) + } + + @Test + func `step finish parts win over message fallback without double counting`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + let withParts = try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 5, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, messageID: withParts, createdMs: Self.ms(now), cost: 2, + tokens: ["input": 10, "output": 5, "reasoning": 1, "cache": ["read": 4, "write": 0]]) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 3, + model: "gpt-5.1-codex", providerID: "openai") + + let loaded = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 5) + #expect(entry.requestCount == 2) + #expect(entry.inputTokens == 10) + #expect(entry.outputTokens == 5) + #expect(entry.reasoningTokens == 1) + #expect(entry.cacheReadTokens == 4) + #expect(entry.cacheCreationTokens == 0) + // The message-level fallback row has no token metadata, so the day's aggregate total + // is conservatively unknown even though the component dimensions merge additively. + #expect(entry.totalTokens == nil) + } + + @Test + func `v2 session messages add new turns without double counting migrated ones`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL, includeSessionMessages: true) + let now = Date(timeIntervalSince1970: 1_772_798_400) + let migratedID = try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 3, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertSessionMessage( + databaseURL: env.databaseURL, id: migratedID, createdMs: Self.ms(now), cost: 3, + model: "gpt-5.1-codex") + try Self.insertSessionMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 2, + model: "gpt-5.2", tokens: ["input": 7, "output": 3, "total": 10]) + try Self.insertSessionMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 9, + model: "glm-5.3-max", providerID: "nchc") + + let loaded = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 5) + #expect(entry.requestCount == 2) + #expect(entry.modelsUsed?.sorted() == ["gpt-5.1-codex (OpenCode local)", "gpt-5.2 (OpenCode local)"]) + } + + @Test + func `v2 only database without legacy tables is read`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createV2OnlyDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertSessionMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", tokens: ["input": 2, "output": 1, "total": 3]) + + let loaded = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 1) + #expect(entry.requestCount == 1) + #expect(entry.totalTokens == 3) + } + + @Test + func `recorded zero cost keeps token history`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 0, + model: "gpt-5.1-codex", providerID: "openai", + tokens: ["input": 100, "output": 50, "total": 150]) + + let loaded = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 0) + #expect(entry.totalTokens == 150) + } + + @Test + func `missing and non numeric cost rows are skipped`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: nil, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertRawMessage( + databaseURL: env.databaseURL, + json: "{\"providerID\":\"openai\",\"role\":\"assistant\",\"cost\":\"free\"}") + + let result = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let entry = try #require(result?.data.first) + #expect(entry.requestCount == 1) + #expect(entry.costUSD == 1) + } + + @Test + func `negative or overflowing token counts are dropped`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "negative", providerID: "openai", + tokens: ["input": -5, "output": 1, "total": -4]) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "overflow", providerID: "openai", + tokens: ["input": Int.max, "output": 1, "total": Int.max]) + + let result = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let entry = try #require(result?.data.first) + #expect(entry.requestCount == 2) + #expect(entry.totalTokens == nil) + #expect(entry.modelsUsed?.sorted() == ["negative (OpenCode local)", "overflow (OpenCode local)"]) + } + + @Test + func `rows outside the requested window are excluded`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms(now.addingTimeInterval(-48 * 3600)), cost: 8, + model: "gpt-5.1-codex", providerID: "openai") + + let result = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let entry = try #require(result?.data.first) + #expect(entry.requestCount == 1) + #expect(entry.costUSD == 1) + } + + @Test + func `missing database or empty history returns nil`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + #expect(try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: Date.distantPast, until: Date(), calendar: .current) == nil) + + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: 1_772_798_400_000, cost: 9, + model: "glm-5.3-max", providerID: "nchc") + + #expect(try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: Date.distantPast, until: Date(), calendar: .current) == nil) + } + + @Test + func `idle wal database without sidecars is still read`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 4, + model: "gpt-5.1-codex", providerID: "openai") + try Self.configureIdleWAL(at: env.databaseURL) + + let loaded = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 4) + } + + @Test + func `active wal frames are read`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + + var writer = try Self.OpenWriter(url: env.databaseURL) + defer { writer.close() } + try writer.exec("PRAGMA journal_mode = WAL;") + try writer.insertMessage(createdMs: Self.ms(now), cost: 6, model: "gpt-5.1-codex", providerID: "openai") + // Keep the write transaction uncommitted... committed but un-checkpointed frames must be visible. + try writer.exec("PRAGMA wal_checkpoint(PASSIVE);") + + let loaded = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 6) + #expect(entry.requestCount == 1) + } + + @Test + func `absolute OPENCODE_DB override is honored`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 2, + model: "gpt-5.1-codex", providerID: "openai") + + let reader = OpenCodeLocalUsageReader( + homeDirectory: env.root, + environment: ["OPENCODE_DB": env.databaseURL.path]) + let loaded = try reader.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + #expect(result.data.first?.requestCount == 1) + } + + @Test + func `selection follows the upstream single database contract`() { + let dataDirectory = URL(fileURLWithPath: "/tmp/opencode-data", isDirectory: true) + // No explicit selection: the release database, regardless of environment channel + // hints (upstream derives the channel from a build constant, not the environment). + let plain = OpenCodeLocalUsageReader.candidateDatabaseURLs( + dataDirectory: dataDirectory, + environment: ["OPENCODE_UPDATE_CHANNEL": "canary"]) + #expect(plain.map(\.lastPathComponent) == ["opencode.db"]) + + let disabled = OpenCodeLocalUsageReader.candidateDatabaseURLs( + dataDirectory: dataDirectory, + environment: ["OPENCODE_UPDATE_CHANNEL": "canary", "OPENCODE_DISABLE_CHANNEL_DB": "1"]) + #expect(disabled.map(\.lastPathComponent) == ["opencode.db"]) + + let stable = OpenCodeLocalUsageReader.candidateDatabaseURLs( + dataDirectory: dataDirectory, + environment: ["OPENCODE_UPDATE_CHANNEL": "latest"]) + #expect(stable.map(\.lastPathComponent) == ["opencode.db"]) + + // Explicit selections are never substituted: one database, exactly as requested. + let relative = OpenCodeLocalUsageReader.candidateDatabaseURLs( + dataDirectory: dataDirectory, + environment: ["OPENCODE_DB": "custom.db"]) + #expect(relative.map(\.lastPathComponent) == ["custom.db"]) + + let absolute = OpenCodeLocalUsageReader.candidateDatabaseURLs( + dataDirectory: dataDirectory, + environment: ["OPENCODE_DB": "/elsewhere/open.db"]) + #expect(absolute.map(\.path) == ["/elsewhere/open.db"]) + + // In-memory history lives off disk; an on-disk reader sees nothing and must not + // silently substitute the stable database. + let memory = OpenCodeLocalUsageReader.candidateDatabaseURLs( + dataDirectory: dataDirectory, + environment: ["OPENCODE_DB": ":memory:"]) + #expect(memory.isEmpty) + } + + @Test + func `an explicit selection is never substituted with the default database`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + // The default database holds rows, but the explicitly selected one does not. + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + let selectedDirectory = env.root.appendingPathComponent("selected", isDirectory: true) + try FileManager.default.createDirectory(at: selectedDirectory, withIntermediateDirectories: true) + let selectedURL = selectedDirectory.appendingPathComponent("opencode.db", isDirectory: false) + try Self.createDatabase(at: selectedURL) + + let reader = OpenCodeLocalUsageReader(databaseURLs: OpenCodeLocalUsageReader.candidateDatabaseURLs( + dataDirectory: env.databaseURL.deletingLastPathComponent(), + environment: ["OPENCODE_DB": selectedURL.path])) + #expect(try reader.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) == nil) + } + + @Test + func `earlier today rows survive a clock time lower bound`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + // Pin the calendar so the test is independent of the host time zone: 2026-03-06 + // 15:00 UTC with the row at 10:00 UTC the same day. + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(identifier: "UTC") ?? .current + let now = try #require(utc.date(from: DateComponents( + year: 2026, month: 3, day: 6, hour: 15, minute: 0))) + let morning = try #require(utc.date(from: DateComponents( + year: 2026, month: 3, day: 6, hour: 10, minute: 0))) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(morning), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + + // A `now`-anchored one-hour window must still include the morning row once the + // lower bound is normalized to the reporting day's start. + let loaded = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: utc) + let result = try #require(loaded) + #expect(result.data.first?.requestCount == 1) + } + + @Test + func `day keys follow the supplied reporting calendar`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + var utcCalendar = Calendar(identifier: .gregorian) + utcCalendar.timeZone = TimeZone(identifier: "UTC") ?? .current + // 2026-10-16 00:30 UTC: a local-time calendar would file this under October 15. + let boundary = try #require(utcCalendar.date( + from: DateComponents(year: 2026, month: 10, day: 16, hour: 0, minute: 30))) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(boundary), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + + let loaded = try OpenCodeLocalUsageReader(databaseURLs: [env.databaseURL]) + .loadDailyReport(since: boundary.addingTimeInterval(-3600), until: boundary.addingTimeInterval(3600), calendar: utcCalendar) + let result = try #require(loaded) + #expect(result.data.first?.date == "2026-10-16") + } + + @Test + func `xdg data home relocates the database discovery`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + let xdgRoot = env.root.appendingPathComponent("xdg-data", isDirectory: true) + let xdgDirectory = xdgRoot.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: xdgDirectory, withIntermediateDirectories: true) + let databaseURL = xdgDirectory.appendingPathComponent("opencode.db", isDirectory: false) + try Self.createDatabase(at: databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + + let reader = OpenCodeLocalUsageReader( + homeDirectory: env.root, + environment: ["XDG_DATA_HOME": xdgRoot.path]) + let loaded = try reader.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + #expect(result.data.first?.requestCount == 1) + + // A relative XDG_DATA_HOME is ignored per the XDG spec; discovery falls back to + // ~/.local/share/opencode, which holds no database in this environment. + let relativeReader = OpenCodeLocalUsageReader( + homeDirectory: env.root, + environment: ["XDG_DATA_HOME": "relative/path"]) + #expect(try relativeReader.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) == nil) + } +} + +extension OpenCodeLocalUsageReaderTests { + private struct Environment { + let root: URL + let databaseURL: URL + } + + private static func makeEnvironment() throws -> Environment { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodeLocalUsageReaderTests-\(UUID().uuidString)", isDirectory: true) + let directory = root + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return Environment(root: root, databaseURL: directory.appendingPathComponent("opencode.db", isDirectory: false)) + } + + private static func createDatabase( + at url: URL, + includeParts: Bool = true, + includeSessionMessages: Bool = false) 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 message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + """) + if !includeParts { + try Self.exec(db: db, sql: "DROP TABLE part") + } + if includeSessionMessages { + try Self.exec( + db: db, + sql: """ + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + type TEXT NOT NULL, + seq INTEGER NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + """) + } + } + + private static func createV2OnlyDatabase(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 session_message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + type TEXT NOT NULL, + seq INTEGER NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + """) + } + + private static func configureIdleWAL(at url: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + do { + try Self.exec(db: db, sql: "PRAGMA journal_mode = WAL; PRAGMA wal_checkpoint(TRUNCATE);") + } catch { + sqlite3_close(db) + throw error + } + guard sqlite3_close(db) == SQLITE_OK else { throw SQLiteTestError.close } + + for suffix in ["-wal", "-shm"] { + let sidecarURL = URL(fileURLWithPath: url.path + suffix) + if FileManager.default.fileExists(atPath: sidecarURL.path) { + try FileManager.default.removeItem(at: sidecarURL) + } + } + } + + @discardableResult + private static func insertMessage( + databaseURL: URL, + createdMs: Int64, + cost: Double?, + model: String? = nil, + providerID: String = "openai", + tokens: Any? = nil) throws -> String + { + let messageID = UUID().uuidString + var payload: [String: Any] = [ + "providerID": providerID, + "role": "assistant", + "time": ["created": createdMs], + ] + if let cost { + payload["cost"] = cost + } + if let model { + payload["modelID"] = model + } + if let tokens { + payload["tokens"] = tokens + } + try Self.insertRawMessage(databaseURL: databaseURL, id: messageID, createdMs: createdMs, json: Self.json(payload)) + return messageID + } + + private static func insertRawMessage( + databaseURL: URL, + id: String = UUID().uuidString, + createdMs: Int64 = 0, + json: String) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, id, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_text(stmt, 3, json, -1, transient) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_int64(stmt, 5, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func insertStepFinishPart( + databaseURL: URL, + messageID: String, + createdMs: Int64, + cost: Double, + tokens: [String: Any] = ["input": 1, "output": 1, "total": 2]) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let payload: [String: Any] = [ + "type": "step-finish", + "cost": cost, + "tokens": tokens, + ] + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO part (id, message_id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, UUID().uuidString, -1, transient) + sqlite3_bind_text(stmt, 2, messageID, -1, transient) + sqlite3_bind_text(stmt, 3, "session-1", -1, transient) + sqlite3_bind_text(stmt, 4, Self.json(payload), -1, transient) + sqlite3_bind_int64(stmt, 5, createdMs) + sqlite3_bind_int64(stmt, 6, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func insertSessionMessage( + databaseURL: URL, + id: String = UUID().uuidString, + createdMs: Int64, + cost: Double, + model: String, + providerID: String = "openai", + tokens: [String: Any]? = nil) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + var payload: [String: Any] = [ + "model": ["providerID": providerID, "id": model], + "cost": cost, + "time": ["created": createdMs], + ] + if let tokens { + payload["tokens"] = tokens + } + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + """ + INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) + VALUES (?, ?, 'assistant', 0, ?, ?, ?) + """, + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, id, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_int64(stmt, 3, createdMs) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_text(stmt, 5, Self.json(payload), -1, transient) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func json(_ payload: [String: Any]) -> String { + let data = try? JSONSerialization.data(withJSONObject: payload) + return String(data: data ?? Data(), encoding: .utf8) ?? "{}" + } + + private static func exec(db: OpaquePointer?, sql: String) throws { + var message: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &message) == SQLITE_OK else { + sqlite3_free(message) + throw SQLiteTestError.exec + } + } + + private static func ms(_ date: Date) -> Int64 { + Int64(date.timeIntervalSince1970 * 1000) + } + + private enum SQLiteTestError: Error { + case close + case open + case prepare + case step + case exec + } + + /// A database connection that stays open across WAL writes, exercising the reader's + /// normal (non-immutable) open path against a live WAL. + private struct OpenWriter { + private var db: OpaquePointer? + + init(url: URL) throws { + guard sqlite3_open(url.path, &self.db) == SQLITE_OK else { throw SQLiteTestError.open } + } + + func exec(_ sql: String) throws { + try OpenCodeLocalUsageReaderTests.exec(db: self.db, sql: sql) + } + + func insertMessage(createdMs: Int64, cost: Double, model: String, providerID: String) throws { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + self.db, + "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let payload: [String: Any] = [ + "providerID": providerID, + "role": "assistant", + "modelID": model, + "cost": cost, + "time": ["created": createdMs], + ] + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, UUID().uuidString, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_text(stmt, 3, OpenCodeLocalUsageReaderTests.json(payload), -1, transient) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_int64(stmt, 5, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + mutating func close() { + if let db { + sqlite3_close(db) + } + self.db = nil + } + } +} + +#endif diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 6e1ddffac82..6d3b8afd2cf 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1206,7 +1206,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - anchor: "&& self.settings.isCostUsageEffectivelyEnabled(for: .codex)", + anchor: "&& self.settings.isHistoryLaneEffectivelyEnabled(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( @@ -1361,7 +1361,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - anchor: "self.settings.isCostUsageEffectivelyEnabled(for: .codex),", + anchor: "self.settings.isHistoryLaneEffectivelyEnabled(for: .codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( @@ -1428,13 +1428,13 @@ struct ProviderArchitectureGatekeeperTests { SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", occurrence: 1, - anchor: "lines.append(Self.costEstimateHint(provider: .codex))", + anchor: "lines.append(Self.costEstimateHint(provider: .codex, provenance: snapshot.costProvenance))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", occurrence: 2, - anchor: "lines.append(Self.costEstimateHint(provider: .codex))", + anchor: "lines.append(Self.costEstimateHint(provider: .codex, provenance: snapshot.costProvenance))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( @@ -3311,13 +3311,6 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 1, expectedReferenceFingerprint: ["claude@0"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), - AllowedProviderConstruct( - path: "Sources/CodexBarCLI/CLICostCommand.swift", - anchor: "provider == .codex", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", anchor: "let projects = provider == .codex", diff --git a/docs/codex.md b/docs/codex.md index a54717bf080..c7662cdedf8 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -340,7 +340,7 @@ token record. CodexBar cannot convert a quota percentage into dollars or recover | Native Codex | Supported session and archived-session records in the selected home. | | Pi / OMP | Supported backend history; enable [Pi](pi.md) for a separate Usage & Spend source. Account-scoped Codex rows stay native. | | OpenCodex | Opt-in `~/.opencodex/usage.jsonl` (or `$OPENCODEX_HOME/usage.jsonl`), with recorded provider provenance. This is distinct from OpenCode's session database. | -| OpenCode using OpenAI/Codex | No native session import; reusing its OAuth credentials only enables remote quota. The OpenCode Go SQLite reader selects `opencode-go` records. | +| OpenCode using OpenAI/Codex | Opt-in "Include OpenCode local history (OpenAI)" Codex setting (off by default) merges machine-local `openai`-provider OpenCode usage into this Mac's ambient Codex history as `… (OpenCode local)` model rows carrying source-recorded cost, never account-attributed. Reusing its OAuth credentials only enables remote quota. Scoped homes, managed accounts, and the Spend Dashboard always stay native. The OpenCode Go SQLite reader remains a separate `opencode-go` source. | | Amp | Account allowances and credits are supported, but Amp session-token history is not imported into Codex costs. | | Dots / cloud tasks | Included only when supported usage records are available in a scanned local source. There is no dots/cloud usage-history importer. | diff --git a/docs/opencode.md b/docs/opencode.md index 6494a4594be..7b8eb972986 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -60,10 +60,12 @@ reuse its `openai` OAuth entry for remote quota. Native Codex credentials take p `CODEX_HOME` prevents external fallback. External credentials stay read-only; stale credentials fail closed, and API-key entries are ignored. See [Codex external OAuth sources](codex.md#optional-external-oauth-sources-off-by-default). -This does not import OpenCode sessions into Codex token or spend totals. The base OpenCode provider tracks its web -dashboard, while the local SQLite reader described here selects only `opencode-go` assistant records for OpenCode Go. -Ordinary OpenCode sessions using OpenAI/Codex are not currently included in local cost history. OpenAI API-platform -usage is a separate [OpenAI provider](openai.md), not Codex subscription quota. +The base OpenCode provider tracks its web dashboard, while the local SQLite reader described here selects only +`opencode-go` assistant records for OpenCode Go. Ordinary OpenCode sessions using OpenAI/Codex can join this Mac's +ambient Codex history only through the opt-in "Include OpenCode local history (OpenAI)" Codex setting (off by +default); see the [Codex coverage table](codex.md#local-token-and-cost-coverage) for that boundary — managed +accounts, scoped homes, and the Spend Dashboard always stay native. OpenAI API-platform usage is a separate +[OpenAI provider](openai.md), not Codex subscription quota. ## Notes - OpenCode Go token accounts accept raw API keys as well as existing Cookie headers. Add each key with its own diff --git a/docs/providers.md b/docs/providers.md index 551e472a056..525b9ae40a9 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -66,6 +66,14 @@ Rows explicitly billed to `nous` appear under Nous Portal with OpenCodex provena name another vendor. Their dollar amounts are estimates from exact Nous/model pricing, never Portal credits or the producer's ignored `_meta` values; missing rates stay unpriced. See [Nous local usage and spend](nous.md#local-usage-and-spend). +OpenCode `~/.local/share/opencode/opencode.db` is a separate opt-in, read-only source for the ambient Codex +history (off by default). It selects only assistant records whose provider is exactly `openai`, so Codex usage +generated through OpenCode appears in the machine-local Codex menu/CLI history. Rows carry the recorded model and +token dimensions; recorded cost is surfaced as-is (OpenCode records `$0` for subscription usage) and is never +recomputed. Deduplication uses stable message IDs across OpenCode's v1 and v2 tables, never fuzzy matching, and +historical rows are permanently unscoped: they never join managed accounts, scoped `CODEX_HOME` scans, or the +Spend Dashboard. + The view stays local and does not upload usage history. Refreshes retain the last successful model if a replacement scan fails, while provider/account configuration changes replace obsolete results. Coverage text reports how many days of the selected local calendar window are covered by the scan window; a 30-day selection is not labeled as @@ -192,6 +200,7 @@ complete when the available scan window covers fewer days. - CLI RPC default: `codex ... app-server` JSON-RPC (`account/read`, `account/rateLimits/read`). - CLI PTY: manual diagnostics/parser coverage only; automatic refresh does not launch bare Codex TUI. - Local cost usage: scans `CODEX_HOME` (or `~/.codex`) `sessions` and sibling `archived_sessions` JSONL files for the configured history window. +- Optional OpenCode local history (off by default): a Codex settings toggle merges machine-local OpenCode usage for assistant records with provider `openai` from `~/.local/share/opencode/opencode.db` (v1 `message`/`part` tables with step-finish precedence, and v2 `session_message` rows deduplicated by stable message ID) into this Mac's ambient Codex history only. It is read-only usage metadata (model, tokens, recorded cost); prompts, responses, and `auth.json` are never read, rows are never attributed to an account, and recorded `$0` subscription cost is never reinterpreted as spend. Scoped `CODEX_HOME` scans, managed accounts, and the Spend Dashboard always exclude it. The database location follows OpenCode's own resolution: `OPENCODE_DB` selects exactly one database (`:memory:` reads as no history), otherwise the release database under the XDG data root (`XDG_DATA_HOME` when absolute, else `~/.local/share`). Merged rows surface as `(OpenCode local)` model names with source-recorded cost, so grouped totals and provenance stay truthful; session breakdowns remain native. - Completed cost catch-up publishes validated cached history without starting another scan. Native and included Pi/OMP caches must cover the requested window; unavailable or incompatible history preserves existing totals until a later refresh. Token timestamps retain the actual cache scan time. This does not resolve catch-up that remains pending while an active log continuously grows. - Status: Statuspage.io (OpenAI). - Details: `docs/codex.md`.