Skip to content

Commit ada0215

Browse files
committed
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.
1 parent ada3660 commit ada0215

50 files changed

Lines changed: 948 additions & 5 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Sources/CodexBar/InlineUsageDashboardContent.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,11 @@ extension UsageMenuCardView.Model {
177177
{
178178
return Self.openRouterInlineDashboard(usage)
179179
}
180+
if input.provider == .crossmodel,
181+
let usage = input.snapshot?.crossModelUsage
182+
{
183+
return Self.crossModelInlineDashboard(usage)
184+
}
180185
if input.provider == .zai,
181186
let modelUsage = input.snapshot?.zaiUsage?.modelUsage
182187
{
@@ -424,6 +429,43 @@ extension UsageMenuCardView.Model {
424429
detailLines: details)
425430
}
426431

432+
private static func crossModelInlineDashboard(_ usage: CrossModelUsageSnapshot) -> InlineUsageDashboardModel? {
433+
let periodValues: [(String, String, Double?)] = [
434+
("day", L("Today"), usage.daily?.costUSD),
435+
("week", L("Week"), usage.weekly?.costUSD),
436+
("month", L("Month"), usage.monthly?.costUSD),
437+
]
438+
let points = periodValues.compactMap { id, label, value -> InlineUsageDashboardModel.Point? in
439+
guard let value else { return nil }
440+
return InlineUsageDashboardModel.Point(
441+
id: id,
442+
label: label,
443+
value: value,
444+
accessibilityValue: "\(label): \(Self.openRouterCurrencyString(value))")
445+
}
446+
guard !points.isEmpty else { return nil }
447+
return InlineUsageDashboardModel(
448+
accessibilityLabel: L("CrossModel API spend trend"),
449+
valueStyle: .currencyUSD,
450+
kpis: [
451+
.init(title: L("Balance"), value: Self.openRouterCurrencyString(usage.balanceUSD), emphasis: true),
452+
.init(
453+
title: L("Today"),
454+
value: usage.daily.map { Self.openRouterCurrencyString($0.costUSD) } ?? "",
455+
emphasis: false),
456+
.init(
457+
title: L("Week"),
458+
value: usage.weekly.map { Self.openRouterCurrencyString($0.costUSD) } ?? "",
459+
emphasis: false),
460+
.init(
461+
title: L("Month"),
462+
value: usage.monthly.map { Self.openRouterCurrencyString($0.costUSD) } ?? "",
463+
emphasis: false),
464+
],
465+
points: points,
466+
detailLines: [])
467+
}
468+
427469
private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? {
428470
let periodValues: [(String, String, Double?)] = [
429471
("day", L("Today"), usage.keyUsageDaily),

Sources/CodexBar/MenuCardView+ModelHelpers.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,20 @@ extension UsageMenuCardView.Model {
646646
return nil
647647
}
648648

649+
static func crossModelSpendNotes(_ usage: CrossModelUsageSnapshot) -> [String] {
650+
let candidates: [(String, Double?)] = [
651+
(L("Today"), usage.daily?.costUSD),
652+
(L("This week"), usage.weekly?.costUSD),
653+
(L("This month"), usage.monthly?.costUSD),
654+
]
655+
let rendered = candidates.compactMap { candidate -> String? in
656+
guard let value = candidate.1 else { return nil }
657+
return "\(candidate.0): \(String(format: "$%.2f", value))"
658+
}
659+
guard !rendered.isEmpty else { return [] }
660+
return [rendered.joined(separator: " · ")]
661+
}
662+
649663
static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot) -> String? {
650664
guard provider == .openrouter,
651665
let usage = snapshot.openRouterUsage,

Sources/CodexBar/MenuCardView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,6 +968,10 @@ extension UsageMenuCardView.Model {
968968
return notes + subscriptionNotes
969969
}
970970

971+
if input.provider == .crossmodel, let crossModel = input.snapshot?.crossModelUsage {
972+
return Self.crossModelSpendNotes(crossModel) + subscriptionNotes
973+
}
974+
971975
guard input.provider == .openrouter,
972976
let openRouter = input.snapshot?.openRouterUsage
973977
else {

Sources/CodexBar/MenuDescriptor.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,9 @@ struct MenuDescriptor {
278278
if let openRouterUsage = snapshot.openRouterUsage {
279279
Self.appendOpenRouterUsageSummary(entries: &entries, usage: openRouterUsage)
280280
}
281+
if let crossModelUsage = snapshot.crossModelUsage {
282+
Self.appendCrossModelUsageSummary(entries: &entries, usage: crossModelUsage)
283+
}
281284
if let poeUsage = snapshot.poeUsage, !poeUsage.daily.isEmpty {
282285
Self.appendPoeUsageSummary(entries: &entries, usage: poeUsage)
283286
}
@@ -355,6 +358,31 @@ struct MenuDescriptor {
355358
}
356359
}
357360

361+
private static func appendCrossModelUsageSummary(
362+
entries: inout [Entry],
363+
usage: CrossModelUsageSnapshot)
364+
{
365+
entries.append(.text("\(L("Balance")): \(usage.balanceDisplay)", .primary))
366+
if let daily = usage.daily {
367+
entries.append(.text(
368+
"\(L("Today")): \(UsageFormatter.usdString(daily.costUSD)) · " +
369+
"\(UsageFormatter.tokenCountString(daily.totalTokens)) \(L("tokens"))",
370+
.secondary))
371+
}
372+
if let weekly = usage.weekly {
373+
entries.append(.text(
374+
"\(L("Week")): \(UsageFormatter.usdString(weekly.costUSD)) · " +
375+
"\(UsageFormatter.tokenCountString(weekly.requestCount)) \(L("requests"))",
376+
.secondary))
377+
}
378+
if let monthly = usage.monthly {
379+
entries.append(.text(
380+
"\(L("Month")): \(UsageFormatter.usdString(monthly.costUSD)) · " +
381+
"\(UsageFormatter.tokenCountString(monthly.requestCount)) \(L("requests"))",
382+
.secondary))
383+
}
384+
}
385+
358386
private static func appendMistralUsageSummary(
359387
entries: inout [Entry],
360388
usage: MistralUsageSnapshot)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import AppKit
2+
import CodexBarCore
3+
import Foundation
4+
import SwiftUI
5+
6+
struct CrossModelProviderImplementation: ProviderImplementation {
7+
let id: UsageProvider = .crossmodel
8+
9+
@MainActor
10+
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
11+
ProviderPresentation { _ in "api" }
12+
}
13+
14+
@MainActor
15+
func observeSettings(_ settings: SettingsStore) {
16+
_ = settings.crossModelAPIToken
17+
}
18+
19+
@MainActor
20+
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
21+
_ = context
22+
return nil
23+
}
24+
25+
@MainActor
26+
func isAvailable(context: ProviderAvailabilityContext) -> Bool {
27+
if CrossModelSettingsReader.apiToken(environment: context.environment) != nil {
28+
return true
29+
}
30+
return !context.settings.crossModelAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
31+
}
32+
33+
@MainActor
34+
func settingsPickers(context _: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
35+
[]
36+
}
37+
38+
@MainActor
39+
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
40+
[
41+
ProviderSettingsFieldDescriptor(
42+
id: "crossmodel-api-key",
43+
title: "API key",
44+
subtitle: "Stored in ~/.codexbar/config.json. "
45+
+ "Create a key in the CrossModel console at crossmodel.ai/console/api-keys.",
46+
kind: .secure,
47+
placeholder: "cm-...",
48+
binding: context.stringBinding(\.crossModelAPIToken),
49+
actions: [],
50+
isVisible: nil,
51+
onActivate: nil),
52+
]
53+
}
54+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import CodexBarCore
2+
import Foundation
3+
4+
extension SettingsStore {
5+
var crossModelAPIToken: String {
6+
get { self.configSnapshot.providerConfig(for: .crossmodel)?.sanitizedAPIKey ?? "" }
7+
set {
8+
self.updateProviderConfig(provider: .crossmodel) { entry in
9+
entry.apiKey = self.normalizedConfigValue(newValue)
10+
}
11+
self.logSecretUpdate(provider: .crossmodel, field: "apiKey", value: newValue)
12+
}
13+
}
14+
}

Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ enum ProviderImplementationRegistry {
6666
case .deepgram: DeepgramProviderImplementation()
6767
case .poe: PoeProviderImplementation()
6868
case .chutes: ChutesProviderImplementation()
69+
case .crossmodel: CrossModelProviderImplementation()
6970
}
7071
}
7172

Lines changed: 5 additions & 0 deletions
Loading

Sources/CodexBar/Resources/ar.lproj/Localizable.strings

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,3 +1110,4 @@
11101110
"byte_unit_megabytes" = "ميغابايتات";
11111111
"byte_unit_gigabyte" = "غيغابايت";
11121112
"byte_unit_gigabytes" = "غيغابايتات";
1113+
"CrossModel API spend trend" = "اتجاه إنفاق CrossModel API";

Sources/CodexBar/Resources/ca.lproj/Localizable.strings

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,3 +1109,4 @@
11091109
"Weekly" = "Setmanal";
11101110
"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.";
11111111
"≈ %d%% run-out risk" = "≈ %d%% risc d'esgotament";
1112+
"CrossModel API spend trend" = "Tendència de despesa de l'API de CrossModel";

0 commit comments

Comments
 (0)