From 0a18391e1838ee9d110395f3409f785bfbca7c99 Mon Sep 17 00:00:00 2001 From: JC <1214493362@qq.com> Date: Thu, 2 Jul 2026 21:41:12 +0800 Subject: [PATCH 1/2] Add CrossModel provider (#1747) * fix(sakana): parse weekly/5-hour reset time as UTC, not device-local console.sakana.ai/billing always server-renders "Resets on " in UTC (confirmed via the raw HTML's embedded "timeZone":"UTC" hydration payload marker, and Sakana's own "Weekly usage resets every Monday at 00:00 UTC" copy elsewhere on the page). The browser only corrects that text to the viewer's local timezone client-side, after JS hydration -- which this HTML-only fetcher never runs. SakanaUsageFetcher.parseResetDate parsed the raw string using TimeZone.current instead, so every non-UTC user got a reset time off by exactly their UTC offset -- 5.5h early on an IST machine, verified by building main into the codexbar CLI and running it against a real account: the tool's own JSON output landed 5.5h before the true reset instant shown in a browser on the same page at the same moment. Removes the timeZone parameter entirely (it no longer has a legitimate caller-supplied value; the source data is always UTC) and hardcodes UTC in parseResetDate. Updates the existing fixture-based tests, which previously asserted the buggy local-timezone interpretation as if it were correct, and adds a regression test pinned to a literal Unix timestamp independent of the date-building helper. Fixes #1826. * address clawsweeper review: strengthen regression test, fix stale docs - The UTC regression test didn't actually pin the fix: on a UTC CI runner, the pre-fix TimeZone.current code would coincidentally still produce the correct instant, so the test would pass either way. Force NSTimeZone.default to UTC+14 for the test's duration (same pattern as BedrockUsageStatsTests) so it only passes when the parser genuinely ignores the device timezone. Verified by temporarily reverting the fix locally and confirming the test now fails with the expected 5.5h-off result before restoring it. - docs/sakana.md still said reset dates use the device's local time zone (TimeZone.current) -- exactly the behavior this PR removes. Updated to describe the actual UTC parsing and why, linking #1826. * test(sakana): serialize suite with process-global timezone mutation clawsweeper's re-review flagged that the new NSTimeZone.default override test lives in a non-serialized suite, unlike the existing BedrockUsageStatsTests that uses the same pattern -- unrelated parallel Swift Testing cases could observe UTC+14 and fail nondeterministically. Mark @Suite(.serialized), matching Bedrock's precedent exactly. * Localize provider menu strings Move remaining provider UI copy through localization helpers and add Traditional Chinese translations. Preserve existing personal-info redaction behavior while localizing the visible placeholders and usage labels. * fix(codex): keep cached error envelopes parseable Keep stored OpenAI dashboard and Codex credits cached failure envelopes in English so CodexUIErrorMapper can parse the Cached values from marker. Add zh-Hant regression coverage for localized user-facing cached error messages. * fix(claude): localize Claude Code sign-in action Route the Claude Code sign-in menu label through localization and add English, zh-Hant, and strict-locale catalog entries. Add a zh-Hant regression test for the Claude account action. * fix(localization): complete Claude sign-in catalog parity Add the Claude Code sign-in label to localization catalogs covered by strict parity tests so CI passes after the menu action was localized. * Add CrossModel provider CrossModel (https://crossmodel.ai) is a multi-provider, OpenAI- and Anthropic-compatible API aggregation platform billed against a prepaid USD wallet. This adds it as an API-key usage provider, modeled on the OpenRouter provider since the data shape is nearly identical (wallet balance + daily/weekly/monthly spend). Data source (two read-only endpoints, integer micro units): - GET /v1/credits -> balance_micro, uncollected_micro - GET /v1/usage -> daily/weekly/monthly cost_micro + tokens + counts Display: balance in the identity line (and optional menu bar), today/ this week/this month spend in the menu card, and a shared inline dashboard. No quota meter (prepaid wallet, no per-key limit). Auth: CROSSMODEL_API_KEY env var or Settings/CLI config. Base URL override via CROSSMODEL_API_URL (loopback HTTP allowed for local testing). Tests: CrossModelUsageStatsTests (decode, micro->USD, best-effort usage, 401, redaction, round-trip) and a ProviderConfigEnvironment regression covering Settings/CLI-saved keys. * Fix CrossModel menu metrics and diagnostics * fix: harden CrossModel provider integration * fix: suppress generic credits bar for CrossModel menu card CrossModel's balance lives in crossModelUsage, not the generic credits path, so the fallback creditsHint text ("Wallet balance from the CrossModel API") rendered as a misleading Credits row. Suppress it the same way OpenRouter's balance-only card does. * fix: repair CrossModel currency handling * fix: normalize CrossModel redirect ports * docs: update CrossModel provider count * fix: respect CrossModel optional usage setting * fix: render CrossModel CLI usage * docs: note Sakana reset timezone fix * test: align Sakana fixture with raw UTC HTML * fix: preserve hidden identity behavior * docs: note localization completion * fix: preserve CrossModel balance without usage * fix: expose CrossModel diagnostics --------- Co-authored-by: ss251 Co-authored-by: Shun Min Chang Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + README.md | 2 +- .../InlineUsageDashboardContent.swift | 49 +- .../CodexBar/MenuCardView+ModelHelpers.swift | 14 + Sources/CodexBar/MenuCardView.swift | 4 + Sources/CodexBar/MenuDescriptor.swift | 4 + .../CrossModelProviderImplementation.swift | 79 +++ .../CrossModel/CrossModelSettingsStore.swift | 14 + .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-crossmodel.svg | 5 + .../Resources/ar.lproj/Localizable.strings | 1 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/fa.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/id.lproj/Localizable.strings | 1 + .../Resources/it.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../Resources/nl.lproj/Localizable.strings | 1 + .../Resources/pl.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/sv.lproj/Localizable.strings | 1 + .../Resources/th.lproj/Localizable.strings | 1 + .../Resources/tr.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1 + .../Resources/vi.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../SettingsStore+MenuPreferences.swift | 2 +- .../StatusItemController+Animation.swift | 6 + Sources/CodexBar/UsageStore+APIKeyDebug.swift | 116 ++++ Sources/CodexBar/UsageStore+Accessors.swift | 2 + Sources/CodexBar/UsageStore.swift | 103 +-- Sources/CodexBarCLI/CLIDiagnoseCommand.swift | 2 + Sources/CodexBarCLI/CLIRenderer.swift | 59 ++ .../Config/ProviderConfigEnvironment.swift | 4 +- .../Generated/CodexParserHash.generated.swift | 2 +- .../CodexBarCore/Logging/LogCategories.swift | 1 + .../CrossModelProviderDescriptor.swift | 63 ++ .../CrossModel/CrossModelSettingsReader.swift | 52 ++ .../CrossModel/CrossModelUsageStats.swift | 417 ++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderDiagnosticExport.swift | 1 + .../Providers/ProviderTokenResolver.swift | 10 + .../CodexBarCore/Providers/Providers.swift | 2 + Sources/CodexBarCore/UsageFetcher.swift | 7 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../CLIDiagnoseCommandTests.swift | 13 + Tests/CodexBarTests/CLIOutputTests.swift | 71 ++ .../CrossModelUsageStatsTests.swift | 641 ++++++++++++++++++ .../MenuDescriptorCrossModelTests.swift | 193 ++++++ .../ProviderConfigEnvironmentTests.swift | 12 + .../ProviderDiagnosticExportTests.swift | 18 + .../SettingsStoreAdditionalTests.swift | 2 +- .../StatusItemBalanceDisplayTests.swift | 26 + .../UsageStorePathDebugTests.swift | 23 + docs/configuration.md | 2 +- docs/crossmodel.md | 69 ++ docs/index.html | 6 +- docs/llms.txt | 4 +- docs/providers.md | 10 +- docs/site-locales.mjs | 126 ++-- docs/social.html | 2 +- docs/social.png | Bin 581080 -> 581419 bytes 69 files changed, 2089 insertions(+), 179 deletions(-) create mode 100644 Sources/CodexBar/Providers/CrossModel/CrossModelProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/CrossModel/CrossModelSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-crossmodel.svg create mode 100644 Sources/CodexBar/UsageStore+APIKeyDebug.swift create mode 100644 Sources/CodexBarCore/Providers/CrossModel/CrossModelProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/CrossModel/CrossModelSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/CrossModel/CrossModelUsageStats.swift create mode 100644 Tests/CodexBarTests/CrossModelUsageStatsTests.swift create mode 100644 Tests/CodexBarTests/MenuDescriptorCrossModelTests.swift create mode 100644 docs/crossmodel.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a48e6a9..eed3653df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- CrossModel: add API-key wallet balance and UTC daily, weekly, and monthly spend tracking. Thanks @hujuncheng! - Localization: complete Traditional Chinese provider and menu coverage, and route remaining provider UI copy through localized formatters. Thanks @jack24254029! - Menu: add an opt-in setting to refresh provider usage whenever the menu opens without changing the periodic refresh clock. Thanks @dstier-git! - Qoder: add big-model credit usage from qoder.com and qoder.com.cn browser sessions or manual cookies. Thanks @Yuxin-Qiao! diff --git a/README.md b/README.md index e40daf9a2..414da327e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE) [![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app) -CodexBar — every AI coding limit in your menu bar. 55 providers. +CodexBar — every AI coding limit in your menu bar. 56 providers. Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons. diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 0408b1764..cde0dfed2 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -177,6 +177,11 @@ extension UsageMenuCardView.Model { { return Self.openRouterInlineDashboard(usage) } + if input.provider == .crossmodel, + let usage = input.snapshot?.crossModelUsage + { + return Self.crossModelInlineDashboard(usage) + } if input.provider == .zai, let modelUsage = input.snapshot?.zaiUsage?.modelUsage { @@ -424,6 +429,42 @@ extension UsageMenuCardView.Model { detailLines: details) } + private static func crossModelInlineDashboard(_ usage: CrossModelUsageSnapshot) -> InlineUsageDashboardModel? { + let periodValues: [(String, String, Double?)] = [ + ("day", L("Today"), usage.daily?.cost), + ("week", L("Week"), usage.weekly?.cost), + ("month", L("Month"), usage.monthly?.cost), + ] + let points = periodValues.compactMap { id, label, value -> InlineUsageDashboardModel.Point? in + guard let value else { return nil } + return InlineUsageDashboardModel.Point( + id: id, + label: label, + value: value, + accessibilityValue: "\(label): \(usage.currencyString(value))") + } + return InlineUsageDashboardModel( + accessibilityLabel: L("CrossModel API spend trend"), + valueStyle: Self.costValueStyle(currencyCode: usage.currency), + kpis: [ + .init(title: L("Balance"), value: usage.balanceDisplay, emphasis: true), + .init( + title: L("Today"), + value: usage.daily.map { usage.currencyString($0.cost) } ?? "—", + emphasis: false), + .init( + title: L("Week"), + value: usage.weekly.map { usage.currencyString($0.cost) } ?? "—", + emphasis: false), + .init( + title: L("Month"), + value: usage.monthly.map { usage.currencyString($0.cost) } ?? "—", + emphasis: false), + ], + points: points, + detailLines: []) + } + private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? { let periodValues: [(String, String, Double?)] = [ ("day", L("Today"), usage.keyUsageDaily), @@ -696,9 +737,11 @@ struct InlineUsageDashboardContent: View { var body: some View { VStack(alignment: .leading, spacing: 10) { self.kpis - MiniUsageBars(model: self.model) - .frame(height: 58) - .accessibilityLabel(self.model.accessibilityLabel) + if !self.model.points.isEmpty { + MiniUsageBars(model: self.model) + .frame(height: 58) + .accessibilityLabel(self.model.accessibilityLabel) + } self.detailLines } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index f785c3310..b46308b92 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -650,6 +650,20 @@ extension UsageMenuCardView.Model { return nil } + static func crossModelSpendNotes(_ usage: CrossModelUsageSnapshot) -> [String] { + let candidates: [(String, Double?)] = [ + (L("Today"), usage.daily?.cost), + (L("This week"), usage.weekly?.cost), + (L("This month"), usage.monthly?.cost), + ] + let rendered = candidates.compactMap { candidate -> String? in + guard let value = candidate.1 else { return nil } + return "\(candidate.0): \(usage.currencyString(value))" + } + guard !rendered.isEmpty else { return [] } + return [rendered.joined(separator: " · ")] + } + static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { guard provider == .openrouter, let usage = snapshot.openRouterUsage, diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 3eb839624..cb8c5282e 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -896,6 +896,10 @@ extension UsageMenuCardView.Model { return notes + subscriptionNotes } + if input.provider == .crossmodel, let crossModel = input.snapshot?.crossModelUsage { + return Self.crossModelSpendNotes(crossModel) + subscriptionNotes + } + guard input.provider == .openrouter, let openRouter = input.snapshot?.openRouterUsage else { diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 796d0fae0..3bc927c42 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -513,6 +513,10 @@ struct MenuDescriptor { for detail in kiloLogin.details { entries.append(.text("\(L("Activity")): \(detail)", .secondary)) } + } else if provider == .crossmodel { + if let loginMethodText, !loginMethodText.isEmpty { + entries.append(.text("\(L("Auth")): \(loginMethodText)", .secondary)) + } } else if let loginMethodText, !loginMethodText.isEmpty { if provider == .openrouter || provider == .mimo || provider == .poe, loginMethodText.localizedCaseInsensitiveContains("balance:") diff --git a/Sources/CodexBar/Providers/CrossModel/CrossModelProviderImplementation.swift b/Sources/CodexBar/Providers/CrossModel/CrossModelProviderImplementation.swift new file mode 100644 index 000000000..ab193c1b9 --- /dev/null +++ b/Sources/CodexBar/Providers/CrossModel/CrossModelProviderImplementation.swift @@ -0,0 +1,79 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct CrossModelProviderImplementation: ProviderImplementation { + let id: UsageProvider = .crossmodel + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.crossModelAPIToken + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + _ = context + return nil + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if CrossModelSettingsReader.apiToken(environment: context.environment) != nil { + return true + } + return !context.settings.crossModelAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsPickers(context _: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + [] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "crossmodel-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. " + + "Create a key in the CrossModel console at crossmodel.ai/console/api-keys.", + kind: .secure, + placeholder: "cm-...", + binding: context.stringBinding(\.crossModelAPIToken), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } + + @MainActor + func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { + guard let usage = context.snapshot?.crossModelUsage else { return } + + entries.append(.text("\(L("Balance")): \(usage.balanceDisplay)", .primary)) + if let daily = usage.daily { + entries.append(.text( + "\(L("Today")): \(usage.currencyString(daily.cost)) · " + + "\(UsageFormatter.tokenCountString(daily.totalTokens)) \(L("tokens"))", + .secondary)) + } + if let weekly = usage.weekly { + entries.append(.text( + "\(L("Week")): \(usage.currencyString(weekly.cost)) · " + + "\(UsageFormatter.tokenCountString(weekly.requestCount)) \(L("requests"))", + .secondary)) + } + if let monthly = usage.monthly { + entries.append(.text( + "\(L("Month")): \(usage.currencyString(monthly.cost)) · " + + "\(UsageFormatter.tokenCountString(monthly.requestCount)) \(L("requests"))", + .secondary)) + } + } +} diff --git a/Sources/CodexBar/Providers/CrossModel/CrossModelSettingsStore.swift b/Sources/CodexBar/Providers/CrossModel/CrossModelSettingsStore.swift new file mode 100644 index 000000000..efd07a9f2 --- /dev/null +++ b/Sources/CodexBar/Providers/CrossModel/CrossModelSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var crossModelAPIToken: String { + get { self.configSnapshot.providerConfig(for: .crossmodel)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .crossmodel) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .crossmodel, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index a19736a49..4433bef1b 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -68,6 +68,7 @@ enum ProviderImplementationRegistry { case .deepgram: DeepgramProviderImplementation() case .poe: PoeProviderImplementation() case .chutes: ChutesProviderImplementation() + case .crossmodel: CrossModelProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-crossmodel.svg b/Sources/CodexBar/Resources/ProviderIcon-crossmodel.svg new file mode 100644 index 000000000..e7634bd5c --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-crossmodel.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 55d3230c9..33a51901e 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1134,3 +1134,4 @@ "byte_unit_megabytes" = "ميغابايتات"; "byte_unit_gigabyte" = "غيغابايت"; "byte_unit_gigabytes" = "غيغابايتات"; +"CrossModel API spend trend" = "اتجاه إنفاق CrossModel API"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index ccda20fce..bc9e8fed3 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1129,3 +1129,4 @@ "Weekly" = "Setmanal"; "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "No s'ha trobat el token d'API de z.ai. Definiu apiKey a ~/.codexbar/config.json o Z_AI_API_KEY."; "≈ %d%% run-out risk" = "≈ %d%% risc d'esgotament"; +"CrossModel API spend trend" = "Tendència de despesa de l'API de CrossModel"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 103082c68..28388c60e 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1127,3 +1127,4 @@ "byte_unit_megabytes" = "Megabyte"; "byte_unit_gigabyte" = "Gigabyte"; "byte_unit_gigabytes" = "Gigabyte"; +"CrossModel API spend trend" = "CrossModel-API-Ausgabentrend"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 2ecdf854b..b80e975a9 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -859,6 +859,7 @@ "Cache read" = "Cache read"; "Claude Admin API 30 day spend trend" = "Claude Admin API 30 day spend trend"; "OpenRouter API key spend trend" = "OpenRouter API key spend trend"; +"CrossModel API spend trend" = "CrossModel API spend trend"; "z.ai hourly token trend" = "z.ai hourly token trend"; "MiniMax 30 day token usage trend" = "MiniMax 30 day token usage trend"; "Today cash" = "Today cash"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 8da5c7323..7ddfb63fa 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -986,3 +986,4 @@ "byte_unit_gigabyte" = "gigabyte"; "byte_unit_gigabytes" = "gigabytes"; "Open Status Page" = "Abrir página de estado"; +"CrossModel API spend trend" = "Tendencia de gasto de la API de CrossModel"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index c535f0c2f..16c98d216 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1134,3 +1134,4 @@ "byte_unit_megabytes" = "مگابایت"; "byte_unit_gigabyte" = "گیگابایت"; "byte_unit_gigabytes" = "گیگابایت"; +"CrossModel API spend trend" = "روند هزینه‌ی CrossModel API"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index b04928028..283331f94 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1125,3 +1125,4 @@ "byte_unit_megabytes" = "mégaoctets"; "byte_unit_gigabyte" = "gigaoctet"; "byte_unit_gigabytes" = "gigaoctets"; +"CrossModel API spend trend" = "Tendance des dépenses de l'API CrossModel"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 82d70f677..76f90b9f2 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1133,3 +1133,4 @@ "byte_unit_megabytes" = "megabyte"; "byte_unit_gigabyte" = "gigabyte"; "byte_unit_gigabytes" = "gigabyte"; +"CrossModel API spend trend" = "Tren pengeluaran API CrossModel"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index cfb2dfab0..2446cbada 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1133,3 +1133,4 @@ "byte_unit_megabytes" = "megabyte"; "byte_unit_gigabyte" = "gigabyte"; "byte_unit_gigabytes" = "gigabyte"; +"CrossModel API spend trend" = "Andamento di spesa dell'API CrossModel"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index dba5926cb..ef43e17f5 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1126,3 +1126,4 @@ "byte_unit_megabytes" = "メガバイト"; "byte_unit_gigabyte" = "ギガバイト"; "byte_unit_gigabytes" = "ギガバイト"; +"CrossModel API spend trend" = "CrossModel API の支出推移"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 1afb7e1b9..e29d134a0 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1096,3 +1096,4 @@ "byte_unit_megabytes" = "메가바이트"; "byte_unit_gigabyte" = "기가바이트"; "byte_unit_gigabytes" = "기가바이트"; +"CrossModel API spend trend" = "CrossModel API 지출 추이"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index e86e6e4b7..4e7ff15c0 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1125,3 +1125,4 @@ "byte_unit_megabytes" = "megabytes"; "byte_unit_gigabyte" = "gigabyte"; "byte_unit_gigabytes" = "gigabytes"; +"CrossModel API spend trend" = "CrossModel API-uitgaventrend"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 45493c35a..cdaafbf11 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1133,3 +1133,4 @@ "byte_unit_megabytes" = "megabajty"; "byte_unit_gigabyte" = "gigabajt"; "byte_unit_gigabytes" = "gigabajty"; +"CrossModel API spend trend" = "Trend wydatków API CrossModel"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index c98ef9311..7c8d4fab8 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1126,3 +1126,4 @@ "byte_unit_megabytes" = "megabytes"; "byte_unit_gigabyte" = "gigabyte"; "byte_unit_gigabytes" = "gigabytes"; +"CrossModel API spend trend" = "Tendência de gastos da API CrossModel"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index ab493fe7f..30dd85fc5 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1124,3 +1124,4 @@ "byte_unit_megabytes" = "megabyte"; "byte_unit_gigabyte" = "gigabyte"; "byte_unit_gigabytes" = "gigabyte"; +"CrossModel API spend trend" = "CrossModel API-utgiftstrend"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index f9e0acadf..1246ad516 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1134,3 +1134,4 @@ "byte_unit_megabytes" = "เมกะไบต์"; "byte_unit_gigabyte" = "กิกะไบต์"; "byte_unit_gigabytes" = "กิกะไบต์"; +"CrossModel API spend trend" = "แนวโน้มค่าใช้จ่ายของ CrossModel API"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 4361304de..a8214897e 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1131,3 +1131,4 @@ "byte_unit_megabytes" = "megabayt"; "byte_unit_gigabyte" = "gigabayt"; "byte_unit_gigabytes" = "gigabayt"; +"CrossModel API spend trend" = "CrossModel API harcama eğilimi"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 1378b7ead..86bca0b2d 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1125,3 +1125,4 @@ "byte_unit_megabytes" = "мегабайти"; "byte_unit_gigabyte" = "гігабайт"; "byte_unit_gigabytes" = "гігабайти"; +"CrossModel API spend trend" = "Тенденція витрат CrossModel API"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index cd3d01cd9..a21bc3223 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1126,3 +1126,4 @@ "byte_unit_megabytes" = "megabyte"; "byte_unit_gigabyte" = "gigabyte"; "byte_unit_gigabytes" = "gigabyte"; +"CrossModel API spend trend" = "Xu hướng chi tiêu API CrossModel"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 052f271bf..bc4b7cf13 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -832,6 +832,7 @@ "Cache read" = "缓存读取"; "Claude Admin API 30 day spend trend" = "Claude Admin API 30 天支出趋势"; "OpenRouter API key spend trend" = "OpenRouter API 密钥支出趋势"; +"CrossModel API spend trend" = "CrossModel API 支出趋势"; "z.ai hourly token trend" = "z.ai 每小时 token 趋势"; "MiniMax 30 day token usage trend" = "MiniMax 30 天 token 用量趋势"; "Today cash" = "今日现金"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 7e92fcea6..79a39fc9b 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1000,6 +1000,7 @@ "byte_unit_megabytes" = "MB"; "byte_unit_gigabyte" = "GB"; "byte_unit_gigabytes" = "GB"; +"CrossModel API spend trend" = "CrossModel API 支出趨勢"; /* Added zh-Hant parity with English catalog */ "minimax_service_music_generation" = "音樂生成"; diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index d532b5d79..960e1455c 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -143,7 +143,7 @@ extension SettingsStore { static func isBalanceOnlyProvider(_ provider: UsageProvider) -> Bool { switch provider { - case .deepseek, .mistral, .kimik2, .moonshot, .poe: + case .deepseek, .mistral, .kimik2, .moonshot, .poe, .crossmodel: true default: false diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index a84c5c517..304e2a8ab 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -675,6 +675,12 @@ extension StatusItemController { { return UsageFormatter.usdString(balance) } + if provider == .crossmodel, + self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .automatic, + let usage = snapshot?.crossModelUsage + { + return usage.balanceDisplay + } if provider == .opencodego, let balance = Self.openCodeGoZenBalanceDisplayText(snapshot: snapshot) { diff --git a/Sources/CodexBar/UsageStore+APIKeyDebug.swift b/Sources/CodexBar/UsageStore+APIKeyDebug.swift new file mode 100644 index 000000000..36b33d0d6 --- /dev/null +++ b/Sources/CodexBar/UsageStore+APIKeyDebug.swift @@ -0,0 +1,116 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + struct APIKeyDebugContext { + let label: String + let resolution: ProviderTokenResolution? + let configToken: String? + let hasEnvToken: Bool + let hasTokenAccount: Bool + } + + func openAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + self.apiKeyDebugContext( + provider: .openai, + label: "OPENAI_API_KEY", + processEnvironment: processEnvironment, + resolution: ProviderTokenResolver.openAIAPIResolution, + hasEnvToken: { OpenAIAPISettingsReader.apiKey(environment: $0) != nil }) + } + + func azureOpenAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .azureopenai) + let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: processEnvironment, + provider: .azureopenai, + config: config) + return APIKeyDebugContext( + label: "AZURE_OPENAI_API_KEY", + resolution: ProviderTokenResolver.azureOpenAIResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: AzureOpenAISettingsReader.apiKey(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + func openRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + self.apiKeyDebugContext( + provider: .openrouter, + label: "OPENROUTER_API_KEY", + processEnvironment: processEnvironment, + resolution: ProviderTokenResolver.openRouterResolution, + hasEnvToken: { OpenRouterSettingsReader.apiToken(environment: $0) != nil }) + } + + func crossModelAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + self.apiKeyDebugContext( + provider: .crossmodel, + label: "CROSSMODEL_API_KEY", + processEnvironment: processEnvironment, + resolution: ProviderTokenResolver.crossModelResolution, + hasEnvToken: { CrossModelSettingsReader.apiToken(environment: $0) != nil }) + } + + func elevenLabsAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + self.apiKeyDebugContext( + provider: .elevenlabs, + label: "ELEVENLABS_API_KEY", + processEnvironment: processEnvironment, + resolution: ProviderTokenResolver.elevenLabsResolution, + hasEnvToken: { ElevenLabsSettingsReader.apiKey(environment: $0) != nil }) + } + + func apiKeyDebugContext( + provider: UsageProvider, + label: String, + processEnvironment: [String: String], + resolution: ([String: String]) -> ProviderTokenResolution?, + hasEnvToken: ([String: String]) -> Bool) -> APIKeyDebugContext + { + let config = self.settings.providerConfig(for: provider) + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: provider, + config: config) + return APIKeyDebugContext( + label: label, + resolution: resolution(environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: hasEnvToken(processEnvironment), + hasTokenAccount: false) + } + + nonisolated static func apiKeyDebugLine(_ context: APIKeyDebugContext) -> String { + self.apiKeyDebugLine( + label: context.label, + resolution: context.resolution, + configToken: context.configToken, + hasEnvToken: context.hasEnvToken, + hasTokenAccount: context.hasTokenAccount) + } + + nonisolated static func apiKeyDebugLine( + label: String, + resolution: ProviderTokenResolution?, + configToken: String?, + hasEnvToken: Bool, + hasTokenAccount: Bool = false) -> String + { + let hasAny = resolution != nil + let hasConfigToken = !(configToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + let source: String = if resolution == nil { + "none" + } else if hasTokenAccount, hasEnvToken { + "settings-token-account (overrides env)" + } else if hasTokenAccount { + "settings-token-account" + } else if hasConfigToken, hasEnvToken { + "settings-config (overrides env)" + } else if hasConfigToken { + "settings-config" + } else { + resolution?.source.rawValue ?? "environment" + } + return "\(label)=\(hasAny ? "present" : "missing") source=\(source)" + } +} diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 9f8884a35..0b00f8324 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -56,6 +56,8 @@ extension UsageStore { return ZaiSettingsError.missingToken.errorDescription case .openrouter: return OpenRouterSettingsError.missingToken.errorDescription + case .crossmodel: + return CrossModelSettingsError.missingToken.errorDescription case .azureopenai: return AzureOpenAISettingsError.missingAPIKey.errorDescription case .elevenlabs: diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index b0334bc82..68d079b19 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -975,7 +975,7 @@ extension UsageStore { await AugmentStatusProbe.latestDumps() } - // swiftlint:disable:next function_body_length + // swiftlint:disable:next function_body_length cyclomatic_complexity func debugLog(for provider: UsageProvider) async -> String { if let cached = self.probeLogs[provider], !cached.isEmpty { return cached @@ -1004,6 +1004,7 @@ extension UsageStore { let openAIDebugContext = self.openAIAPIKeyDebugContext(processEnvironment: processEnvironment) let azureOpenAIDebugContext = self.azureOpenAIAPIKeyDebugContext(processEnvironment: processEnvironment) let openRouterDebugContext = self.openRouterAPIKeyDebugContext(processEnvironment: processEnvironment) + let crossModelDebugContext = self.crossModelAPIKeyDebugContext(processEnvironment: processEnvironment) let elevenLabsDebugContext = self.elevenLabsAPIKeyDebugContext(processEnvironment: processEnvironment) let deepSeekHasEnvToken = DeepSeekSettingsReader.apiKey(environment: processEnvironment) != nil let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil @@ -1106,6 +1107,8 @@ extension UsageStore { ollamaCookieHeader: ollamaCookieHeader) case .openrouter: return Self.apiKeyDebugLine(openRouterDebugContext) + case .crossmodel: + return Self.apiKeyDebugLine(crossModelDebugContext) case .elevenlabs: return Self.apiKeyDebugLine(elevenLabsDebugContext) case .warp: @@ -1245,104 +1248,6 @@ extension UsageStore { #endif } - private struct APIKeyDebugContext { - let label: String - let resolution: ProviderTokenResolution? - let configToken: String? - let hasEnvToken: Bool - let hasTokenAccount: Bool - } - - private func openAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .openai) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .openai, - config: config) - return APIKeyDebugContext( - label: "OPENAI_API_KEY", - resolution: ProviderTokenResolver.openAIAPIResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: OpenAIAPISettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func azureOpenAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .azureopenai) - let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( - base: processEnvironment, - provider: .azureopenai, - config: config) - return APIKeyDebugContext( - label: "AZURE_OPENAI_API_KEY", - resolution: ProviderTokenResolver.azureOpenAIResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: AzureOpenAISettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func openRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .openrouter) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .openrouter, - config: config) - return APIKeyDebugContext( - label: "OPENROUTER_API_KEY", - resolution: ProviderTokenResolver.openRouterResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func elevenLabsAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .elevenlabs) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .elevenlabs, - config: config) - return APIKeyDebugContext( - label: "ELEVENLABS_API_KEY", - resolution: ProviderTokenResolver.elevenLabsResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: ElevenLabsSettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private nonisolated static func apiKeyDebugLine(_ context: APIKeyDebugContext) -> String { - self.apiKeyDebugLine( - label: context.label, - resolution: context.resolution, - configToken: context.configToken, - hasEnvToken: context.hasEnvToken, - hasTokenAccount: context.hasTokenAccount) - } - - private nonisolated static func apiKeyDebugLine( - label: String, - resolution: ProviderTokenResolution?, - configToken: String?, - hasEnvToken: Bool, - hasTokenAccount: Bool = false) -> String - { - let hasAny = resolution != nil - let hasConfigToken = !(configToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - let source: String = if resolution == nil { - "none" - } else if hasTokenAccount, hasEnvToken { - "settings-token-account (overrides env)" - } else if hasTokenAccount { - "settings-token-account" - } else if hasConfigToken, hasEnvToken { - "settings-config (overrides env)" - } else if hasConfigToken { - "settings-config" - } else { - resolution?.source.rawValue ?? "environment" - } - return "\(label)=\(hasAny ? "present" : "missing") source=\(source)" - } - private static func debugCursorLog( browserDetection: BrowserDetection, cursorCookieSource: ProviderCookieSource, diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index c5337d4d5..4591a7d3a 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -225,6 +225,8 @@ extension CodexBarCLI { ChutesSettingsReader.apiKey(environment: environment) != nil case .crof: CrofSettingsReader.apiKey(environment: environment) != nil + case .crossmodel: + CrossModelSettingsReader.apiToken(environment: environment) != nil case .deepgram: DeepgramSettingsReader.apiKey(environment: environment) != nil case .deepseek: diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 3beb14519..49a3c6bde 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -35,6 +35,7 @@ enum CLIRenderer { lines: &lines) self.appendTertiaryLines(snapshot: snapshot, labels: labels, context: context, now: now, lines: &lines) self.appendMiMoBalanceLine(snapshot: snapshot, useColor: context.useColor, lines: &lines) + self.appendCrossModelUsageLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendDeepgramLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendAmpBalanceLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) self.appendLimitsUnavailableLine( @@ -146,6 +147,64 @@ enum CLIRenderer { lines.append(self.labelValueLine("Balance", value: usage.balanceDetail, useColor: useColor)) } + private static func appendCrossModelUsageLines( + snapshot: UsageSnapshot, + useColor: Bool, + lines: inout [String]) + { + guard let usage = snapshot.crossModelUsage else { return } + + lines.append(self.labelValueLine("Balance", value: usage.balanceDisplay, useColor: useColor)) + if let daily = usage.daily { + lines.append(self.crossModelUsageLine( + title: "Today", + usage: usage, + window: daily, + metric: .tokens, + useColor: useColor)) + } + if let weekly = usage.weekly { + lines.append(self.crossModelUsageLine( + title: "Week", + usage: usage, + window: weekly, + metric: .requests, + useColor: useColor)) + } + if let monthly = usage.monthly { + lines.append(self.crossModelUsageLine( + title: "Month", + usage: usage, + window: monthly, + metric: .requests, + useColor: useColor)) + } + } + + private enum CrossModelMetric { + case tokens + case requests + } + + private static func crossModelUsageLine( + title: String, + usage: CrossModelUsageSnapshot, + window: CrossModelUsageWindow, + metric: CrossModelMetric, + useColor: Bool) -> String + { + let metricText = switch metric { + case .tokens: + "\(UsageFormatter.tokenCountString(window.totalTokens)) tokens" + case .requests: + "\(UsageFormatter.tokenCountString(window.requestCount)) requests" + } + return self.labelValueLine( + title, + value: "\(usage.currencyString(window.cost)) · \(metricText)", + useColor: useColor) + } + private static func appendTertiaryLines( snapshot: UsageSnapshot, labels: RateWindowLabels, diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index f38a560ea..b344ce085 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -140,7 +140,7 @@ public enum ProviderConfigEnvironment { GroqSettingsReader.apiKeyEnvironmentKey case .llmproxy: LLMProxySettingsReader.apiKeyEnvironmentKey - case .chutes, .poe, .litellm: + case .chutes, .poe, .litellm, .crossmodel: self.additionalAPIKeyEnvironmentKey(for: provider) default: nil @@ -155,6 +155,8 @@ public enum ProviderConfigEnvironment { PoeSettingsReader.apiKeyEnvironmentKey case .litellm: LiteLLMSettingsReader.apiKeyEnvironmentKey + case .crossmodel: + CrossModelSettingsReader.envKey default: nil } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 5afb7aedd..1ce765f1c 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "1f61293684d223c2" + static let value = "5f6d277d7c7b3f56" } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 3374bfaef..add755e2d 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -22,6 +22,7 @@ public enum LogCategories { public static let cookieHeaderStore = "cookie-header-store" public static let copilotTokenStore = "copilot-token-store" public static let creditsPurchase = "creditsPurchase" + public static let crossModelUsage = "crossmodel-usage" public static let cursorLogin = "cursor-login" public static let deepSeekSettings = "deepseek-settings" public static let deepSeekUsage = "deepseek-usage" diff --git a/Sources/CodexBarCore/Providers/CrossModel/CrossModelProviderDescriptor.swift b/Sources/CodexBarCore/Providers/CrossModel/CrossModelProviderDescriptor.swift new file mode 100644 index 000000000..9a9c465a6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/CrossModel/CrossModelProviderDescriptor.swift @@ -0,0 +1,63 @@ +import Foundation + +public enum CrossModelProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .crossmodel, + metadata: ProviderMetadata( + id: .crossmodel, + displayName: "CrossModel", + sessionLabel: "Credits", + weeklyLabel: "Usage", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show CrossModel usage", + cliName: "crossmodel", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://crossmodel.ai/console/usage", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .crossmodel, + iconResourceName: "ProviderIcon-crossmodel", + color: ProviderColor(red: 124 / 255, green: 58 / 255, blue: 237 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "CrossModel cost summary is not yet supported." }), + fetchPlan: .apiToken( + strategyID: "crossmodel.api", + resolveToken: { ProviderTokenResolver.crossModelToken(environment: $0) }, + missingCredentialsError: { CrossModelSettingsError.missingToken }, + loadUsage: { apiKey, context in + try await CrossModelUsageFetcher.fetchUsage( + apiKey: apiKey, + environment: context.env, + includeOptionalUsage: context.includeOptionalUsage).toUsageSnapshot() + }), + cli: ProviderCLIConfig( + name: "crossmodel", + aliases: ["cm"], + versionDetector: nil)) + } +} + +/// Errors related to CrossModel settings. +public enum CrossModelSettingsError: LocalizedError, Sendable, Equatable { + case missingToken + case invalidEndpointOverride(String) + + public var errorDescription: String? { + switch self { + case .missingToken: + "CrossModel API token not configured. Set CROSSMODEL_API_KEY environment variable or configure in Settings." + case let .invalidEndpointOverride(key): + "CrossModel endpoint override \(key) must use HTTPS (or a loopback HTTP host)." + } + } +} diff --git a/Sources/CodexBarCore/Providers/CrossModel/CrossModelSettingsReader.swift b/Sources/CodexBarCore/Providers/CrossModel/CrossModelSettingsReader.swift new file mode 100644 index 000000000..3e0123777 --- /dev/null +++ b/Sources/CodexBarCore/Providers/CrossModel/CrossModelSettingsReader.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Reads CrossModel settings from environment variables. +public enum CrossModelSettingsReader { + /// Environment variable key for the CrossModel API token. + public static let envKey = "CROSSMODEL_API_KEY" + /// Environment variable key for an optional API base URL override. + public static let urlEnvKey = "CROSSMODEL_API_URL" + + /// Returns the API token from environment if present and non-empty. + public static func apiToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.envKey]) + } + + /// Returns the API base URL, defaulting to the production endpoint. + public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = self.validAPIURL(environment: environment) { + return override + } + return URL(string: "https://api.crossmodel.ai/v1")! + } + + public static func validateEndpointOverrides( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + guard let raw = self.cleaned(environment[self.urlEnvKey]) else { return } + // Loopback HTTP is allowed so the provider can be exercised end-to-end + // against a locally running gateway during development. + guard ProviderEndpointOverrideValidator().validatedURLAllowingLoopbackHTTP(raw) == nil else { return } + throw CrossModelSettingsError.invalidEndpointOverride(self.urlEnvKey) + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private static func validAPIURL(environment: [String: String]) -> URL? { + guard let raw = self.cleaned(environment[self.urlEnvKey]) else { return nil } + return ProviderEndpointOverrideValidator().validatedURLAllowingLoopbackHTTP(raw) + } +} diff --git a/Sources/CodexBarCore/Providers/CrossModel/CrossModelUsageStats.swift b/Sources/CodexBarCore/Providers/CrossModel/CrossModelUsageStats.swift new file mode 100644 index 000000000..7eb9242c3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/CrossModel/CrossModelUsageStats.swift @@ -0,0 +1,417 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - Wire DTOs (CrossModel returns integer micro units; 1 major currency unit = 1_000_000 micro) + +/// `GET /v1/credits` response. +struct CrossModelCreditsResponse: Decodable { + let currency: String + let balanceMicro: Int64 + let uncollectedMicro: Int64 + + private enum CodingKeys: String, CodingKey { + case currency + case balanceMicro = "balance_micro" + case uncollectedMicro = "uncollected_micro" + } +} + +/// One usage window from `GET /v1/usage`. +struct CrossModelUsageWindowDTO: Decodable { + let costMicro: Int64 + let promptTokens: Int64 + let completionTokens: Int64 + let totalTokens: Int64 + let requestCount: Int64 + let successCount: Int64 + + private enum CodingKeys: String, CodingKey { + case costMicro = "cost_micro" + case promptTokens = "prompt_tokens" + case completionTokens = "completion_tokens" + case totalTokens = "total_tokens" + case requestCount = "request_count" + case successCount = "success_count" + } +} + +/// `GET /v1/usage` response. +struct CrossModelUsageResponse: Decodable { + let currency: String + let daily: CrossModelUsageWindowDTO + let weekly: CrossModelUsageWindowDTO + let monthly: CrossModelUsageWindowDTO +} + +// MARK: - Snapshot (currency-normalized, persisted in UsageSnapshot) + +/// One usage window with cost converted to the response currency's major unit. +public struct CrossModelUsageWindow: Codable, Sendable, Equatable { + public let cost: Double + public let promptTokens: Int + public let completionTokens: Int + public let totalTokens: Int + public let requestCount: Int + public let successCount: Int + + public init( + cost: Double, + promptTokens: Int, + completionTokens: Int, + totalTokens: Int, + requestCount: Int, + successCount: Int) + { + self.cost = cost + self.promptTokens = promptTokens + self.completionTokens = completionTokens + self.totalTokens = totalTokens + self.requestCount = requestCount + self.successCount = successCount + } + + init(dto: CrossModelUsageWindowDTO) { + self.cost = CrossModelUsageSnapshot.majorUnits(dto.costMicro) + self.promptTokens = Int(dto.promptTokens) + self.completionTokens = Int(dto.completionTokens) + self.totalTokens = Int(dto.totalTokens) + self.requestCount = Int(dto.requestCount) + self.successCount = Int(dto.successCount) + } +} + +/// Complete CrossModel usage snapshot: wallet balance plus UTC day/week/month spend. +public struct CrossModelUsageSnapshot: Codable, Sendable, Equatable { + public let currency: String + public let balance: Double + public let uncollected: Double + public let daily: CrossModelUsageWindow? + public let weekly: CrossModelUsageWindow? + public let monthly: CrossModelUsageWindow? + public let updatedAt: Date + + public init( + currency: String, + balance: Double, + uncollected: Double, + daily: CrossModelUsageWindow?, + weekly: CrossModelUsageWindow?, + monthly: CrossModelUsageWindow?, + updatedAt: Date) + { + self.currency = Self.normalizedCurrency(currency) + self.balance = balance + self.uncollected = uncollected + self.daily = daily + self.weekly = weekly + self.monthly = monthly + self.updatedAt = updatedAt + } + + static func majorUnits(_ micro: Int64) -> Double { + Double(micro) / 1_000_000.0 + } + + static func normalizedCurrency(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + } + + /// Formatted balance for provider-owned menu/status display. + public var balanceDisplay: String { + self.currencyString(self.balance) + } + + public func currencyString(_ value: Double) -> String { + UsageFormatter.currencyString(value, currencyCode: self.currency) + } +} + +extension CrossModelUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let identity = ProviderIdentitySnapshot( + providerID: .crossmodel, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "API key") + + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + crossModelUsage: self, + updatedAt: self.updatedAt, + identity: identity, + dataConfidence: .exact) + } +} + +// MARK: - Fetcher + +/// Fetches balance + usage stats from the CrossModel API. +public struct CrossModelUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.crossModelUsage) + private static let creditsRequestTimeoutSeconds: TimeInterval = 15 + private static let usageRequestTimeoutSeconds: TimeInterval = 3 + private static let maxErrorBodyLength = 240 + + /// Fetches balance (required) and usage windows (best-effort) using the provided API key. + public static func fetchUsage( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + includeOptionalUsage: Bool = true, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + usageJoinGrace: Duration = .seconds(3)) async throws -> CrossModelUsageSnapshot + { + guard !apiKey.isEmpty else { + throw CrossModelUsageError.invalidCredentials + } + try CrossModelSettingsReader.validateEndpointOverrides(environment: environment) + + let baseURL = CrossModelSettingsReader.apiURL(environment: environment) + let credits = try await self.fetchCredits(apiKey: apiKey, baseURL: baseURL, transport: transport) + let currency = try self.normalizedResponseCurrency(credits.currency, endpoint: "credits") + + guard includeOptionalUsage else { + return CrossModelUsageSnapshot( + currency: currency, + balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro), + uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro), + daily: nil, + weekly: nil, + monthly: nil, + updatedAt: Date()) + } + + // Usage windows are best-effort: a slow or failing /usage call should not + // block the balance the user came to see. + let usage = try await self.fetchUsageWindows( + apiKey: apiKey, + baseURL: baseURL, + transport: transport, + joinGrace: usageJoinGrace) + let validatedUsage: CrossModelUsageResponse? + if let usage { + let usageCurrency: String + do { + usageCurrency = try self.normalizedResponseCurrency(usage.currency, endpoint: "usage") + } catch { + Self.log.error("CrossModel /usage omitted: invalid currency") + validatedUsage = nil + return CrossModelUsageSnapshot( + currency: currency, + balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro), + uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro), + daily: nil, + weekly: nil, + monthly: nil, + updatedAt: Date()) + } + guard usageCurrency == currency else { + Self.log.error("CrossModel currency mismatch: credits=\(currency) usage=\(usageCurrency)") + return CrossModelUsageSnapshot( + currency: currency, + balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro), + uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro), + daily: nil, + weekly: nil, + monthly: nil, + updatedAt: Date()) + } + validatedUsage = usage + } else { + validatedUsage = nil + } + + return CrossModelUsageSnapshot( + currency: currency, + balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro), + uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro), + daily: validatedUsage.map { CrossModelUsageWindow(dto: $0.daily) }, + weekly: validatedUsage.map { CrossModelUsageWindow(dto: $0.weekly) }, + monthly: validatedUsage.map { CrossModelUsageWindow(dto: $0.monthly) }, + updatedAt: Date()) + } + + private static func fetchCredits( + apiKey: String, + baseURL: URL, + transport: any ProviderHTTPTransport) async throws -> CrossModelCreditsResponse + { + let url = baseURL.appendingPathComponent("credits") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.creditsRequestTimeoutSeconds + + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + let summary = LogRedactor.redact(Self.sanitizedResponseBodySummary(response.data)) + Self.log.error("CrossModel /credits returned \(response.statusCode): \(summary)") + if response.statusCode == 401 || response.statusCode == 403 { + throw CrossModelUsageError.invalidCredentials + } + throw CrossModelUsageError.apiError("HTTP \(response.statusCode)") + } + try self.validateSameOrigin(response: response, request: request, endpoint: "credits") + + do { + return try JSONDecoder().decode(CrossModelCreditsResponse.self, from: response.data) + } catch let error as DecodingError { + Self.log.error("CrossModel /credits decode error: \(error.localizedDescription)") + throw CrossModelUsageError.parseFailed(error.localizedDescription) + } + } + + private static func fetchUsageWindows( + apiKey: String, + baseURL: URL, + transport: any ProviderHTTPTransport, + joinGrace: Duration) async throws -> CrossModelUsageResponse? + { + let url = baseURL.appendingPathComponent("usage") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.usageRequestTimeoutSeconds + + let sourceTask = Task { + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + Self.log.debug("CrossModel /usage enrichment returned \(response.statusCode)") + return nil + } + try self.validateSameOrigin(response: response, request: request, endpoint: "usage") + return try JSONDecoder().decode(CrossModelUsageResponse.self, from: response.data) + } + let join = BoundedTaskJoin(sourceTask: sourceTask) + let outcome = await join.value(joinGrace: joinGrace) + do { + switch outcome { + case let .value(response): + return response + case .timedOut: + Self.log.debug("Timed out fetching CrossModel /usage enrichment") + return nil + case let .failure(error): + throw error + } + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + if Task.isCancelled { + throw CancellationError() + } + Self.log.debug("Failed to fetch CrossModel /usage enrichment: \(error.localizedDescription)") + return nil + } + } + + private static func normalizedResponseCurrency(_ raw: String, endpoint: String) throws -> String { + let currency = CrossModelUsageSnapshot.normalizedCurrency(raw) + guard !currency.isEmpty else { + throw CrossModelUsageError.parseFailed("Missing CrossModel /\(endpoint) currency") + } + return currency + } + + private static func validateSameOrigin( + response: ProviderHTTPResponse, + request: URLRequest, + endpoint: String) throws + { + guard let requestURL = request.url, + let responseURL = response.response.url, + let requestHost = requestURL.host?.lowercased(), + let responseHost = responseURL.host?.lowercased(), + requestURL.scheme?.lowercased() == responseURL.scheme?.lowercased(), + requestHost == responseHost, + self.effectivePort(for: requestURL) == self.effectivePort(for: responseURL) + else { + throw CrossModelUsageError.apiError("CrossModel /\(endpoint) redirected to a different origin") + } + } + + private static func effectivePort(for url: URL) -> Int? { + if let port = url.port { + return port + } + switch url.scheme?.lowercased() { + case "https": + return 443 + case "http": + return 80 + default: + return nil + } + } + + private static func sanitizedResponseBodySummary(_ data: Data) -> String { + guard !data.isEmpty else { return "empty body" } + guard let rawBody = String(bytes: data, encoding: .utf8) else { + return "non-text body (\(data.count) bytes)" + } + + let body = Self.redactSensitiveBodyContent(rawBody) + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !body.isEmpty else { return "non-text body (\(data.count) bytes)" } + guard body.count > Self.maxErrorBodyLength else { return body } + + let index = body.index(body.startIndex, offsetBy: Self.maxErrorBodyLength) + return "\(body[.. String { + let replacements: [(String, String)] = [ + (#"(?i)(bearer\s+)[A-Za-z0-9._\-]+"#, "$1[REDACTED]"), + (#"(?i)(cm-)[A-Za-z0-9._\-]+"#, "$1[REDACTED]"), + ( + #"(?i)(\"(?:api_?key|authorization|token|access_token|refresh_token)\"\s*:\s*\")([^\"]+)(\")"#, + "$1[REDACTED]$3"), + ( + #"(?i)((?:api_?key|authorization|token|access_token|refresh_token)\s*[=:]\s*)([^,\s]+)"#, + "$1[REDACTED]"), + ] + + return replacements.reduce(text) { partial, replacement in + partial.replacingOccurrences( + of: replacement.0, + with: replacement.1, + options: .regularExpression) + } + } + + #if DEBUG + static func _sanitizedResponseBodySummaryForTesting(_ body: String) -> String { + self.sanitizedResponseBodySummary(Data(body.utf8)) + } + #endif +} + +/// Errors that can occur during CrossModel usage fetching. +public enum CrossModelUsageError: LocalizedError, Sendable, Equatable { + case invalidCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidCredentials: + "Invalid CrossModel API credentials" + case let .networkError(message): + "CrossModel network error: \(message)" + case let .apiError(message): + "CrossModel API error: \(message)" + case let .parseFailed(message): + "Failed to parse CrossModel response: \(message)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 0b98477ea..43ddfb72f 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -108,6 +108,7 @@ public enum ProviderDescriptorRegistry { .deepgram: DeepgramProviderDescriptor.descriptor, .poe: PoeProviderDescriptor.descriptor, .chutes: ChutesProviderDescriptor.descriptor, + .crossmodel: CrossModelProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift index c12c59f85..929bf0f00 100644 --- a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift +++ b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift @@ -185,6 +185,7 @@ public struct ProviderDiagnosticUsageSummary: Codable, Sendable { if snapshot.mistralUsage != nil { providerSpecificData.append("mistralUsage") } if snapshot.deepgramUsage != nil { providerSpecificData.append("deepgramUsage") } if snapshot.cursorRequests != nil { providerSpecificData.append("cursorRequests") } + if snapshot.crossModelUsage != nil { providerSpecificData.append("crossModelUsage") } self.updatedAt = snapshot.updatedAt self.dataConfidence = snapshot.dataConfidence.rawValue diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index 3bf7117fa..065ac499c 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -99,6 +99,10 @@ public enum ProviderTokenResolver { self.openRouterResolution(environment: environment)?.token } + public static func crossModelToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.crossModelResolution(environment: environment)?.token + } + public static func elevenLabsToken( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { @@ -340,6 +344,12 @@ public enum ProviderTokenResolver { self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment)) } + public static func crossModelResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(CrossModelSettingsReader.apiToken(environment: environment)) + } + public static func elevenLabsResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index f010418d1..92d00837a 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -58,6 +58,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case deepgram case poe case chutes + case crossmodel } // swiftformat:enable sortDeclarations @@ -116,6 +117,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case deepgram case poe case chutes + case crossmodel case combined } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 0cb0ea021..6d071f636 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -184,6 +184,7 @@ public struct UsageSnapshot: Codable, Sendable { public let deepseekUsage: DeepSeekUsageSummary? public let mimoUsage: MiMoUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? + public let crossModelUsage: CrossModelUsageSnapshot? public let openAIAPIUsage: OpenAIAPIUsageSnapshot? public let codexResetCredits: CodexRateLimitResetCreditsSnapshot? public let claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? @@ -213,6 +214,7 @@ public struct UsageSnapshot: Codable, Sendable { case ampUsage case mimoUsage case openRouterUsage + case crossModelUsage case openAIAPIUsage case codexResetCredits case claudeAdminAPIUsage @@ -242,6 +244,7 @@ public struct UsageSnapshot: Codable, Sendable { deepseekUsage: DeepSeekUsageSummary? = nil, mimoUsage: MiMoUsageSnapshot? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, + crossModelUsage: CrossModelUsageSnapshot? = nil, openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil, codexResetCredits: CodexRateLimitResetCreditsSnapshot? = nil, claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? = nil, @@ -270,6 +273,7 @@ public struct UsageSnapshot: Codable, Sendable { self.deepseekUsage = deepseekUsage self.mimoUsage = mimoUsage self.openRouterUsage = openRouterUsage + self.crossModelUsage = crossModelUsage self.openAIAPIUsage = openAIAPIUsage self.codexResetCredits = codexResetCredits self.claudeAdminAPIUsage = claudeAdminAPIUsage @@ -315,6 +319,7 @@ public struct UsageSnapshot: Codable, Sendable { self.deepseekUsage = nil // Not persisted, fetched fresh each time self.mimoUsage = try container.decodeIfPresent(MiMoUsageSnapshot.self, forKey: .mimoUsage) self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) + self.crossModelUsage = try container.decodeIfPresent(CrossModelUsageSnapshot.self, forKey: .crossModelUsage) self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) self.codexResetCredits = try container.decodeIfPresent( CodexRateLimitResetCreditsSnapshot.self, @@ -367,6 +372,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encodeIfPresent(self.ampUsage, forKey: .ampUsage) try container.encodeIfPresent(self.mimoUsage, forKey: .mimoUsage) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) + try container.encodeIfPresent(self.crossModelUsage, forKey: .crossModelUsage) try container.encodeIfPresent(self.openAIAPIUsage, forKey: .openAIAPIUsage) try container.encodeIfPresent(self.codexResetCredits, forKey: .codexResetCredits) try container.encodeIfPresent(self.claudeAdminAPIUsage, forKey: .claudeAdminAPIUsage) @@ -545,6 +551,7 @@ public struct UsageSnapshot: Codable, Sendable { deepseekUsage: self.deepseekUsage, mimoUsage: self.mimoUsage, openRouterUsage: self.openRouterUsage, + crossModelUsage: self.crossModelUsage, openAIAPIUsage: self.openAIAPIUsage, codexResetCredits: codexResetCredits.resolving(self.codexResetCredits), claudeAdminAPIUsage: self.claudeAdminAPIUsage, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index a79d4239d..a4f6c4093 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -464,7 +464,7 @@ enum CostUsageScanner { .copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .deepseek, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .qoder, .stepfun, - .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes: + .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .crossmodel: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 7c38cfcfa..98b828b55 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -84,6 +84,7 @@ enum ProviderChoice: String, AppEnum { case .ollama: return nil // Ollama not yet supported in widgets case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets + case .crossmodel: return nil // CrossModel not yet supported in widgets case .elevenlabs: return nil // ElevenLabs not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets case .windsurf: return nil // Windsurf not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index d4d28488a..dcae1d7bb 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -299,6 +299,7 @@ private struct ProviderSwitchChip: View { case .ollama: "Ollama" case .synthetic: "Synthetic" case .openrouter: "OpenRouter" + case .crossmodel: "CrossModel" case .elevenlabs: "ElevenLabs" case .warp: "Warp" case .windsurf: "Windsurf" @@ -858,6 +859,8 @@ enum WidgetColors { Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal case .openrouter: Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple + case .crossmodel: + Color(red: 124 / 255, green: 58 / 255, blue: 237 / 255) // CrossModel purple case .elevenlabs: Color(red: 235 / 255, green: 235 / 255, blue: 230 / 255) case .warp: diff --git a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift index 68175d630..2f962124b 100644 --- a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift +++ b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift @@ -115,6 +115,19 @@ struct CLIDiagnoseCommandTests { #expect(summary.modes == ["api"]) } + @Test + func `generic diagnose auth summary detects CrossModel environment credentials`() { + let summary = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .crossmodel, + account: nil, + config: nil, + environment: [CrossModelSettingsReader.envKey: "cm-test"], + settings: nil) + + #expect(summary.configured) + #expect(summary.modes == ["api"]) + } + @Test func `generic diagnose auth summary requires complete Bedrock credentials`() { let partial = CodexBarCLI._diagnosticAuthSummaryForTesting( diff --git a/Tests/CodexBarTests/CLIOutputTests.swift b/Tests/CodexBarTests/CLIOutputTests.swift index 143651a14..d7c7ad5c5 100644 --- a/Tests/CodexBarTests/CLIOutputTests.swift +++ b/Tests/CodexBarTests/CLIOutputTests.swift @@ -149,4 +149,75 @@ struct CLIOutputTests { #expect(text.contains("Plan: Standard")) #expect(!text.contains("Window: 100%")) } + + @Test + func `text renderer includes crossmodel balance and usage`() { + let snapshot = CrossModelUsageSnapshot( + currency: "USD", + balance: 8.059489, + uncollected: 0, + daily: Self.crossModelWindow(cost: 0.005746, totalTokens: 12467, requestCount: 9), + weekly: Self.crossModelWindow(cost: 0.665033, totalTokens: 1_925_790, requestCount: 529), + monthly: Self.crossModelWindow(cost: 5.368746, totalTokens: 35_412_471, requestCount: 3166), + updatedAt: Date(timeIntervalSince1970: 0)) + .toUsageSnapshot() + + let text = CLIRenderer.renderText( + provider: .crossmodel, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "CrossModel (api)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Balance: $8.06")) + #expect(text.contains("Today: $0.01 · 12K tokens")) + #expect(text.contains("Week: $0.67 · 529 requests")) + #expect(text.contains("Month: $5.37 · 3.2K requests")) + #expect(text.contains("Plan: Api Key")) + } + + @Test + func `text renderer preserves crossmodel non USD currency`() { + let snapshot = CrossModelUsageSnapshot( + currency: "EUR", + balance: 8.059489, + uncollected: 0, + daily: Self.crossModelWindow(cost: 0.005746, totalTokens: 12467, requestCount: 9), + weekly: Self.crossModelWindow(cost: 0.665033, totalTokens: 1_925_790, requestCount: 529), + monthly: nil, + updatedAt: Date(timeIntervalSince1970: 0)) + .toUsageSnapshot() + + let text = CLIRenderer.renderText( + provider: .crossmodel, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "CrossModel (api)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Balance: €8.06")) + #expect(text.contains("Today: €0.01 · 12K tokens")) + #expect(text.contains("Week: €0.67 · 529 requests")) + #expect(!text.contains("$")) + } + + private static func crossModelWindow( + cost: Double, + totalTokens: Int, + requestCount: Int) -> CrossModelUsageWindow + { + CrossModelUsageWindow( + cost: cost, + promptTokens: 0, + completionTokens: 0, + totalTokens: totalTokens, + requestCount: requestCount, + successCount: requestCount) + } } diff --git a/Tests/CodexBarTests/CrossModelUsageStatsTests.swift b/Tests/CodexBarTests/CrossModelUsageStatsTests.swift new file mode 100644 index 000000000..9aec8f0c3 --- /dev/null +++ b/Tests/CodexBarTests/CrossModelUsageStatsTests.swift @@ -0,0 +1,641 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CrossModelUsageStatsTests { + @Test + func `to usage snapshot exposes balance identity and omits rate windows`() { + let snapshot = CrossModelUsageSnapshot( + currency: "USD", + balance: 8.059489, + uncollected: 0, + daily: CrossModelUsageWindow( + cost: 0.005746, + promptTokens: 9176, + completionTokens: 3291, + totalTokens: 12467, + requestCount: 9, + successCount: 9), + weekly: nil, + monthly: nil, + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.dataConfidence == .exact) + #expect(usage.identity?.providerID == .crossmodel) + #expect(usage.identity?.loginMethod == "API key") + #expect(usage.crossModelUsage?.balance == 8.059489) + #expect(usage.crossModelUsage?.daily?.totalTokens == 12467) + } + + @Test + func `fetch usage converts micro units and reads both endpoints`() async throws { + let registered = URLProtocol.registerClass(CrossModelStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(CrossModelStubURLProtocol.self) + } + CrossModelStubURLProtocol.handler = nil + } + + CrossModelStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/v1/credits": + #expect(request.timeoutInterval == 15) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer cm-test") + let body = #"{"currency":"USD","balance_micro":8059489,"uncollected_micro":0}"# + return Self.makeResponse(url: url, body: body, statusCode: 200) + case "/v1/usage": + #expect(request.timeoutInterval == 3) + let body = #""" + {"currency":"USD", + "daily":{"cost_micro":5746,"prompt_tokens":9176,"completion_tokens":3291, + "total_tokens":12467,"request_count":9,"success_count":9}, + "weekly":{"cost_micro":665033,"prompt_tokens":1368222,"completion_tokens":557568, + "total_tokens":1925790,"request_count":529,"success_count":529}, + "monthly":{"cost_micro":5368746,"prompt_tokens":33488242,"completion_tokens":1924229, + "total_tokens":35412471,"request_count":3166,"success_count":3057}} + """# + return Self.makeResponse(url: url, body: body, statusCode: 200) + default: + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + } + + let usage = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"]) + + #expect(usage.currency == "USD") + #expect(usage.balance == 8.059489) + #expect(usage.uncollected == 0) + #expect(usage.daily?.cost == 0.005746) + #expect(usage.weekly?.cost == 0.665033) + #expect(usage.monthly?.cost == 5.368746) + #expect(usage.monthly?.requestCount == 3166) + #expect(usage.monthly?.successCount == 3057) + #expect(usage.balanceDisplay == "$8.06") + } + + @Test + func `fetch usage propagates cancellation from optional enrichment`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/v1/credits" { + let body = #"{"currency":"USD","balance_micro":1500000,"uncollected_micro":0}"# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + } + throw CancellationError() + } + + do { + _ = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport) + Issue.record("Expected cancellation") + } catch is CancellationError { + // Expected: best-effort enrichment must not swallow parent task cancellation. + } + } + + @Test + func `fetch usage maps url session cancellation from optional enrichment`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/v1/credits" { + let body = #"{"currency":"USD","balance_micro":1500000,"uncollected_micro":0}"# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + } + throw URLError(.cancelled) + } + + await #expect(throws: CancellationError.self) { + _ = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport) + } + } + + @Test + func `fetch usage keeps balance when usage endpoint fails`() async throws { + let registered = URLProtocol.registerClass(CrossModelStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(CrossModelStubURLProtocol.self) + } + CrossModelStubURLProtocol.handler = nil + } + + CrossModelStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/v1/credits": + let body = #"{"currency":"USD","balance_micro":1500000,"uncollected_micro":250000}"# + return Self.makeResponse(url: url, body: body, statusCode: 200) + case "/v1/usage": + return Self.makeResponse(url: url, body: "{}", statusCode: 500) + default: + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + } + + let usage = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"]) + + #expect(usage.balance == 1.5) + #expect(usage.uncollected == 0.25) + #expect(usage.daily == nil) + #expect(usage.weekly == nil) + #expect(usage.monthly == nil) + } + + @Test + func `fetch usage skips optional usage endpoint when disabled`() async throws { + let requestedPaths = CrossModelRequestPathRecorder() + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + requestedPaths.append(url.path) + switch url.path { + case "/v1/credits": + let body = #"{"currency":"USD","balance_micro":1500000,"uncollected_micro":250000}"# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + case "/v1/usage": + Issue.record("Optional usage endpoint must not be requested") + throw URLError(.badURL) + default: + throw URLError(.badURL) + } + } + + let usage = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + includeOptionalUsage: false, + transport: transport) + + #expect(requestedPaths.snapshot() == ["/v1/credits"]) + #expect(usage.balance == 1.5) + #expect(usage.uncollected == 0.25) + #expect(usage.daily == nil) + #expect(usage.weekly == nil) + #expect(usage.monthly == nil) + } + + @Test + func `fetch usage throws invalid credentials on 401`() async throws { + let registered = URLProtocol.registerClass(CrossModelStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(CrossModelStubURLProtocol.self) + } + CrossModelStubURLProtocol.handler = nil + } + + CrossModelStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let body = #"{"error":{"message":"Invalid API key.","code":"invalid_api_key"}}"# + return Self.makeResponse(url: url, body: body, statusCode: 401) + } + + do { + _ = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-bogus", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"]) + Issue.record("Expected CrossModelUsageError.invalidCredentials") + } catch let error as CrossModelUsageError { + guard case .invalidCredentials = error else { + Issue.record("Expected invalidCredentials, got: \(error)") + return + } + } + } + + @Test + func `fetch usage formats non USD currency without false dollar labels`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/v1/credits": + let body = #"{"currency":"EUR","balance_micro":8059489,"uncollected_micro":0}"# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + case "/v1/usage": + let body = #""" + {"currency":"EUR", + "daily":{"cost_micro":5746,"prompt_tokens":9176,"completion_tokens":3291, + "total_tokens":12467,"request_count":9,"success_count":9}, + "weekly":{"cost_micro":665033,"prompt_tokens":1368222,"completion_tokens":557568, + "total_tokens":1925790,"request_count":529,"success_count":529}, + "monthly":{"cost_micro":5368746,"prompt_tokens":33488242,"completion_tokens":1924229, + "total_tokens":35412471,"request_count":3166,"success_count":3057}} + """# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + default: + throw URLError(.badURL) + } + } + + let usage = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport) + + #expect(usage.currency == "EUR") + #expect(usage.balance == 8.059489) + #expect(usage.daily?.cost == 0.005746) + #expect(usage.balanceDisplay.hasPrefix("€")) + #expect(!usage.balanceDisplay.contains("$")) + } + + @Test + func `fetch usage omits usage windows when endpoint currencies differ`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/v1/credits": + let body = #"{"currency":"EUR","balance_micro":8059489,"uncollected_micro":0}"# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + case "/v1/usage": + let body = #""" + {"currency":"USD", + "daily":{"cost_micro":5746,"prompt_tokens":9176,"completion_tokens":3291, + "total_tokens":12467,"request_count":9,"success_count":9}, + "weekly":{"cost_micro":665033,"prompt_tokens":1368222,"completion_tokens":557568, + "total_tokens":1925790,"request_count":529,"success_count":529}, + "monthly":{"cost_micro":5368746,"prompt_tokens":33488242,"completion_tokens":1924229, + "total_tokens":35412471,"request_count":3166,"success_count":3057}} + """# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + default: + throw URLError(.badURL) + } + } + + let usage = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport) + + #expect(usage.currency == "EUR") + #expect(usage.balance == 8.059489) + #expect(usage.daily == nil) + #expect(usage.weekly == nil) + #expect(usage.monthly == nil) + } + + @Test + func `fetch usage omits usage windows when usage currency is invalid`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/v1/credits": + let body = #"{"currency":"EUR","balance_micro":8059489,"uncollected_micro":0}"# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + case "/v1/usage": + let body = #""" + {"currency":" ", + "daily":{"cost_micro":5746,"prompt_tokens":9176,"completion_tokens":3291, + "total_tokens":12467,"request_count":9,"success_count":9}, + "weekly":{"cost_micro":665033,"prompt_tokens":1368222,"completion_tokens":557568, + "total_tokens":1925790,"request_count":529,"success_count":529}, + "monthly":{"cost_micro":5368746,"prompt_tokens":33488242,"completion_tokens":1924229, + "total_tokens":35412471,"request_count":3166,"success_count":3057}} + """# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + default: + throw URLError(.badURL) + } + } + + let usage = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport) + + #expect(usage.currency == "EUR") + #expect(usage.balance == 8.059489) + #expect(usage.daily == nil) + #expect(usage.weekly == nil) + #expect(usage.monthly == nil) + } + + @Test + func `fetch usage rejects unsafe endpoint override before attaching credentials`() async throws { + await #expect(throws: CrossModelSettingsError.invalidEndpointOverride("CROSSMODEL_API_URL")) { + _ = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "http://api.crossmodel.ai/v1"], + transport: ProviderHTTPTransportHandler { _ in + Issue.record("Transport must not be called for invalid endpoint override") + throw URLError(.badURL) + }) + } + } + + @Test + func `fetch usage rejects cross origin credits redirect`() async throws { + let transport = ProviderHTTPTransportHandler { _ in + let body = #"{"currency":"USD","balance_micro":1500000,"uncollected_micro":0}"# + let redirectedURL = URL(string: "https://evil.example/v1/credits")! + let (response, data) = Self.makeResponse(url: redirectedURL, body: body) + return (data, response) + } + + await #expect(throws: CrossModelUsageError.apiError("CrossModel /credits redirected to a different origin")) { + _ = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport) + } + } + + @Test + func `fetch usage accepts same origin default https port redirect`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/v1/credits" { + let body = #"{"currency":"USD","balance_micro":1500000,"uncollected_micro":0}"# + let redirectedURL = URL(string: "https://crossmodel.test:443/v1/credits")! + let (response, data) = Self.makeResponse(url: redirectedURL, body: body) + return (data, response) + } + + let body = #""" + {"currency":"USD", + "daily":{"cost_micro":5746,"prompt_tokens":9176,"completion_tokens":3291, + "total_tokens":12467,"request_count":9,"success_count":9}, + "weekly":{"cost_micro":665033,"prompt_tokens":1368222,"completion_tokens":557568, + "total_tokens":1925790,"request_count":529,"success_count":529}, + "monthly":{"cost_micro":5368746,"prompt_tokens":33488242,"completion_tokens":1924229, + "total_tokens":35412471,"request_count":3166,"success_count":3057}} + """# + let redirectedURL = URL(string: "https://crossmodel.test:443/v1/usage")! + let (response, data) = Self.makeResponse(url: redirectedURL, body: body) + return (data, response) + } + + let usage = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport) + + #expect(usage.balance == 1.5) + #expect(usage.daily?.cost == 0.005746) + } + + @Test + func `fetch usage rejects credits redirect to different port`() async throws { + let transport = ProviderHTTPTransportHandler { _ in + let body = #"{"currency":"USD","balance_micro":1500000,"uncollected_micro":0}"# + let redirectedURL = URL(string: "https://crossmodel.test:444/v1/credits")! + let (response, data) = Self.makeResponse(url: redirectedURL, body: body) + return (data, response) + } + + await #expect(throws: CrossModelUsageError.apiError("CrossModel /credits redirected to a different origin")) { + _ = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport) + } + } + + @Test + func `fetch usage omits cross origin usage redirect`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/v1/credits" { + let body = #"{"currency":"USD","balance_micro":1500000,"uncollected_micro":0}"# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + } + let body = #""" + {"currency":"USD", + "daily":{"cost_micro":5746,"prompt_tokens":9176,"completion_tokens":3291, + "total_tokens":12467,"request_count":9,"success_count":9}, + "weekly":{"cost_micro":665033,"prompt_tokens":1368222,"completion_tokens":557568, + "total_tokens":1925790,"request_count":529,"success_count":529}, + "monthly":{"cost_micro":5368746,"prompt_tokens":33488242,"completion_tokens":1924229, + "total_tokens":35412471,"request_count":3166,"success_count":3057}} + """# + let redirectedURL = URL(string: "https://evil.example/v1/usage")! + let (response, data) = Self.makeResponse(url: redirectedURL, body: body) + return (data, response) + } + + let usage = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport) + + #expect(usage.balance == 1.5) + #expect(usage.daily == nil) + } + + @Test + func `fetch usage omits usage redirect to different port`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/v1/credits" { + let body = #"{"currency":"USD","balance_micro":1500000,"uncollected_micro":0}"# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + } + let body = #""" + {"currency":"USD", + "daily":{"cost_micro":5746,"prompt_tokens":9176,"completion_tokens":3291, + "total_tokens":12467,"request_count":9,"success_count":9}, + "weekly":{"cost_micro":665033,"prompt_tokens":1368222,"completion_tokens":557568, + "total_tokens":1925790,"request_count":529,"success_count":529}, + "monthly":{"cost_micro":5368746,"prompt_tokens":33488242,"completion_tokens":1924229, + "total_tokens":35412471,"request_count":3166,"success_count":3057}} + """# + let redirectedURL = URL(string: "https://crossmodel.test:444/v1/usage")! + let (response, data) = Self.makeResponse(url: redirectedURL, body: body) + return (data, response) + } + + let usage = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport) + + #expect(usage.balance == 1.5) + #expect(usage.daily == nil) + } + + @Test + func `fetch usage hard deadlines never returning optional usage`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/v1/credits" { + let body = #"{"currency":"USD","balance_micro":1500000,"uncollected_micro":0}"# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + } + try await Task.sleep(for: .seconds(60)) + throw CancellationError() + } + + let start = ContinuousClock.now + let usage = try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport, + usageJoinGrace: .milliseconds(50)) + let elapsed = start.duration(to: .now) + + #expect(usage.balance == 1.5) + #expect(usage.daily == nil) + #expect(elapsed < .seconds(2)) + } + + @Test + func `fetch usage cancels bounded optional usage join with parent task`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/v1/credits" { + let body = #"{"currency":"USD","balance_micro":1500000,"uncollected_micro":0}"# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + } + try await Task.sleep(for: .seconds(60)) + let body = #""" + {"currency":"USD", + "daily":{"cost_micro":5746,"prompt_tokens":9176,"completion_tokens":3291, + "total_tokens":12467,"request_count":9,"success_count":9}, + "weekly":{"cost_micro":665033,"prompt_tokens":1368222,"completion_tokens":557568, + "total_tokens":1925790,"request_count":529,"success_count":529}, + "monthly":{"cost_micro":5368746,"prompt_tokens":33488242,"completion_tokens":1924229, + "total_tokens":35412471,"request_count":3166,"success_count":3057}} + """# + let (response, data) = Self.makeResponse(url: url, body: body) + return (data, response) + } + + let task = Task { + try await CrossModelUsageFetcher.fetchUsage( + apiKey: "cm-test", + environment: ["CROSSMODEL_API_URL": "https://crossmodel.test/v1"], + transport: transport, + usageJoinGrace: .seconds(30)) + } + + try await Task.sleep(for: .milliseconds(50)) + task.cancel() + + await #expect(throws: CancellationError.self) { + _ = try await task.value + } + } + + @Test + func `sanitizer redacts cm token shapes`() { + let body = #"{"error":"bad token cm-abc123","authorization":"Bearer cm-xyz789"}"# + let summary = CrossModelUsageFetcher._sanitizedResponseBodySummaryForTesting(body) + #expect(summary.contains("cm-[REDACTED]")) + #expect(!summary.contains("cm-abc123")) + #expect(!summary.contains("cm-xyz789")) + } + + @Test + func `usage snapshot round trip persists cross model usage metadata`() throws { + let crossModel = CrossModelUsageSnapshot( + currency: "USD", + balance: 8.06, + uncollected: 0, + daily: nil, + weekly: nil, + monthly: CrossModelUsageWindow( + cost: 5.368746, + promptTokens: 33_488_242, + completionTokens: 1_924_229, + totalTokens: 35_412_471, + requestCount: 3166, + successCount: 3057), + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + let snapshot = crossModel.toUsageSnapshot() + + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: data) + + #expect(decoded.crossModelUsage?.balance == 8.06) + #expect(decoded.crossModelUsage?.monthly?.cost == 5.368746) + #expect(decoded.crossModelUsage?.monthly?.requestCount == 3166) + #expect(decoded.identity?.loginMethod == "API key") + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } +} + +private final class CrossModelRequestPathRecorder: @unchecked Sendable { + private let lock = NSLock() + private var paths: [String] = [] + + func append(_ path: String) { + self.lock.withLock { + self.paths.append(path) + } + } + + func snapshot() -> [String] { + self.lock.withLock { + self.paths + } + } +} + +final class CrossModelStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "crossmodel.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/MenuDescriptorCrossModelTests.swift b/Tests/CodexBarTests/MenuDescriptorCrossModelTests.swift new file mode 100644 index 000000000..99d5a11ad --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorCrossModelTests.swift @@ -0,0 +1,193 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorCrossModelTests { + @Test + func `crossmodel provider contributes balance and usage windows`() throws { + let suite = "MenuDescriptorCrossModelTests-usage" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let usage = CrossModelUsageSnapshot( + currency: "USD", + balance: 8.059489, + uncollected: 0, + daily: Self.window(cost: 0.005746, totalTokens: 12467, requestCount: 9), + weekly: Self.window(cost: 0.665033, totalTokens: 1_925_790, requestCount: 529), + monthly: Self.window(cost: 5.368746, totalTokens: 35_412_471, requestCount: 3166), + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .crossmodel) + + let descriptor = MenuDescriptor.build( + provider: .crossmodel, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("Balance: $8.06")) + #expect(lines.count(where: { $0 == "Balance: $8.06" }) == 1) + #expect(!lines.contains("Plan: Balance: $8.06")) + #expect(lines.contains("Auth: API key")) + #expect(lines.contains("Today: $0.01 · 12K tokens")) + #expect(lines.contains("Week: $0.67 · 529 requests")) + #expect(lines.contains("Month: $5.37 · 3.2K requests")) + } + + @Test + func `crossmodel provider preserves non USD menu currency`() throws { + let suite = "MenuDescriptorCrossModelTests-eur" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let usage = CrossModelUsageSnapshot( + currency: "EUR", + balance: 8.059489, + uncollected: 0, + daily: Self.window(cost: 0.005746, totalTokens: 12467, requestCount: 9), + weekly: Self.window(cost: 0.665033, totalTokens: 1_925_790, requestCount: 529), + monthly: Self.window(cost: 5.368746, totalTokens: 35_412_471, requestCount: 3166), + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .crossmodel) + + let descriptor = MenuDescriptor.build( + provider: .crossmodel, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("Balance: €8.06")) + #expect(lines.contains("Today: €0.01 · 12K tokens")) + #expect(!lines.contains(where: { $0.contains("$") })) + } + + @Test + @MainActor + func `crossmodel menu card does not render generic credits bar`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.crossmodel]) + let usage = CrossModelUsageSnapshot( + currency: "USD", + balance: 8.059489, + uncollected: 0, + daily: Self.window(cost: 0.005746, totalTokens: 12467, requestCount: 9), + weekly: Self.window(cost: 0.665033, totalTokens: 1_925_790, requestCount: 529), + monthly: Self.window(cost: 5.368746, totalTokens: 35_412_471, requestCount: 3166), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .crossmodel, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.creditsText == nil) + } + + @Test + func `crossmodel menu card preserves balance when optional usage is unavailable`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.crossmodel]) + let usage = CrossModelUsageSnapshot( + currency: "USD", + balance: 8.059489, + uncollected: 0, + daily: nil, + weekly: nil, + monthly: nil, + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .crossmodel, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let dashboard = try #require(model.inlineUsageDashboard) + #expect(dashboard.kpis.map(\.value) == ["$8.06", "—", "—", "—"]) + #expect(dashboard.points.isEmpty) + #expect(model.creditsText == nil) + } + + private static func window( + cost: Double, + totalTokens: Int, + requestCount: Int) -> CrossModelUsageWindow + { + CrossModelUsageWindow( + cost: cost, + promptTokens: 0, + completionTokens: 0, + totalTokens: totalTokens, + requestCount: requestCount, + successCount: requestCount) + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 84a139c5c..edf81c4f1 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -53,6 +53,18 @@ struct ProviderConfigEnvironmentTests { #expect(env[OpenRouterSettingsReader.envKey] == "or-token") } + @Test + func `applies API key override for cross model`() { + let config = ProviderConfig(id: .crossmodel, apiKey: "cm-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .crossmodel, + config: config) + + #expect(env[CrossModelSettingsReader.envKey] == "cm-token") + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .crossmodel)) + } + @Test func `applies API key override for doubao`() { let config = ProviderConfig(id: .doubao, apiKey: "db-token") diff --git a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift index 5209e3596..093ee163e 100644 --- a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift +++ b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift @@ -159,6 +159,24 @@ struct ProviderDiagnosticExportTests { #expect(summary.dataConfidence == "exact") } + @Test + func `diagnostic usage summary includes CrossModel data`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = CrossModelUsageSnapshot( + currency: "USD", + balance: 8.06, + uncollected: 0, + daily: nil, + weekly: nil, + monthly: nil, + updatedAt: now).toUsageSnapshot() + + let summary = ProviderDiagnosticUsageSummary(from: usage) + + #expect(summary.windows.isEmpty) + #expect(summary.providerSpecificData == ["crossModelUsage"]) + } + @Test func `diagnostic usage summary defaults legacy payloads to unknown confidence`() throws { let json = """ diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 10207eac5..7fe2a08a9 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -154,7 +154,7 @@ struct SettingsStoreAdditionalTests { func `menu bar metric preference restricts text only balance providers to automatic`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-text-only-metric") - for provider in [UsageProvider.deepseek, .kimik2, .poe] { + for provider in [UsageProvider.deepseek, .kimik2, .poe, .crossmodel] { settings.setMenuBarMetricPreference(.primary, for: provider) #expect(settings.menuBarMetricPreference(for: provider) == .automatic) diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift index 2e0670ed6..e834dd242 100644 --- a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -43,6 +43,32 @@ struct StatusItemBalanceDisplayTests { #expect(displayText == "$12.34") } + @Test + func `menu bar display text uses crossmodel balance currency`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-crossmodel-eur-balance", + provider: .crossmodel) + settings.setMenuBarMetricPreference(.automatic, for: .crossmodel) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = CrossModelUsageSnapshot( + currency: "EUR", + balance: 8.059489, + uncollected: 0, + daily: nil, + weekly: nil, + monthly: nil, + updatedAt: Date()) + .toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .crossmodel) + store._setErrorForTesting(nil, provider: .crossmodel) + + let displayText = controller.menuBarDisplayText(for: .crossmodel, snapshot: snapshot) + + #expect(displayText == "€8.06") + } + @Test func `menu bar display text uses zen balance when open code has no subscription`() { let settings = self.makeSettings( diff --git a/Tests/CodexBarTests/UsageStorePathDebugTests.swift b/Tests/CodexBarTests/UsageStorePathDebugTests.swift index 856fc178c..ac2efe2ae 100644 --- a/Tests/CodexBarTests/UsageStorePathDebugTests.swift +++ b/Tests/CodexBarTests/UsageStorePathDebugTests.swift @@ -52,4 +52,27 @@ struct UsageStorePathDebugTests { #expect(debugLog == "DEEPSEEK_API_KEY=present source=settings-token-account") } + + @Test + func `crossmodel debug log includes config backed api key`() async throws { + let suite = "UsageStorePathDebugTests-crossmodel-debug-config-key" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + settings.crossModelAPIToken = "cm-config-test" + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + let debugLog = await store.debugLog(for: UsageProvider.crossmodel) + + #expect(debugLog == "CROSSMODEL_API_KEY=present source=settings-config") + } } diff --git a/docs/configuration.md b/docs/configuration.md index f15603e1f..69684801e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -169,7 +169,7 @@ z.ai team accounts also use `usageScope`, `organizationId`, and `workspaceID`; s ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`. +`codex`, `openai`, `azureopenai`, `claude`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `kimik2`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `crossmodel`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/crossmodel.md b/docs/crossmodel.md new file mode 100644 index 000000000..454233a58 --- /dev/null +++ b/docs/crossmodel.md @@ -0,0 +1,69 @@ +--- +summary: "CrossModel provider: API key wallet balance and daily/weekly/monthly spend." +read_when: + - Debugging CrossModel API key usage or spend parsing + - Updating CrossModel balance or spend display + - Explaining CrossModel setup and environment variables +--- + +# CrossModel Provider + +[CrossModel](https://crossmodel.ai) is a multi-provider, OpenAI- and Anthropic-compatible API aggregation platform. You call one API and CrossModel routes the request to the right upstream provider, billing a prepaid wallet. + +## Authentication + +CrossModel uses API key authentication. Create a key in the [CrossModel console](https://crossmodel.ai/console/api-keys). Keys start with `cm-`. + +### Environment Variable + +Set the `CROSSMODEL_API_KEY` environment variable: + +```bash +export CROSSMODEL_API_KEY="cm-..." +``` + +### Settings + +You can also configure the API key in CodexBar Settings → Providers → CrossModel. + +### CLI config + +```bash +printf '%s' "$CROSSMODEL_API_KEY" | codexbar config set-api-key --provider crossmodel --stdin +``` + +## Data Source + +The CrossModel provider fetches data from two read-only API endpoints: + +1. **Credits API** (`/v1/credits`): Returns the wallet balance (`balance_micro`), currency, and any in-flight holds (`uncollected_micro`). All amounts are integer micro units (1 major currency unit = 1,000,000 micro). + +2. **Usage API** (`/v1/usage`): Returns currency plus spend, token, and request counts for the current UTC day, ISO week, and calendar month. CodexBar only displays usage spend when `/usage` currency matches `/credits` currency. + +## Display + +The CrossModel menu card shows: + +- **Balance**: Displayed in the CrossModel menu section and (optionally) in the menu bar using the API currency +- **Spend notes**: Today / this week / this month spend +- **Spend chart**: Day/week/month spend via the shared inline dashboard + +## CLI Usage + +```bash +codexbar --provider crossmodel +codexbar -p cm # alias +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `CROSSMODEL_API_KEY` | Your CrossModel API key (required) | +| `CROSSMODEL_API_URL` | Override the base API URL (optional, defaults to `https://api.crossmodel.ai/v1`; loopback HTTP is allowed for local testing) | + +## Notes + +- Usage values are cached on CrossModel's side and may be up to 60 seconds stale. +- CrossModel uses a prepaid wallet; there is no per-key spending limit, so no quota meter is shown. +- The balance call is required; the usage call is best-effort, deadline-bounded, and will not block the balance if it is slow, unavailable, or returns a mismatched currency. diff --git a/docs/index.html b/docs/index.html index dbeb86e81..9205394f3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ CodexBar — every AI coding limit in your menu bar @@ -36,7 +36,7 @@ @@ -168,7 +168,7 @@

Every AI coding limit,
-

55 providers, one menu bar

+

56 providers, one menu bar

Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus.