diff --git a/CHANGELOG.md b/CHANGELOG.md index 934dd2879b..d44ad76b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.43.1 — Unreleased ### Added +- AnyRouter: add credit balance, lifetime spend, and multi-account API keys from the AnyRouter credits API. Thanks @duyet! - ZenMux: add Management API usage with five-hour and weekly quotas, subscription expiry, and USD PAYG balance. Thanks @kays0x! ### Fixed diff --git a/README.md b/README.md index ebfebf8de2..d9a0a8898c 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ [![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. 60 providers. +CodexBar — every AI coding limit in your menu bar. 64 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. +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, ClinePass, 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. CodexBar menu popover with provider tiles, usage bars, and reset countdowns @@ -107,6 +107,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Warp](docs/warp.md) — API token for GraphQL request limits and monthly credits. - [ElevenLabs](docs/elevenlabs.md) — API key for character credits and voice slot usage. - [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers. +- [AnyRouter](docs/anyrouter.md) — API key for credit balance and spend across the AnyRouter gateway. - [CrossModel](docs/crossmodel.md) — API key wallet balance with daily, weekly, and monthly spend. - [Windsurf](docs/windsurf.md) — Browser localStorage session import or local SQLite cache for plan usage. - [Zed](docs/zed.md) — Zed editor Keychain session for plan, edit-prediction quota, billing cycle, and overdue invoices. @@ -135,6 +136,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Deepgram](docs/deepgram.md) — API key usage summaries across speech, agent, token, and TTS metrics. - [Poe](docs/poe.md) — API key for current point balance and recent points history. - [Chutes](docs/chutes.md) — API key for subscription usage, rolling and monthly quota windows, and pay-as-you-go quotas. +- [Neuralwatt](docs/neuralwatt.md) — API key for USD credit balance and optional per-key spending allowance. - [ZenMux](docs/zenmux.md) — Management API key for rolling five-hour and seven-day quota windows plus PAYG balance. - Open to new providers: [provider authoring guide](docs/provider.md). diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index e1d1efbd07..28d2bb0b21 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -86,10 +86,28 @@ extension UsageMenuCardView.Model { ] } - if input.provider == .deepseek, - input.showOptionalCreditsAndExtraUsage, - let usage = input.snapshot?.deepseekUsage - { + if input.provider == .deepseek { + if input.isRefreshing { + return [] + } + if input.snapshot?.primary == nil { + if input.snapshot?.deepseekDetailedUsageState == .webSessionRequired { + return [L("Sign in to DeepSeek Platform in Chrome for detailed usage.")] + } + if input.snapshot?.deepseekDetailedUsageState == .profileSelectionRequired { + return [L("Select a DeepSeek Chrome profile in Settings.")] + } + } + guard input.showOptionalCreditsAndExtraUsage else { return nil } + guard let usage = input.snapshot?.deepseekUsage else { + if input.snapshot?.deepseekDetailedUsageState == .webSessionRequired { + return [L("Sign in to DeepSeek Platform in Chrome for detailed usage.")] + } + if input.snapshot?.deepseekDetailedUsageState == .profileSelectionRequired { + return [L("Select a DeepSeek Chrome profile in Settings.")] + } + return [L("Detailed usage unavailable.")] + } let symbol = usage.currency == "CNY" ? "¥" : "$" let todayCostStr = usage.todayCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—" return [ @@ -226,6 +244,7 @@ extension UsageMenuCardView.Model { return Self.minimaxInlineDashboard(billing) } if input.provider == .deepseek, + !input.isRefreshing, input.showOptionalCreditsAndExtraUsage, let usage = input.snapshot?.deepseekUsage, !usage.daily.isEmpty @@ -673,7 +692,7 @@ extension UsageMenuCardView.Model { let monthTokensStr = UsageFormatter.tokenCountString(usage.currentMonthTokens) return InlineUsageDashboardModel( - accessibilityLabel: L("DeepSeek 30 day token usage trend"), + accessibilityLabel: L("DeepSeek this month token usage trend"), valueStyle: .tokens, kpis: [ .init( diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 5668429c77..0d54323882 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -380,7 +380,7 @@ extension UsageMenuCardView.Model { private static func subscriptionDateString(_ date: Date, provider: UsageProvider) -> String { let formatter = DateFormatter() - formatter.locale = Locale.current + formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = self.subscriptionDateTimeZone(provider: provider) formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") return formatter.string(from: date) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index c564fd34df..51a4fb3dc3 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1280,7 +1280,7 @@ extension UsageMenuCardView.Model { { primaryDetailLeft = detail } - if [.warp, .kilo, .mimo, .deepseek, .qoder, .mistral, .litellm].contains(input.provider), + if [.warp, .kilo, .mimo, .deepseek, .qoder, .mistral, .neuralwatt, .litellm].contains(input.provider), let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -1309,7 +1309,7 @@ extension UsageMenuCardView.Model { primaryResetText = nil } } - if [.warp, .kilo, .mimo, .deepseek, .qoder, .mistral, .litellm, .zenmux].contains(input.provider), + if [.warp, .kilo, .mimo, .deepseek, .qoder, .mistral, .neuralwatt, .litellm, .zenmux].contains(input.provider), primary.resetsAt == nil { primaryResetText = nil @@ -1377,7 +1377,7 @@ extension UsageMenuCardView.Model { primaryPacePercent = regen.pace.pacePercent primaryPaceOnTop = regen.pace.paceOnTop } - let usesBalanceStatusText = input.provider == .deepseek + let usesBalanceStatusText = input.provider == .deepseek || input.provider == .neuralwatt let primaryStatusText = usesBalanceStatusText ? primaryDetailText : nil if usesBalanceStatusText { primaryDetailText = nil diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index b08fe8bc90..dbd6af541c 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -228,8 +228,8 @@ struct MenuDescriptor { if let primary = snap.primary { let primaryDetail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) let primaryDescriptionIsDetail = provider == .warp || provider == .kilo || provider == .abacus || - provider == .deepseek || provider == .azureopenai || provider == .mimo || provider == .qoder || - provider == .sub2api + provider == .deepseek || provider == .neuralwatt || provider == .azureopenai || provider == .mimo || + provider == .qoder || provider == .sub2api let primaryWindow = if primaryDescriptionIsDetail { // Some providers use resetDescription for non-reset detail // (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line. diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index f2cf4da0c3..42f777d2a6 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -134,7 +134,8 @@ struct ProviderDetailView: View { provider: self.provider, model: self.model, openAIWebDiagnostic: self.openAIWebDiagnostic, - isEnabled: self.isEnabled) + isEnabled: self.isEnabled, + isRefreshing: self.store.refreshingProviders.contains(self.provider)) } header: { Text(L("Usage")) } @@ -350,6 +351,7 @@ struct ProviderMetricsInlineView: View { let model: UsageMenuCardView.Model let openAIWebDiagnostic: String? let isEnabled: Bool + let isRefreshing: Bool struct InfoRow: Identifiable, Equatable { enum ID: Hashable { @@ -430,10 +432,24 @@ struct ProviderMetricsInlineView: View { } private var placeholderText: String { - if !self.isEnabled { + Self.placeholderText( + isEnabled: self.isEnabled, + isRefreshing: self.isRefreshing, + modelPlaceholder: self.model.placeholder) + } + + static func placeholderText( + isEnabled: Bool, + isRefreshing: Bool, + modelPlaceholder: String?) -> String + { + if !isEnabled { return L("Disabled — no recent data") } - return self.model.placeholder.map(L) ?? L("No usage yet") + if isRefreshing { + return L("Refreshing") + } + return modelPlaceholder.map(L) ?? L("No usage yet") } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 9be5a35ab5..54a50bea7d 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -163,7 +163,7 @@ struct ProvidersPane: View { usageText = L("last_fetch_failed") } else if self.store.knownLimitsAvailability(for: provider)?.isUnavailable == true { usageText = L("Limits not available") - } else if let snapshot = self.store.snapshot(for: provider) { + } else if let snapshot = self.store.presentationSnapshot(for: provider) { let relative = snapshot.updatedAt.relativeDescription() usageText = relative } else { @@ -189,7 +189,7 @@ struct ProvidersPane: View { L("last_fetch_failed") } else if self.store.knownLimitsAvailability(for: provider)?.isUnavailable == true { L("Limits not available") - } else if let snapshot = self.store.snapshot(for: provider) { + } else if let snapshot = self.store.presentationSnapshot(for: provider) { snapshot.updatedAt.relativeDescription() } else { L("usage_not_fetched_yet") @@ -620,7 +620,7 @@ struct ProvidersPane: View { func menuCardModel(for provider: UsageProvider) -> UsageMenuCardView.Model { let metadata = self.store.metadata(for: provider) - let snapshot = self.store.snapshot(for: provider) + let snapshot = self.store.presentationSnapshot(for: provider) let now = Date() let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: provider, diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index e16cf8015f..6304ce0d87 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -56,7 +56,10 @@ struct ProviderRegistry { runtime: .app, sourceMode: sourceMode, includeCredits: false, - includeOptionalUsage: settings.showOptionalCreditsAndExtraUsage, + includeOptionalUsage: ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: provider, + settings: settings, + override: nil), webTimeout: 60, webDebugDumpHTML: false, verbose: verbose, diff --git a/Sources/CodexBar/Providers/AnyRouter/AnyRouterProviderImplementation.swift b/Sources/CodexBar/Providers/AnyRouter/AnyRouterProviderImplementation.swift new file mode 100644 index 0000000000..48b091abab --- /dev/null +++ b/Sources/CodexBar/Providers/AnyRouter/AnyRouterProviderImplementation.swift @@ -0,0 +1,29 @@ +import CodexBarCore +import Foundation + +struct AnyRouterProviderImplementation: ProviderImplementation { + let id: UsageProvider = .anyrouter + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.tokenAccountsData(for: .anyrouter) + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if AnyRouterSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.tokenAccounts(for: .anyrouter).isEmpty + } + + @MainActor + func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [] + } +} diff --git a/Sources/CodexBar/Providers/ClinePass/ClinePassProviderImplementation.swift b/Sources/CodexBar/Providers/ClinePass/ClinePassProviderImplementation.swift new file mode 100644 index 0000000000..0abdd11345 --- /dev/null +++ b/Sources/CodexBar/Providers/ClinePass/ClinePassProviderImplementation.swift @@ -0,0 +1,40 @@ +import CodexBarCore +import Foundation + +struct ClinePassProviderImplementation: ProviderImplementation { + let id: UsageProvider = .clinepass + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.clinePassAPIKey + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if ClinePassSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.clinePassAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "clinepass-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Paste a ClinePass API key.", + kind: .secure, + placeholder: "ClinePass API key...", + binding: context.stringBinding(\.clinePassAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/ClinePass/ClinePassSettingsStore.swift b/Sources/CodexBar/Providers/ClinePass/ClinePassSettingsStore.swift new file mode 100644 index 0000000000..786b2cee99 --- /dev/null +++ b/Sources/CodexBar/Providers/ClinePass/ClinePassSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var clinePassAPIKey: String { + get { self.configSnapshot.providerConfig(for: .clinepass)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .clinepass) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .clinepass, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift index 2d9b000e33..0d484e40ff 100644 --- a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift @@ -1,23 +1,71 @@ import CodexBarCore import Foundation +import SwiftUI struct DeepSeekProviderImplementation: ProviderImplementation { let id: UsageProvider = .deepseek @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "api" } + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } } @MainActor func observeSettings(_: SettingsStore) {} @MainActor - func isAvailable(context: ProviderAvailabilityContext) -> Bool { - if DeepSeekSettingsReader.apiKey(environment: context.environment) != nil { - return true - } - return !context.settings.tokenAccounts(for: .deepseek).isEmpty + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let presentationSnapshot = context.store.presentationSnapshot(for: .deepseek) + ?? context.store.lastKnownResetSnapshots[.deepseek] + let profiles = presentationSnapshot?.deepseekPlatformProfiles ?? [] + guard profiles.count > 1 || presentationSnapshot?.deepseekDetailedUsageState == .profileSelectionRequired + else { return [] } + let apiKey = context.settings.selectedTokenAccount(for: .deepseek)?.token + ?? DeepSeekSettingsReader.apiKey(environment: context.store.environmentBase) + let selectedProfileID = context.settings.deepseekProfileID(apiKey: apiKey) + let hasValidSelection = profiles.contains { $0.id == selectedProfileID } + let profileBinding = Binding( + get: { + let profileID = context.settings.deepseekProfileID(apiKey: apiKey) + return profiles.contains { $0.id == profileID } ? profileID : "" + }, + set: { profileID in + guard !profileID.isEmpty else { return } + context.store.beginDeepSeekProfileTransition(preservingBalance: apiKey != nil) + context.settings.setDeepSeekProfileID(profileID, apiKey: apiKey) + }) + let options = (hasValidSelection + ? [] + : [ProviderSettingsPickerOption(id: "", title: "Select profile…")]) + + profiles.map { ProviderSettingsPickerOption(id: $0.id, title: $0.name) } + + return [ + ProviderSettingsPickerDescriptor( + id: "deepseek-chrome-profile", + title: "Chrome profile", + subtitle: "Choose which signed-in DeepSeek Platform session supplies detailed usage.", + dynamicSubtitle: { + context.store.refreshingProviders.contains(.deepseek) + ? "Refreshing" + : nil + }, + binding: profileBinding, + options: options, + isVisible: nil, + isEnabled: { !context.store.refreshingProviders.contains(.deepseek) }, + onChange: { _ in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await context.store.refreshProvider(.deepseek, allowDisabled: true) + } + }), + ] + } + + @MainActor + func isAvailable(context _: ProviderAvailabilityContext) -> Bool { + true } @MainActor diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift new file mode 100644 index 0000000000..bc24e63341 --- /dev/null +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + func deepseekProfileID(apiKey: String?) -> String { + _ = self.configRevision + _ = self.providerDetailSettingsRevision + guard let config = self.config.providerConfig(for: .deepseek), + let profileID = config.sanitizedDeepSeekProfileID + else { return "" } + let accountID = self.selectedTokenAccount(for: .deepseek)?.id + let expectedScope = DeepSeekSettingsReader.profileScope(selectedTokenAccountID: accountID, apiKey: apiKey) + guard let expectedScope, config.sanitizedDeepSeekProfileScope == expectedScope else { return "" } + return profileID + } + + func setDeepSeekProfileID(_ newValue: String, apiKey: String?) { + let profileID = self.normalizedConfigValue(newValue) + let profileScope = DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: self.selectedTokenAccount(for: .deepseek)?.id, + apiKey: apiKey) + guard profileID == nil || profileScope != nil else { return } + self.updateProviderDetailConfig(provider: .deepseek) { entry in + entry.deepseekProfileID = profileID + entry.deepseekProfileScope = profileID == nil ? nil : profileScope + } + } +} diff --git a/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift b/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift new file mode 100644 index 0000000000..d4b1cec73f --- /dev/null +++ b/Sources/CodexBar/Providers/LongCat/LongCatProviderImplementation.swift @@ -0,0 +1,100 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct LongCatProviderImplementation: ProviderImplementation { + let id: UsageProvider = .longcat + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.longcatUsageDataSource + _ = settings.longcatCookieSource + _ = settings.longcatManualCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .longcat(context.settings.longcatSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { + context.settings.longcatUsageDataSource.rawValue + } + + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + switch context.settings.longcatUsageDataSource { + case .web: .web + case .auto, .api, .cli, .oauth: .auto + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.longcatCookieSource.rawValue }, + set: { raw in + context.settings.longcatCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let options = ProviderCookieSourceUI.options( + allowsOff: true, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let subtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.longcatCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports longcat.chat cookies from your browser.", + manual: "Paste a Cookie header copied from longcat.chat.", + off: "LongCat cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "longcat-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports longcat.chat cookies from your browser.", + dynamicSubtitle: subtitle, + binding: cookieBinding, + options: options, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "longcat-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: \u{2026}", + binding: context.stringBinding(\.longcatManualCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "longcat-open-console", + title: "Open Console", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://longcat.chat/platform/") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.longcatCookieSource == .manual }, + onActivate: { context.settings.ensureLongCatCookieLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift b/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift new file mode 100644 index 0000000000..f0747b19df --- /dev/null +++ b/Sources/CodexBar/Providers/LongCat/LongCatSettingsStore.swift @@ -0,0 +1,54 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var longcatUsageDataSource: ProviderSourceMode { + get { self.configSnapshot.providerConfig(for: .longcat)?.source ?? .auto } + set { + let source: ProviderSourceMode? = switch newValue { + case .auto: .auto + case .web: .web + case .api, .cli, .oauth: .auto + } + self.updateProviderConfig(provider: .longcat) { entry in + entry.source = source + } + self.logProviderModeChange(provider: .longcat, field: "usageSource", value: newValue.rawValue) + } + } + + var longcatManualCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .longcat)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .longcat) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .longcat, field: "cookieHeader", value: newValue) + } + } + + var longcatCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .longcat, fallback: .auto) } + set { + self.updateProviderConfig(provider: .longcat) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .longcat, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureLongCatCookieLoaded() {} +} + +extension SettingsStore { + func longcatSettingsSnapshot(tokenOverride: TokenAccountOverride?) + -> ProviderSettingsSnapshot.LongCatProviderSettings + { + self.ensureLongCatCookieLoaded() + return self.resolvedCookieSettings( + provider: .longcat, + configuredSource: self.longcatCookieSource, + configuredHeader: self.longcatManualCookieHeader, + tokenOverride: tokenOverride) + } +} diff --git a/Sources/CodexBar/Providers/NeuralWatt/NeuralWattProviderImplementation.swift b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattProviderImplementation.swift new file mode 100644 index 0000000000..54847e3f98 --- /dev/null +++ b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattProviderImplementation.swift @@ -0,0 +1,43 @@ +import CodexBarCore +import Foundation + +struct NeuralWattProviderImplementation: ProviderImplementation { + let id: UsageProvider = .neuralwatt + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.neuralWattAPIKey + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if NeuralWattSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + if !context.settings.neuralWattAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + return !context.settings.tokenAccounts(for: .neuralwatt).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "neuralwatt-api-key", + title: "API key", + subtitle: "Stored in the CodexBar config file. Manage keys from the Neuralwatt dashboard.", + kind: .secure, + placeholder: "sk-...", + binding: context.stringBinding(\.neuralWattAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/NeuralWatt/NeuralWattSettingsStore.swift b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattSettingsStore.swift new file mode 100644 index 0000000000..c5aa7a1625 --- /dev/null +++ b/Sources/CodexBar/Providers/NeuralWatt/NeuralWattSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var neuralWattAPIKey: String { + get { self.configSnapshot.providerConfig(for: .neuralwatt)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .neuralwatt) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .neuralwatt, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 3a62b34e91..93205f2cdd 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -17,6 +17,7 @@ enum ProviderImplementationRegistry { case .openai: OpenAIAPIProviderImplementation() case .azureopenai: AzureOpenAIProviderImplementation() case .claude: ClaudeProviderImplementation() + case .clinepass: ClinePassProviderImplementation() case .cursor: CursorProviderImplementation() case .opencode: OpenCodeProviderImplementation() case .opencodego: OpenCodeGoProviderImplementation() @@ -68,10 +69,13 @@ enum ProviderImplementationRegistry { case .deepgram: DeepgramProviderImplementation() case .poe: PoeProviderImplementation() case .chutes: ChutesProviderImplementation() + case .neuralwatt: NeuralWattProviderImplementation() + case .longcat: LongCatProviderImplementation() case .crossmodel: CrossModelProviderImplementation() case .clawrouter: ClawRouterProviderImplementation() case .sub2api: Sub2APIProviderImplementation() case .wayfinder: WayfinderProviderImplementation() + case .anyrouter: AnyRouterProviderImplementation() case .zenmux: ZenMuxProviderImplementation() } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift b/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift index a5d8430574..5e98253091 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift @@ -16,4 +16,18 @@ enum ProviderTokenAccountSelection { if let override, override.provider == provider { return override.account } return settings.effectiveSelectedTokenAccount(for: provider) } + + @MainActor + static func shouldIncludeOptionalUsage( + provider: UsageProvider, + settings: SettingsStore, + override: TokenAccountOverride?) -> Bool + { + guard settings.showOptionalCreditsAndExtraUsage else { return false } + guard provider == .deepseek, + let override, + override.provider == provider + else { return true } + return settings.selectedTokenAccount(for: provider)?.id == override.account.id + } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-anyrouter.svg b/Sources/CodexBar/Resources/ProviderIcon-anyrouter.svg new file mode 100644 index 0000000000..83c6860b1f --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-anyrouter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-clinepass.svg b/Sources/CodexBar/Resources/ProviderIcon-clinepass.svg new file mode 100644 index 0000000000..1ce7fe20be --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-clinepass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-longcat.svg b/Sources/CodexBar/Resources/ProviderIcon-longcat.svg new file mode 100644 index 0000000000..dd1201c95e --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-longcat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-neuralwatt.svg b/Sources/CodexBar/Resources/ProviderIcon-neuralwatt.svg new file mode 100644 index 0000000000..cf43777aca --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-neuralwatt.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index dabdc4e0d0..e75cbc4251 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -902,6 +902,13 @@ "MiniMax 30 day token usage trend" = "MiniMax اتجاه استخدام الرموز خلال 30 يوما"; "Today cash" = "اليوم نقدا"; "DeepSeek 30 day token usage trend" = "DeepSeek اتجاه استخدام الرموز خلال 30 يوما"; +"Detailed usage unavailable." = "الاستخدام التفصيلي غير متاح."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "سجّل الدخول إلى منصة DeepSeek في Chrome لعرض الاستخدام التفصيلي."; +"Select a DeepSeek Chrome profile in Settings." = "حدد ملف تعريف Chrome لـ DeepSeek في الإعدادات."; +"DeepSeek this month token usage trend" = "اتجاه استخدام رموز DeepSeek لهذا الشهر"; +"Chrome profile" = "ملف تعريف Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "اختر جلسة DeepSeek Platform المسجّل دخولها التي توفّر تفاصيل الاستخدام."; +"Select profile…" = "اختر ملفًا شخصيًا…"; "cache-hit input" = "إدخال الضربات المؤقتة"; "cache-miss input" = "إدخال ذاكرة تخزين مؤقت (CACHE-miss)"; "output" = "الإنتاج"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 51a1af3c62..16f7e19201 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -765,6 +765,13 @@ "MiniMax 30 day token usage trend" = "Tendència d'ús de tokens de 30 dies de MiniMax"; "Today cash" = "Efectiu d'avui"; "DeepSeek 30 day token usage trend" = "Tendència d'ús de tokens de 30 dies de DeepSeek"; +"Detailed usage unavailable." = "L'ús detallat no està disponible."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Inicia la sessió a DeepSeek Platform al Chrome per veure l'ús detallat."; +"Select a DeepSeek Chrome profile in Settings." = "Selecciona un perfil de Chrome de DeepSeek a Configuració."; +"DeepSeek this month token usage trend" = "Tendència d'ús de tokens de DeepSeek aquest mes"; +"Chrome profile" = "Perfil de Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Tria quina sessió iniciada de DeepSeek Platform proporciona l'ús detallat."; +"Select profile…" = "Selecciona un perfil…"; "cache-hit input" = "entrada amb encert de memòria cau"; "cache-miss input" = "entrada sense encert de memòria cau"; "output" = "sortida"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index b936d7308a..e995fb8fce 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -903,6 +903,13 @@ "MiniMax 30 day token usage trend" = "MiniMax 30 day token usage trend"; "Today cash" = "Today cash"; "DeepSeek 30 day token usage trend" = "DeepSeek 30 day token usage trend"; +"DeepSeek this month token usage trend" = "DeepSeek this month token usage trend"; +"Chrome profile" = "Chrome profile"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Choose which signed-in DeepSeek Platform session supplies detailed usage."; +"Detailed usage unavailable." = "Detailed usage unavailable."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Sign in to DeepSeek Platform in Chrome for detailed usage."; +"Select a DeepSeek Chrome profile in Settings." = "Select a DeepSeek Chrome profile in Settings."; +"Select profile…" = "Select profile…"; "cache-hit input" = "cache-hit input"; "cache-miss input" = "cache-miss input"; "output" = "output"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 0cf9c5a89a..e0606bbf7c 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -902,6 +902,13 @@ "MiniMax 30 day token usage trend" = "MiniMax روند استفاده ۳۰ روزه از توکن"; "Today cash" = "امروزه پول نقد"; "DeepSeek 30 day token usage trend" = "DeepSeek روند استفاده ۳۰ روزه از توکن"; +"Detailed usage unavailable." = "جزئیات استفاده در دسترس نیست."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "برای مشاهده جزئیات استفاده، در Chrome وارد DeepSeek Platform شوید."; +"Select a DeepSeek Chrome profile in Settings." = "یک نمایه Chrome دیپ‌سیک را در تنظیمات انتخاب کنید."; +"DeepSeek this month token usage trend" = "روند استفاده از توکن DeepSeek در این ماه"; +"Chrome profile" = "نمایه Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "انتخاب کنید کدام نشست واردشدهٔ DeepSeek Platform جزئیات استفاده را ارائه دهد."; +"Select profile…" = "انتخاب نمایه…"; "cache-hit input" = "ورودی کش و ضربه"; "cache-miss input" = "ورودی کش-خطا"; "output" = "خروجی"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index f5c830c7a4..148629e379 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -759,6 +759,13 @@ "MiniMax 30 day token usage trend" = "Tendencia de uso de tokens de MiniMax en 30 días"; "Today cash" = "Gasto de hoxe"; "DeepSeek 30 day token usage trend" = "Tendencia de uso de tokens de DeepSeek en 30 días"; +"DeepSeek this month token usage trend" = "Tendencia de uso de tokens de DeepSeek este mes"; +"Chrome profile" = "Perfil de Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Escolle que sesión iniciada de DeepSeek Platform fornece o uso detallado."; +"Detailed usage unavailable." = "O uso detallado non está dispoñible."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Inicia sesión en DeepSeek Platform en Chrome para ver o uso detallado."; +"Select a DeepSeek Chrome profile in Settings." = "Selecciona un perfil de Chrome de DeepSeek en Axustes."; +"Select profile…" = "Seleccionar perfil…"; "cache-hit input" = "entrada atopada na caché"; "cache-miss input" = "entrada non atopada na caché"; "output" = "saída"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 2d0cc2c0e8..649aace083 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -904,6 +904,13 @@ "MiniMax 30 day token usage trend" = "Tren penggunaan token 30 hari MiniMax"; "Today cash" = "Kas hari ini"; "DeepSeek 30 day token usage trend" = "Tren penggunaan token 30 hari DeepSeek"; +"DeepSeek this month token usage trend" = "Tren penggunaan token DeepSeek bulan ini"; +"Chrome profile" = "Profil Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Pilih sesi DeepSeek Platform yang telah masuk untuk menyediakan rincian penggunaan."; +"Detailed usage unavailable." = "Rincian penggunaan tidak tersedia."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Masuk ke DeepSeek Platform di Chrome untuk melihat rincian penggunaan."; +"Select a DeepSeek Chrome profile in Settings." = "Pilih profil Chrome DeepSeek di Pengaturan."; +"Select profile…" = "Pilih profil…"; "cache-hit input" = "input cache-hit"; "cache-miss input" = "input cache-miss"; "output" = "output"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 4747e332b6..daea4103b5 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -904,6 +904,13 @@ "MiniMax 30 day token usage trend" = "Trend utilizzo token 30 giorni MiniMax"; "Today cash" = "Spesa di oggi"; "DeepSeek 30 day token usage trend" = "Trend utilizzo token 30 giorni DeepSeek"; +"DeepSeek this month token usage trend" = "Trend utilizzo token DeepSeek di questo mese"; +"Chrome profile" = "Profilo Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Scegli quale sessione di DeepSeek Platform con accesso effettuato fornisce l'utilizzo dettagliato."; +"Detailed usage unavailable." = "Utilizzo dettagliato non disponibile."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Accedi a DeepSeek Platform in Chrome per l'utilizzo dettagliato."; +"Select a DeepSeek Chrome profile in Settings." = "Seleziona un profilo Chrome di DeepSeek nelle Impostazioni."; +"Select profile…" = "Seleziona profilo…"; "cache-hit input" = "input cache-hit"; "cache-miss input" = "input cache-miss"; "output" = "uscita"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 247eb2a71a..9690fb5e12 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -904,6 +904,13 @@ "MiniMax 30 day token usage trend" = "Trend użycia tokenów MiniMax z 30 dni"; "Today cash" = "Dzisiejsza gotówka"; "DeepSeek 30 day token usage trend" = "Trend użycia tokenów DeepSeek z 30 dni"; +"DeepSeek this month token usage trend" = "Trend użycia tokenów DeepSeek w tym miesiącu"; +"Chrome profile" = "Profil Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Wybierz zalogowaną sesję DeepSeek Platform, która ma dostarczać szczegółowe dane użycia."; +"Detailed usage unavailable." = "Szczegółowe dane użycia są niedostępne."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Zaloguj się do DeepSeek Platform w Chrome, aby uzyskać szczegółowe dane użycia."; +"Select a DeepSeek Chrome profile in Settings." = "Wybierz profil Chrome DeepSeek w Ustawieniach."; +"Select profile…" = "Wybierz profil…"; "cache-hit input" = "wejście trafienia pamięci podręcznej"; "cache-miss input" = "wejście chybienia pamięci podręcznej"; "output" = "wynik"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 2172c83095..4e85a91d4f 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -902,6 +902,13 @@ "MiniMax 30 day token usage trend" = "MiniMax แนวโน้มการใช้โทเค็น 30 วัน"; "Today cash" = "เงินสดวันนี้"; "DeepSeek 30 day token usage trend" = "DeepSeek แนวโน้มการใช้โทเค็น 30 วัน"; +"Detailed usage unavailable." = "ไม่มีข้อมูลการใช้งานโดยละเอียด"; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "ลงชื่อเข้าใช้ DeepSeek Platform ใน Chrome เพื่อดูรายละเอียดการใช้งาน"; +"Select a DeepSeek Chrome profile in Settings." = "เลือกโปรไฟล์ Chrome ของ DeepSeek ในการตั้งค่า"; +"DeepSeek this month token usage trend" = "แนวโน้มการใช้โทเค็น DeepSeek เดือนนี้"; +"Chrome profile" = "โปรไฟล์ Chrome"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "เลือกเซสชัน DeepSeek Platform ที่ลงชื่อเข้าใช้เพื่อแสดงรายละเอียดการใช้งาน"; +"Select profile…" = "เลือกโปรไฟล์…"; "cache-hit input" = "อินพุต cache-hit"; "cache-miss input" = "อินพุตแคชพลาด"; "output" = "เอาท์พุท"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 6a1fa3a57e..8e508d94ba 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -898,6 +898,13 @@ "MiniMax 30 day token usage trend" = "MiniMax 30 günlük jeton kullanım trendi"; "Today cash" = "Bugünkü nakit"; "DeepSeek 30 day token usage trend" = "DeepSeek 30 günlük jeton kullanım trendi"; +"DeepSeek this month token usage trend" = "DeepSeek bu ayki jeton kullanım trendi"; +"Chrome profile" = "Chrome profili"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Ayrıntılı kullanımı sağlayacak, oturum açılmış DeepSeek Platform oturumunu seçin."; +"Detailed usage unavailable." = "Ayrıntılı kullanım kullanılamıyor."; +"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Ayrıntılı kullanım için Chrome'da DeepSeek Platform'a giriş yapın."; +"Select a DeepSeek Chrome profile in Settings." = "Ayarlarda bir DeepSeek Chrome profili seçin."; +"Select profile…" = "Profil seç…"; "cache-hit input" = "önbellek-isabetli girdi"; "cache-miss input" = "önbellek-kaçan girdi"; "output" = "çıktı"; diff --git a/Sources/CodexBar/SettingsStore+ConfigPersistence.swift b/Sources/CodexBar/SettingsStore+ConfigPersistence.swift index cf6ce6b327..14e8ed6017 100644 --- a/Sources/CodexBar/SettingsStore+ConfigPersistence.swift +++ b/Sources/CodexBar/SettingsStore+ConfigPersistence.swift @@ -58,6 +58,29 @@ extension SettingsStore { } } + /// Persists provider settings that only affect an already-visible provider detail. + /// This avoids rebuilding status items and open menus for a local selection change. + func updateProviderDetailConfig( + provider: UsageProvider, + mutate: (inout ProviderConfig) -> Void) + { + guard !self.configLoading else { return } + var config = self.config + if let index = config.providers.firstIndex(where: { $0.id == provider }) { + var entry = config.providers[index] + mutate(&entry) + config.providers[index] = entry + } else { + var entry = ProviderConfig(id: provider) + mutate(&entry) + config.providers.append(entry) + } + self.config = config.normalized() + self.updateProviderState(config: self.config) + self.schedulePersistConfig() + self.providerDetailSettingsRevision &+= 1 + } + func updateProviderTokenAccounts(_ accounts: [UsageProvider: ProviderTokenAccountData]) { let summary = accounts .sorted { $0.key.rawValue < $1.key.rawValue } diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index 48a62fe9b8..0c6c67a7ad 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -302,7 +302,7 @@ extension SettingsStore { static func isBalanceOnlyProvider(_ provider: UsageProvider) -> Bool { switch provider { - case .deepseek, .mistral, .kimik2, .moonshot, .poe, .crossmodel: + case .deepseek, .mistral, .kimik2, .moonshot, .neuralwatt, .poe, .crossmodel: true default: false diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 2689d22576..6683d75c17 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -215,6 +215,7 @@ final class SettingsStore { @ObservationIgnored var selectedMenuProviderRawStorage: String? var defaultsState: SettingsDefaultsState var configRevision: Int = 0 + var providerDetailSettingsRevision: Int = 0 var backgroundWorkSettingsRevision: Int = 0 var providerOrder: [UsageProvider] = [] var providerEnablement: [UsageProvider: Bool] = [:] diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 04e49b4dec..3d82578043 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -34,7 +34,7 @@ extension StatusItemController { let snapshot: UsageSnapshot? = if surface == .overrideCard { snapshotOverride } else { - snapshotOverride ?? self.store.snapshot(for: target) + snapshotOverride ?? self.store.presentationSnapshot(for: target) } let projectedTokenSnapshot = self.store.tokenSnapshot(fromProviderSnapshot: snapshot, provider: target) let storedTokenSnapshot = UsageStore.tokenCostRequiresProviderSnapshot(target) diff --git a/Sources/CodexBar/UsageStore+APIKeyDebug.swift b/Sources/CodexBar/UsageStore+APIKeyDebug.swift index 36b33d0d61..e1e0f49022 100644 --- a/Sources/CodexBar/UsageStore+APIKeyDebug.swift +++ b/Sources/CodexBar/UsageStore+APIKeyDebug.swift @@ -42,6 +42,15 @@ extension UsageStore { hasEnvToken: { OpenRouterSettingsReader.apiToken(environment: $0) != nil }) } + func anyRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + self.apiKeyDebugContext( + provider: .anyrouter, + label: "ANYROUTER_API_KEY", + processEnvironment: processEnvironment, + resolution: ProviderTokenResolver.anyRouterResolution, + hasEnvToken: { AnyRouterSettingsReader.apiKey(environment: $0) != nil }) + } + func crossModelAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { self.apiKeyDebugContext( provider: .crossmodel, diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 5759af4abe..65058ba73c 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -2,6 +2,12 @@ import CodexBarCore import Foundation extension UsageStore { + struct DeepSeekProfileTransition { + var snapshot: UsageSnapshot + let accountID: UUID? + let hasSyntheticBalance: Bool + } + func version(for provider: UsageProvider) -> String? { self.versions[provider] } @@ -14,6 +20,62 @@ extension UsageStore { self.snapshots[.claude] } + func presentationSnapshot(for provider: UsageProvider) -> UsageSnapshot? { + if provider == .deepseek, + let transition = self.deepseekProfileTransition, + transition.accountID == self.settings.selectedTokenAccount(for: .deepseek)?.id + { + return transition.snapshot + } + if let snapshot = self.snapshots[provider] { + return snapshot + } + guard provider == .deepseek, self.refreshingProviders.contains(provider) else { return nil } + return self.lastKnownResetSnapshots[provider] + } + + func beginDeepSeekProfileTransition(preservingBalance: Bool = true) { + guard self.deepseekProfileTransition == nil, + let snapshot = self.snapshots[.deepseek] ?? self.lastKnownResetSnapshots[.deepseek] + else { return } + var transitionSnapshot = snapshot.withoutDeepSeekDetailedUsage() + if !preservingBalance { + transitionSnapshot = transitionSnapshot.with( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: L("Refreshing")), + secondary: nil) + } + self.deepseekProfileTransition = DeepSeekProfileTransition( + snapshot: transitionSnapshot, + accountID: self.settings.selectedTokenAccount(for: .deepseek)?.id, + hasSyntheticBalance: !preservingBalance) + } + + func markDeepSeekProfileTransitionUnavailable() { + guard var transition = self.deepseekProfileTransition, + transition.hasSyntheticBalance + else { return } + transition.snapshot = transition.snapshot.with( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: L("Unavailable")), + secondary: nil) + self.deepseekProfileTransition = transition + } + + func clearDeepSeekProfileTransition() { + self.deepseekProfileTransition = nil + } + + var deepseekProfileTransitionSnapshot: UsageSnapshot? { + self.deepseekProfileTransition?.snapshot + } + var lastCodexError: String? { self.errors[.codex] } @@ -60,6 +122,8 @@ extension UsageStore { return ZaiSettingsError.missingToken.errorDescription case .openrouter: return OpenRouterSettingsError.missingToken.errorDescription + case .anyrouter: + return AnyRouterUsageError.missingCredentials.errorDescription case .crossmodel: return CrossModelSettingsError.missingToken.errorDescription case .clawrouter: diff --git a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift index ad7f96a5c2..a7e5615aec 100644 --- a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift +++ b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift @@ -30,6 +30,9 @@ extension UsageStore { self.snapshots.removeValue(forKey: provider) self.lastKnownResetSnapshots.removeValue(forKey: provider) self.errors[provider] = nil + if provider == .deepseek { + self.clearDeepSeekProfileTransition() + } if provider == .gemini { self.clearGeminiConsumerTierDeprecationObservation() } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index aa78e2017c..59b5309f68 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -515,8 +515,9 @@ extension UsageStore { } else { self.lastKnownResetSnapshots[provider] } + let profileStable = self.preservingDeepSeekProfileCatalog(in: accountScoped, provider: provider) let stabilized = Self.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( - current: accountScoped, + current: profileStable, previous: self.snapshots[provider]) let backfilled = stabilized.backfillingResetTimes(from: resetBackfillSource) let warningAccountDiscriminator = Self.warningAccountDiscriminator( @@ -541,6 +542,9 @@ extension UsageStore { } self.lastKnownResetSnapshots[provider] = backfilled self.snapshots[provider] = backfilled + if provider == .deepseek { + self.clearDeepSeekProfileTransition() + } if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: backfilled, provider: provider) { self.tokenSnapshots[provider] = tokenSnapshot self.tokenErrors[provider] = nil @@ -631,8 +635,18 @@ extension UsageStore { return } // Credential-change cleanup already ran above; cancellation is now safe to suppress. - guard !Self.errorIsCancellation(error) else { return } + if Self.errorIsCancellation(error) { + if provider == .deepseek, + self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) + { + self.markDeepSeekProfileTransitionUnavailable() + } + return + } guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } + if provider == .deepseek { + self.markDeepSeekProfileTransitionUnavailable() + } self.bindCodexFailurePublicationOwner( provider: provider, expectedGuard: context.codexExpectedGuard) @@ -644,6 +658,14 @@ extension UsageStore { generation: context.generation) } + private func preservingDeepSeekProfileCatalog( + in snapshot: UsageSnapshot, + provider: UsageProvider) -> UsageSnapshot + { + guard provider == .deepseek else { return snapshot } + return snapshot.preservingDeepSeekPlatformProfiles(from: self.presentationSnapshot(for: .deepseek)) + } + private func bindCodexFailurePublicationOwner( provider: UsageProvider, expectedGuard: CodexAccountScopedRefreshGuard?) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 76f2420b9c..4fc4cfed65 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -755,6 +755,34 @@ extension UsageStore { return (index, account, descriptor, context) } + if let delay = TokenAccountSupportCatalog.support(for: provider)?.minimumDelayBetweenAccountRefreshes { + var results: [TokenAccountFetchResult] = [] + results.reserveCapacity(requests.count) + for request in requests { + if !results.isEmpty { + do { + try await Task.sleep(for: delay) + } catch { + for pending in requests.dropFirst(results.count) { + results.append(TokenAccountFetchResult( + index: pending.index, + account: pending.account, + outcome: ProviderFetchOutcome( + result: .failure(CancellationError()), + attempts: []))) + } + return results + } + } + let outcome = await request.descriptor.fetchOutcome(context: request.context) + results.append(TokenAccountFetchResult( + index: request.index, + account: request.account, + outcome: outcome)) + } + return results + } + return await withTaskGroup( of: TokenAccountFetchResult.self, returning: [TokenAccountFetchResult].self) @@ -896,7 +924,10 @@ extension UsageStore { runtime: .app, sourceMode: sourceMode, includeCredits: includeCredits, - includeOptionalUsage: self.settings.showOptionalCreditsAndExtraUsage, + includeOptionalUsage: ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: provider, + settings: self.settings, + override: override), webTimeout: 60, webDebugDumpHTML: false, verbose: verbose, @@ -1471,7 +1502,11 @@ extension UsageStore { guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return nil as UsageSnapshot? } - let backfilled = labeled.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) + let profileStable = provider == .deepseek + ? labeled.preservingDeepSeekPlatformProfiles( + from: self.presentationSnapshot(for: .deepseek)) + : labeled + let backfilled = profileStable.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) let warningAccountDiscriminator = Self.warningTokenAccountDiscriminator(account) self.handleQuotaWarningTransitions( provider: provider, @@ -1484,6 +1519,9 @@ extension UsageStore { accountDiscriminatorOverride: provider == .claude ? warningAccountDiscriminator : nil) self.lastKnownResetSnapshots[provider] = backfilled self.snapshots[provider] = backfilled + if provider == .deepseek { + self.clearDeepSeekProfileTransition() + } self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) @@ -1498,6 +1536,9 @@ extension UsageStore { case let .failure(error): await MainActor.run { self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) + if provider == .deepseek { + self.markDeepSeekProfileTransitionUnavailable() + } guard let message = self.tokenAccountErrorMessage(error) else { self.errors[provider] = nil return diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index efc58a5fe1..916386af2c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -327,6 +327,7 @@ final class UsageStore { @ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset? @ObservationIgnored var codexHistoricalDatasetAccountKey: String? @ObservationIgnored var lastKnownResetSnapshots: [UsageProvider: UsageSnapshot] = [:] + @ObservationIgnored var deepseekProfileTransition: DeepSeekProfileTransition? @ObservationIgnored var sessionQuotaTransitionStates: [UsageProvider: SessionQuotaTransitionState] = [:] @ObservationIgnored var codexSessionQuotaBaselineRequirement: CodexSessionQuotaBaselineRequirement? var codexSessionQuotaBaselineRequired: Bool { @@ -1029,6 +1030,7 @@ extension UsageStore { let azureOpenAIDebugContext = self.azureOpenAIAPIKeyDebugContext(processEnvironment: processEnvironment) let openRouterDebugContext = self.openRouterAPIKeyDebugContext(processEnvironment: processEnvironment) let crossModelDebugContext = self.crossModelAPIKeyDebugContext(processEnvironment: processEnvironment) + let anyRouterDebugContext = self.anyRouterAPIKeyDebugContext(processEnvironment: processEnvironment) let elevenLabsDebugContext = self.elevenLabsAPIKeyDebugContext(processEnvironment: processEnvironment) let deepSeekHasEnvToken = DeepSeekSettingsReader.apiKey(environment: processEnvironment) != nil let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil @@ -1044,6 +1046,7 @@ extension UsageStore { let unimplementedDebugLogMessages: [UsageProvider: String] = [ .gemini: "Gemini debug log not yet implemented", .antigravity: "Antigravity debug log not yet implemented", + .clinepass: "ClinePass debug log not yet implemented", .opencode: "OpenCode debug log not yet implemented", .alibaba: "Alibaba Coding Plan debug log not yet implemented", .alibabatokenplan: "Alibaba Token Plan debug log not yet implemented", @@ -1135,6 +1138,8 @@ extension UsageStore { ollamaCookieHeader: ollamaCookieHeader) case .openrouter: return Self.apiKeyDebugLine(openRouterDebugContext) + case .anyrouter: + return Self.apiKeyDebugLine(anyRouterDebugContext) case .crossmodel: return Self.apiKeyDebugLine(crossModelDebugContext) case .elevenlabs: @@ -1151,12 +1156,11 @@ extension UsageStore { configToken: nil, hasEnvToken: deepSeekHasEnvToken, hasTokenAccount: deepSeekHasTokenAccount) - case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, .devin, - .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, - .sakana, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .qoder, - .stepfun, - .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes, - .clawrouter, .wayfinder, .sub2api, .zenmux: + case .clinepass, .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, + .copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, + .mimo, .doubao, .sakana, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, + .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, + .deepgram, .poe, .chutes, .neuralwatt, .longcat, .clawrouter, .wayfinder, .sub2api, .zenmux: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index fd0931f66d..a264a7cd43 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -219,6 +219,8 @@ extension CodexBarCLI { BedrockSettingsReader.hasCredentials(environment: environment) case .claude: ClaudeAdminAPISettingsReader.apiKey(environment: environment) != nil + case .clinepass: + ClinePassSettingsReader.apiKey(environment: environment) != nil case .codebuff: CodebuffSettingsReader.apiKey(environment: environment) != nil case .chutes: @@ -243,6 +245,8 @@ extension CodexBarCLI { KiloSettingsReader.apiKey(environment: environment) != nil case .factory: FactorySettingsReader.apiKey(environment: environment) != nil + case .neuralwatt: + NeuralWattSettingsReader.apiKey(environment: environment) != nil default: false } @@ -263,6 +267,8 @@ extension CodexBarCLI { ClawRouterSettingsReader.apiKey(environment: environment) != nil case .sub2api: Sub2APISettingsReader.apiKey(environment: environment) != nil + case .anyrouter: + AnyRouterSettingsReader.apiKey(environment: environment) != nil case .moonshot: MoonshotSettingsReader.apiKey(environment: environment) != nil case .ollama: diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 3ebee626f3..7900e8a4d3 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -231,7 +231,16 @@ extension CodexBarCLI { let selections = Self.accountSelections(from: accounts) var output = UsageCommandOutput() - for account in selections { + let accountRefreshDelay = TokenAccountSupportCatalog + .support(for: provider)?.minimumDelayBetweenAccountRefreshes + for (index, account) in selections.enumerated() { + if index > 0, let accountRefreshDelay { + do { + try await Task.sleep(for: accountRefreshDelay) + } catch { + return output + } + } let result = await Self.fetchUsageOutput( provider: provider, account: account, diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index 515f488fef..7343b68262 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -51,9 +51,13 @@ public struct CodexBarConfig: Codable, Sendable { var normalized: [ProviderConfig] = [] normalized.reserveCapacity(max(self.providers.count, UsageProvider.allCases.count)) - for provider in self.providers { + for var provider in self.providers { guard !seen.contains(provider.id) else { continue } seen.insert(provider.id) + if provider.id == .deepseek { + provider.deepseekProfileID = provider.sanitizedDeepSeekProfileID + provider.deepseekProfileScope = provider.sanitizedDeepSeekProfileScope + } normalized.append(provider) } @@ -128,6 +132,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var kiloEnabledOrganizationIDs: [String]? public var awsProfile: String? public var awsAuthMode: String? + public var deepseekProfileID: String? + public var deepseekProfileScope: String? public init( id: UsageProvider, @@ -150,7 +156,9 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { kiloKnownOrganizations: [KiloOrganization]? = nil, kiloEnabledOrganizationIDs: [String]? = nil, awsProfile: String? = nil, - awsAuthMode: String? = nil) + awsAuthMode: String? = nil, + deepseekProfileID: String? = nil, + deepseekProfileScope: String? = nil) { self.id = id self.enabled = enabled @@ -173,6 +181,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.kiloEnabledOrganizationIDs = kiloEnabledOrganizationIDs self.awsProfile = awsProfile self.awsAuthMode = awsAuthMode + self.deepseekProfileID = deepseekProfileID + self.deepseekProfileScope = deepseekProfileScope } public var sanitizedAPIKey: String? { @@ -211,6 +221,14 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { Self.clean(self.awsAuthMode) } + public var sanitizedDeepSeekProfileID: String? { + Self.clean(self.deepseekProfileID).map(DeepSeekSettingsReader.canonicalProfileID) + } + + public var sanitizedDeepSeekProfileScope: String? { + Self.clean(self.deepseekProfileScope) + } + private static func clean(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index f8fda2b523..4084b30c7c 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -95,6 +95,8 @@ public enum ProviderConfigEnvironment { return env } return switch provider { + case .deepseek: + self.applyDeepSeekOverrides(base: base, config: config) case .deepgram: self.applyDeepgramOverrides(base: base, config: config) case .azureopenai: @@ -110,6 +112,23 @@ public enum ProviderConfigEnvironment { } } + private static func applyDeepSeekOverrides( + base: [String: String], + config: ProviderConfig?) -> [String: String] + { + var env = base + if let platformToken = config?.sanitizedCookieHeader { + env[DeepSeekSettingsReader.platformTokenEnvironmentKey] = platformToken + } + if let profileID = config?.sanitizedDeepSeekProfileID { + env[DeepSeekSettingsReader.profileIDEnvironmentKey] = profileID + } + if let profileScope = config?.sanitizedDeepSeekProfileScope { + env[DeepSeekSettingsReader.profileScopeEnvironmentKey] = profileScope + } + return env + } + private static func applyMultiFieldCredentialOverrides( base: [String: String], provider: UsageProvider, @@ -125,6 +144,7 @@ public enum ProviderConfigEnvironment { } } + // swiftlint:disable:next cyclomatic_complexity private static func directAPIKeyEnvironmentKey(for provider: UsageProvider) -> String? { switch provider { case .amp: @@ -135,6 +155,8 @@ public enum ProviderConfigEnvironment { AzureOpenAISettingsReader.apiKeyEnvironmentKey case .claude: ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey + case .clinepass: + ClinePassSettingsReader.apiKeyEnvironmentKey case .zai: ZaiSettingsReader.apiTokenKey case .minimax: @@ -147,8 +169,12 @@ public enum ProviderConfigEnvironment { SyntheticSettingsReader.apiKeyKey case .openrouter: OpenRouterSettingsReader.envKey + case .anyrouter: + AnyRouterSettingsReader.apiKeyEnvironmentKey case .elevenlabs: ElevenLabsSettingsReader.apiKeyEnvironmentKey + case .neuralwatt: + NeuralWattSettingsReader.apiKeyEnvironmentKey case .moonshot: MoonshotSettingsReader.apiKeyEnvironmentKeys.first case .kimi: diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index e16a88f5cb..302f677e3d 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 = "9b26fd821cf090dc" + static let value = "12193a380f3ef2fe" } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 547df9120e..c4b204ff94 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -4,6 +4,7 @@ public enum LogCategories { public static let adaptiveRefresh = "adaptive-refresh" public static let amp = "amp" public static let antigravity = "antigravity" + public static let anyRouterUsage = "anyrouter-usage" public static let app = "app" public static let auggieCLI = "auggie-cli" public static let augment = "augment" @@ -44,6 +45,9 @@ public enum LogCategories { public static let kimiTokenStore = "kimi-token-store" public static let kimiWeb = "kimi-web" public static let kiro = "kiro" + public static let longcatAPI = "longcat-api" + public static let longcatCookie = "longcat-cookie" + public static let longcatWeb = "longcat-web" public static let launchAtLogin = "launch-at-login" public static let login = "login" public static let logging = "logging" @@ -57,6 +61,7 @@ public enum LogCategories { public static let minimaxUsage = "minimax-usage" public static let minimaxWeb = "minimax-web" public static let moonshotUsage = "moonshot-usage" + public static let neuralWattUsage = "neuralwatt-usage" public static let notifications = "notifications" public static let openAIWeb = "openai-web" public static let openAIWebview = "openai-webview" diff --git a/Sources/CodexBarCore/Providers/AnyRouter/AnyRouterProviderDescriptor.swift b/Sources/CodexBarCore/Providers/AnyRouter/AnyRouterProviderDescriptor.swift new file mode 100644 index 0000000000..190a9e185b --- /dev/null +++ b/Sources/CodexBarCore/Providers/AnyRouter/AnyRouterProviderDescriptor.swift @@ -0,0 +1,47 @@ +import Foundation + +public enum AnyRouterProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .anyrouter, + metadata: ProviderMetadata( + id: .anyrouter, + displayName: "AnyRouter", + sessionLabel: "Credits", + weeklyLabel: "Usage", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show AnyRouter usage", + cliName: "anyrouter", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://anyrouter.dev/dashboard/credits", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .anyrouter, + iconResourceName: "ProviderIcon-anyrouter", + color: ProviderColor(red: 26 / 255, green: 26 / 255, blue: 46 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "AnyRouter spend is reported by its credits API." }), + fetchPlan: .apiToken( + strategyID: "anyrouter.api", + resolveToken: { ProviderTokenResolver.anyRouterToken(environment: $0) }, + missingCredentialsError: { AnyRouterUsageError.missingCredentials }, + loadUsage: { apiKey, context in + try AnyRouterSettingsReader.validateEndpointOverride(environment: context.env) + return try await AnyRouterUsageFetcher.fetchUsage( + apiKey: apiKey, + baseURL: AnyRouterSettingsReader.baseURL(environment: context.env)).toUsageSnapshot() + }), + cli: ProviderCLIConfig( + name: "anyrouter", + aliases: ["ar"], + versionDetector: nil)) + } +} diff --git a/Sources/CodexBarCore/Providers/AnyRouter/AnyRouterSettingsReader.swift b/Sources/CodexBarCore/Providers/AnyRouter/AnyRouterSettingsReader.swift new file mode 100644 index 0000000000..32da86c565 --- /dev/null +++ b/Sources/CodexBarCore/Providers/AnyRouter/AnyRouterSettingsReader.swift @@ -0,0 +1,56 @@ +import Foundation + +public enum AnyRouterSettingsError: LocalizedError, Equatable, Sendable { + case invalidEndpointOverride(String) + + public var errorDescription: String? { + switch self { + case let .invalidEndpointOverride(key): + "AnyRouter endpoint override \(key) must use HTTPS or a bare host." + } + } +} + +/// Reads AnyRouter settings from environment variables. +public enum AnyRouterSettingsReader { + public static let apiKeyEnvironmentKey = "ANYROUTER_API_KEY" + public static let baseURLEnvironmentKey = "ANYROUTER_API_URL" + public static let defaultBaseURL = URL(string: "https://anyrouter.dev/api/v1")! + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.apiKeyEnvironmentKey]) + } + + public static func baseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else { + return self.defaultBaseURL + } + return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) ?? self.defaultBaseURL + } + + public static func validateEndpointOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else { return } + guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) != nil else { + throw AnyRouterSettingsError.invalidEndpointOverride(self.baseURLEnvironmentKey) + } + } + + 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 + } +} diff --git a/Sources/CodexBarCore/Providers/AnyRouter/AnyRouterUsageFetcher.swift b/Sources/CodexBarCore/Providers/AnyRouter/AnyRouterUsageFetcher.swift new file mode 100644 index 0000000000..8f258cd9a5 --- /dev/null +++ b/Sources/CodexBarCore/Providers/AnyRouter/AnyRouterUsageFetcher.swift @@ -0,0 +1,154 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum AnyRouterUsageError: LocalizedError, Equatable, Sendable { + case missingCredentials + case invalidCredentials + case insufficientScope + case apiError(Int) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing AnyRouter API key. Add one in Settings or set ANYROUTER_API_KEY." + case .invalidCredentials: + "AnyRouter rejected the API key. Check the key on the AnyRouter dashboard." + case .insufficientScope: + "This AnyRouter key is not permitted to read credits. Allow the /api/v1/credits endpoint " + + "on the key, or use a key without an endpoint allow-list." + case let .apiError(statusCode): + "AnyRouter API returned HTTP \(statusCode)." + case let .parseFailed(message): + "Could not parse AnyRouter credits: \(message)" + } + } +} + +/// `GET /api/v1/credits` response. AnyRouter returns the balance fields at the top level +/// (unlike OpenRouter, which wraps them in a `data` object). The payload also carries +/// `monthly_balance`, `topup_balance`, and `today_cost`, which nothing displays yet. +private struct AnyRouterCreditsResponse: Decodable { + let balance: Double + let used: Double + let currency: String? +} + +public struct AnyRouterUsageSnapshot: Codable, Sendable, Equatable { + /// Total credit available to spend, in `currencyCode` (AnyRouter-issued plus purchased). + public let balance: Double + /// Cumulative lifetime spend. + public let used: Double + public let currencyCode: String + public let updatedAt: Date + + public init( + balance: Double, + used: Double, + currencyCode: String, + updatedAt: Date) + { + self.balance = balance + self.used = used + self.currencyCode = currencyCode + self.updatedAt = updatedAt + } + + /// Everything ever granted or purchased: what is still spendable plus what has been spent. + public var totalCredits: Double { + max(0, self.balance + self.used) + } + + /// Share of granted credit already spent (0-100). + public var usedPercent: Double { + let total = self.totalCredits + guard total > 0 else { return 0 } + return min(100, max(0, self.used / total * 100)) + } + + public func toUsageSnapshot() -> UsageSnapshot { + let identity = ProviderIdentitySnapshot( + providerID: .anyrouter, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: \(UsageFormatter.currencyString(self.balance, currencyCode: self.currencyCode))") + + return UsageSnapshot( + primary: RateWindow( + usedPercent: self.usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: self.used, + limit: self.totalCredits, + currencyCode: self.currencyCode, + period: "Lifetime", + updatedAt: self.updatedAt), + updatedAt: self.updatedAt, + identity: identity, + dataConfidence: .exact) + } +} + +public enum AnyRouterUsageFetcher { + private static let log = CodexBarLog.logger(LogCategories.anyRouterUsage) + private static let requestTimeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + apiKey: String, + baseURL: URL = AnyRouterSettingsReader.defaultBaseURL, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + updatedAt: Date = Date()) async throws -> AnyRouterUsageSnapshot + { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw AnyRouterUsageError.missingCredentials + } + + var request = URLRequest(url: baseURL.appendingPathComponent("credits")) + request.httpMethod = "GET" + request.timeoutInterval = Self.requestTimeoutSeconds + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response = try await transport.response(for: request) + // A key carrying an `allowed_endpoints` list that omits /api/v1/credits is valid but + // scoped out, which AnyRouter reports as 403 insufficient_scope — a different fix for + // the user than a rejected key. + if response.statusCode == 403 { + throw AnyRouterUsageError.insufficientScope + } + if response.statusCode == 401 { + throw AnyRouterUsageError.invalidCredentials + } + guard (200..<300).contains(response.statusCode) else { + Self.log.error("AnyRouter credits API returned \(response.statusCode)") + throw AnyRouterUsageError.apiError(response.statusCode) + } + return try self.parseSnapshot(data: response.data, updatedAt: updatedAt) + } + + public static func _parseSnapshotForTesting( + _ data: Data, + updatedAt: Date) throws -> AnyRouterUsageSnapshot + { + try self.parseSnapshot(data: data, updatedAt: updatedAt) + } + + private static func parseSnapshot(data: Data, updatedAt: Date) throws -> AnyRouterUsageSnapshot { + do { + let response = try JSONDecoder().decode(AnyRouterCreditsResponse.self, from: data) + return AnyRouterUsageSnapshot( + balance: response.balance, + used: response.used, + currencyCode: response.currency?.uppercased() ?? "USD", + updatedAt: updatedAt) + } catch { + self.log.error("AnyRouter credits parsing failed: \(error.localizedDescription)") + throw AnyRouterUsageError.parseFailed(error.localizedDescription) + } + } +} diff --git a/Sources/CodexBarCore/Providers/ClinePass/ClinePassProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ClinePass/ClinePassProviderDescriptor.swift new file mode 100644 index 0000000000..38b00391e3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ClinePass/ClinePassProviderDescriptor.swift @@ -0,0 +1,46 @@ +import Foundation + +public enum ClinePassProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .clinepass, + metadata: ProviderMetadata( + id: .clinepass, + displayName: "ClinePass", + sessionLabel: "5-hour", + weeklyLabel: "Weekly", + opusLabel: "Monthly", + supportsOpus: true, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show ClinePass usage", + cliName: "clinepass", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://app.cline.bot/dashboard/subscription?personal=true", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .clinepass, + iconResourceName: "ProviderIcon-clinepass", + color: ProviderColor(red: 0.38, green: 0.64, blue: 0.98)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "ClinePass cost history is not available via the usage-limits API." }), + fetchPlan: .apiToken( + strategyID: "clinepass.api", + resolveToken: { ProviderTokenResolver.clinePassToken(environment: $0) }, + missingCredentialsError: { ClinePassUsageError.missingCredentials }, + loadUsage: { apiKey, _ in + try await ClinePassUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }), + cli: ProviderCLIConfig( + name: "clinepass", + aliases: [], + versionDetector: nil)) + } +} diff --git a/Sources/CodexBarCore/Providers/ClinePass/ClinePassSettingsReader.swift b/Sources/CodexBarCore/Providers/ClinePass/ClinePassSettingsReader.swift new file mode 100644 index 0000000000..a8b2be22a2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ClinePass/ClinePassSettingsReader.swift @@ -0,0 +1,34 @@ +import Foundation + +public enum ClinePassSettingsReader { + public static let apiKeyEnvironmentKey = "CLINE_API_KEY" + public static let alternateAPIKeyEnvironmentKey = "CLINEPASS_API_KEY" + public static let apiKeyEnvironmentKeys = [ + Self.apiKeyEnvironmentKey, + Self.alternateAPIKeyEnvironmentKey, + ] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + if let value = self.cleaned(environment[key]) { + return value + } + } + return nil + } + + private 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 + } +} diff --git a/Sources/CodexBarCore/Providers/ClinePass/ClinePassUsageFetcher.swift b/Sources/CodexBarCore/Providers/ClinePass/ClinePassUsageFetcher.swift new file mode 100644 index 0000000000..ec281ee99f --- /dev/null +++ b/Sources/CodexBarCore/Providers/ClinePass/ClinePassUsageFetcher.swift @@ -0,0 +1,194 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum ClinePassUsageError: LocalizedError, Sendable, Equatable { + case missingCredentials + case unauthorized + case apiError(Int) + case networkError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing ClinePass API key. Set CLINE_API_KEY or CLINEPASS_API_KEY." + case .unauthorized: + "ClinePass API key was rejected." + case let .apiError(statusCode): + "ClinePass API error: HTTP \(statusCode)" + case let .networkError(message): + "ClinePass network error: \(message)" + case let .parseFailed(message): + "Failed to parse ClinePass response: \(message)" + } + } +} + +public struct ClinePassUsageSnapshot: Sendable, Equatable { + public let primary: RateWindow? + public let secondary: RateWindow? + public let tertiary: RateWindow? + public let updatedAt: Date + + public init( + primary: RateWindow?, + secondary: RateWindow?, + tertiary: RateWindow?, + updatedAt: Date) + { + self.primary = primary + self.secondary = secondary + self.tertiary = tertiary + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: self.primary, + secondary: self.secondary, + tertiary: self.tertiary, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .clinepass, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "API key")) + } +} + +private enum ClinePassLimitType: String, Decodable, Sendable { + case fiveHour = "five_hour" + case weekly + case monthly + + var windowMinutes: Int { + switch self { + case .fiveHour: + 5 * 60 + case .weekly: + 7 * 24 * 60 + case .monthly: + 30 * 24 * 60 + } + } +} + +private struct ClinePassLimit: Decodable, Sendable { + let type: ClinePassLimitType + let percentUsed: Double + let resetsAt: String? +} + +private struct ClinePassLimitsData: Decodable, Sendable { + let limits: [ClinePassLimit] +} + +private struct ClinePassLimitsResponse: Decodable, Sendable { + let data: ClinePassLimitsData + let success: Bool +} + +public struct ClinePassUsageFetcher: Sendable { + private static let usageURL = URL(string: "https://api.cline.bot/api/v1/users/me/plan/usage-limits")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage(apiKey: String) async throws -> ClinePassUsageSnapshot { + try await self._fetchUsage(apiKey: apiKey, transport: ProviderHTTPClient.shared) + } + + static func _fetchUsage( + apiKey: String, + transport: any ProviderHTTPTransport, + now: Date = Date()) async throws -> ClinePassUsageSnapshot + { + let cleanedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanedKey.isEmpty else { + throw ClinePassUsageError.missingCredentials + } + + var request = URLRequest(url: self.usageURL) + request.httpMethod = "GET" + request.setValue("Bearer \(cleanedKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = self.timeoutSeconds + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw ClinePassUsageError.networkError(error.localizedDescription) + } + + switch response.statusCode { + case 200: + return try self.parseSnapshot(data: response.data, updatedAt: now) + case 401, 403: + throw ClinePassUsageError.unauthorized + default: + throw ClinePassUsageError.apiError(response.statusCode) + } + } + + static func _parseSnapshotForTesting( + _ data: Data, + updatedAt: Date = Date()) throws -> ClinePassUsageSnapshot + { + try self.parseSnapshot(data: data, updatedAt: updatedAt) + } + + private static func parseSnapshot(data: Data, updatedAt: Date) throws -> ClinePassUsageSnapshot { + let response: ClinePassLimitsResponse + do { + response = try JSONDecoder().decode(ClinePassLimitsResponse.self, from: data) + } catch { + throw ClinePassUsageError.parseFailed(error.localizedDescription) + } + + guard response.success else { + throw ClinePassUsageError.parseFailed("Response success was false.") + } + + var windows: [ClinePassLimitType: RateWindow] = [:] + for limit in response.data.limits { + windows[limit.type] = try self.rateWindow(for: limit) + } + + return ClinePassUsageSnapshot( + primary: windows[.fiveHour], + secondary: windows[.weekly], + tertiary: windows[.monthly], + updatedAt: updatedAt) + } + + private static func rateWindow(for limit: ClinePassLimit) throws -> RateWindow { + let resetsAt: Date? + if let raw = limit.resetsAt { + guard let parsed = self.parseISO8601Date(raw) else { + throw ClinePassUsageError.parseFailed("Invalid resetsAt timestamp for \(limit.type.rawValue).") + } + resetsAt = parsed + } else { + resetsAt = nil + } + + return RateWindow( + usedPercent: min(100, max(0, limit.percentUsed)), + windowMinutes: limit.type.windowMinutes, + resetsAt: resetsAt, + resetDescription: nil) + } + + private static func parseISO8601Date(_ raw: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: raw) { + return date + } + + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: raw) + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift new file mode 100644 index 0000000000..840059c5fd --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift @@ -0,0 +1,475 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif + +enum DeepSeekPlatformTokenImporter { + struct TokenInfo: Sendable, Equatable { + let id: String + let token: String + let sourceLabel: String + } + + struct Resolution: Sendable { + let profiles: [DeepSeekPlatformProfile] + let selectedSummary: DeepSeekUsageSummary? + let selectedBalance: DeepSeekUsageSnapshot? + let detailedUsageState: DeepSeekDetailedUsageState + + init( + profiles: [DeepSeekPlatformProfile], + selectedSummary: DeepSeekUsageSummary?, + selectedBalance: DeepSeekUsageSnapshot? = nil, + detailedUsageState: DeepSeekDetailedUsageState) + { + self.profiles = profiles + self.selectedSummary = selectedSummary + self.selectedBalance = selectedBalance + self.detailedUsageState = detailedUsageState + } + } + + private struct PlatformSessionData: Sendable { + let summary: DeepSeekUsageSummary? + let balance: DeepSeekUsageSnapshot? + let detailedUsageState: DeepSeekDetailedUsageState + } + + private enum ValidationOutcome: Sendable { + case valid(PlatformSessionData) + case invalid + case unavailable + } + + private struct ValidationResult: Sendable { + let candidate: TokenInfo + let outcome: ValidationOutcome + } + + private static let validationCache = DeepSeekPlatformValidationCache() + + static func resolveAutomaticSession( + selectedProfileID: String?, + requiresExplicitSelection: Bool = false, + includePlatformBalance: Bool = false, + includeOptionalUsage: Bool = true, + browserDetection: BrowserDetection, + logger: (@Sendable (String) -> Void)? = nil) async -> Resolution + { + #if os(macOS) + let candidates = self.importTokens(browserDetection: browserDetection, logger: logger) + guard !Task.isCancelled else { + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .unavailable) + } + return await self.resolve( + candidates: candidates, + selection: DeepSeekSettingsReader.ProfileSelection( + profileID: selectedProfileID, + requiresExplicitSelection: requiresExplicitSelection), + logger: logger, + cache: self.validationCache, + validate: { token in + if includePlatformBalance { + let snapshot = try await DeepSeekUsageFetcher.fetchPlatformUsage( + platformToken: token, + includeOptionalUsage: includeOptionalUsage) + return PlatformSessionData( + summary: snapshot.usageSummary, + balance: snapshot, + detailedUsageState: snapshot.detailedUsageState) + } + return try await PlatformSessionData( + summary: DeepSeekUsageFetcher.fetchUsageSummary(platformToken: token), + balance: nil, + detailedUsageState: .available) + }) + #else + _ = selectedProfileID + _ = requiresExplicitSelection + _ = includePlatformBalance + _ = includeOptionalUsage + _ = browserDetection + _ = logger + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .webSessionRequired) + #endif + } + + #if os(macOS) + static func importTokens( + browserDetection: BrowserDetection, + logger: (@Sendable (String) -> Void)? = nil) -> [TokenInfo] + { + let log: (String) -> Void = { message in logger?("[deepseek-storage] \(message)") } + let installedBrowsers = [Browser.chrome].browsersWithProfileData(using: browserDetection) + let roots = ChromiumProfileLocator.roots( + for: installedBrowsers, + homeDirectories: BrowserCookieClient.defaultHomeDirectories()) + + var results: [TokenInfo] = [] + for root in roots { + guard !Task.isCancelled else { return results } + for candidate in self.localStorageCandidates( + root: root.url, + browserID: root.browser.rawValue, + labelPrefix: root.labelPrefix) + { + guard !Task.isCancelled else { return results } + log("Checking \(candidate.id)") + let entries = SweetCookieKit.ChromiumLocalStorageReader.readEntries( + for: "https://platform.deepseek.com", + in: candidate.url, + logger: log) + guard let entry = entries.first(where: { $0.key == "userToken" }), + let token = self.extractUserToken(from: entry.value) + else { + log("No DeepSeek userToken found in \(candidate.id)") + continue + } + + log("Found DeepSeek platform token in \(candidate.id)") + results.append(TokenInfo(id: candidate.id, token: token, sourceLabel: candidate.label)) + } + } + + if results.isEmpty { + log("No DeepSeek userToken found in Chrome local storage") + } + return results + } + + private struct LocalStorageCandidate { + let id: String + let label: String + let url: URL + } + + private static func localStorageCandidates( + root: URL, + browserID: String, + labelPrefix: String) -> [LocalStorageCandidate] + { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return [] } + + let profileNames = self.chromeProfileNames(root: root) + return entries.compactMap { directory in + guard let isDirectory = try? directory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory, + isDirectory + else { return nil } + let name = directory.lastPathComponent + guard name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-") else { + return nil + } + let levelDB = directory.appendingPathComponent("Local Storage").appendingPathComponent("leveldb") + guard FileManager.default.fileExists(atPath: levelDB.path) else { return nil } + + let displayName = profileNames[name]?.trimmingCharacters(in: .whitespacesAndNewlines) + let label = if let displayName, !displayName.isEmpty, displayName != name { + "\(labelPrefix) — \(displayName)" + } else { + "\(labelPrefix) \(name)" + } + return LocalStorageCandidate( + id: "\(browserID):\(name)", + label: label, + url: levelDB) + } + .sorted { $0.label.localizedStandardCompare($1.label) == .orderedAscending } + } + + private static func chromeProfileNames(root: URL) -> [String: String] { + let localStateURL = root.appendingPathComponent("Local State") + guard let data = try? Data(contentsOf: localStateURL), + let rootObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let profile = rootObject["profile"] as? [String: Any], + let infoCache = profile["info_cache"] as? [String: Any] + else { return [:] } + + return infoCache.reduce(into: [:]) { result, entry in + guard let info = entry.value as? [String: Any] else { return } + let name = (info["gaia_given_name"] as? String) ?? (info["name"] as? String) + guard let name else { return } + result[entry.key] = name + } + } + + #endif + + private static func resolve( + candidates: [TokenInfo], + selection: DeepSeekSettingsReader.ProfileSelection, + logger: (@Sendable (String) -> Void)?, + cache: DeepSeekPlatformValidationCache, + validate: @escaping @Sendable (String) async throws -> PlatformSessionData) async -> Resolution + { + guard !Task.isCancelled else { + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .unavailable) + } + guard !candidates.isEmpty else { + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .webSessionRequired) + } + + let now = Date() + var lookups: [String: DeepSeekPlatformValidationCache.Lookup] = [:] + var candidatesToValidate: [TokenInfo] = [] + for candidate in candidates { + guard !Task.isCancelled else { + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .unavailable) + } + let lookup = await cache.lookup(candidate: candidate, now: now) + lookups[candidate.id] = lookup + if lookup.freshStatus == nil || candidate.id == selection.profileID { + candidatesToValidate.append(candidate) + } + } + + var outcomes = await self.validate( + candidates: candidatesToValidate, + logger: logger, + validate: validate) + guard !Task.isCancelled else { + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .unavailable) + } + await self.record(outcomes: outcomes, cache: cache, now: now) + + var statusByID = self.resolvedStatuses(candidates: candidates, lookups: lookups, outcomes: outcomes) + var validCandidates = candidates.filter { statusByID[$0.id] == true } + var sessionDataByID = self.sessionDataByID(outcomes: outcomes) + + if validCandidates.count == 1, sessionDataByID[validCandidates[0].id] == nil { + let candidate = validCandidates[0] + let refresh = await self.validate(candidates: [candidate], logger: logger, validate: validate) + await self.record(outcomes: refresh, cache: cache, now: now) + outcomes.append(contentsOf: refresh) + statusByID = self.resolvedStatuses(candidates: candidates, lookups: lookups, outcomes: outcomes) + validCandidates = candidates.filter { statusByID[$0.id] == true } + sessionDataByID = self.sessionDataByID(outcomes: outcomes) + } + + let profiles = validCandidates.map { DeepSeekPlatformProfile(id: $0.id, name: $0.sourceLabel) } + guard !validCandidates.isEmpty else { + let validationWasUnavailable = outcomes.contains { result in + if case .unavailable = result.outcome { return true } + return false + } + return Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: validationWasUnavailable ? .unavailable : .webSessionRequired) + } + + let selected: TokenInfo? = if selection.requiresExplicitSelection { + nil + } else if let selectedProfileID = selection.profileID { + validCandidates.first(where: { $0.id == selectedProfileID }) + } else { + validCandidates.count == 1 ? validCandidates[0] : nil + } + guard let selected else { + return Resolution( + profiles: profiles, + selectedSummary: nil, + detailedUsageState: .profileSelectionRequired) + } + + if let sessionData = sessionDataByID[selected.id] { + return Resolution( + profiles: profiles, + selectedSummary: sessionData.summary, + selectedBalance: sessionData.balance, + detailedUsageState: sessionData.detailedUsageState) + } + return Resolution(profiles: profiles, selectedSummary: nil, detailedUsageState: .unavailable) + } + + private static func validate( + candidates: [TokenInfo], + logger: (@Sendable (String) -> Void)?, + validate: @escaping @Sendable (String) async throws -> PlatformSessionData) async -> [ValidationResult] + { + let results = await withTaskGroup(of: ValidationResult.self, returning: [ValidationResult].self) { group in + for candidate in candidates { + group.addTask { + guard !Task.isCancelled else { + return ValidationResult(candidate: candidate, outcome: .unavailable) + } + do { + let summary = try await validate(candidate.token) + return ValidationResult(candidate: candidate, outcome: .valid(summary)) + } catch DeepSeekUsageError.invalidPlatformToken { + return ValidationResult(candidate: candidate, outcome: .invalid) + } catch { + return ValidationResult(candidate: candidate, outcome: .unavailable) + } + } + } + + var results: [ValidationResult] = [] + for await result in group { + results.append(result) + } + return results + } + for result in results { + switch result.outcome { + case .valid: + logger?("[deepseek-storage] Validated \(result.candidate.id)") + case .invalid: + logger?("[deepseek-storage] Rejected expired session for \(result.candidate.id)") + case .unavailable: + logger?("[deepseek-storage] Could not validate \(result.candidate.id)") + } + } + return results + } + + private static func resolvedStatuses( + candidates: [TokenInfo], + lookups: [String: DeepSeekPlatformValidationCache.Lookup], + outcomes: [ValidationResult]) -> [String: Bool] + { + var statusByID = Dictionary(uniqueKeysWithValues: candidates.compactMap { candidate in + lookups[candidate.id]?.freshStatus.map { (candidate.id, $0) } + }) + for result in outcomes { + switch result.outcome { + case .valid: + statusByID[result.candidate.id] = true + case .invalid: + statusByID[result.candidate.id] = false + case .unavailable: + if let lastKnownStatus = lookups[result.candidate.id]?.lastKnownStatus { + statusByID[result.candidate.id] = lastKnownStatus + } + } + } + return statusByID + } + + private static func sessionDataByID(outcomes: [ValidationResult]) -> [String: PlatformSessionData] { + var sessionData: [String: PlatformSessionData] = [:] + for result in outcomes { + guard case let .valid(value) = result.outcome else { continue } + sessionData[result.candidate.id] = value + } + return sessionData + } + + private static func record( + outcomes: [ValidationResult], + cache: DeepSeekPlatformValidationCache, + now: Date) async + { + for result in outcomes { + switch result.outcome { + case .valid: + await cache.record(candidate: result.candidate, status: true, now: now) + case .invalid: + await cache.record(candidate: result.candidate, status: false, now: now) + case .unavailable: + break + } + } + } + + private static func extractUserToken(from rawValue: String) -> String? { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) + { + return self.token(fromJSONObject: object) + } + + let unquoted: String = if (trimmed.hasPrefix("\"") && trimmed.hasSuffix("\"")) || + (trimmed.hasPrefix("'") && trimmed.hasSuffix("'")) + { + String(trimmed.dropFirst().dropLast()) + } else { + trimmed + } + return self.isPlausibleToken(unquoted) ? unquoted : nil + } + + private static func token(fromJSONObject value: Any) -> String? { + if let string = value as? String { + return self.isPlausibleToken(string) ? string : nil + } + guard let dictionary = value as? [String: Any] else { return nil } + for key in ["value", "token", "access_token", "accessToken", "userToken"] { + guard let token = dictionary[key] as? String, self.isPlausibleToken(token) else { continue } + return token + } + return nil + } + + private static func isPlausibleToken(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.count >= 20 && !trimmed.contains(where: \.isWhitespace) + } + + static func _extractUserTokenForTesting(_ rawValue: String) -> String? { + self.extractUserToken(from: rawValue) + } + + static func _resolveForTesting( + candidates: [TokenInfo], + selectedProfileID: String?, + requiresExplicitSelection: Bool = false, + detailedUsageState: DeepSeekDetailedUsageState = .available, + cache: DeepSeekPlatformValidationCache? = nil, + validate: @escaping @Sendable (String) async throws -> DeepSeekUsageSummary) async -> Resolution + { + await self.resolve( + candidates: candidates, + selection: DeepSeekSettingsReader.ProfileSelection( + profileID: selectedProfileID, + requiresExplicitSelection: requiresExplicitSelection), + logger: nil, + cache: cache ?? DeepSeekPlatformValidationCache(validityTTL: 0), + validate: { token in + try await PlatformSessionData( + summary: validate(token), + balance: nil, + detailedUsageState: detailedUsageState) + }) + } +} + +actor DeepSeekPlatformValidationCache { + struct Lookup: Sendable { + let freshStatus: Bool? + let lastKnownStatus: Bool? + } + + private struct Entry: Sendable { + let token: String + let status: Bool + let checkedAt: Date + } + + private let validityTTL: TimeInterval + private var entries: [String: Entry] = [:] + + init(validityTTL: TimeInterval = 30 * 60) { + self.validityTTL = validityTTL + } + + func lookup(candidate: DeepSeekPlatformTokenImporter.TokenInfo, now: Date) -> Lookup { + guard let entry = self.entries[candidate.id], entry.token == candidate.token else { + return Lookup(freshStatus: nil, lastKnownStatus: nil) + } + let isFresh = now.timeIntervalSince(entry.checkedAt) < self.validityTTL + return Lookup( + freshStatus: isFresh ? entry.status : nil, + lastKnownStatus: entry.status) + } + + func record(candidate: DeepSeekPlatformTokenImporter.TokenInfo, status: Bool, now: Date) { + self.entries[candidate.id] = Entry(token: candidate.token, status: status, checkedAt: now) + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift index f2847c787d..0577abb47c 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift @@ -3,6 +3,43 @@ import Foundation public enum DeepSeekProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + private static let optionalResolutionJoinGrace: Duration = .seconds(5) + private static let platformResolutionJoinGrace: Duration = .seconds(20) + + struct FetchOperations: Sendable { + let fetchUsage: @Sendable (String, String?, Bool) async throws -> DeepSeekUsageSnapshot + let resolveAutomaticSession: + @Sendable (String?, Bool, Bool, Bool, BrowserDetection, Bool) async + -> DeepSeekPlatformTokenImporter.Resolution + + static var live: FetchOperations { + FetchOperations( + fetchUsage: { apiKey, platformToken, includeOptionalUsage in + try await DeepSeekUsageFetcher.fetchUsage( + apiKey: apiKey, + platformToken: platformToken, + includeOptionalUsage: includeOptionalUsage) + }, + resolveAutomaticSession: { profileID, explicit, includeBalance, includeOptional, detection, verbose in + if verbose { + return await DeepSeekPlatformTokenImporter.resolveAutomaticSession( + selectedProfileID: profileID, + requiresExplicitSelection: explicit, + includePlatformBalance: includeBalance, + includeOptionalUsage: includeOptional, + browserDetection: detection, + logger: { print($0) }) + } + return await DeepSeekPlatformTokenImporter.resolveAutomaticSession( + selectedProfileID: profileID, + requiresExplicitSelection: explicit, + includePlatformBalance: includeBalance, + includeOptionalUsage: includeOptional, + browserDetection: detection) + }) + } + } + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .deepseek, @@ -31,18 +68,240 @@ public enum DeepSeekProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "DeepSeek per-day cost history is not available via API." }), - fetchPlan: .apiToken( - strategyID: "deepseek.api", - resolveToken: { ProviderTokenResolver.deepseekToken(environment: $0) }, - missingCredentialsError: { DeepSeekUsageError.missingCredentials }, - loadUsage: { apiKey, context in - try await DeepSeekUsageFetcher.fetchUsage( - apiKey: apiKey, - includeOptionalUsage: context.includeOptionalUsage).toUsageSnapshot() - }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "deepseek", aliases: ["deep-seek", "ds"], versionDetector: nil)) } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + switch context.sourceMode { + case .api: + [DeepSeekAPIFetchStrategy()] + case .web: + [DeepSeekPlatformFetchStrategy()] + case .auto: + if ProviderTokenResolver.deepseekToken(environment: context.env) != nil { + [DeepSeekAPIFetchStrategy()] + } else { + [DeepSeekPlatformFetchStrategy()] + } + case .cli, .oauth: + [] + } + } + + private static func loadUsage( + apiKey: String, + context: ProviderFetchContext, + optionalResolutionJoinGrace: Duration, + operations: FetchOperations) async throws -> UsageSnapshot + { + guard context.includeOptionalUsage else { + return try await operations.fetchUsage(apiKey, nil, false).toUsageSnapshot() + } + if let platformToken = DeepSeekSettingsReader.platformToken(environment: context.env) { + return try await operations.fetchUsage(apiKey, platformToken, true).toUsageSnapshot() + } + + return try await self.loadAutomaticUsage( + apiKey: apiKey, + context: context, + optionalResolutionJoinGrace: optionalResolutionJoinGrace, + operations: operations) + } + + fileprivate static func loadAPIUsage(apiKey: String, context: ProviderFetchContext) async throws -> UsageSnapshot { + try await self.loadUsage( + apiKey: apiKey, + context: context, + optionalResolutionJoinGrace: self.optionalResolutionJoinGrace, + operations: .live) + } + + private static func loadAutomaticUsage( + apiKey: String, + context: ProviderFetchContext, + optionalResolutionJoinGrace: Duration, + operations: FetchOperations) async throws -> UsageSnapshot + { + let profileSelection = DeepSeekSettingsReader.profileSelection( + environment: context.env, + selectedTokenAccountID: context.selectedTokenAccountID, + apiKey: apiKey) + let resolutionTask = Task { + await operations.resolveAutomaticSession( + profileSelection.profileID, + profileSelection.requiresExplicitSelection, + false, + context.includeOptionalUsage, + context.browserDetection, + context.verbose) + } + let resolutionJoin = BoundedTaskJoin(sourceTask: resolutionTask) + + let balance: DeepSeekUsageSnapshot + do { + balance = try await operations.fetchUsage(apiKey, nil, false) + } catch { + resolutionTask.cancel() + throw error + } + + switch await resolutionJoin.value(joinGrace: optionalResolutionJoinGrace) { + case let .value(resolution): + try Task.checkCancellation() + return self.combinedSnapshot(balance: balance, resolution: resolution) + case .timedOut: + try Task.checkCancellation() + return self.combinedSnapshot( + balance: balance, + resolution: DeepSeekPlatformTokenImporter.Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: .unavailable)) + case let .failure(error): + if error is CancellationError || Task.isCancelled { + throw error + } + return self.combinedSnapshot( + balance: balance, + resolution: DeepSeekPlatformTokenImporter.Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: .unavailable)) + } + } + + private static func combinedSnapshot( + balance: DeepSeekUsageSnapshot, + resolution: DeepSeekPlatformTokenImporter.Resolution) -> UsageSnapshot + { + DeepSeekUsageSnapshot( + hasBalance: balance.hasBalance, + isAvailable: balance.isAvailable, + currency: balance.currency, + totalBalance: balance.totalBalance, + grantedBalance: balance.grantedBalance, + toppedUpBalance: balance.toppedUpBalance, + usageSummary: resolution.selectedSummary, + detailedUsageState: resolution.detailedUsageState, + platformProfiles: resolution.profiles, + updatedAt: balance.updatedAt).toUsageSnapshot() + } + + fileprivate static func loadPlatformUsage( + context: ProviderFetchContext, + resolutionJoinGrace: Duration = DeepSeekProviderDescriptor.platformResolutionJoinGrace, + operations: FetchOperations = .live) async throws -> UsageSnapshot + { + if let platformToken = DeepSeekSettingsReader.platformToken(environment: context.env) { + return try await DeepSeekUsageFetcher.fetchPlatformUsage( + platformToken: platformToken, + includeOptionalUsage: context.includeOptionalUsage).toUsageSnapshot() + } + + let profileSelection = DeepSeekSettingsReader.profileSelection( + environment: context.env, + selectedTokenAccountID: nil, + apiKey: nil) + let resolutionTask = Task { + await operations.resolveAutomaticSession( + profileSelection.profileID, + profileSelection.requiresExplicitSelection, + true, + context.includeOptionalUsage, + context.browserDetection, + context.verbose) + } + let resolutionJoin = BoundedTaskJoin(sourceTask: resolutionTask) + let resolution: DeepSeekPlatformTokenImporter.Resolution + switch await resolutionJoin.value(joinGrace: resolutionJoinGrace) { + case let .value(value): + resolution = value + case .timedOut: + throw DeepSeekUsageError.networkError("Chrome session resolution timed out") + case let .failure(error): + throw error + } + try Task.checkCancellation() + if resolution.selectedBalance == nil, resolution.detailedUsageState == .unavailable { + throw DeepSeekUsageError.networkError("Chrome session resolution unavailable") + } + let balance = resolution.selectedBalance ?? DeepSeekUsageSnapshot( + hasBalance: false, + isAvailable: false, + currency: resolution.selectedSummary?.currency ?? "USD", + totalBalance: 0, + grantedBalance: 0, + toppedUpBalance: 0, + updatedAt: resolution.selectedSummary?.updatedAt ?? Date()) + return self.combinedSnapshot(balance: balance, resolution: resolution) + } + + static func _loadUsageForTesting( + apiKey: String, + context: ProviderFetchContext, + optionalResolutionJoinGrace: Duration, + operations: FetchOperations) async throws -> UsageSnapshot + { + try await self.loadUsage( + apiKey: apiKey, + context: context, + optionalResolutionJoinGrace: optionalResolutionJoinGrace, + operations: operations) + } + + static func _loadPlatformUsageForTesting( + context: ProviderFetchContext, + resolutionJoinGrace: Duration = DeepSeekProviderDescriptor.platformResolutionJoinGrace, + operations: FetchOperations) async throws -> UsageSnapshot + { + try await self.loadPlatformUsage( + context: context, + resolutionJoinGrace: resolutionJoinGrace, + operations: operations) + } +} + +private struct DeepSeekAPIFetchStrategy: ProviderFetchStrategy { + let id = "deepseek.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + context.sourceMode == .api || ProviderTokenResolver.deepseekToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = ProviderTokenResolver.deepseekToken(environment: context.env) else { + throw DeepSeekUsageError.missingCredentials + } + let usage = try await DeepSeekProviderDescriptor.loadAPIUsage(apiKey: apiKey, context: context) + return self.makeResult(usage: usage, sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct DeepSeekPlatformFetchStrategy: ProviderFetchStrategy { + let id = "deepseek.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let usage = try await DeepSeekProviderDescriptor.loadPlatformUsage(context: context) + return self.makeResult(usage: usage, sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } } diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift index 3c787cabc3..caa2adc21b 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift @@ -1,13 +1,88 @@ import Foundation +#if canImport(CryptoKit) +import CryptoKit +#endif public struct DeepSeekSettingsReader: Sendable { + struct ProfileSelection: Sendable, Equatable { + let profileID: String? + let requiresExplicitSelection: Bool + } + public static let apiKeyEnvironmentKey = "DEEPSEEK_API_KEY" public static let apiKeyEnvironmentKeys = [Self.apiKeyEnvironmentKey, "DEEPSEEK_KEY"] + public static let platformTokenEnvironmentKey = "DEEPSEEK_PLATFORM_TOKEN" + public static let platformTokenEnvironmentKeys = [Self.platformTokenEnvironmentKey, "DEEPSEEK_USER_TOKEN"] + public static let profileIDEnvironmentKey = "CODEXBAR_DEEPSEEK_PROFILE_ID" + public static let profileScopeEnvironmentKey = "CODEXBAR_DEEPSEEK_PROFILE_SCOPE" + private static let browserProfileScope = "browser:v1" public static func apiKey( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { - for key in self.apiKeyEnvironmentKeys { + self.value(for: self.apiKeyEnvironmentKeys, environment: environment) + } + + public static func platformToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.value(for: self.platformTokenEnvironmentKeys, environment: environment) + } + + public static func profileID( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.value(for: [self.profileIDEnvironmentKey], environment: environment) + .map(self.canonicalProfileID) + } + + public static func profileScope( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.value(for: [self.profileScopeEnvironmentKey], environment: environment) + } + + public static func profileScope(selectedTokenAccountID: UUID?, apiKey: String?) -> String? { + guard let apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines), !apiKey.isEmpty else { + return self.browserProfileScope + } + #if canImport(CryptoKit) + let accountScope = selectedTokenAccountID?.uuidString.lowercased() ?? "environment" + let input = "com.steipete.codexbar.deepseek-profile-scope.v1\0\(accountScope)\0\(apiKey)" + let digest = SHA256.hash(data: Data(input.utf8)) + return "v1:" + digest.map { String(format: "%02x", $0) }.joined() + #else + return nil + #endif + } + + static func profileSelection( + environment: [String: String], + selectedTokenAccountID: UUID?, + apiKey: String?) -> ProfileSelection + { + let profileID = self.profileID(environment: environment) + let expectedScope = self.profileScope(selectedTokenAccountID: selectedTokenAccountID, apiKey: apiKey) + let storedScope = self.profileScope(environment: environment) + + if let expectedScope, storedScope == expectedScope { + return ProfileSelection(profileID: profileID, requiresExplicitSelection: false) + } + if apiKey == nil { + return ProfileSelection(profileID: nil, requiresExplicitSelection: false) + } + return ProfileSelection(profileID: nil, requiresExplicitSelection: true) + } + + public static func canonicalProfileID(_ rawValue: String) -> String { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.hasPrefix("/") else { return value } + let profileName = URL(fileURLWithPath: value).lastPathComponent + return profileName.isEmpty ? value : "chrome:\(profileName)" + } + + private static func value(for keys: [String], environment: [String: String]) -> String? { + for key in keys { guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift index 77e6e4f25b..7edecc721d 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift @@ -15,14 +15,11 @@ struct DeepSeekAmountPayload: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.code = try container.decodeIfPresent(Int.self, forKey: .code) self.msg = try container.decodeIfPresent(String.self, forKey: .msg) - if container.contains(.data) { - if let dataValue = try? container.decodeIfPresent(DeepSeekAmountData.self, forKey: .data) { - self.data = dataValue - } else { - self.data = nil - } + if let code, code != 0 { + // Error envelopes are not schema-stable. Preserve their code even if `data` has an unexpected shape. + self.data = try? container.decodeIfPresent(DeepSeekAmountData.self, forKey: .data) } else { - self.data = nil + self.data = try container.decodeIfPresent(DeepSeekAmountData.self, forKey: .data) } } } @@ -42,14 +39,10 @@ struct DeepSeekAmountData: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.bizCode = try container.decodeIfPresent(Int.self, forKey: .bizCode) self.bizMsg = try container.decodeIfPresent(String.self, forKey: .bizMsg) - if container.contains(.bizData) { - if let dataValue = try? container.decodeIfPresent(DeepSeekAmountBizData.self, forKey: .bizData) { - self.bizData = dataValue - } else { - self.bizData = nil - } + if let bizCode, bizCode != 0 { + self.bizData = try? container.decodeIfPresent(DeepSeekAmountBizData.self, forKey: .bizData) } else { - self.bizData = nil + self.bizData = try container.decodeIfPresent(DeepSeekAmountBizData.self, forKey: .bizData) } } } @@ -78,14 +71,10 @@ struct DeepSeekCostPayload: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.code = try container.decodeIfPresent(Int.self, forKey: .code) self.msg = try container.decodeIfPresent(String.self, forKey: .msg) - if container.contains(.data) { - if let dataValue = try? container.decodeIfPresent(DeepSeekCostData.self, forKey: .data) { - self.data = dataValue - } else { - self.data = nil - } + if let code, code != 0 { + self.data = try? container.decodeIfPresent(DeepSeekCostData.self, forKey: .data) } else { - self.data = nil + self.data = try container.decodeIfPresent(DeepSeekCostData.self, forKey: .data) } } } @@ -105,7 +94,11 @@ struct DeepSeekCostData: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.bizCode = try container.decodeIfPresent(Int.self, forKey: .bizCode) self.bizMsg = try container.decodeIfPresent(String.self, forKey: .bizMsg) - self.bizData = try container.decodeIfPresent([DeepSeekCostBizDataItem].self, forKey: .bizData) + if let bizCode, bizCode != 0 { + self.bizData = try? container.decodeIfPresent([DeepSeekCostBizDataItem].self, forKey: .bizData) + } else { + self.bizData = try container.decodeIfPresent([DeepSeekCostBizDataItem].self, forKey: .bizData) + } } } @@ -287,25 +280,37 @@ enum DeepSeekUsageCostParser { do { amountPayload = try self.decodeAmountPayload(data: amountData) } catch { - throw DeepSeekUsageError.parseFailed("amount: \(error.localizedDescription)") + throw DeepSeekUsageError.parseFailed("amount: \(self.decodingFailureDescription(error))") } do { costPayload = try self.decodeCostPayload(data: costData) } catch { - throw DeepSeekUsageError.parseFailed("cost: \(error.localizedDescription)") + throw DeepSeekUsageError.parseFailed("cost: \(self.decodingFailureDescription(error))") } // Validate responses if let code = amountPayload.code, code != 0 { + if self.isAuthenticationError(code) { + throw DeepSeekUsageError.invalidPlatformToken + } throw DeepSeekUsageError.apiError("amount code \(code)") } if let bizCode = amountPayload.data?.bizCode, bizCode != 0 { + if self.isAuthenticationError(bizCode) { + throw DeepSeekUsageError.invalidPlatformToken + } throw DeepSeekUsageError.apiError("amount biz_code \(bizCode)") } if let code = costPayload.code, code != 0 { + if self.isAuthenticationError(code) { + throw DeepSeekUsageError.invalidPlatformToken + } throw DeepSeekUsageError.apiError("cost code \(code)") } if let bizCode = costPayload.data?.bizCode, bizCode != 0 { + if self.isAuthenticationError(bizCode) { + throw DeepSeekUsageError.invalidPlatformToken + } throw DeepSeekUsageError.apiError("cost biz_code \(bizCode)") } @@ -333,6 +338,17 @@ enum DeepSeekUsageCostParser { calendar: calendar)) } + private static func isAuthenticationError(_ code: Int) -> Bool { + code == 40002 || code == 40003 + } + + private static func decodingFailureDescription(_ error: any Error) -> String { + if error is DecodingError { + return String(describing: error) + } + return error.localizedDescription + } + // MARK: - Aggregation private struct AggregationContext { diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift index d2f956ce49..d5edda5193 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift @@ -29,32 +29,135 @@ public struct DeepSeekBalanceInfo: Decodable, Sendable { } } +private struct DeepSeekPlatformUserSummaryResponse: Decodable { + let code: Int? + let data: DeepSeekPlatformUserSummaryData? + + private enum CodingKeys: String, CodingKey { + case code, data + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.code = try container.decodeIfPresent(Int.self, forKey: .code) + if let code, code != 0 { + // Error envelopes are not schema-stable. Preserve their code before inspecting `data`. + self.data = try? container.decodeIfPresent(DeepSeekPlatformUserSummaryData.self, forKey: .data) + } else { + self.data = try container.decodeIfPresent(DeepSeekPlatformUserSummaryData.self, forKey: .data) + } + } +} + +private struct DeepSeekPlatformUserSummaryData: Decodable { + let bizCode: Int? + let bizData: DeepSeekPlatformUserSummary? + + enum CodingKeys: String, CodingKey { + case bizCode = "biz_code" + case bizData = "biz_data" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.bizCode = try container.decodeIfPresent(Int.self, forKey: .bizCode) + if let bizCode, bizCode != 0 { + self.bizData = try? container.decodeIfPresent(DeepSeekPlatformUserSummary.self, forKey: .bizData) + } else { + self.bizData = try container.decodeIfPresent(DeepSeekPlatformUserSummary.self, forKey: .bizData) + } + } +} + +private struct DeepSeekPlatformUserSummary: Decodable { + let normalWallets: [DeepSeekPlatformWallet] + let bonusWallets: [DeepSeekPlatformWallet] + + enum CodingKeys: String, CodingKey { + case normalWallets = "normal_wallets" + case bonusWallets = "bonus_wallets" + } +} + +private struct DeepSeekPlatformWallet: Decodable { + let balance: Double + let currency: String + + enum CodingKeys: String, CodingKey { + case balance, currency + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.currency = try container.decode(String.self, forKey: .currency) + if let number = try? container.decode(Double.self, forKey: .balance) { + self.balance = number + return + } + let value = try container.decode(String.self, forKey: .balance) + guard let number = Double(value) else { + throw DecodingError.dataCorruptedError( + forKey: .balance, + in: container, + debugDescription: "Expected a numeric wallet balance") + } + self.balance = number + } +} + // MARK: - Domain snapshot +public enum DeepSeekDetailedUsageState: Sendable, Equatable { + case notRequested + case available + case webSessionRequired + case profileSelectionRequired + case unavailable +} + +public struct DeepSeekPlatformProfile: Sendable, Equatable { + public let id: String + public let name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } +} + public struct DeepSeekUsageSnapshot: Sendable { + public let hasBalance: Bool public let isAvailable: Bool public let currency: String public let totalBalance: Double public let grantedBalance: Double public let toppedUpBalance: Double public let usageSummary: DeepSeekUsageSummary? + public let detailedUsageState: DeepSeekDetailedUsageState + public let platformProfiles: [DeepSeekPlatformProfile] public let updatedAt: Date public init( + hasBalance: Bool = true, isAvailable: Bool, currency: String, totalBalance: Double, grantedBalance: Double, toppedUpBalance: Double, usageSummary: DeepSeekUsageSummary? = nil, + detailedUsageState: DeepSeekDetailedUsageState? = nil, + platformProfiles: [DeepSeekPlatformProfile] = [], updatedAt: Date) { + self.hasBalance = hasBalance self.isAvailable = isAvailable self.currency = currency self.totalBalance = totalBalance self.grantedBalance = grantedBalance self.toppedUpBalance = toppedUpBalance self.usageSummary = usageSummary + self.detailedUsageState = detailedUsageState ?? (usageSummary == nil ? .notRequested : .available) + self.platformProfiles = platformProfiles self.updatedAt = updatedAt } @@ -82,11 +185,15 @@ public struct DeepSeekUsageSnapshot: Sendable { accountEmail: nil, accountOrganization: nil, loginMethod: nil) - let balanceWindow = RateWindow( - usedPercent: usedPercent, - windowMinutes: nil, - resetsAt: nil, - resetDescription: balanceDetail) + let balanceWindow: RateWindow? = if self.hasBalance { + RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: balanceDetail) + } else { + nil + } return UsageSnapshot( primary: balanceWindow, @@ -94,6 +201,8 @@ public struct DeepSeekUsageSnapshot: Sendable { tertiary: nil, providerCost: nil, deepseekUsage: self.usageSummary, + deepseekDetailedUsageState: self.detailedUsageState, + deepseekPlatformProfiles: self.platformProfiles, updatedAt: self.updatedAt, identity: identity) } @@ -101,8 +210,9 @@ public struct DeepSeekUsageSnapshot: Sendable { // MARK: - Errors -public enum DeepSeekUsageError: LocalizedError, Sendable { +public enum DeepSeekUsageError: LocalizedError, Sendable, Equatable { case missingCredentials + case invalidPlatformToken case networkError(String) case apiError(String) case parseFailed(String) @@ -111,6 +221,8 @@ public enum DeepSeekUsageError: LocalizedError, Sendable { switch self { case .missingCredentials: "Missing DeepSeek API key." + case .invalidPlatformToken: + "DeepSeek Platform session is missing or expired." case let .networkError(message): "DeepSeek network error: \(message)" case let .apiError(message): @@ -124,12 +236,19 @@ public enum DeepSeekUsageError: LocalizedError, Sendable { // MARK: - Fetcher public struct DeepSeekUsageFetcher: Sendable { + private enum UsageSummaryPayload: Sendable { + case amount(Data) + case cost(Data) + } + private static let log = CodexBarLog.logger(LogCategories.deepSeekUsage) private static let balanceURL = URL(string: "https://api.deepseek.com/user/balance")! private static let usageAmountURL = URL(string: "https://platform.deepseek.com/api/v0/usage/amount")! private static let usageCostURL = URL(string: "https://platform.deepseek.com/api/v0/usage/cost")! + private static let platformUserSummaryURL = URL( + string: "https://platform.deepseek.com/api/v0/users/get_user_summary")! private static let timeoutSeconds: TimeInterval = 15 - private static let optionalSummaryJoinGrace: Duration = .seconds(2) + private static let optionalSummaryJoinGrace: Duration = .seconds(5) private static var apiCalendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.locale = Locale(identifier: "en_US_POSIX") @@ -139,22 +258,26 @@ public struct DeepSeekUsageFetcher: Sendable { public static func fetchUsage( apiKey: String, + platformToken: String? = nil, includeOptionalUsage: Bool = true) async throws -> DeepSeekUsageSnapshot { try await self.fetchUsage( apiKey: apiKey, + platformToken: platformToken, includeOptionalUsage: includeOptionalUsage, optionalSummaryJoinGrace: self.optionalSummaryJoinGrace, - fetchBalanceData: { key in - try await self.fetchBalanceData(apiKey: key) - }, - fetchSummary: { key in - try await self.fetchUsageSummary(apiKey: key) - }) + operations: FetchOperations( + fetchBalanceData: { key in + try await self.fetchBalanceData(apiKey: key) + }, + fetchSummary: { token in + try await self.fetchUsageSummary(platformToken: token) + })) } static func _fetchUsageForTesting( apiKey: String, + platformToken: String? = nil, includeOptionalUsage: Bool, optionalSummaryJoinGrace: Duration = .zero, fetchBalanceData: @escaping @Sendable (String) async throws -> Data, @@ -163,27 +286,36 @@ public struct DeepSeekUsageFetcher: Sendable { { try await self.fetchUsage( apiKey: apiKey, + platformToken: platformToken, includeOptionalUsage: includeOptionalUsage, optionalSummaryJoinGrace: optionalSummaryJoinGrace, - fetchBalanceData: fetchBalanceData, - fetchSummary: fetchSummary) + operations: FetchOperations( + fetchBalanceData: fetchBalanceData, + fetchSummary: fetchSummary)) + } + + private struct FetchOperations: Sendable { + let fetchBalanceData: @Sendable (String) async throws -> Data + let fetchSummary: @Sendable (String) async throws -> DeepSeekUsageSummary } private static func fetchUsage( apiKey: String, + platformToken: String?, includeOptionalUsage: Bool, optionalSummaryJoinGrace: Duration, - fetchBalanceData: @escaping @Sendable (String) async throws -> Data, - fetchSummary: @escaping @Sendable (String) async throws -> DeepSeekUsageSummary) + operations: FetchOperations) async throws -> DeepSeekUsageSnapshot { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw DeepSeekUsageError.missingCredentials } - let summaryTask: Task? = if includeOptionalUsage { + let normalizedPlatformToken = platformToken?.trimmingCharacters(in: .whitespacesAndNewlines) + let usablePlatformToken = normalizedPlatformToken?.isEmpty == false ? normalizedPlatformToken : nil + let summaryTask: Task? = if includeOptionalUsage, let usablePlatformToken { Task { - try await fetchSummary(apiKey) + try await operations.fetchSummary(usablePlatformToken) } } else { nil @@ -192,7 +324,7 @@ public struct DeepSeekUsageFetcher: Sendable { let balanceData: Data do { balanceData = try await withTaskCancellationHandler { - try await fetchBalanceData(apiKey) + try await operations.fetchBalanceData(apiKey) } onCancel: { summaryTask?.cancel() } @@ -208,20 +340,38 @@ public struct DeepSeekUsageFetcher: Sendable { throw error } + let initialDetailedUsageState: DeepSeekDetailedUsageState = if !includeOptionalUsage { + .notRequested + } else if usablePlatformToken == nil { + .webSessionRequired + } else { + .unavailable + } + snapshot = DeepSeekUsageSnapshot( + isAvailable: snapshot.isAvailable, + currency: snapshot.currency, + totalBalance: snapshot.totalBalance, + grantedBalance: snapshot.grantedBalance, + toppedUpBalance: snapshot.toppedUpBalance, + usageSummary: nil, + detailedUsageState: initialDetailedUsageState, + platformProfiles: snapshot.platformProfiles, + updatedAt: snapshot.updatedAt) + if let summaryTask { - let summary = try await self.completedOptionalUsageSummary( + let completion = try await self.completedOptionalUsageSummary( from: summaryTask, joinGrace: optionalSummaryJoinGrace) - if let summary { - snapshot = DeepSeekUsageSnapshot( - isAvailable: snapshot.isAvailable, - currency: snapshot.currency, - totalBalance: snapshot.totalBalance, - grantedBalance: snapshot.grantedBalance, - toppedUpBalance: snapshot.toppedUpBalance, - usageSummary: summary, - updatedAt: snapshot.updatedAt) - } + snapshot = DeepSeekUsageSnapshot( + isAvailable: snapshot.isAvailable, + currency: snapshot.currency, + totalBalance: snapshot.totalBalance, + grantedBalance: snapshot.grantedBalance, + toppedUpBalance: snapshot.toppedUpBalance, + usageSummary: completion.summary, + detailedUsageState: completion.state, + platformProfiles: snapshot.platformProfiles, + updatedAt: snapshot.updatedAt) } return snapshot @@ -246,22 +396,46 @@ public struct DeepSeekUsageFetcher: Sendable { private static func completedOptionalUsageSummary( from task: Task, - joinGrace: Duration) async throws -> DeepSeekUsageSummary? + joinGrace: Duration) async throws + -> (summary: DeepSeekUsageSummary?, state: DeepSeekDetailedUsageState) { let race = BoundedTaskJoin(sourceTask: task) switch await race.value(joinGrace: joinGrace) { case let .value(summary): try Task.checkCancellation() - return summary + return (summary, .available) case .timedOut: try Task.checkCancellation() - return nil + Self.log.warning("DeepSeek detailed usage unavailable", metadata: ["reason": "timeout"]) + return (nil, .unavailable) case let .failure(error): task.cancel() if Task.isCancelled { throw error } - return nil + Self.log.warning( + "DeepSeek detailed usage unavailable", + metadata: ["reason": self.optionalSummaryFailureReason(error)]) + return (nil, error is DeepSeekUsageError ? self.optionalSummaryFailureState(error) : .unavailable) + } + } + + private static func optionalSummaryFailureState(_ error: any Error) -> DeepSeekDetailedUsageState { + guard let error = error as? DeepSeekUsageError else { return .unavailable } + return error == .invalidPlatformToken ? .webSessionRequired : .unavailable + } + + private static func optionalSummaryFailureReason(_ error: any Error) -> String { + guard let error = error as? DeepSeekUsageError else { + return error is CancellationError ? "cancelled" : "unknown" + } + + switch error { + case .missingCredentials: return "missing_credentials" + case .invalidPlatformToken: return "platform_auth" + case .networkError: return "network" + case .apiError: return "api" + case .parseFailed: return "parse" } } @@ -270,23 +444,154 @@ public struct DeepSeekUsageFetcher: Sendable { } public static func fetchUsageSummary( - apiKey: String, + platformToken: String, now: Date = Date(), calendar: Calendar? = nil) async throws -> DeepSeekUsageSummary { let calendar = calendar ?? self.apiCalendar let period = try self.usagePeriod(now: now, calendar: calendar) - - let amountData = try await self.fetchAmount(apiKey: apiKey, month: period.month, year: period.year) - let costData = try await self.fetchCost(apiKey: apiKey, month: period.month, year: period.year) + let payloads = try await self.fetchUsagePayloads( + fetchAmount: { + try await self.fetchAmount(platformToken: platformToken, month: period.month, year: period.year) + }, + fetchCost: { + try await self.fetchCost(platformToken: platformToken, month: period.month, year: period.year) + }) return try DeepSeekUsageCostParser.parse( - amountData: amountData, - costData: costData, + amountData: payloads.amount, + costData: payloads.cost, now: now, calendar: calendar) } + public static func fetchPlatformUsage( + platformToken: String, + includeOptionalUsage: Bool = true) async throws -> DeepSeekUsageSnapshot + { + try await self.fetchPlatformUsage( + platformToken: platformToken, + includeOptionalUsage: includeOptionalUsage, + optionalSummaryJoinGrace: self.optionalSummaryJoinGrace, + operations: PlatformFetchOperations( + fetchBalance: { token in + try await self.fetchPlatformBalance(platformToken: token) + }, + fetchSummary: { token in + try await self.fetchUsageSummary(platformToken: token) + })) + } + + static func _fetchPlatformUsageForTesting( + includeOptionalUsage: Bool, + optionalSummaryJoinGrace: Duration = .zero, + fetchBalance: @escaping @Sendable () async throws -> DeepSeekUsageSnapshot, + fetchSummary: @escaping @Sendable () async throws -> DeepSeekUsageSummary) async throws + -> DeepSeekUsageSnapshot + { + try await self.fetchPlatformUsage( + platformToken: "test-platform-token", + includeOptionalUsage: includeOptionalUsage, + optionalSummaryJoinGrace: optionalSummaryJoinGrace, + operations: PlatformFetchOperations( + fetchBalance: { _ in try await fetchBalance() }, + fetchSummary: { _ in try await fetchSummary() })) + } + + private struct PlatformFetchOperations: Sendable { + let fetchBalance: @Sendable (String) async throws -> DeepSeekUsageSnapshot + let fetchSummary: @Sendable (String) async throws -> DeepSeekUsageSummary + } + + private static func fetchPlatformUsage( + platformToken: String, + includeOptionalUsage: Bool, + optionalSummaryJoinGrace: Duration, + operations: PlatformFetchOperations) async throws -> DeepSeekUsageSnapshot + { + let token = platformToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { throw DeepSeekUsageError.invalidPlatformToken } + + let summaryTask: Task? = if includeOptionalUsage { + Task { + try await operations.fetchSummary(token) + } + } else { + nil + } + + let balance: DeepSeekUsageSnapshot + do { + balance = try await withTaskCancellationHandler { + try await operations.fetchBalance(token) + } onCancel: { + summaryTask?.cancel() + } + } catch { + summaryTask?.cancel() + throw error + } + + var snapshot = DeepSeekUsageSnapshot( + isAvailable: balance.isAvailable, + currency: balance.currency, + totalBalance: balance.totalBalance, + grantedBalance: balance.grantedBalance, + toppedUpBalance: balance.toppedUpBalance, + detailedUsageState: includeOptionalUsage ? .unavailable : .notRequested, + updatedAt: balance.updatedAt) + if let summaryTask { + let completion = try await self.completedOptionalUsageSummary( + from: summaryTask, + joinGrace: optionalSummaryJoinGrace) + snapshot = DeepSeekUsageSnapshot( + isAvailable: balance.isAvailable, + currency: balance.currency, + totalBalance: balance.totalBalance, + grantedBalance: balance.grantedBalance, + toppedUpBalance: balance.toppedUpBalance, + usageSummary: completion.summary, + detailedUsageState: completion.state, + updatedAt: balance.updatedAt) + } + return snapshot + } + + static func _fetchUsagePayloadsForTesting( + fetchAmount: @escaping @Sendable () async throws -> Data, + fetchCost: @escaping @Sendable () async throws -> Data) async throws -> (amount: Data, cost: Data) + { + try await self.fetchUsagePayloads(fetchAmount: fetchAmount, fetchCost: fetchCost) + } + + private static func fetchUsagePayloads( + fetchAmount: @escaping @Sendable () async throws -> Data, + fetchCost: @escaping @Sendable () async throws -> Data) async throws -> (amount: Data, cost: Data) + { + try await withThrowingTaskGroup(of: UsageSummaryPayload.self) { group in + group.addTask { + try await .amount(fetchAmount()) + } + group.addTask { + try await .cost(fetchCost()) + } + + var amountData: Data? + var costData: Data? + for try await payload in group { + switch payload { + case let .amount(data): amountData = data + case let .cost(data): costData = data + } + } + + guard let amountData, let costData else { + throw DeepSeekUsageError.parseFailed("Missing usage response") + } + return (amount: amountData, cost: costData) + } + } + static func _apiUsagePeriodForTesting(now: Date, calendar: Calendar? = nil) throws -> (month: Int, year: Int) { try self.usagePeriod(now: now, calendar: calendar ?? self.apiCalendar) } @@ -299,7 +604,7 @@ public struct DeepSeekUsageFetcher: Sendable { return (month: month, year: year) } - private static func fetchAmount(apiKey: String, month: Int, year: Int) async throws -> Data { + private static func fetchAmount(platformToken: String, month: Int, year: Int) async throws -> Data { guard var components = URLComponents(url: self.usageAmountURL, resolvingAgainstBaseURL: false) else { throw DeepSeekUsageError.networkError("Invalid URL") } @@ -313,7 +618,7 @@ public struct DeepSeekUsageFetcher: Sendable { var request = URLRequest(url: url) request.httpMethod = "GET" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("Bearer \(platformToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = Self.timeoutSeconds @@ -321,7 +626,7 @@ public struct DeepSeekUsageFetcher: Sendable { let data = response.data guard response.statusCode == 200 else { if response.statusCode == 401 || response.statusCode == 403 { - throw DeepSeekUsageError.missingCredentials + throw DeepSeekUsageError.invalidPlatformToken } throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") } @@ -329,7 +634,24 @@ public struct DeepSeekUsageFetcher: Sendable { return data } - private static func fetchCost(apiKey: String, month: Int, year: Int) async throws -> Data { + private static func fetchPlatformBalance(platformToken: String) async throws -> DeepSeekUsageSnapshot { + var request = URLRequest(url: self.platformUserSummaryURL) + request.httpMethod = "GET" + request.setValue("Bearer \(platformToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw DeepSeekUsageError.invalidPlatformToken + } + throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") + } + return try self.parsePlatformBalance(data: response.data) + } + + private static func fetchCost(platformToken: String, month: Int, year: Int) async throws -> Data { guard var components = URLComponents(url: self.usageCostURL, resolvingAgainstBaseURL: false) else { throw DeepSeekUsageError.networkError("Invalid URL") } @@ -343,7 +665,7 @@ public struct DeepSeekUsageFetcher: Sendable { var request = URLRequest(url: url) request.httpMethod = "GET" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("Bearer \(platformToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = Self.timeoutSeconds @@ -351,7 +673,7 @@ public struct DeepSeekUsageFetcher: Sendable { let data = response.data guard response.statusCode == 200 else { if response.statusCode == 401 || response.statusCode == 403 { - throw DeepSeekUsageError.missingCredentials + throw DeepSeekUsageError.invalidPlatformToken } throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") } @@ -372,6 +694,69 @@ public struct DeepSeekUsageFetcher: Sendable { calendar: calendar) } + static func _parsePlatformBalanceForTesting(_ data: Data) throws -> DeepSeekUsageSnapshot { + try self.parsePlatformBalance(data: data) + } + + private static func parsePlatformBalance(data: Data) throws -> DeepSeekUsageSnapshot { + let payload: DeepSeekPlatformUserSummaryResponse + do { + payload = try JSONDecoder().decode(DeepSeekPlatformUserSummaryResponse.self, from: data) + } catch { + throw DeepSeekUsageError.parseFailed(error.localizedDescription) + } + if let code = payload.code, code != 0 { + if self.isPlatformAuthenticationError(code) { + throw DeepSeekUsageError.invalidPlatformToken + } + throw DeepSeekUsageError.apiError("user summary code \(code)") + } + if let bizCode = payload.data?.bizCode, bizCode != 0 { + if self.isPlatformAuthenticationError(bizCode) { + throw DeepSeekUsageError.invalidPlatformToken + } + throw DeepSeekUsageError.apiError("user summary biz_code \(bizCode)") + } + guard let summary = payload.data?.bizData else { + throw DeepSeekUsageError.parseFailed("Missing user summary biz_data") + } + + let toppedUp = Dictionary(grouping: summary.normalWallets, by: \.currency) + .mapValues { $0.reduce(0) { $0 + $1.balance } } + let granted = Dictionary(grouping: summary.bonusWallets, by: \.currency) + .mapValues { $0.reduce(0) { $0 + $1.balance } } + let currencies = Set(toppedUp.keys).union(granted.keys).sorted() + guard !currencies.isEmpty else { + return DeepSeekUsageSnapshot( + isAvailable: false, + currency: "USD", + totalBalance: 0, + grantedBalance: 0, + toppedUpBalance: 0, + updatedAt: Date()) + } + + let selectedCurrency = currencies.first { currency in + currency == "USD" && (toppedUp[currency, default: 0] + granted[currency, default: 0]) > 0 + } ?? currencies.first { currency in + toppedUp[currency, default: 0] + granted[currency, default: 0] > 0 + } ?? currencies.first(where: { $0 == "USD" }) ?? currencies[0] + let paidBalance = toppedUp[selectedCurrency, default: 0] + let grantedBalance = granted[selectedCurrency, default: 0] + let totalBalance = paidBalance + grantedBalance + return DeepSeekUsageSnapshot( + isAvailable: totalBalance > 0, + currency: selectedCurrency, + totalBalance: totalBalance, + grantedBalance: grantedBalance, + toppedUpBalance: paidBalance, + updatedAt: Date()) + } + + private static func isPlatformAuthenticationError(_ code: Int) -> Bool { + code == 40002 || code == 40003 + } + private static func parseSnapshot(data: Data) throws -> DeepSeekUsageSnapshot { let decoded: DeepSeekBalanceResponse do { diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift new file mode 100644 index 0000000000..5f8d741c4d --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatAPIError.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum LongCatAPIError: LocalizedError, Sendable, Equatable { + case missingCookies + case invalidSession + case invalidRequest(String) + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCookies: + "LongCat session cookies are missing. Sign in at longcat.chat, or paste a cookie header." + case .invalidSession: + "LongCat session is invalid or expired. Please sign in again at longcat.chat." + case let .invalidRequest(message): + "Invalid request: \(message)" + case let .networkError(message): + "LongCat network error: \(message)" + case let .apiError(message): + "LongCat API error: \(message)" + case let .parseFailed(message): + "Failed to parse LongCat usage data: \(message)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift new file mode 100644 index 0000000000..fe3e07006e --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieHeader.swift @@ -0,0 +1,77 @@ +import Foundation + +public struct LongCatCookieOverride: Sendable { + /// Full `Cookie:` header value (e.g. `name=value; name2=value2`). + public let cookieHeader: String + + public init(cookieHeader: String) { + self.cookieHeader = cookieHeader + } +} + +public enum LongCatCookieHeader { + private static let log = CodexBarLog.logger(LogCategories.longcatCookie) + private static let headerPatterns: [String] = [ + #"(?i)-H\s*'Cookie:\s*([^']+)'"#, + #"(?i)-H\s*"Cookie:\s*([^"]+)""#, + #"(?i)\bcookie:\s*'([^']+)'"#, + #"(?i)\bcookie:\s*"([^"]+)""#, + #"(?i)\bcookie:\s*([^\r\n]+)"#, + ] + + public static func resolveCookieOverride(context: ProviderFetchContext) -> LongCatCookieOverride? { + // Off disables LongCat web auth entirely — including a lingering env cookie. + if context.settings?.longcat?.cookieSource == .off { + return nil + } + + if let settings = context.settings?.longcat, settings.cookieSource == .manual { + if let manual = settings.manualCookieHeader, !manual.isEmpty { + return self.override(from: manual) + } + } + + // Route env cookies through the settings reader so the lower-case + // `longcat_manual_cookie` alias and quote-trimming apply on the env path too. + if let envValue = LongCatSettingsReader.cookieHeader(environment: context.env), + let envHeader = self.override(from: envValue) + { + return envHeader + } + + return nil + } + + public static func override(from raw: String?) -> LongCatCookieOverride? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + + if let header = self.extractHeader(from: raw) { + return LongCatCookieOverride(cookieHeader: header) + } + + // A bare `name=value; ...` string is itself a usable cookie header. + if raw.contains("=") { + return LongCatCookieOverride(cookieHeader: raw) + } + + return nil + } + + private static func extractHeader(from raw: String) -> String? { + for pattern in self.headerPatterns { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { continue } + let range = NSRange(raw.startIndex..= 2, + let captureRange = Range(match.range(at: 1), in: raw) + else { + continue + } + let captured = String(raw[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines) + if !captured.isEmpty { return captured } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift new file mode 100644 index 0000000000..1c011b68dd --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatCookieImporter.swift @@ -0,0 +1,180 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +public enum LongCatCookieImporter { + private static let log = CodexBarLog.logger(LogCategories.longcatCookie) + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["longcat.chat", "www.longcat.chat"] + private static let cookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.longcat]?.browserCookieOrder ?? Browser.defaultImportOrder + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + /// Full `Cookie:` header built from every longcat.chat cookie. LongCat's + /// console uses Meituan passport SSO; the exact auth cookie name is not + /// documented, so we forward the whole jar rather than keying on one name. + public var cookieHeader: String? { + guard !self.cookies.isEmpty else { return nil } + let header = HTTPCookie.requestHeaderFields(with: self.cookies)["Cookie"] + if let header, !header.isEmpty { return header } + return nil + } + } + + public static func importSessions( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + var sessions: [SessionInfo] = [] + let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection) + for browserSource in candidates { + do { + let perSource = try self.importSessions(from: browserSource, logger: logger) + sessions.append(contentsOf: perSource) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + self.emit( + "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)", + logger: logger) + } + } + + guard !sessions.isEmpty else { + throw LongCatCookieImportError.noCookies + } + return sessions + } + + public static func importSessions( + from browserSource: Browser, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let log: (String) -> Void = { msg in self.emit(msg, logger: logger) } + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + + var sessions: [SessionInfo] = [] + let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id }) + let sortedGroups = grouped.values.sorted { lhs, rhs in + self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs) + } + + for group in sortedGroups where !group.isEmpty { + let label = self.mergedLabel(for: group) + let mergedRecords = self.mergeRecords(group) + guard !mergedRecords.isEmpty else { continue } + let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin) + guard !httpCookies.isEmpty else { continue } + + log("Found \(httpCookies.count) longcat.chat cookie(s) in \(label)") + sessions.append(SessionInfo(cookies: httpCookies, sourceLabel: label)) + } + return sessions + } + + public static func importSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) + guard let first = sessions.first else { + throw LongCatCookieImportError.noCookies + } + return first + } + + public static func hasSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) -> Bool + { + do { + return try !self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty + } catch { + return false + } + } + + private static func emit(_ message: String, logger: ((String) -> Void)?) { + logger?("[longcat-cookie] \(message)") + self.log.debug(message) + } + + private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String { + guard let base = sources.map(\.label).min() else { + return "Unknown" + } + if base.hasSuffix(" (Network)") { + return String(base.dropLast(" (Network)".count)) + } + return base + } + + private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] { + let sortedSources = sources.sorted { lhs, rhs in + self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind) + } + var mergedByKey: [String: BrowserCookieRecord] = [:] + for source in sortedSources { + for record in source.records { + let key = self.recordKey(record) + if let existing = mergedByKey[key] { + if self.shouldReplace(existing: existing, candidate: record) { + mergedByKey[key] = record + } + } else { + mergedByKey[key] = record + } + } + } + return Array(mergedByKey.values) + } + + private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int { + switch kind { + case .network: 0 + case .primary: 1 + case .safari: 2 + } + } + + private static func recordKey(_ record: BrowserCookieRecord) -> String { + "\(record.name)|\(record.domain)|\(record.path)" + } + + private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool { + switch (existing.expires, candidate.expires) { + case let (lhs?, rhs?): + rhs > lhs + case (nil, .some): + true + case (.some, nil): + false + case (nil, nil): + false + } + } +} + +enum LongCatCookieImportError: LocalizedError { + case noCookies + + var errorDescription: String? { + switch self { + case .noCookies: + "No LongCat session cookies found in browsers." + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift new file mode 100644 index 0000000000..7cc1213ee0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatModels.swift @@ -0,0 +1,81 @@ +import Foundation + +/// LongCat's web console wraps every response in a Meituan-style envelope: +/// `{ "code": 0, "message": "...", "data": { ... } }`. +/// +/// The exact `data` field names are not documented and cannot be derived from the +/// minified front-end bundle, so extraction is intentionally lenient: we walk the +/// decoded JSON trying a list of candidate keys and log the raw shape once so the +/// mapping can be tightened against a real response. See `LongCatUsageFetcher`. +enum LongCatEnvelope { + /// Returns the `data` payload if the envelope reports success, else throws. + static func unwrap(_ object: Any?) throws -> Any { + guard let dict = object as? [String: Any] else { + throw LongCatAPIError.parseFailed("response was not a JSON object") + } + // Meituan envelopes use code == 0 for success; some surfaces use 200. + if let code = LongCatJSON.int(dict["code"]), code != 0, code != 200 { + let message = LongCatJSON.string(dict["message"]) ?? LongCatJSON.string(dict["msg"]) ?? "code \(code)" + if code == 401 || code == 403 { throw LongCatAPIError.invalidSession } + throw LongCatAPIError.apiError(message) + } + return dict["data"] ?? dict + } +} + +/// Tiny dynamic-JSON helper for lenient extraction by candidate key names. +enum LongCatJSON { + static func int(_ value: Any?) -> Int? { + switch value { + case let v as Int: v + case let v as Double: Int(v) + case let v as String: Int(v) ?? Double(v).map(Int.init) + case let v as NSNumber: v.intValue + default: nil + } + } + + static func double(_ value: Any?) -> Double? { + switch value { + case let v as Double: v + case let v as Int: Double(v) + case let v as String: Double(v) + case let v as NSNumber: v.doubleValue + default: nil + } + } + + static func string(_ value: Any?) -> String? { + switch value { + case let v as String: v + case let v as NSNumber: v.stringValue + default: nil + } + } + + static func object(_ value: Any?) -> [String: Any]? { + value as? [String: Any] + } + + static func array(_ value: Any?) -> [[String: Any]]? { + if let arr = value as? [[String: Any]] { return arr } + if let arr = value as? [Any] { return arr.compactMap { $0 as? [String: Any] } } + return nil + } + + /// First numeric value found under any of `keys`, searched at the top level + /// and one level deep (LongCat nests some figures under `quota`/`detail`). + static func firstNumber(in object: [String: Any], keys: [String]) -> Double? { + for key in keys { + if let value = double(object[key]) { return value } + } + for value in object.values { + if let nested = value as? [String: Any] { + for key in keys { + if let found = double(nested[key]) { return found } + } + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift new file mode 100644 index 0000000000..6f3068b536 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatProviderDescriptor.swift @@ -0,0 +1,107 @@ +import Foundation + +public enum LongCatProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .longcat, + metadata: ProviderMetadata( + id: .longcat, + displayName: "LongCat", + sessionLabel: "Quota", + weeklyLabel: "Fuel Pack", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show LongCat usage", + cliName: "longcat", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.longcatCookieImportOrder, + dashboardURL: "https://longcat.chat/platform/", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .longcat, + iconResourceName: "ProviderIcon-longcat", + color: ProviderColor(red: 255 / 255, green: 209 / 255, blue: 0 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "LongCat cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [LongCatWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "longcat", + aliases: ["long-cat", "lc"], + versionDetector: nil)) + } +} + +struct LongCatWebFetchStrategy: ProviderFetchStrategy { + let id: String = "longcat.web" + let kind: ProviderFetchKind = .web + private static let log = CodexBarLog.logger(LogCategories.longcatWeb) + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + if LongCatCookieHeader.resolveCookieOverride(context: context) != nil { + return true + } + + #if os(macOS) + if Self.allowsBrowserImport(context: context) { + return LongCatCookieImporter.hasSession() + } + #endif + + return false + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let cookieHeader = self.resolveCookieHeader(context: context) else { + throw LongCatAPIError.missingCookies + } + + let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: cookieHeader) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } + + func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { + if case LongCatAPIError.missingCookies = error { return false } + if case LongCatAPIError.invalidSession = error { return false } + return true + } + + private func resolveCookieHeader(context: ProviderFetchContext) -> String? { + if let override = LongCatCookieHeader.resolveCookieOverride(context: context) { + return override.cookieHeader + } + + #if os(macOS) + if Self.allowsBrowserImport(context: context) { + if let session = try? LongCatCookieImporter.importSession(), + let header = session.cookieHeader + { + return header + } + } + #endif + + return nil + } + + /// Browser cookie/keychain import is only used for user-initiated app + /// refreshes in the Auto source. Manual must use the pasted header and Off + /// disables web auth, so neither should silently fall back to a browser + /// session. + static func allowsBrowserImport(context: ProviderFetchContext) -> Bool { + let source = context.settings?.longcat?.cookieSource + return context.runtime == .app && + ProviderInteractionContext.current == .userInitiated && + (source == nil || source == .auto) + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift new file mode 100644 index 0000000000..9fcc78227a --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatSettingsReader.swift @@ -0,0 +1,31 @@ +import Foundation + +public enum LongCatSettingsReader { + /// Manual cookie header for the LongCat web console (longcat.chat). + public static func cookieHeader(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + let raw = environment["LONGCAT_MANUAL_COOKIE"] ?? environment["longcat_manual_cookie"] + return self.cleaned(raw) + } + + /// LongCat OpenAI/Anthropic-compatible API key. Not used for usage (the public + /// API exposes no usage endpoint) but kept for parity and future signals. + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + let raw = environment["LONGCAT_API_KEY"] ?? environment["longcat_api_key"] + return self.cleaned(raw) + } + + private 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 + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift new file mode 100644 index 0000000000..7176a5c87a --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageFetcher.swift @@ -0,0 +1,198 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct LongCatUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.longcatAPI) + private static let host = "https://longcat.chat" + + private static let userCurrentPath = "/api/v1/user-current" + private static let tokenUsagePath = "/api/lc-platform/v1/tokenUsage" + private static let pendingFuelPath = "/api/lc-platform/v1/pending-fuel-packages" + + /// LongCat fetches run on an isolated, ephemeral, cookie-free session so the + /// console's `Set-Cookie` responses never enter the shared provider cookie jar; + /// auth is carried solely by the explicit request `Cookie` header. Mirrors the + /// Sakana provider's isolated transport. + private static let defaultTransport: ProviderHTTPClient = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + let session = ProviderHTTPClient.redirectGuardedSession(configuration: configuration) + return ProviderHTTPClient(session: session) + }() + + public static func fetchUsage( + cookieHeader: String, + transport transportOverride: (any ProviderHTTPTransport)? = nil, + now: Date = Date()) async throws -> LongCatUsageSnapshot + { + let transport = transportOverride ?? Self.defaultTransport + // Account name. The user-current payload also carries a session token and + // phone number, so its body is never logged. This is the required probe: + // a Meituan envelope with HTTP 200 but code 401/403 surfaces as + // `.invalidSession` here (via unwrap) so expired cookies are reported + // rather than masked by an empty snapshot. + var account: [String: Any]? + if let data = try await self.get( + self.userCurrentPath, + cookieHeader: cookieHeader, + transport: transport, + required: true) + { + account = try LongCatEnvelope.unwrap(self.json(data)) as? [String: Any] + } + + var usage: [String: Any]? + if let data = try? await self.get( + self.tokenUsagePath, + cookieHeader: cookieHeader, + transport: transport, + required: false) + { + self.logRawShape(self.tokenUsagePath, data) + usage = (try? LongCatEnvelope.unwrap(self.json(data))) as? [String: Any] + } + + var fuel: [String: Any]? + if let data = try? await self.get( + self.pendingFuelPath, + cookieHeader: cookieHeader, + transport: transport, + required: false) + { + self.logRawShape(self.pendingFuelPath, data) + fuel = (try? LongCatEnvelope.unwrap(self.json(data))) as? [String: Any] + } + + return self.buildSnapshot(account: account, tokenUsage: usage, pendingFuel: fuel, now: now) + } + + /// Pure extraction over the unwrapped `data` payloads. Field paths are locked + /// against captured live responses; see `LongCatProviderTests`. + static func buildSnapshot( + account: [String: Any]?, + tokenUsage: [String: Any]?, + pendingFuel: [String: Any]?, + now: Date = Date()) -> LongCatUsageSnapshot + { + var snapshot = LongCatUsageSnapshot(updatedAt: now) + + if let account { + snapshot.accountName = LongCatJSON.string(account["name"]) ?? LongCatJSON.string(account["nickName"]) + } + + // Token quota: data.usage is the canonical aggregate; extData holds the + // per-model breakdown (LongCat-Flash-Lite, LongCat-2.0-Preview, ...). + if let tokenUsage { + let usage = LongCatJSON.object(tokenUsage["usage"]) ?? tokenUsage + snapshot.totalQuota = LongCatJSON.double(usage["totalToken"]) + snapshot.usedQuota = LongCatJSON.double(usage["usedToken"]) + snapshot.remainingQuota = LongCatJSON.double(usage["availableToken"]) + } + + if let pendingFuel { + self.applyFuelPackages(pendingFuel, to: &snapshot) + } + + return snapshot + } + + private static func applyFuelPackages(_ dict: [String: Any], to snapshot: inout LongCatUsageSnapshot) { + let total = LongCatJSON.double(dict["totalQuota"]) + let packages = LongCatJSON.array(dict["list"]) ?? [] + + var remaining = 0.0 + var sawRemaining = false + var nearestExpiry: Date? + for package in packages { + // Field names are pinned to the shapes captured from live longcat.chat + // responses (see LongCatProviderTests): a fuel package reports its remaining + // balance under `availableToken` and its expiry under `expireTime`. + if let value = LongCatJSON.double(package["availableToken"]) { + remaining += value + sawRemaining = true + } + if let expiry = self.parseDate(package["expireTime"]) { + if nearestExpiry == nil || expiry < nearestExpiry! { nearestExpiry = expiry } + } + } + + if let total, total > 0 { + snapshot.fuelPackTotal = total + snapshot.fuelPackRemaining = sawRemaining ? remaining : total + } + snapshot.nearestFuelExpiry = nearestExpiry + } + + // MARK: - HTTP + + private static func get( + _ path: String, + cookieHeader: String, + transport: any ProviderHTTPTransport, + required: Bool) async throws -> Data? + { + guard let url = URL(string: self.host + path) else { + throw LongCatAPIError.invalidRequest("bad URL: \(path)") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue(self.host, forHTTPHeaderField: "Origin") + request.setValue("\(self.host)/platform/usage", forHTTPHeaderField: "Referer") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + // The shared transport's redirect guard drops cross-origin / non-HTTPS + // hops, so an expired-cookie login redirect surfaces here as the raw 3xx. + // Classify 3xx (and explicit 401/403) as an invalid session rather than a + // generic HTTP error, so users see "sign in again" instead of "HTTP 302". + if response.statusCode == 401 || response.statusCode == 403 + || (300..<400).contains(response.statusCode) + { + throw LongCatAPIError.invalidSession + } + if required { + throw LongCatAPIError.apiError("HTTP \(response.statusCode) for \(path)") + } + Self.log.error("LongCat \(path) returned \(response.statusCode)") + return nil + } + return response.data + } + + private static func json(_ data: Data) -> Any? { + try? JSONSerialization.jsonObject(with: data) + } + + /// Logs the (non-sensitive) response shape to help future debugging. Never + /// called for user-current, whose body carries a session token + phone. + private static func logRawShape(_ path: String, _ data: Data) { + guard let body = String(data: data, encoding: .utf8) else { return } + Self.log.debug("LongCat \(path) raw: \(body.prefix(1200))") + } + + private static func parseDate(_ value: Any?) -> Date? { + if let number = LongCatJSON.double(value) { + let seconds = number > 1_000_000_000_000 ? number / 1000 : number + if seconds > 1_000_000_000 { return Date(timeIntervalSince1970: seconds) } + } + if let string = LongCatJSON.string(value) { + let iso = ISO8601DateFormatter() + if let date = iso.date(from: string) { return date } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + if let date = formatter.date(from: string) { return date } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift new file mode 100644 index 0000000000..e2452ed126 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LongCat/LongCatUsageSnapshot.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Parsed, Sendable view of the LongCat console quota model: +/// 总额度 (total token quota) plus 加油包额度 (fuel packs, which expire). +public struct LongCatUsageSnapshot: Sendable { + public var totalQuota: Double? + public var usedQuota: Double? + public var remainingQuota: Double? + public var fuelPackTotal: Double? + public var fuelPackRemaining: Double? + public var nearestFuelExpiry: Date? + public var accountName: String? + public var updatedAt: Date + + public init( + totalQuota: Double? = nil, + usedQuota: Double? = nil, + remainingQuota: Double? = nil, + fuelPackTotal: Double? = nil, + fuelPackRemaining: Double? = nil, + nearestFuelExpiry: Date? = nil, + accountName: String? = nil, + updatedAt: Date = Date()) + { + self.totalQuota = totalQuota + self.usedQuota = usedQuota + self.remainingQuota = remainingQuota + self.fuelPackTotal = fuelPackTotal + self.fuelPackRemaining = fuelPackRemaining + self.nearestFuelExpiry = nearestFuelExpiry + self.accountName = accountName + self.updatedAt = updatedAt + } +} + +extension LongCatUsageSnapshot { + private func resolvedUsed(total: Double) -> Double { + if let used = usedQuota { return max(0, used) } + if let remaining = remainingQuota { return max(0, total - remaining) } + return 0 + } + + public func toUsageSnapshot() -> UsageSnapshot { + // Primary: overall token quota consumption (总额度). + var primary: RateWindow? + if let total = totalQuota, total > 0 { + let used = self.resolvedUsed(total: total) + primary = RateWindow( + usedPercent: min(100, used / total * 100), + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(Int(used))/\(Int(total))") + } + + // Secondary: fuel-pack balance (加油包额度), with nearest expiry as reset. + var secondary: RateWindow? + if let total = fuelPackTotal, total > 0 { + let remaining = self.fuelPackRemaining ?? total + let used = max(0, total - remaining) + secondary = RateWindow( + usedPercent: min(100, used / total * 100), + windowMinutes: nil, + resetsAt: self.nearestFuelExpiry, + resetDescription: "Fuel pack: \(Int(remaining))/\(Int(total))") + } + + let identity = ProviderIdentitySnapshot( + providerID: .longcat, + accountEmail: nil, + accountOrganization: self.accountName, + loginMethod: nil) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} diff --git a/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattProviderDescriptor.swift b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattProviderDescriptor.swift new file mode 100644 index 0000000000..98e3f0f0ab --- /dev/null +++ b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattProviderDescriptor.swift @@ -0,0 +1,50 @@ +import Foundation + +public enum NeuralWattProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .neuralwatt, + metadata: ProviderMetadata( + id: .neuralwatt, + displayName: "Neuralwatt", + sessionLabel: "Credits", + weeklyLabel: "Spend", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "Energy-based USD credit balance.", + toggleTitle: "Show Neuralwatt usage", + cliName: "neuralwatt", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://portal.neuralwatt.com/dashboard", + subscriptionDashboardURL: "https://portal.neuralwatt.com/dashboard", + changelogURL: nil, + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .neuralwatt, + iconResourceName: "ProviderIcon-neuralwatt", + color: ProviderColor(red: 0.22, green: 0.85, blue: 0.55)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Neuralwatt token cost history is not available via the quota API." }), + fetchPlan: .apiToken( + strategyID: "neuralwatt.api", + resolveToken: { ProviderTokenResolver.neuralWattToken(environment: $0) }, + missingCredentialsError: { NeuralWattUsageError.missingCredentials }, + loadUsage: { apiKey, context in + try await NeuralWattUsageFetcher.fetchUsage( + apiKey: apiKey, + environment: context.env).toUsageSnapshot() + }), + cli: ProviderCLIConfig( + name: "neuralwatt", + aliases: ["nw", "neural"], + versionDetector: nil)) + } +} diff --git a/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattSettingsReader.swift b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattSettingsReader.swift new file mode 100644 index 0000000000..cea42dab70 --- /dev/null +++ b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattSettingsReader.swift @@ -0,0 +1,61 @@ +import Foundation + +public enum NeuralWattSettingsReader { + public static let apiKeyEnvironmentKey = "NEURALWATT_API_KEY" + public static let apiKeyEnvironmentKeys = [ + Self.apiKeyEnvironmentKey, + ] + public static let apiURLEnvironmentKey = "NEURALWATT_API_URL" + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + for key in self.apiKeyEnvironmentKeys { + guard let token = self.cleaned(environment[key]) else { continue } + return token + } + return nil + } + + 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.neuralwatt.com")! + } + + public static func validateEndpointOverrides( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + guard let raw = self.cleaned(environment[self.apiURLEnvironmentKey]) else { return } + guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) == nil else { return } + throw NeuralWattSettingsError.invalidEndpointOverride(self.apiURLEnvironmentKey) + } + + 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.apiURLEnvironmentKey]) else { return nil } + return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) + } +} + +public enum NeuralWattSettingsError: LocalizedError, Sendable, Equatable { + case invalidEndpointOverride(String) + + public var errorDescription: String? { + switch self { + case let .invalidEndpointOverride(key): + "Neuralwatt endpoint override \(key) must use HTTPS or a bare host." + } + } +} diff --git a/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattUsageFetcher.swift b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattUsageFetcher.swift new file mode 100644 index 0000000000..33b351c290 --- /dev/null +++ b/Sources/CodexBarCore/Providers/NeuralWatt/NeuralWattUsageFetcher.swift @@ -0,0 +1,442 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - Response models + +public struct NeuralWattBalance: Codable, Sendable, Equatable { + public let creditsRemainingUSD: Double? + public let totalCreditsUSD: Double? + public let creditsUsedUSD: Double? + public let accountingMethod: String? + + private enum CodingKeys: String, CodingKey { + case creditsRemainingUSD = "credits_remaining_usd" + case totalCreditsUSD = "total_credits_usd" + case creditsUsedUSD = "credits_used_usd" + case accountingMethod = "accounting_method" + } +} + +public struct NeuralWattUsagePeriod: Codable, Sendable, Equatable { + public let costUSD: Double? + public let requests: Int? + public let tokens: Int? + public let energyKWh: Double? + + private enum CodingKeys: String, CodingKey { + case costUSD = "cost_usd" + case requests + case tokens + case energyKWh = "energy_kwh" + } +} + +public struct NeuralWattUsage: Codable, Sendable, Equatable { + public let lifetime: NeuralWattUsagePeriod? + public let currentMonth: NeuralWattUsagePeriod? + + private enum CodingKeys: String, CodingKey { + case lifetime + case currentMonth = "current_month" + } +} + +public struct NeuralWattLimits: Codable, Sendable, Equatable { + public let overageLimitUSD: Double? + public let rateLimitTier: String? + + private enum CodingKeys: String, CodingKey { + case overageLimitUSD = "overage_limit_usd" + case rateLimitTier = "rate_limit_tier" + } +} + +public struct NeuralWattSubscription: Codable, Sendable, Equatable { + public let plan: String? + public let status: String? + public let billingInterval: String? + public let currentPeriodStart: Date? + public let currentPeriodEnd: Date? + public let autoRenew: Bool? + public let kwhIncluded: Double? + public let kwhUsed: Double? + public let kwhRemaining: Double? + public let inOverage: Bool? + + private enum CodingKeys: String, CodingKey { + case plan + case status + case billingInterval = "billing_interval" + case currentPeriodStart = "current_period_start" + case currentPeriodEnd = "current_period_end" + case autoRenew = "auto_renew" + case kwhIncluded = "kwh_included" + case kwhUsed = "kwh_used" + case kwhRemaining = "kwh_remaining" + case inOverage = "in_overage" + } +} + +public struct NeuralWattKeyAllowance: Codable, Sendable, Equatable { + public let limitUSD: Double? + public let period: String? + public let spentUSD: Double? + public let remainingUSD: Double? + public let blocked: Bool? + + private enum CodingKeys: String, CodingKey { + case limitUSD = "limit_usd" + case period + case spentUSD = "spent_usd" + case remainingUSD = "remaining_usd" + case blocked + } +} + +public struct NeuralWattKey: Codable, Sendable, Equatable { + public let name: String? + public let allowance: NeuralWattKeyAllowance? +} + +public struct NeuralWattQuotaResponse: Decodable, Sendable { + public let snapshotAt: String? + public let balance: NeuralWattBalance? + public let usage: NeuralWattUsage? + public let limits: NeuralWattLimits? + public let subscription: NeuralWattSubscription? + public let key: NeuralWattKey? + + private enum CodingKeys: String, CodingKey { + case snapshotAt = "snapshot_at" + case balance + case usage + case limits + case subscription + case key + } + + private init( + snapshotAt: String?, + balance: NeuralWattBalance?, + usage: NeuralWattUsage?, + limits: NeuralWattLimits?, + subscription: NeuralWattSubscription?, + key: NeuralWattKey?) + { + self.snapshotAt = snapshotAt + self.balance = balance + self.usage = usage + self.limits = limits + self.subscription = subscription + self.key = key + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.snapshotAt = try container.decodeIfPresent(String.self, forKey: .snapshotAt) + self.balance = try container.decodeIfPresent(NeuralWattBalance.self, forKey: .balance) + self.usage = try container.decodeIfPresent(NeuralWattUsage.self, forKey: .usage) + self.limits = try container.decodeIfPresent(NeuralWattLimits.self, forKey: .limits) + // `subscription` is documented as always-present: object when active, `null` otherwise. + self.subscription = try container.decodeIfPresent(NeuralWattSubscription.self, forKey: .subscription) + self.key = try container.decodeIfPresent(NeuralWattKey.self, forKey: .key) + } +} + +// MARK: - Snapshot + +public struct NeuralWattUsageSnapshot: Codable, Sendable, Equatable { + public let creditsRemainingUSD: Double? + public let totalCreditsUSD: Double? + public let creditsUsedUSD: Double? + public let accountingMethod: String? + public let currentMonthCostUSD: Double? + public let currentMonthEnergyKWh: Double? + public let subscription: NeuralWattSubscription? + public let keyAllowance: NeuralWattKeyAllowance? + public let rateLimitTier: String? + public let updatedAt: Date + + public init( + creditsRemainingUSD: Double?, + totalCreditsUSD: Double?, + creditsUsedUSD: Double?, + accountingMethod: String?, + currentMonthCostUSD: Double?, + currentMonthEnergyKWh: Double?, + subscription: NeuralWattSubscription?, + keyAllowance: NeuralWattKeyAllowance?, + rateLimitTier: String?, + updatedAt: Date) + { + self.creditsRemainingUSD = creditsRemainingUSD + self.totalCreditsUSD = totalCreditsUSD + self.creditsUsedUSD = creditsUsedUSD + self.accountingMethod = accountingMethod + self.currentMonthCostUSD = currentMonthCostUSD + self.currentMonthEnergyKWh = currentMonthEnergyKWh + self.subscription = subscription + self.keyAllowance = keyAllowance + self.rateLimitTier = rateLimitTier + self.updatedAt = updatedAt + } + + public var creditUsedPercent: Double { + if self.hasKnownZeroRemainingBalance { + return 100 + } + guard let used = self.effectiveUsedCredits, let total = self.effectiveTotalCredits, total > 0 else { + return 0 + } + return min(100, max(0, used / total * 100)) + } + + private var hasKnownZeroRemainingBalance: Bool { + Self.validNonNegative(self.creditsRemainingUSD) == 0 && self.effectiveTotalCredits == nil + } + + public var effectiveRemainingCredits: Double? { + if let remaining = Self.validNonNegative(self.creditsRemainingUSD) { return remaining } + guard let total = self.effectiveTotalCredits, let used = self.effectiveUsedCredits else { return nil } + return max(0, total - used) + } + + public var effectiveTotalCredits: Double? { + if let total = Self.validPositive(self.totalCreditsUSD) { return total } + guard let remaining = Self.validNonNegative(self.creditsRemainingUSD), + let used = Self.validNonNegative(self.creditsUsedUSD) + else { return nil } + let total = remaining + used + return total > 0 ? total : nil + } + + public var effectiveUsedCredits: Double? { + if let used = Self.validNonNegative(self.creditsUsedUSD) { return used } + guard let total = Self.validPositive(self.totalCreditsUSD), + let remaining = Self.validNonNegative(self.creditsRemainingUSD) + else { return nil } + return max(0, total - remaining) + } + + public var keyAllowanceUsedPercent: Double? { + guard let spent = self.keyAllowance?.spentUSD, let limit = self.keyAllowance?.limitUSD, limit > 0 else { + return nil + } + return min(100, max(0, spent / limit * 100)) + } + + public func toUsageSnapshot() -> UsageSnapshot { + // Neuralwatt is a credit-exhaustion model (like DeepSeek): USD credits deplete + // as you use the API and do not reset on a billing cycle. There is no renewal + // date to surface, so the primary window carries only the balance summary. + let primary = RateWindow( + usedPercent: self.creditUsedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: self.creditSummary) + + var extras: [NamedRateWindow] = [] + if let percent = self.keyAllowanceUsedPercent, let allowance = self.keyAllowance { + let periodTitle = (allowance.period ?? "allowance").capitalized + extras.append(NamedRateWindow( + id: "key-allowance", + title: "Key \(periodTitle)", + window: RateWindow( + usedPercent: percent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil))) + } + + let identity = ProviderIdentitySnapshot( + providerID: .neuralwatt, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.displayLoginMethod) + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + extraRateWindows: extras.isEmpty ? nil : extras, + subscriptionRenewsAt: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + private var creditSummary: String { + guard let remaining = self.effectiveRemainingCredits else { return "Balance unavailable" } + guard let total = self.effectiveTotalCredits else { + return "\(Self.formatUSD(remaining)) remaining" + } + return "\(Self.formatUSD(remaining)) remaining of \(Self.formatUSD(total))" + } + + private var displayLoginMethod: String? { + // Credits are account-wide; surface the accounting method (Token vs Energy) + // when present. Subscription plan is shown only as supplementary identity. + if let method = self.accountingMethod, !method.isEmpty { + return method.capitalized + } + return self.subscription?.plan?.replacingOccurrences(of: "_", with: " ").capitalized + } + + fileprivate static func validNonNegative(_ value: Double?) -> Double? { + guard let value, value.isFinite, value >= 0 else { return nil } + return value + } + + fileprivate static func validPositive(_ value: Double?) -> Double? { + guard let value, value.isFinite, value > 0 else { return nil } + return value + } + + private static func formatUSD(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.numberStyle = .currency + formatter.currencyCode = "USD" + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = 2 + return formatter.string(from: NSNumber(value: value)) ?? String(format: "$%.2f", value) + } +} + +// MARK: - Errors + +public enum NeuralWattUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Neuralwatt API key. Set apiKey in the CodexBar config file or NEURALWATT_API_KEY." + case let .networkError(message): + "Neuralwatt network error: \(message)" + case let .apiError(message): + "Neuralwatt API error: \(message)" + case let .parseFailed(message): + "Failed to parse Neuralwatt response: \(message)" + } + } +} + +// MARK: - Fetcher + +public struct NeuralWattUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.neuralWattUsage) + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> NeuralWattUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw NeuralWattUsageError.missingCredentials + } + try NeuralWattSettingsReader.validateEndpointOverrides(environment: environment) + + let url = Self.quotaURL(baseURL: NeuralWattSettingsReader.apiURL(environment: environment)) + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + throw NeuralWattUsageError.networkError(error.localizedDescription) + } + + switch response.statusCode { + case 200: + return try Self.parseSnapshot(data: response.data, updatedAt: Date()) + case 401, 403: + throw NeuralWattUsageError.missingCredentials + default: + Self.log.error("Neuralwatt API returned \(response.statusCode)") + throw NeuralWattUsageError.apiError("HTTP \(response.statusCode)") + } + } + + static func _parseSnapshotForTesting(_ data: Data, updatedAt: Date) throws -> NeuralWattUsageSnapshot { + try self.parseSnapshot(data: data, updatedAt: updatedAt) + } + + private static func parseSnapshot(data: Data, updatedAt: Date) throws -> NeuralWattUsageSnapshot { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom(Self.decodeISO8601Date) + let decoded: NeuralWattQuotaResponse + do { + decoded = try decoder.decode(NeuralWattQuotaResponse.self, from: data) + } catch { + throw NeuralWattUsageError.parseFailed(error.localizedDescription) + } + + guard let balance = decoded.balance else { + throw NeuralWattUsageError.parseFailed("Missing Neuralwatt balance object") + } + guard NeuralWattUsageSnapshot.validNonNegative(balance.creditsRemainingUSD) != nil || + NeuralWattUsageSnapshot.validNonNegative(balance.creditsUsedUSD) != nil || + NeuralWattUsageSnapshot.validPositive(balance.totalCreditsUSD) != nil + else { + throw NeuralWattUsageError.parseFailed("Missing Neuralwatt credit balance fields") + } + + return NeuralWattUsageSnapshot( + creditsRemainingUSD: balance.creditsRemainingUSD, + totalCreditsUSD: balance.totalCreditsUSD, + creditsUsedUSD: balance.creditsUsedUSD, + accountingMethod: balance.accountingMethod, + currentMonthCostUSD: decoded.usage?.currentMonth?.costUSD, + currentMonthEnergyKWh: decoded.usage?.currentMonth?.energyKWh, + subscription: decoded.subscription, + keyAllowance: decoded.key?.allowance, + rateLimitTier: decoded.limits?.rateLimitTier, + updatedAt: updatedAt) + } + + private static func decodeISO8601Date(from decoder: Decoder) throws -> Date { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + let standardFormatter = ISO8601DateFormatter() + standardFormatter.formatOptions = [.withInternetDateTime] + if let date = standardFormatter.date(from: value) { + return date + } + + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractionalFormatter.date(from: value) { + return date + } + + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ISO8601 date: \(value)") + } + + private static func quotaURL(baseURL: URL) -> URL { + var url = baseURL + let pathComponents = url.path.split(separator: "/") + if pathComponents.last == "v1" { + url.append(path: "quota") + } else { + url.append(path: "v1/quota") + } + return url + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 9f16b60743..4457881e5a 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -57,6 +57,7 @@ public enum ProviderDescriptorRegistry { .openai: OpenAIAPIProviderDescriptor.descriptor, .azureopenai: AzureOpenAIProviderDescriptor.descriptor, .claude: ClaudeProviderDescriptor.descriptor, + .clinepass: ClinePassProviderDescriptor.descriptor, .cursor: CursorProviderDescriptor.descriptor, .opencode: OpenCodeProviderDescriptor.descriptor, .opencodego: OpenCodeGoProviderDescriptor.descriptor, @@ -108,10 +109,13 @@ public enum ProviderDescriptorRegistry { .deepgram: DeepgramProviderDescriptor.descriptor, .poe: PoeProviderDescriptor.descriptor, .chutes: ChutesProviderDescriptor.descriptor, + .neuralwatt: NeuralWattProviderDescriptor.descriptor, + .longcat: LongCatProviderDescriptor.descriptor, .crossmodel: CrossModelProviderDescriptor.descriptor, .clawrouter: ClawRouterProviderDescriptor.descriptor, .sub2api: Sub2APIProviderDescriptor.descriptor, .wayfinder: WayfinderProviderDescriptor.descriptor, + .anyrouter: AnyRouterProviderDescriptor.descriptor, .zenmux: ZenMuxProviderDescriptor.descriptor, ] private static let bootstrap: Void = { diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 09a53275de..9c744f589e 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -25,6 +25,7 @@ public struct ProviderSettingsSnapshot: Sendable { copilot: CopilotProviderSettings? = nil, kilo: KiloProviderSettings? = nil, kimi: KimiProviderSettings? = nil, + longcat: LongCatProviderSettings? = nil, augment: AugmentProviderSettings? = nil, moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings? = nil, @@ -58,6 +59,7 @@ public struct ProviderSettingsSnapshot: Sendable { copilot: copilot, kilo: kilo, kimi: kimi, + longcat: longcat, augment: augment, moonshot: moonshot, amp: amp, @@ -293,6 +295,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct LongCatProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct AugmentProviderSettings: ProviderCookieSettings { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -472,6 +484,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let copilot: CopilotProviderSettings? public let kilo: KiloProviderSettings? public let kimi: KimiProviderSettings? + public let longcat: LongCatProviderSettings? public let augment: AugmentProviderSettings? public let moonshot: MoonshotProviderSettings? public let amp: AmpProviderSettings? @@ -509,6 +522,7 @@ public struct ProviderSettingsSnapshot: Sendable { copilot: CopilotProviderSettings?, kilo: KiloProviderSettings?, kimi: KimiProviderSettings?, + longcat: LongCatProviderSettings? = nil, augment: AugmentProviderSettings?, moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings?, @@ -541,6 +555,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.copilot = copilot self.kilo = kilo self.kimi = kimi + self.longcat = longcat self.augment = augment self.moonshot = moonshot self.amp = amp @@ -574,6 +589,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case copilot(ProviderSettingsSnapshot.CopilotProviderSettings) case kilo(ProviderSettingsSnapshot.KiloProviderSettings) case kimi(ProviderSettingsSnapshot.KimiProviderSettings) + case longcat(ProviderSettingsSnapshot.LongCatProviderSettings) case augment(ProviderSettingsSnapshot.AugmentProviderSettings) case moonshot(ProviderSettingsSnapshot.MoonshotProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) @@ -608,6 +624,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var copilot: ProviderSettingsSnapshot.CopilotProviderSettings? public var kilo: ProviderSettingsSnapshot.KiloProviderSettings? public var kimi: ProviderSettingsSnapshot.KimiProviderSettings? + public var longcat: ProviderSettingsSnapshot.LongCatProviderSettings? public var augment: ProviderSettingsSnapshot.AugmentProviderSettings? public var moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? @@ -646,6 +663,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .copilot(value): self.copilot = value case let .kilo(value): self.kilo = value case let .kimi(value): self.kimi = value + case let .longcat(value): self.longcat = value case let .augment(value): self.augment = value case let .moonshot(value): self.moonshot = value case let .amp(value): self.amp = value @@ -682,6 +700,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { copilot: self.copilot, kilo: self.kilo, kimi: self.kimi, + longcat: self.longcat, augment: self.augment, moonshot: self.moonshot, amp: self.amp, diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index b0f7ce6a04..a07502a78f 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -48,6 +48,12 @@ public enum ProviderTokenResolver { self.claudeAdminAPIResolution(environment: environment)?.token } + public static func clinePassToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.clinePassResolution(environment: environment)?.token + } + public static func copilotToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.copilotResolution(environment: environment)?.token } @@ -99,6 +105,10 @@ public enum ProviderTokenResolver { self.openRouterResolution(environment: environment)?.token } + public static func anyRouterToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.anyRouterResolution(environment: environment)?.token + } + public static func crossModelToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.crossModelResolution(environment: environment)?.token } @@ -109,6 +119,12 @@ public enum ProviderTokenResolver { self.elevenLabsResolution(environment: environment)?.token } + public static func neuralWattToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.neuralWattResolution(environment: environment)?.token + } + public static func groqToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.groqResolution(environment: environment)?.token } @@ -258,6 +274,12 @@ public enum ProviderTokenResolver { self.resolveEnv(ClaudeAdminAPISettingsReader.apiKey(environment: environment)) } + public static func clinePassResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(ClinePassSettingsReader.apiKey(environment: environment)) + } + public static func copilotResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -350,6 +372,12 @@ public enum ProviderTokenResolver { self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment)) } + public static func anyRouterResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(AnyRouterSettingsReader.apiKey(environment: environment)) + } + public static func crossModelResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -362,6 +390,12 @@ public enum ProviderTokenResolver { self.resolveEnv(ElevenLabsSettingsReader.apiKey(environment: environment)) } + public static func neuralWattResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(NeuralWattSettingsReader.apiKey(environment: environment)) + } + public static func groqResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index bef804bfc0..b3299ca73e 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -7,6 +7,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case openai case azureopenai case claude + case clinepass case cursor case opencode case opencodego @@ -58,10 +59,13 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case deepgram case poe case chutes + case neuralwatt + case longcat case crossmodel case clawrouter case sub2api case wayfinder + case anyrouter case zenmux } @@ -71,6 +75,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case codex case openai case claude + case clinepass case zai case minimax case manus @@ -121,10 +126,13 @@ public enum IconStyle: String, Sendable, CaseIterable { case deepgram case poe case chutes + case neuralwatt + case longcat case crossmodel case clawrouter case sub2api case wayfinder + case anyrouter case zenmux case combined } @@ -294,4 +302,13 @@ public enum ProviderBrowserCookieDefaults { nil #endif } + + /// LongCat Auto imports only from Chrome by default to avoid prompting unrelated browser keychains. + public static var longcatCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } } diff --git a/Sources/CodexBarCore/TokenAccountSupport.swift b/Sources/CodexBarCore/TokenAccountSupport.swift index 37ec660a97..eeca07e208 100644 --- a/Sources/CodexBarCore/TokenAccountSupport.swift +++ b/Sources/CodexBarCore/TokenAccountSupport.swift @@ -13,6 +13,7 @@ public struct TokenAccountSupport: Sendable { public let requiresManualCookieSource: Bool public let cookieName: String? public let environmentKeysToScrub: [String] + public let minimumDelayBetweenAccountRefreshes: Duration? public init( title: String, @@ -21,7 +22,8 @@ public struct TokenAccountSupport: Sendable { injection: TokenAccountInjection, requiresManualCookieSource: Bool, cookieName: String?, - environmentKeysToScrub: [String] = []) + environmentKeysToScrub: [String] = [], + minimumDelayBetweenAccountRefreshes: Duration? = nil) { self.title = title self.subtitle = subtitle @@ -30,6 +32,7 @@ public struct TokenAccountSupport: Sendable { self.requiresManualCookieSource = requiresManualCookieSource self.cookieName = cookieName self.environmentKeysToScrub = environmentKeysToScrub + self.minimumDelayBetweenAccountRefreshes = minimumDelayBetweenAccountRefreshes } } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 4b8854592e..e1aa697c3a 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -129,6 +129,13 @@ extension TokenAccountSupportCatalog { injection: .environment(key: VeniceSettingsReader.apiKeyEnvironmentKey), requiresManualCookieSource: false, cookieName: nil), + .anyrouter: TokenAccountSupport( + title: "API keys", + subtitle: "Store one labeled AnyRouter inference key per environment you want to monitor.", + placeholder: "Paste AnyRouter API key (sk-ar-v1-…)…", + injection: .environment(key: AnyRouterSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), .elevenlabs: TokenAccountSupport( title: "API keys", subtitle: "Store multiple ElevenLabs API keys.", @@ -136,6 +143,14 @@ extension TokenAccountSupportCatalog { injection: .environment(key: ElevenLabsSettingsReader.apiKeyEnvironmentKey), requiresManualCookieSource: false, cookieName: nil), + .neuralwatt: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple Neuralwatt API keys.", + placeholder: "sk-...", + injection: .environment(key: NeuralWattSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil, + minimumDelayBetweenAccountRefreshes: .seconds(1)), .groq: TokenAccountSupport( title: "API keys", subtitle: "Store multiple Groq API keys.", diff --git a/Sources/CodexBarCore/UsageFetcher+Testing.swift b/Sources/CodexBarCore/UsageFetcher+Testing.swift new file mode 100644 index 0000000000..3bb4a0cd44 --- /dev/null +++ b/Sources/CodexBarCore/UsageFetcher+Testing.swift @@ -0,0 +1,67 @@ +import Foundation + +#if DEBUG +extension UsageFetcher { + static func _mapCodexRPCLimitsForTesting( + primary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, + secondary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, + planType: String? = nil) throws -> UsageSnapshot + { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.normalizedCodexAccountField(planType)) + guard let state = CodexReconciledState.fromCLI( + primary: primary.map(self.makeTestingWindow), + secondary: secondary.map(self.makeTestingWindow), + identity: identity) + else { + if let usage = self.emptyCodexUsageSnapshotIfIdentified(identity: identity) { + return usage + } + throw UsageError.noRateLimitsFound + } + return state.toUsageSnapshot() + } + + static func _mapCodexStatusForTesting(_ status: CodexStatusSnapshot) throws -> UsageSnapshot { + guard let state = CodexReconciledState.fromCLI( + primary: self.makeTTYWindow( + percentLeft: status.fiveHourPercentLeft, + windowMinutes: 300, + resetsAt: status.fiveHourResetsAt, + resetDescription: status.fiveHourResetDescription), + secondary: self.makeTTYWindow( + percentLeft: status.weeklyPercentLeft, + windowMinutes: 10080, + resetsAt: status.weeklyResetsAt, + resetDescription: status.weeklyResetDescription), + identity: nil) + else { + throw UsageError.noRateLimitsFound + } + return state.toUsageSnapshot() + } + + public static func _recoverCodexRPCUsageFromErrorForTesting(_ message: String) -> UsageSnapshot? { + self.recoverUsageFromRPCError(RPCWireError.requestFailed(message)) + } + + public static func _recoverCodexRPCCreditsFromErrorForTesting(_ message: String) -> CreditsSnapshot? { + self.recoverCreditsFromRPCError(RPCWireError.requestFailed(message)) + } + + private static func makeTestingWindow( + _ value: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)) + -> RateWindow + { + let resetsAt = value.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + return RateWindow( + usedPercent: value.usedPercent, + windowMinutes: value.windowMinutes, + resetsAt: resetsAt, + resetDescription: resetsAt.map { UsageFormatter.resetDescription(from: $0) }) + } +} +#endif diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index a474ffbf7a..56f491e021 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -190,6 +190,8 @@ public struct UsageSnapshot: Codable, Sendable { public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? public let deepseekUsage: DeepSeekUsageSummary? + public let deepseekDetailedUsageState: DeepSeekDetailedUsageState + public let deepseekPlatformProfiles: [DeepSeekPlatformProfile] public let mimoUsage: MiMoUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? public let sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? @@ -258,6 +260,8 @@ public struct UsageSnapshot: Codable, Sendable { zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, deepseekUsage: DeepSeekUsageSummary? = nil, + deepseekDetailedUsageState: DeepSeekDetailedUsageState = .notRequested, + deepseekPlatformProfiles: [DeepSeekPlatformProfile] = [], mimoUsage: MiMoUsageSnapshot? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? = nil, @@ -291,6 +295,8 @@ public struct UsageSnapshot: Codable, Sendable { self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage self.deepseekUsage = deepseekUsage + self.deepseekDetailedUsageState = deepseekDetailedUsageState + self.deepseekPlatformProfiles = deepseekPlatformProfiles self.mimoUsage = mimoUsage self.openRouterUsage = openRouterUsage self.sakanaPayAsYouGo = sakanaPayAsYouGo @@ -329,6 +335,24 @@ public struct UsageSnapshot: Codable, Sendable { secondary: .value(secondary)) } + public func withoutDeepSeekDetailedUsage( + state: DeepSeekDetailedUsageState = .unavailable) -> UsageSnapshot + { + self.replacing( + deepseekUsage: .value(nil), + deepseekDetailedUsageState: .value(state)) + } + + public func preservingDeepSeekPlatformProfiles(from previous: UsageSnapshot?) -> UsageSnapshot { + guard self.deepseekDetailedUsageState == .unavailable, + self.deepseekPlatformProfiles.isEmpty, + let previous, + !previous.deepseekPlatformProfiles.isEmpty + else { return self } + return self.replacing( + deepseekPlatformProfiles: .value(previous.deepseekPlatformProfiles)) + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) @@ -341,6 +365,8 @@ public struct UsageSnapshot: Codable, Sendable { self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time self.deepseekUsage = nil // Not persisted, fetched fresh each time + self.deepseekDetailedUsageState = .notRequested // Live-only fetch state + self.deepseekPlatformProfiles = [] // Live-only browser profile catalog self.mimoUsage = try container.decodeIfPresent(MiMoUsageSnapshot.self, forKey: .mimoUsage) self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) self.sakanaPayAsYouGo = try container.decodeIfPresent( @@ -586,6 +612,9 @@ public struct UsageSnapshot: Codable, Sendable { secondary: Replacement = .unchanged, tertiary: Replacement = .unchanged, extraRateWindows: Replacement<[NamedRateWindow]?> = .unchanged, + deepseekUsage: Replacement = .unchanged, + deepseekDetailedUsageState: Replacement = .unchanged, + deepseekPlatformProfiles: Replacement<[DeepSeekPlatformProfile]> = .unchanged, codexResetCredits: Replacement = .unchanged, identity: Replacement = .unchanged, dataConfidence: Replacement = .unchanged) -> UsageSnapshot @@ -600,7 +629,9 @@ public struct UsageSnapshot: Codable, Sendable { providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, - deepseekUsage: self.deepseekUsage, + deepseekUsage: deepseekUsage.resolving(self.deepseekUsage), + deepseekDetailedUsageState: deepseekDetailedUsageState.resolving(self.deepseekDetailedUsageState), + deepseekPlatformProfiles: deepseekPlatformProfiles.resolving(self.deepseekPlatformProfiles), mimoUsage: self.mimoUsage, openRouterUsage: self.openRouterUsage, sakanaPayAsYouGo: self.sakanaPayAsYouGo, @@ -1407,7 +1438,7 @@ public struct UsageFetcher: Sendable { resetDescription: UsageFormatter.resetDescription(from: resetsAtDate)) } - private static func makeTTYWindow( + static func makeTTYWindow( percentLeft: Int?, windowMinutes: Int, resetsAt: Date?, @@ -1495,7 +1526,7 @@ public struct UsageFetcher: Sendable { return trimmed } - private static func emptyCodexUsageSnapshotIfIdentified(identity: ProviderIdentitySnapshot) -> UsageSnapshot? { + static func emptyCodexUsageSnapshotIfIdentified(identity: ProviderIdentitySnapshot) -> UsageSnapshot? { guard identity.accountEmail != nil || identity.loginMethod != nil else { return nil } return UsageSnapshot( primary: nil, @@ -1505,7 +1536,7 @@ public struct UsageFetcher: Sendable { identity: identity) } - private static func recoverUsageFromRPCError(_ error: Error) -> UsageSnapshot? { + static func recoverUsageFromRPCError(_ error: Error) -> UsageSnapshot? { guard let body = self.decodeRateLimitsErrorBody(from: error) else { return nil } let identity = ProviderIdentitySnapshot( providerID: .codex, @@ -1527,7 +1558,7 @@ public struct UsageFetcher: Sendable { return state.toUsageSnapshot() } - private static func recoverCreditsFromRPCError(_ error: Error) -> CreditsSnapshot? { + static func recoverCreditsFromRPCError(_ error: Error) -> CreditsSnapshot? { guard let credits = self.decodeRateLimitsErrorBody(from: error)?.credits else { return nil } guard let remaining = credits.balance else { return nil } return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) @@ -1581,7 +1612,7 @@ public struct UsageFetcher: Sendable { return nil } - private static func normalizedCodexAccountField(_ value: String?) -> String? { + static func normalizedCodexAccountField(_ value: String?) -> String? { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } @@ -1604,69 +1635,3 @@ public struct UsageFetcher: Sendable { return json } } - -#if DEBUG -extension UsageFetcher { - static func _mapCodexRPCLimitsForTesting( - primary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, - secondary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, - planType: String? = nil) throws -> UsageSnapshot - { - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: nil, - accountOrganization: nil, - loginMethod: self.normalizedCodexAccountField(planType)) - guard let state = CodexReconciledState.fromCLI( - primary: primary.map(self.makeTestingWindow), - secondary: secondary.map(self.makeTestingWindow), - identity: identity) - else { - if let usage = self.emptyCodexUsageSnapshotIfIdentified(identity: identity) { - return usage - } - throw UsageError.noRateLimitsFound - } - return state.toUsageSnapshot() - } - - static func _mapCodexStatusForTesting(_ status: CodexStatusSnapshot) throws -> UsageSnapshot { - guard let state = CodexReconciledState.fromCLI( - primary: self.makeTTYWindow( - percentLeft: status.fiveHourPercentLeft, - windowMinutes: 300, - resetsAt: status.fiveHourResetsAt, - resetDescription: status.fiveHourResetDescription), - secondary: self.makeTTYWindow( - percentLeft: status.weeklyPercentLeft, - windowMinutes: 10080, - resetsAt: status.weeklyResetsAt, - resetDescription: status.weeklyResetDescription), - identity: nil) - else { - throw UsageError.noRateLimitsFound - } - return state.toUsageSnapshot() - } - - public static func _recoverCodexRPCUsageFromErrorForTesting(_ message: String) -> UsageSnapshot? { - self.recoverUsageFromRPCError(RPCWireError.requestFailed(message)) - } - - public static func _recoverCodexRPCCreditsFromErrorForTesting(_ message: String) -> CreditsSnapshot? { - self.recoverCreditsFromRPCError(RPCWireError.requestFailed(message)) - } - - private static func makeTestingWindow( - _ value: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)) - -> RateWindow - { - let resetsAt = value.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } - return RateWindow( - usedPercent: value.usedPercent, - windowMinutes: value.windowMinutes, - resetsAt: resetsAt, - resetDescription: resetsAt.map { UsageFormatter.resetDescription(from: $0) }) - } -} -#endif diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index de337ed815..00eff83fad 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -820,13 +820,13 @@ enum CostUsageScanner { now: now, options: filtered, checkCancellation: checkCancellation) - case .openai, .azureopenai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, + case .openai, .azureopenai, .clinepass, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .alibabatokenplan, .factory, .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, .crossmodel, .clawrouter, - .sub2api, .wayfinder, .zenmux: + .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .neuralwatt, .longcat, + .crossmodel, .clawrouter, .sub2api, .wayfinder, .anyrouter, .zenmux: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 52ae23da1f..af9c565f82 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -70,6 +70,7 @@ enum ProviderChoice: String, AppEnum { case .openai: return nil // OpenAI not yet supported in widgets case .azureopenai: return nil // Azure OpenAI not yet supported in widgets case .claude: self = .claude + case .clinepass: return nil // ClinePass not yet supported in widgets case .gemini: self = .gemini case .alibaba: self = .alibaba case .alibabatokenplan: self = .alibabatokenplan @@ -99,6 +100,7 @@ enum ProviderChoice: String, AppEnum { case .crossmodel: return nil // CrossModel not yet supported in widgets case .clawrouter: return nil // ClawRouter not yet supported in widgets case .sub2api: return nil // sub2api not yet supported in widgets + case .anyrouter: return nil // AnyRouter not yet supported in widgets case .wayfinder: return nil // Wayfinder 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 @@ -124,7 +126,9 @@ enum ProviderChoice: String, AppEnum { case .deepgram: return nil // Deepgram not yet supported in widgets case .poe: return nil // Poe not yet supported in widgets case .chutes: return nil // Chutes not yet supported in widgets + case .longcat: return nil // LongCat not yet supported in widgets case .zed: return nil // Zed not yet supported in widgets + case .neuralwatt: return nil // Neuralwatt not yet supported in widgets case .zenmux: return nil // ZenMux not yet supported in widgets } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 4457269111..b8f644ce3e 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -291,6 +291,7 @@ private struct ProviderSwitchChip: View { case .openai: "OpenAI" case .azureopenai: "Azure OpenAI" case .claude: "Claude" + case .clinepass: "ClinePass" case .gemini: "Gemini" case .antigravity: "Anti" case .cursor: "Cursor" @@ -320,6 +321,7 @@ private struct ProviderSwitchChip: View { case .crossmodel: "CrossModel" case .clawrouter: "ClawRouter" case .sub2api: "sub2api" + case .anyrouter: "AnyRouter" case .wayfinder: "Wayfinder" case .elevenlabs: "ElevenLabs" case .warp: "Warp" @@ -345,7 +347,9 @@ private struct ProviderSwitchChip: View { case .deepgram: "Deepgram" case .poe: "Poe" case .chutes: "Chutes" + case .longcat: "LongCat" case .zed: "Zed" + case .neuralwatt: "Neuralwatt" case .zenmux: "ZenMux" } } @@ -976,6 +980,11 @@ enum WidgetColors { Color(red: 0, green: 120 / 255, blue: 212 / 255) case .claude: Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255) + case .clinepass: + Color( + red: ClinePassProviderDescriptor.descriptor.branding.color.red, + green: ClinePassProviderDescriptor.descriptor.branding.color.green, + blue: ClinePassProviderDescriptor.descriptor.branding.color.blue) case .gemini: Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255) case .antigravity: @@ -1032,6 +1041,8 @@ enum WidgetColors { Color(red: 89 / 255, green: 110 / 255, blue: 246 / 255) case .sub2api: Color(red: 45 / 255, green: 198 / 255, blue: 216 / 255) + case .anyrouter: + Color(red: 26 / 255, green: 26 / 255, blue: 46 / 255) case .wayfinder: Color(red: 16 / 255, green: 163 / 255, blue: 127 / 255) case .elevenlabs: @@ -1082,8 +1093,12 @@ enum WidgetColors { Color(red: 93 / 255, green: 92 / 255, blue: 222 / 255) // Poe purple case .chutes: Color(red: 24 / 255, green: 160 / 255, blue: 88 / 255) + case .longcat: + Color(red: 255 / 255, green: 209 / 255, blue: 0 / 255) case .zed: Color(red: 64 / 255, green: 156 / 255, blue: 255 / 255) + case .neuralwatt: + Color(red: 56 / 255, green: 217 / 255, blue: 140 / 255) case .zenmux: Color(red: 108 / 255, green: 92 / 255, blue: 231 / 255) } diff --git a/Tests/CodexBarTests/AnyRouterSettingsReaderTests.swift b/Tests/CodexBarTests/AnyRouterSettingsReaderTests.swift new file mode 100644 index 0000000000..e5e778be63 --- /dev/null +++ b/Tests/CodexBarTests/AnyRouterSettingsReaderTests.swift @@ -0,0 +1,55 @@ +import CodexBarCore +import Foundation +import Testing + +struct AnyRouterSettingsReaderTests { + @Test + func `reads ANYROUTER_API_KEY`() { + let env = ["ANYROUTER_API_KEY": "sk-ar-v1-abc"] + #expect(AnyRouterSettingsReader.apiKey(environment: env) == "sk-ar-v1-abc") + } + + @Test + func `trims whitespace and strips quotes`() { + #expect(AnyRouterSettingsReader.apiKey(environment: ["ANYROUTER_API_KEY": " sk-ar-v1-a "]) == "sk-ar-v1-a") + #expect(AnyRouterSettingsReader.apiKey(environment: ["ANYROUTER_API_KEY": "\"sk-ar-v1-b\""]) == "sk-ar-v1-b") + #expect(AnyRouterSettingsReader.apiKey(environment: ["ANYROUTER_API_KEY": "'sk-ar-v1-c'"]) == "sk-ar-v1-c") + } + + @Test + func `returns nil when key is absent or empty`() { + #expect(AnyRouterSettingsReader.apiKey(environment: [:]) == nil) + #expect(AnyRouterSettingsReader.apiKey(environment: ["ANYROUTER_API_KEY": " "]) == nil) + } + + @Test + func `defaults to the AnyRouter gateway`() { + #expect(AnyRouterSettingsReader.baseURL(environment: [:]).absoluteString == "https://anyrouter.dev/api/v1") + } + + @Test + func `accepts an HTTPS base URL override`() { + let env = ["ANYROUTER_API_URL": "https://gateway.example.com/api/v1"] + #expect( + AnyRouterSettingsReader.baseURL(environment: env).absoluteString + == "https://gateway.example.com/api/v1") + #expect(throws: Never.self) { + try AnyRouterSettingsReader.validateEndpointOverride(environment: env) + } + } + + @Test + func `rejects a plaintext HTTP base URL override`() { + let env = ["ANYROUTER_API_URL": "http://gateway.example.com/api/v1"] + #expect(throws: AnyRouterSettingsError.invalidEndpointOverride("ANYROUTER_API_URL")) { + try AnyRouterSettingsReader.validateEndpointOverride(environment: env) + } + } + + @Test + func `validation passes when no override is set`() { + #expect(throws: Never.self) { + try AnyRouterSettingsReader.validateEndpointOverride(environment: [:]) + } + } +} diff --git a/Tests/CodexBarTests/AnyRouterUsageFetcherTests.swift b/Tests/CodexBarTests/AnyRouterUsageFetcherTests.swift new file mode 100644 index 0000000000..09d2a641f5 --- /dev/null +++ b/Tests/CodexBarTests/AnyRouterUsageFetcherTests.swift @@ -0,0 +1,198 @@ +import CodexBarCore +import Foundation +import Testing + +struct AnyRouterUsageFetcherTests { + /// Mirrors the payload AnyRouter's own `/api/v1/credits` handler returns: the balance + /// fields sit at the top level, not inside a `data` object like OpenRouter's. + private static let creditsJSON = #""" + { + "balance": 4.2, + "monthly_balance": 3, + "topup_balance": 1.2, + "used": 0.8, + "today_cost": 0.5, + "currency": "usd", + "billing_provider": "polar" + } + """# + + /// The payload carries fields we do not display (`monthly_balance`, `topup_balance`, + /// `today_cost`, `billing_provider`); decoding must ignore them rather than fail. + @Test + func `parses flat credits payload and ignores undisplayed fields`() throws { + let updatedAt = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = try AnyRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.creditsJSON.utf8), + updatedAt: updatedAt) + + #expect(snapshot.balance == 4.2) + #expect(snapshot.used == 0.8) + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.updatedAt == updatedAt) + } + + @Test + func `total credits count spent plus remaining balance`() throws { + let snapshot = try AnyRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.creditsJSON.utf8), + updatedAt: Date()) + + #expect(snapshot.totalCredits == 5.0) + #expect(abs(snapshot.usedPercent - 16.0) < 0.001) + } + + @Test + func `used percent is zero when no credit was ever granted`() throws { + let snapshot = try AnyRouterUsageFetcher._parseSnapshotForTesting( + Data(#"{"balance":0,"used":0,"currency":"usd"}"#.utf8), + updatedAt: Date()) + + #expect(snapshot.totalCredits == 0) + #expect(snapshot.usedPercent == 0) + } + + @Test + func `usage snapshot exposes spend meter and balance identity`() throws { + let snapshot = try AnyRouterUsageFetcher._parseSnapshotForTesting( + Data(Self.creditsJSON.utf8), + updatedAt: Date()).toUsageSnapshot() + + let primary = try #require(snapshot.primary) + #expect(abs(primary.usedPercent - 16.0) < 0.001) + + let cost = try #require(snapshot.providerCost) + #expect(cost.used == 0.8) + #expect(cost.limit == 5.0) + #expect(cost.currencyCode == "USD") + + // Identity must stay scoped to AnyRouter — no fields borrowed from another provider. + let identity = try #require(snapshot.identity) + #expect(identity.providerID == .anyrouter) + #expect(identity.accountEmail == nil) + #expect(identity.loginMethod == "Balance: $4.20") + } + + @Test + func `defaults currency to USD when omitted`() throws { + let snapshot = try AnyRouterUsageFetcher._parseSnapshotForTesting( + Data(#"{"balance":1,"used":0}"#.utf8), + updatedAt: Date()) + + #expect(snapshot.currencyCode == "USD") + } + + @Test + func `rejects malformed payload`() { + #expect(throws: AnyRouterUsageError.self) { + try AnyRouterUsageFetcher._parseSnapshotForTesting( + Data(#"{"credits":"none"}"#.utf8), + updatedAt: Date()) + } + } + + @Test + func `fetch requests credits with bearer key`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let requestURL = try #require(request.url) + #expect(requestURL.absoluteString == "https://anyrouter.dev/api/v1/credits") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-ar-v1-test") + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(Self.creditsJSON.utf8), response) + } + + let snapshot = try await AnyRouterUsageFetcher.fetchUsage( + apiKey: "sk-ar-v1-test", + transport: transport) + + #expect(snapshot.balance == 4.2) + } + + @Test + func `fetch honors base URL override`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let requestURL = try #require(request.url) + #expect(requestURL.absoluteString == "https://gateway.example.com/api/v1/credits") + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(Self.creditsJSON.utf8), response) + } + + _ = try await AnyRouterUsageFetcher.fetchUsage( + apiKey: "sk-ar-v1-test", + baseURL: #require(URL(string: "https://gateway.example.com/api/v1")), + transport: transport) + } + + @Test + func `fetch reports rejected keys as invalid credentials`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 401, + httpVersion: nil, + headerFields: nil)) + return (Data(#"{"error":{"code":"invalid_api_key"}}"#.utf8), response) + } + + await #expect(throws: AnyRouterUsageError.invalidCredentials) { + try await AnyRouterUsageFetcher.fetchUsage(apiKey: "sk-ar-v1-revoked", transport: transport) + } + } + + /// A key restricted to an endpoint allow-list that omits /api/v1/credits is valid but + /// scoped out. AnyRouter answers 403 insufficient_scope, and the fix differs from a bad key. + @Test + func `fetch reports a scoped-out key separately from a rejected one`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 403, + httpVersion: nil, + headerFields: nil)) + return (Data(#"{"error":{"code":"insufficient_scope"}}"#.utf8), response) + } + + await #expect(throws: AnyRouterUsageError.insufficientScope) { + try await AnyRouterUsageFetcher.fetchUsage(apiKey: "sk-ar-v1-scoped", transport: transport) + } + } + + @Test + func `fetch surfaces server errors`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let requestURL = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: requestURL, + statusCode: 500, + httpVersion: nil, + headerFields: nil)) + return (Data(), response) + } + + await #expect(throws: AnyRouterUsageError.apiError(500)) { + try await AnyRouterUsageFetcher.fetchUsage(apiKey: "sk-ar-v1-test", transport: transport) + } + } + + @Test + func `fetch rejects an empty API key without calling the network`() async throws { + let transport = ProviderHTTPTransportHandler { _ in + Issue.record("Fetcher must not issue a request without an API key") + throw AnyRouterUsageError.missingCredentials + } + + await #expect(throws: AnyRouterUsageError.missingCredentials) { + try await AnyRouterUsageFetcher.fetchUsage(apiKey: " ", transport: transport) + } + } +} diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index 8824497bb2..ca9912275c 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -86,5 +86,11 @@ struct BrowserCookieOrderStatusStringTests { #expect(ProviderDefaults.metadata[.copilot]?.browserCookieOrder == [.chrome]) #expect(ProviderBrowserCookieDefaults.copilotCookieImportOrder == [.chrome]) } + + @Test + func `longcat cookie imports default to chrome only`() { + #expect(ProviderDefaults.metadata[.longcat]?.browserCookieOrder == [.chrome]) + #expect(ProviderBrowserCookieDefaults.longcatCookieImportOrder == [.chrome]) + } #endif } diff --git a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift index 2f962124bf..76175576ac 100644 --- a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift +++ b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift @@ -128,6 +128,19 @@ struct CLIDiagnoseCommandTests { #expect(summary.modes == ["api"]) } + @Test + func `generic diagnose auth summary detects Neuralwatt environment credentials`() { + let summary = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .neuralwatt, + account: nil, + config: nil, + environment: [NeuralWattSettingsReader.apiKeyEnvironmentKey: "sk-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/ClinePassProviderTests.swift b/Tests/CodexBarTests/ClinePassProviderTests.swift new file mode 100644 index 0000000000..8cc4481688 --- /dev/null +++ b/Tests/CodexBarTests/ClinePassProviderTests.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftUI +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct ClinePassProviderTests { + @Test + func `provider appears in settings with API key field and official icon`() throws { + let suite = "ClinePassProviderTests-settings" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let implementation = ClinePassProviderImplementation() + let context = ProviderSettingsContext( + provider: .clinepass, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + + #expect(settings.orderedProviders().contains(.clinepass)) + #expect(ProviderCatalog.implementation(for: .clinepass)?.id == .clinepass) + #expect(ProviderDescriptorRegistry.descriptor(for: .clinepass).branding.iconResourceName == + "ProviderIcon-clinepass") + #expect(!implementation.isAvailable(context: ProviderAvailabilityContext( + provider: .clinepass, + settings: settings, + environment: [:]))) + + let field = try #require(implementation.settingsFields(context: context).first) + #expect(field.id == "clinepass-api-key") + #expect(field.kind == .secure) + + field.binding.wrappedValue = "clinepass-test-key" + + #expect(settings.clinePassAPIKey == "clinepass-test-key") + #expect(settings.providerConfig(for: .clinepass)?.sanitizedAPIKey == "clinepass-test-key") + #expect(implementation.isAvailable(context: ProviderAvailabilityContext( + provider: .clinepass, + settings: settings, + environment: [:]))) + } +} diff --git a/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift b/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift new file mode 100644 index 0000000000..4e8cc6c4df --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift @@ -0,0 +1,194 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekPlatformTokenImporterTests { + @Test + func `extracts plain user token`() { + let token = "browser-user-token-1234567890" + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting(token) == token) + } + + @Test + func `extracts JSON encoded user token`() { + let token = "browser-user-token-abcdefghij" + let value = "{\"userToken\":\"\(token)\"}" + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting(value) == token) + } + + @Test + func `extracts DeepSeek value wrapped user token`() { + let token = "browser-user-token-value-wrapped" + let value = "{\"value\":\"\(token)\",\"expiresAt\":1234567890}" + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting(value) == token) + } + + @Test + func `does not treat an unrecognized JSON object as a token`() { + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting("{\"expiresAt\":1234567890}") == nil) + } + + @Test + func `rejects short or whitespace values`() { + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting("short") == nil) + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting("token with embedded spaces 12345") == nil) + } + + @Test + func `multiple profiles expose only server accepted sessions`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "valid-1"), + Self.candidate(id: "profile-2", token: "expired"), + Self.candidate(id: "profile-3", token: "valid-3"), + ] + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: nil, + validate: { token in + guard token != "expired" else { throw DeepSeekUsageError.invalidPlatformToken } + return Self.summary(marker: token == "valid-1" ? 1 : 3) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-1", "profile-3"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .profileSelectionRequired) + } + + @Test + func `single accepted profile is selected automatically`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "expired-1"), + Self.candidate(id: "profile-2", token: "valid-2"), + Self.candidate(id: "profile-3", token: "expired-3"), + ] + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: nil, + validate: { token in + guard token == "valid-2" else { throw DeepSeekUsageError.invalidPlatformToken } + return Self.summary(marker: 2) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-2"]) + #expect(resolution.selectedSummary?.todayTokens == 2) + #expect(resolution.detailedUsageState == .available) + } + + @Test + func `selected profile preserves its detailed usage state`() async { + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [Self.candidate(id: "profile-1", token: "valid-1")], + selectedProfileID: nil, + detailedUsageState: .notRequested, + validate: { _ in Self.summary(marker: 1) }) + + #expect(resolution.selectedSummary?.todayTokens == 1) + #expect(resolution.detailedUsageState == .notRequested) + } + + @Test + func `explicit selection requirement does not auto select a single accepted profile`() async { + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [Self.candidate(id: "profile-1", token: "valid-1")], + selectedProfileID: nil, + requiresExplicitSelection: true, + validate: { _ in Self.summary(marker: 1) }) + + #expect(resolution.profiles.map(\.id) == ["profile-1"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .profileSelectionRequired) + } + + @Test + func `stored selection chooses one of multiple accepted profiles`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "valid-1"), + Self.candidate(id: "profile-2", token: "valid-2"), + ] + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: "profile-2", + validate: { token in + Self.summary(marker: token == "valid-1" ? 1 : 2) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-1", "profile-2"]) + #expect(resolution.selectedSummary?.todayTokens == 2) + #expect(resolution.detailedUsageState == .available) + } + + @Test + func `expired stored selection does not silently switch to another profile`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "expired"), + Self.candidate(id: "profile-2", token: "valid-2"), + ] + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: "profile-1", + validate: { token in + guard token == "valid-2" else { throw DeepSeekUsageError.invalidPlatformToken } + return Self.summary(marker: 2) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-2"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .profileSelectionRequired) + } + + @Test + func `temporary validation failure is unavailable rather than signed out`() async { + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [Self.candidate(id: "profile-1", token: "maybe-valid")], + selectedProfileID: nil, + validate: { _ in throw DeepSeekUsageError.networkError("offline") }) + + #expect(resolution.profiles.isEmpty) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .unavailable) + } + + @Test + func `temporary validation failure keeps a previously accepted profile`() async { + let candidate = Self.candidate(id: "profile-1", token: "valid-1") + let cache = DeepSeekPlatformValidationCache(validityTTL: 0) + _ = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [candidate], + selectedProfileID: nil, + cache: cache, + validate: { _ in Self.summary(marker: 1) }) + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [candidate], + selectedProfileID: nil, + cache: cache, + validate: { _ in throw DeepSeekUsageError.networkError("offline") }) + + #expect(resolution.profiles.map(\.id) == ["profile-1"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .unavailable) + } + + private static func candidate(id: String, token: String) -> DeepSeekPlatformTokenImporter.TokenInfo { + DeepSeekPlatformTokenImporter.TokenInfo(id: id, token: token, sourceLabel: "Chrome \(id)") + } + + private static func summary(marker: Int) -> DeepSeekUsageSummary { + DeepSeekUsageSummary( + todayTokens: marker, + currentMonthTokens: marker, + todayCost: nil, + currentMonthCost: nil, + requestCount: marker, + currentMonthRequestCount: marker, + topModel: nil, + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 0)) + } +} diff --git a/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift b/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift new file mode 100644 index 0000000000..743fa3c033 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift @@ -0,0 +1,420 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekProviderDescriptorTests { + private actor CancellationProbe { + private(set) var wasCancelled = false + + func markCancelled() { + self.wasCancelled = true + } + } + + private actor ResolutionInputProbe { + private(set) var profileID: String? + private(set) var requiresExplicitSelection = false + private(set) var includesPlatformBalance = false + private(set) var includesOptionalUsage = true + + func record( + profileID: String?, + requiresExplicitSelection: Bool, + includesPlatformBalance: Bool = false, + includesOptionalUsage: Bool = true) + { + self.profileID = profileID + self.requiresExplicitSelection = requiresExplicitSelection + self.includesPlatformBalance = includesPlatformBalance + self.includesOptionalUsage = includesOptionalUsage + } + } + + @Test + func `balance failure cancels automatic session resolution promptly`() async { + let probe = CancellationProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.apiError("invalid key") + }, + resolveAutomaticSession: { _, _, _, _, _, _ in + do { + try await Task.sleep(for: .seconds(10)) + } catch { + await probe.markCancelled() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + await #expect { + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "invalid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .seconds(5), + operations: operations) + } throws: { error in + error as? DeepSeekUsageError == .apiError("invalid key") + } + + #expect(startedAt.duration(to: .now) < .seconds(1)) + let cancellationDeadline = ContinuousClock.now.advanced(by: .milliseconds(200)) + while await !(probe.wasCancelled), ContinuousClock.now < cancellationDeadline { + await Task.yield() + } + #expect(await probe.wasCancelled) + } + + @Test + func `automatic session resolution cannot hold balance past its grace`() async throws { + let probe = CancellationProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + do { + try await Task.sleep(for: .seconds(10)) + } catch { + await probe.markCancelled() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + let snapshot = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .milliseconds(20), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(snapshot.deepseekUsage == nil) + #expect(snapshot.deepseekDetailedUsageState == .unavailable) + #expect(startedAt.duration(to: .now) < .seconds(1)) + let cancellationDeadline = ContinuousClock.now.advanced(by: .milliseconds(200)) + while await !(probe.wasCancelled), ContinuousClock.now < cancellationDeadline { + await Task.yield() + } + #expect(await probe.wasCancelled) + } + + @Test + func `automatic session result enriches the required balance`() async throws { + let summary = DeepSeekUsageSummary( + todayTokens: 123, + currentMonthTokens: 456, + todayCost: 0.1, + currentMonthCost: 0.2, + requestCount: 3, + currentMonthRequestCount: 4, + topModel: "deepseek-chat", + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 1)) + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + DeepSeekPlatformTokenImporter.Resolution( + profiles: [DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal")], + selectedSummary: summary, + detailedUsageState: .available) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(snapshot.deepseekUsage?.todayTokens == 123) + #expect(snapshot.deepseekDetailedUsageState == .available) + #expect(snapshot.deepseekPlatformProfiles.map(\.id) == ["chrome:Default"]) + } + + @Test + func `automatic resolution timeout is hard when the resolver ignores cancellation`() async throws { + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + let deadline = ContinuousClock.now.advanced(by: .milliseconds(500)) + while ContinuousClock.now < deadline { + await Task.yield() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + let snapshot = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .milliseconds(20), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(startedAt.duration(to: .now) < .milliseconds(200)) + } + + @Test + func `profile selection from another api account requires explicit replacement`() async throws { + let probe = ResolutionInputProbe() + let selectedAccountID = UUID() + let otherAccountID = UUID() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { profileID, requiresExplicitSelection, _, _, _, _ in + await probe.record( + profileID: profileID, + requiresExplicitSelection: requiresExplicitSelection) + return Self.unavailableResolution + }) + let otherAccountScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: otherAccountID, + apiKey: "valid")) + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: "chrome:Default", + DeepSeekSettingsReader.profileScopeEnvironmentKey: otherAccountScope, + ] + + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(environment: environment, selectedTokenAccountID: selectedAccountID), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection) + } + + @Test + func `replacing an api key in the same account requires explicit profile replacement`() async throws { + let probe = ResolutionInputProbe() + let selectedAccountID = UUID() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { profileID, requiresExplicitSelection, _, _, _, _ in + await probe.record(profileID: profileID, requiresExplicitSelection: requiresExplicitSelection) + return Self.unavailableResolution + }) + let oldScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: selectedAccountID, + apiKey: "old-key")) + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: "chrome:Default", + DeepSeekSettingsReader.profileScopeEnvironmentKey: oldScope, + ] + + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "new-key", + context: Self.makeContext(environment: environment, selectedTokenAccountID: selectedAccountID), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection) + } + + @Test + func `changing the environment api key requires explicit profile replacement`() async throws { + let probe = ResolutionInputProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { profileID, requiresExplicitSelection, _, _, _, _ in + await probe.record(profileID: profileID, requiresExplicitSelection: requiresExplicitSelection) + return Self.unavailableResolution + }) + let oldScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: nil, + apiKey: "old-key")) + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: "chrome:Default", + DeepSeekSettingsReader.profileScopeEnvironmentKey: oldScope, + ] + + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "new-key", + context: Self.makeContext(environment: environment), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection) + } + + @Test + func `browser only mode returns Platform balance and usage without an API key`() async throws { + let probe = ResolutionInputProbe() + let summary = DeepSeekUsageSummary( + todayTokens: 123, + currentMonthTokens: 456, + todayCost: 0.1, + currentMonthCost: 0.2, + requestCount: 3, + currentMonthRequestCount: 4, + topModel: "deepseek-chat", + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 1)) + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.missingCredentials + }, + resolveAutomaticSession: { profileID, explicit, includeBalance, includeOptional, _, _ in + await probe.record( + profileID: profileID, + requiresExplicitSelection: explicit, + includesPlatformBalance: includeBalance, + includesOptionalUsage: includeOptional) + return DeepSeekPlatformTokenImporter.Resolution( + profiles: [DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Yuqing")], + selectedSummary: summary, + selectedBalance: Self.balance, + detailedUsageState: .available) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(snapshot.deepseekUsage == summary) + #expect(snapshot.deepseekDetailedUsageState == .available) + #expect(snapshot.deepseekPlatformProfiles.map(\.id) == ["chrome:Default"]) + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection == false) + #expect(await probe.includesPlatformBalance) + #expect(await probe.includesOptionalUsage) + } + + @Test + func `browser only mode skips optional usage when extras are disabled`() async throws { + let probe = ResolutionInputProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.missingCredentials + }, + resolveAutomaticSession: { profileID, explicit, includeBalance, includeOptional, _, _ in + await probe.record( + profileID: profileID, + requiresExplicitSelection: explicit, + includesPlatformBalance: includeBalance, + includesOptionalUsage: includeOptional) + return DeepSeekPlatformTokenImporter.Resolution( + profiles: [DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Yuqing")], + selectedSummary: nil, + selectedBalance: Self.balance, + detailedUsageState: .notRequested) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto, includeOptionalUsage: false), + operations: operations) + + #expect(snapshot.primary != nil) + #expect(snapshot.deepseekUsage == nil) + #expect(snapshot.deepseekDetailedUsageState == .notRequested) + #expect(await probe.includesPlatformBalance) + #expect(await probe.includesOptionalUsage == false) + } + + @Test + func `browser only resolution timeout is hard when Chrome ignores cancellation`() async throws { + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + let deadline = ContinuousClock.now.advanced(by: .milliseconds(500)) + while ContinuousClock.now < deadline { + await Task.yield() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + await #expect { + _ = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto), + resolutionJoinGrace: .milliseconds(20), + operations: operations) + } throws: { error in + guard case let DeepSeekUsageError.networkError(message) = error else { return false } + return message.contains("timed out") + } + #expect(startedAt.duration(to: .now) < .milliseconds(200)) + } + + @Test + func `browser only mode asks for Chrome sign in instead of an API key`() async throws { + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.missingCredentials + }, + resolveAutomaticSession: { _, _, _, _, _, _ in + DeepSeekPlatformTokenImporter.Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: .webSessionRequired) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto), + operations: operations) + + #expect(snapshot.primary == nil) + #expect(snapshot.deepseekDetailedUsageState == .webSessionRequired) + } + + @Test + func `automatic source uses Chrome session when API key is absent`() async { + let strategies = await DeepSeekProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies( + Self.makeContext(sourceMode: .auto)) + + #expect(strategies.map(\.id) == ["deepseek.web"]) + } + + @Test + func `automatic source keeps API path when API key is present`() async { + let strategies = await DeepSeekProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies( + Self.makeContext( + environment: [DeepSeekSettingsReader.apiKeyEnvironmentKey: "test-api-key"], + sourceMode: .auto)) + + #expect(strategies.map(\.id) == ["deepseek.api"]) + } + + private static let balance = DeepSeekUsageSnapshot( + isAvailable: true, + currency: "USD", + totalBalance: 8.06, + grantedBalance: 0, + toppedUpBalance: 8.06, + updatedAt: Date(timeIntervalSince1970: 1)) + + private static let unavailableResolution = DeepSeekPlatformTokenImporter.Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: .unavailable) + + private static func makeContext( + environment: [String: String] = [:], + selectedTokenAccountID: UUID? = nil, + sourceMode: ProviderSourceMode = .api, + includeOptionalUsage: Bool = true) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + includeOptionalUsage: includeOptionalUsage, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: nil, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: selectedTokenAccountID) + } +} diff --git a/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift index 0aba8b4a83..bb9e322ff8 100644 --- a/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift +++ b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift @@ -1,4 +1,5 @@ import CodexBarCore +import Foundation import Testing struct DeepSeekSettingsReaderTests { @@ -54,6 +55,65 @@ struct DeepSeekSettingsReaderTests { let env = ["DEEPSEEK_API_KEY": " "] #expect(DeepSeekSettingsReader.apiKey(environment: env) == nil) } + + @Test + func `reads separate platform session token`() { + let env = ["DEEPSEEK_PLATFORM_TOKEN": " browser-session-token "] + #expect(DeepSeekSettingsReader.platformToken(environment: env) == "browser-session-token") + } + + @Test + func `falls back to DeepSeek user token environment key`() { + let env = ["DEEPSEEK_USER_TOKEN": "browser-user-token"] + #expect(DeepSeekSettingsReader.platformToken(environment: env) == "browser-user-token") + } + + @Test + func `reads selected Chrome profile id`() { + let env = [DeepSeekSettingsReader.profileIDEnvironmentKey: " /profiles/Profile 2 "] + #expect(DeepSeekSettingsReader.profileID(environment: env) == "chrome:Profile 2") + } + + @Test + func `migrates an absolute Chrome profile path to a stable identifier`() { + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: + "/Users/example/Library/Application Support/Google/Chrome/Profile 2", + ] + + #expect(DeepSeekSettingsReader.profileID(environment: environment) == "chrome:Profile 2") + } + + @Test + func `profile scope fingerprints the api credential without storing it`() throws { + let accountID = UUID() + let first = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: accountID, + apiKey: "secret-api-key")) + let repeated = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: accountID, + apiKey: "secret-api-key")) + let replacedKey = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: accountID, + apiKey: "replacement-api-key")) + let otherAccount = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: UUID(), + apiKey: "secret-api-key")) + + #expect(first == repeated) + #expect(first != replacedKey) + #expect(first != otherAccount) + #expect(!first.contains("secret-api-key")) + } + + @Test + func `browser only profile scope persists without an API key`() throws { + let scope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: nil, + apiKey: nil)) + + #expect(!scope.isEmpty) + } } struct DeepSeekProviderTokenResolverTests { diff --git a/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift index c7cc32b925..e9d67a5d05 100644 --- a/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift @@ -853,3 +853,150 @@ struct DeepSeekUsageCostParserTests { #expect(summary.todayTokens == 450) // 150 + 300 } } + +struct DeepSeekUsageCostParserAuthorizationTests { + private static let emptyCostJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [] + } + } + """ + + @Test + func `invalid platform token code requests a new web session`() { + let amountJSON = """ + { + "code": 40003, + "msg": "Authorization Failed (invalid token)", + "data": null + } + """ + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [] + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `nested invalid platform token code requests a new web session`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 40002, + "biz_msg": "Authorization Failed", + "biz_data": null + } + } + """ + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [] + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `top level authentication error survives an unexpected data shape`() { + let amountJSON = """ + { + "code": 40003, + "msg": "Authorization Failed", + "data": "unexpected" + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(Self.emptyCostJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `nested authentication error survives an unexpected biz data shape`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 40002, + "biz_msg": "Authorization Failed", + "biz_data": "unexpected" + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(Self.emptyCostJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `successful malformed payload reports its decoding path`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": "unexpected", + "days": [] + } + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(Self.emptyCostJSON.utf8)) + } throws: { error in + guard case let DeepSeekUsageError.parseFailed(message) = error else { return false } + return message.contains("total") && message.contains("typeMismatch") + } + } +} diff --git a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift index 5ebb3abbf4..f348d76939 100644 --- a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift @@ -46,6 +46,34 @@ struct DeepSeekUsageFetcherTests { } } + private actor ConcurrentFetchGate { + private var arrivalCount = 0 + private var waiters: [CheckedContinuation] = [] + + func arriveAndWait() async { + self.arrivalCount += 1 + if self.arrivalCount == 2 { + for waiter in self.waiters { + waiter.resume() + } + self.waiters.removeAll() + return + } + + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } + } + + private actor SummaryCallCounter { + private(set) var value = 0 + + func increment() { + self.value += 1 + } + } + private static func withTimeout( _ timeout: Duration, operation: @escaping @Sendable () async throws -> T) async throws -> T @@ -128,6 +156,90 @@ struct DeepSeekUsageFetcherTests { #expect(snapshot.toppedUpBalance == 40.0) } + @Test + func `parses paid and granted balances from Platform session summary`() throws { + let json = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": { + "normal_wallets": [ + {"balance": "7.97", "currency": "USD"} + ], + "bonus_wallets": [ + {"balance": 0.50, "currency": "USD"} + ] + } + } + } + """ + + let snapshot = try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + + #expect(snapshot.hasBalance) + #expect(snapshot.isAvailable) + #expect(snapshot.currency == "USD") + #expect(abs(snapshot.totalBalance - 8.47) < 0.000_001) + #expect(snapshot.toppedUpBalance == 7.97) + #expect(snapshot.grantedBalance == 0.50) + } + + @Test + func `Platform session summary rejects malformed balance`() { + let json = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": { + "normal_wallets": [{"balance": "not-a-number", "currency": "USD"}], + "bonus_wallets": [] + } + } + } + """ + + #expect(throws: DeepSeekUsageError.self) { + try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + } + } + + @Test + func `Platform session summary maps top level auth envelopes before decoding data`() { + let json = """ + { + "code": 40003, + "data": "unexpected" + } + """ + + #expect { + try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `Platform session summary maps nested auth envelopes before decoding wallets`() { + let json = """ + { + "code": 0, + "data": { + "biz_code": 40002, + "biz_data": "unexpected" + } + } + """ + + #expect { + try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + @Test func `parses CNY balance response`() throws { let json = """ @@ -360,12 +472,32 @@ struct DeepSeekUsageFetcherTests { #expect(usage.deepseekUsage == nil) } + @Test + func `usage amount and cost fetch concurrently`() async throws { + let gate = ConcurrentFetchGate() + let payloads = try await Self.withTimeout(.seconds(1)) { + try await DeepSeekUsageFetcher._fetchUsagePayloadsForTesting( + fetchAmount: { + await gate.arriveAndWait() + return Data("amount".utf8) + }, + fetchCost: { + await gate.arriveAndWait() + return Data("cost".utf8) + }) + } + + #expect(String(bytes: payloads.amount, encoding: .utf8) == "amount") + #expect(String(bytes: payloads.cost, encoding: .utf8) == "cost") + } + @Test func `balance returns promptly when optional usage summary is slow`() async throws { let probe = SummaryCancellationProbe() let snapshot = try await Self.withTimeout(.seconds(10)) { try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .milliseconds(50), fetchBalanceData: { _ in @@ -393,6 +525,7 @@ struct DeepSeekUsageFetcherTests { let startedAt = ContinuousClock.now let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .milliseconds(20), fetchBalanceData: { _ in @@ -419,6 +552,7 @@ struct DeepSeekUsageFetcherTests { func `balance returns when optional usage summary fails closed`() async throws { let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -432,6 +566,54 @@ struct DeepSeekUsageFetcherTests { #expect(snapshot.usageSummary == nil) } + @Test + func `Platform balance returns when optional usage summary fails`() async throws { + let snapshot = try await DeepSeekUsageFetcher._fetchPlatformUsageForTesting( + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(2), + fetchBalance: { + DeepSeekUsageSnapshot( + isAvailable: true, + currency: "USD", + totalBalance: 8.06, + grantedBalance: 0, + toppedUpBalance: 8.06, + updatedAt: Date()) + }, + fetchSummary: { + throw DeepSeekUsageError.networkError("simulated failure") + }) + + #expect(snapshot.totalBalance == 8.06) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .unavailable) + } + + @Test + func `Platform balance skips detailed endpoints when optional usage is disabled`() async throws { + let counter = SummaryCallCounter() + let snapshot = try await DeepSeekUsageFetcher._fetchPlatformUsageForTesting( + includeOptionalUsage: false, + fetchBalance: { + DeepSeekUsageSnapshot( + isAvailable: true, + currency: "USD", + totalBalance: 8.06, + grantedBalance: 0, + toppedUpBalance: 8.06, + updatedAt: Date()) + }, + fetchSummary: { + await counter.increment() + return Self.sampleSummary() + }) + + #expect(snapshot.totalBalance == 8.06) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .notRequested) + #expect(await counter.value == 0) + } + @Test func `cancels optional usage summary when balance fetch fails`() async throws { let probe = SummaryCancellationProbe() @@ -439,6 +621,7 @@ struct DeepSeekUsageFetcherTests { do { _ = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -468,6 +651,7 @@ struct DeepSeekUsageFetcherTests { do { _ = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -496,6 +680,7 @@ struct DeepSeekUsageFetcherTests { let task = Task { try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(30), fetchBalanceData: { _ in @@ -533,6 +718,7 @@ struct DeepSeekUsageFetcherTests { let task = Task { try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(30), fetchBalanceData: { _ in @@ -593,6 +779,7 @@ struct DeepSeekUsageFetcherTests { let expected = Self.sampleSummary() let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -604,6 +791,68 @@ struct DeepSeekUsageFetcherTests { #expect(snapshot.totalBalance == 50.0) #expect(snapshot.usageSummary == expected) + #expect(snapshot.detailedUsageState == .available) + } + + @Test + func `API key alone reports that a web session is required`() async throws { + let summaryCalls = SummaryCallCounter() + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + platformToken: nil, + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(1), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + await summaryCalls.increment() + return Self.sampleSummary() + }) + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .webSessionRequired) + #expect(await summaryCalls.value == 0) + } + + @Test + func `platform token is separate from the balance API key`() async throws { + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "balance-api-key", + platformToken: "browser-user-token", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(1), + fetchBalanceData: { key in + #expect(key == "balance-api-key") + return Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { token in + #expect(token == "browser-user-token") + return Self.sampleSummary() + }) + + #expect(snapshot.usageSummary != nil) + #expect(snapshot.detailedUsageState == .available) + } + + @Test + func `invalid platform token preserves balance and requests sign in`() async throws { + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "balance-api-key", + platformToken: "expired-browser-token", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(1), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + throw DeepSeekUsageError.invalidPlatformToken + }) + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .webSessionRequired) } private static func utcDate(year: Int, month: Int, day: Int) -> Date? { diff --git a/Tests/CodexBarTests/LongCatProviderTests.swift b/Tests/CodexBarTests/LongCatProviderTests.swift new file mode 100644 index 0000000000..c4d3479422 --- /dev/null +++ b/Tests/CodexBarTests/LongCatProviderTests.swift @@ -0,0 +1,272 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct LongCatProviderTests { + // MARK: - Settings reader + + @Test + func `reads LONGCAT_MANUAL_COOKIE`() { + let env = ["LONGCAT_MANUAL_COOKIE": "passport_token=abc; uid=42"] + #expect(LongCatSettingsReader.cookieHeader(environment: env) == "passport_token=abc; uid=42") + } + + @Test + func `reads LONGCAT_API_KEY and trims quotes`() { + #expect(LongCatSettingsReader.apiKey(environment: ["LONGCAT_API_KEY": " \"ak_x\" "]) == "ak_x") + } + + @Test + func `missing env returns nil`() { + #expect(LongCatSettingsReader.cookieHeader(environment: [:]) == nil) + #expect(LongCatSettingsReader.apiKey(environment: [:]) == nil) + } + + @Test + func `cookieHeader reads lowercase alias and trims quotes`() { + // The env path routes through this reader, so the lower-case alias and + // quote-trimming must apply (regression for the env-bypass fix). + #expect(LongCatSettingsReader.cookieHeader(environment: ["longcat_manual_cookie": "'a=b; c=d'"]) == "a=b; c=d") + } + + // MARK: - Cookie header override + + @Test + func `override accepts bare cookie pair string`() { + let override = LongCatCookieHeader.override(from: "passport_token=abc; uid=42") + #expect(override?.cookieHeader == "passport_token=abc; uid=42") + } + + @Test + func `override extracts from a curl Cookie header`() { + let raw = "curl 'https://longcat.chat/api/v1/user-current' -H 'Cookie: passport_token=abc; uid=42'" + let override = LongCatCookieHeader.override(from: raw) + #expect(override?.cookieHeader == "passport_token=abc; uid=42") + } + + @Test + func `override rejects a token-less string`() { + #expect(LongCatCookieHeader.override(from: "not a cookie") == nil) + #expect(LongCatCookieHeader.override(from: " ") == nil) + } + + // MARK: - Snapshot mapping + + @Test + func `total quota maps to primary used percent`() { + let snapshot = LongCatUsageSnapshot(totalQuota: 1000, usedQuota: 250) + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.providerID == .longcat) + #expect(abs((usage.primary?.usedPercent ?? 0) - 25) < 0.001) + } + + @Test + func `remaining quota infers used when used is absent`() { + let snapshot = LongCatUsageSnapshot(totalQuota: 1000, remainingQuota: 400) + #expect(abs((snapshot.toUsageSnapshot().primary?.usedPercent ?? 0) - 60) < 0.001) + } + + @Test + func `missing quota data omits primary window`() { + let usage = LongCatUsageSnapshot(fuelPackTotal: 500, fuelPackRemaining: 200).toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary != nil) + } + + @Test + func `fuel pack populates secondary window`() { + let snapshot = LongCatUsageSnapshot(fuelPackTotal: 500, fuelPackRemaining: 200) + let usage = snapshot.toUsageSnapshot() + #expect(usage.secondary != nil) + #expect(abs((usage.secondary?.usedPercent ?? 0) - 60) < 0.001) + } + + // MARK: - buildSnapshot against captured live response shapes + + private func object(_ json: String) throws -> [String: Any] { + let parsed = try JSONSerialization.jsonObject(with: Data(json.utf8)) + return try #require(parsed as? [String: Any]) + } + + @Test + func `buildSnapshot maps live tokenUsage and account fields`() throws { + // Shapes captured from longcat.chat console (values neutralised). + let account = try self.object(#"{"userId":1,"name":"LongCat User","phone":"x","token":"secret"}"#) + let tokenUsage = try self.object(#""" + {"usage":{"totalToken":500000,"usedToken":120000,"availableToken":380000,"freeAvailableToken":380000}, + "extData":{"LongCat-Flash-Lite":{"totalToken":50000000,"usedToken":0}}} + """#) + let fuel = try self.object(#"{"totalQuota":0,"list":[]}"#) + + let snapshot = LongCatUsageFetcher.buildSnapshot(account: account, tokenUsage: tokenUsage, pendingFuel: fuel) + #expect(snapshot.accountName == "LongCat User") + #expect(snapshot.totalQuota == 500_000) + #expect(snapshot.usedQuota == 120_000) + #expect(snapshot.remainingQuota == 380_000) + #expect(snapshot.fuelPackTotal == nil) // empty fuel list + + let usage = snapshot.toUsageSnapshot() + #expect(abs((usage.primary?.usedPercent ?? 0) - 24) < 0.001) + #expect(usage.secondary == nil) + } + + @Test + func `buildSnapshot sums active fuel packages`() throws { + let fuel = try self.object(#""" + {"totalQuota":1000,"list":[{"availableToken":600,"expireTime":1750000000000}, + {"availableToken":150,"expireTime":1760000000000}]} + """#) + let snapshot = LongCatUsageFetcher.buildSnapshot(account: nil, tokenUsage: nil, pendingFuel: fuel) + #expect(snapshot.fuelPackTotal == 1000) + #expect(snapshot.fuelPackRemaining == 750) + #expect(snapshot.nearestFuelExpiry != nil) + #expect(snapshot.toUsageSnapshot().primary == nil) + } + + // MARK: - Envelope + + @Test + func `envelope surfaces invalid session on auth code`() { + #expect(throws: LongCatAPIError.invalidSession) { + try LongCatEnvelope.unwrap(["code": 401, "message": "unauthorized"]) + } + } + + @Test + func `envelope unwraps data on success`() throws { + let data = try LongCatEnvelope.unwrap(["code": 0, "data": ["x": 1]]) as? [String: Any] + #expect(data?["x"] as? Int == 1) + } + + // MARK: - Cookie source semantics + + private func context( + env: [String: String], + cookieSource: ProviderCookieSource, + runtime: ProviderRuntime = .app) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: runtime, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: ProviderSettingsSnapshot.make( + longcat: .init(cookieSource: cookieSource, manualCookieHeader: nil)), + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `off source disables env cookie override`() { + let ctx = self.context(env: ["LONGCAT_MANUAL_COOKIE": "a=b"], cookieSource: .off) + #expect(LongCatCookieHeader.resolveCookieOverride(context: ctx) == nil) + } + + @Test + func `auto source allows env cookie override`() { + let ctx = self.context(env: ["LONGCAT_MANUAL_COOKIE": "a=b"], cookieSource: .auto) + #expect(LongCatCookieHeader.resolveCookieOverride(context: ctx)?.cookieHeader == "a=b") + } + + @Test + func `browser import is user initiated app auto only`() { + let appAuto = self.context(env: [:], cookieSource: .auto) + let cliAuto = self.context(env: [:], cookieSource: .auto, runtime: .cli) + let appManual = self.context(env: [:], cookieSource: .manual) + let appOff = self.context(env: [:], cookieSource: .off) + + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appAuto) == false) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: cliAuto) == false) + + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appAuto)) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: cliAuto) == false) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appManual) == false) + #expect(LongCatWebFetchStrategy.allowsBrowserImport(context: appOff) == false) + } + } + + // MARK: - HTTP status handling (fetchUsage over an injected transport) + + @Test + func `fetch surfaces invalid session on 401`() async { + let transport = LongCatScriptedTransport(results: [.status(401)]) + await #expect(throws: LongCatAPIError.invalidSession) { + _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + } + } + + @Test + func `fetch surfaces invalid session on 403`() async { + let transport = LongCatScriptedTransport(results: [.status(403)]) + await #expect(throws: LongCatAPIError.invalidSession) { + _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + } + } + + @Test + func `fetch treats a blocked login redirect as invalid session`() async { + // The shared transport's redirect guard drops the cross-origin login hop, so an + // expired cookie surfaces here as a raw 3xx; it must still read as invalid-session. + let transport = LongCatScriptedTransport(results: [.status(302)]) + await #expect(throws: LongCatAPIError.invalidSession) { + _ = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + } + } + + @Test + func `fetch maps a full live response over the transport`() async throws { + let transport = LongCatScriptedTransport(results: [ + .body(#"{"code":0,"data":{"name":"Leo"}}"#), + .body(#"{"code":0,"data":{"usage":{"totalToken":500000,"usedToken":120000,"availableToken":380000}}}"#), + .body(#"{"code":0,"data":{"totalQuota":1000,"list":[{"availableToken":600,"expireTime":1750000000000}]}}"#), + ]) + let snapshot = try await LongCatUsageFetcher.fetchUsage(cookieHeader: "session=x", transport: transport) + #expect(snapshot.accountName == "Leo") + #expect(snapshot.totalQuota == 500_000) + #expect(snapshot.usedQuota == 120_000) + #expect(snapshot.fuelPackTotal == 1000) + #expect(snapshot.fuelPackRemaining == 600) + } +} + +/// Scripted transport for exercising `LongCatUsageFetcher.fetchUsage` HTTP paths +/// without a network. Returns the given results in order; an exhausted script +/// yields an empty 200 so best-effort follow-up probes decode to nil. +private actor LongCatScriptedTransport: ProviderHTTPTransport { + enum Result { + case status(Int) + case body(String) + } + + private var results: [Result] + + init(results: [Result]) { + self.results = results + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let result = self.results.isEmpty ? .status(200) : self.results.removeFirst() + let statusCode: Int + let body: String + switch result { + case let .status(code): + statusCode = code + body = "" + case let .body(text): + statusCode = 200 + body = text + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift index 3a17f9b107..47fee0791c 100644 --- a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift +++ b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift @@ -25,7 +25,11 @@ struct MenuCardDeepSeekTests { updatedAt: now) } - private static func makeSnapshot(now: Date, usageSummary: DeepSeekUsageSummary? = nil) -> UsageSnapshot { + private static func makeSnapshot( + now: Date, + usageSummary: DeepSeekUsageSummary? = nil, + detailedUsageState: DeepSeekDetailedUsageState? = nil) -> UsageSnapshot + { DeepSeekUsageSnapshot( isAvailable: true, currency: "USD", @@ -33,6 +37,7 @@ struct MenuCardDeepSeekTests { grantedBalance: 0, toppedUpBalance: 9.32, usageSummary: usageSummary, + detailedUsageState: detailedUsageState, updatedAt: now) .toUsageSnapshot() } @@ -114,6 +119,168 @@ struct MenuCardDeepSeekTests { #expect(model.usageNotes.isEmpty) } + @Test + func `model explains unavailable optional deepseek usage`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + 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.inlineUsageDashboard == nil) + #expect(model.usageNotes == ["Detailed usage unavailable."]) + } + + @Test + func `model shows balance without stale deepseek usage while refreshing`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, usageSummary: Self.sampleDeepSeekSummary(now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: true, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes.isEmpty) + let balance = try #require(model.metrics.first) + #expect(balance.title == "Balance") + #expect(balance.statusText == "$9.32 (Paid: $9.32 / Granted: $0.00)") + } + + @Test + func `model explains that detailed usage needs a platform session`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, detailedUsageState: .webSessionRequired) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + 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.inlineUsageDashboard == nil) + #expect(model.usageNotes == ["Sign in to DeepSeek Platform in Chrome for detailed usage."]) + } + + @Test + func `browser only sign in remains visible when optional usage is hidden`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = DeepSeekUsageSnapshot( + hasBalance: false, + isAvailable: false, + currency: "USD", + totalBalance: 0, + grantedBalance: 0, + toppedUpBalance: 0, + detailedUsageState: .webSessionRequired, + updatedAt: now) + .toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + 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: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.usageNotes == ["Sign in to DeepSeek Platform in Chrome for detailed usage."]) + } + + @Test + func `model asks for a profile when multiple deepseek sessions are valid`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, detailedUsageState: .profileSelectionRequired) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + 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.inlineUsageDashboard == nil) + #expect(model.usageNotes == ["Select a DeepSeek Chrome profile in Settings."]) + } + @Test func `model shows optional deepseek usage when extras enabled`() throws { let now = Date() @@ -140,7 +307,7 @@ struct MenuCardDeepSeekTests { hidePersonalInfo: false, now: now)) - #expect(model.inlineUsageDashboard?.accessibilityLabel == "DeepSeek 30 day token usage trend") + #expect(model.inlineUsageDashboard?.accessibilityLabel == "DeepSeek this month token usage trend") #expect(model.usageNotes.contains { $0.contains("Today:") }) } } diff --git a/Tests/CodexBarTests/MenuCardNeuralWattTests.swift b/Tests/CodexBarTests/MenuCardNeuralWattTests.swift new file mode 100644 index 0000000000..99b0ce901f --- /dev/null +++ b/Tests/CodexBarTests/MenuCardNeuralWattTests.swift @@ -0,0 +1,56 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardNeuralWattTests { + @Test + func `model shows credit balance as status text without reset wording`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = NeuralWattUsageSnapshot( + creditsRemainingUSD: 51.00, + totalCreditsUSD: 77.04, + creditsUsedUSD: 26.04, + accountingMethod: "energy", + currentMonthCostUSD: 12.34, + currentMonthEnergyKWh: 0.25, + subscription: nil, + keyAllowance: nil, + rateLimitTier: "standard", + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.neuralwatt]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .neuralwatt, + metadata: metadata, + snapshot: snapshot, + 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 primary = try #require(model.metrics.first) + #expect(primary.title == "Credits") + let statusText = primary.statusText?.replacingOccurrences(of: "\u{00A0}", with: "") + #expect(statusText == "$51.00 remaining of $77.04") + #expect(primary.detailText == nil) + #expect(primary.resetText == nil) + #expect(model.creditsText == nil) + #expect(model.metrics.contains { $0.title == "This month" } == false) + #expect(model.metrics.allSatisfy { metric in + metric.resetText?.localizedCaseInsensitiveContains("reset") != true + }) + } +} diff --git a/Tests/CodexBarTests/MenuCardTestDateFormatting.swift b/Tests/CodexBarTests/MenuCardTestDateFormatting.swift index 4f94f865d1..e0eb882ada 100644 --- a/Tests/CodexBarTests/MenuCardTestDateFormatting.swift +++ b/Tests/CodexBarTests/MenuCardTestDateFormatting.swift @@ -2,7 +2,7 @@ import Foundation func minimaxRenewDate(_ timestamp: TimeInterval) -> String { let formatter = DateFormatter() - formatter.locale = .current + formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") return formatter.string(from: Date(timeIntervalSince1970: timestamp)) diff --git a/Tests/CodexBarTests/NeuralWattUsageFetcherTests.swift b/Tests/CodexBarTests/NeuralWattUsageFetcherTests.swift new file mode 100644 index 0000000000..e4ac56859a --- /dev/null +++ b/Tests/CodexBarTests/NeuralWattUsageFetcherTests.swift @@ -0,0 +1,409 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct NeuralWattUsageFetcherTests { + @Test + func `parses quota response into usage snapshot`() throws { + let body = #""" + { + "snapshot_at": "2026-04-16T18:30:00Z", + "balance": { + "credits_remaining_usd": 32.6774, + "total_credits_usd": 52.34, + "credits_used_usd": 19.6626, + "accounting_method": "energy" + }, + "usage": { + "lifetime": { + "cost_usd": 243.9145, + "requests": 37801, + "tokens": 1235477176, + "energy_kwh": 15.6009 + }, + "current_month": { + "cost_usd": 160.1463, + "requests": 23902, + "tokens": 1116658995, + "energy_kwh": 9.7278 + } + }, + "limits": { + "overage_limit_usd": null, + "rate_limit_tier": "standard" + }, + "subscription": { + "plan": "standard", + "status": "active", + "billing_interval": "month", + "current_period_start": "2026-04-11T05:05:25Z", + "current_period_end": "2026-05-11T05:05:25Z", + "auto_renew": true, + "kwh_included": 20.0, + "kwh_used": 13.9023, + "kwh_remaining": 6.0977, + "in_overage": false + }, + "key": { + "name": "my-production-key", + "allowance": { + "limit_usd": 50.0, + "period": "monthly", + "spent_usd": 12.5, + "remaining_usd": 37.5, + "blocked": false + } + } + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.totalCreditsUSD == 52.34) + #expect(snapshot.creditsUsedUSD == 19.6626) + let expectedCreditPercent = 19.6626 / 52.34 * 100 + #expect(abs(snapshot.creditUsedPercent - expectedCreditPercent) < 1e-6) + // Credits exhaust (DeepSeek-style); no kWh window surfaced. + #expect(snapshot.keyAllowanceUsedPercent == 25.0) + #expect(snapshot.currentMonthCostUSD == 160.1463) + let primaryPercent = usage.primary?.usedPercent + #expect(primaryPercent.map { abs($0 - expectedCreditPercent) < 1e-6 } == true) + // No reset cycle — credits deplete, they don't renew. + #expect(usage.primary?.resetsAt == nil) + #expect(usage.subscriptionRenewsAt == nil) + // loginMethod now reflects accounting method when present. + #expect(usage.loginMethod(for: .neuralwatt) == "Energy") + // Only key allowance is surfaced as an extra quota window. Current-month spend is telemetry, + // not a resettable rate window. + #expect(usage.extraRateWindows?.count == 1) + #expect(usage.extraRateWindows?.contains { $0.id == "subscription-kwh" } == false) + #expect(usage.extraRateWindows?.contains { $0.id == "current-month-spend" } == false) + let allowanceWindow = usage.extraRateWindows?.first { $0.id == "key-allowance" } + #expect(allowanceWindow?.title == "Key Monthly") + } + + @Test + func `parses response with null subscription using accounting method`() throws { + let body = #""" + { + "snapshot_at": "2026-04-16T18:30:00Z", + "balance": { + "credits_remaining_usd": 4.5, + "total_credits_usd": 5.0, + "credits_used_usd": 0.5, + "accounting_method": "energy" + }, + "usage": { + "lifetime": {"cost_usd": 0.5, "requests": 10, "tokens": 1000, "energy_kwh": 0.01}, + "current_month": {"cost_usd": 0.5, "requests": 10, "tokens": 1000, "energy_kwh": 0.01} + }, + "limits": {"overage_limit_usd": null, "rate_limit_tier": "free"}, + "subscription": null, + "key": {"name": "trial", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 100)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.creditUsedPercent == 10) + #expect(snapshot.subscription == nil) + #expect(snapshot.keyAllowanceUsedPercent == nil) + #expect(usage.primary?.usedPercent == 10) + #expect(usage.subscriptionRenewsAt == nil) + #expect(usage.loginMethod(for: .neuralwatt) == "Energy") + // No resettable extra quota windows when there is no per-key allowance. + #expect(usage.extraRateWindows == nil) + } + + @Test + func `parses response with missing credits used derived from remaining`() throws { + let body = #""" + { + "balance": { + "credits_remaining_usd": 30.0, + "total_credits_usd": 100.0, + "accounting_method": "energy" + }, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, + "subscription": null, + "key": {"name": "x", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 2)) + // credits_used_usd missing but derived as 100 - 30 = 70. + #expect(snapshot.effectiveUsedCredits == 70) + #expect(snapshot.creditUsedPercent == 70) + } + + @Test + func `marks known zero credit balance as exhausted`() throws { + let body = #""" + { + "balance": { + "credits_remaining_usd": 0.0, + "total_credits_usd": 0.0, + "accounting_method": "energy" + }, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, + "subscription": null, + "key": {"name": "x", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 2)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.effectiveRemainingCredits == 0) + #expect(snapshot.effectiveTotalCredits == nil) + #expect(snapshot.creditUsedPercent == 100) + #expect(usage.primary?.usedPercent == 100) + let resetDescription = usage.primary?.resetDescription?.replacingOccurrences(of: "\u{00A0}", with: "") + #expect(resetDescription == "$0.00 remaining") + } + + @Test + func `parses fractional subscription dates`() throws { + let body = #""" + { + "balance": { + "credits_remaining_usd": 8.0, + "total_credits_usd": 10.0, + "credits_used_usd": 2.0, + "accounting_method": "energy" + }, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, + "subscription": { + "plan": "standard", + "status": "active", + "current_period_start": "2026-04-11T05:05:25.123Z", + "current_period_end": "2026-05-11T05:05:25.456Z" + }, + "key": {"name": "x", "allowance": null} + } + """# + + let snapshot = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 3)) + + #expect(snapshot.subscription?.currentPeriodEnd != nil) + #expect(snapshot.creditUsedPercent == 20) + } + + @Test + func `rejects malformed successful response without balance`() throws { + let body = #"{"error":"temporarily unavailable"}"# + + do { + _ = try NeuralWattUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 4)) + Issue.record("Expected NeuralWattUsageError.parseFailed") + } catch let error as NeuralWattUsageError { + guard case let .parseFailed(message) = error else { + Issue.record("Expected parseFailed, got \(error)") + return + } + #expect(message.contains("balance")) + } + } + + @Test + func `fetch usage rejects blank API key before request`() async throws { + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: " ", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"]) + Issue.record("Expected NeuralWattUsageError.missingCredentials") + } catch let error as NeuralWattUsageError { + guard case .missingCredentials = error else { + Issue.record("Expected missingCredentials, got \(error)") + return + } + } + } + + @Test + func `fetch rejects endpoint override before sending API key`() async throws { + let transport = ProviderHTTPTransportHandler { _ in + Issue.record("Endpoint override validation must happen before the request") + throw URLError(.badURL) + } + + await #expect(throws: NeuralWattSettingsError.invalidEndpointOverride( + NeuralWattSettingsReader.apiURLEnvironmentKey)) + { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://user@example.com"], + transport: transport) + } + } + + @Test + func `fetch preserves transport cancellation`() async throws { + let transport = ProviderHTTPTransportHandler { _ in + throw CancellationError() + } + + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [:], + transport: transport) + Issue.record("Expected CancellationError") + } catch is CancellationError { + // Expected: refresh cancellation must not become a provider error. + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + } + + @Test + func `unauthorized fetch throws missing credentials`() async throws { + let registered = URLProtocol.registerClass(NeuralWattStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(NeuralWattStubURLProtocol.self) + } + NeuralWattStubURLProtocol.handler = nil + } + + NeuralWattStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse(url: url, body: #"{"detail":"bad key"}"#, statusCode: 401) + } + + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"]) + Issue.record("Expected NeuralWattUsageError.missingCredentials") + } catch let error as NeuralWattUsageError { + guard case .missingCredentials = error else { + Issue.record("Expected missingCredentials, got \(error)") + return + } + } + } + + @Test + func `fetch usage sends bearer authorization header`() async throws { + let registered = URLProtocol.registerClass(NeuralWattStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(NeuralWattStubURLProtocol.self) + } + NeuralWattStubURLProtocol.handler = nil + } + + NeuralWattStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.path == "/v1/quota") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-test") + #expect(request.timeoutInterval == 15) + + let body = #""" + { + "balance": {"credits_remaining_usd": 5.0, "total_credits_usd": 10.0, + "credits_used_usd": 5.0, "accounting_method": "energy"}, + "usage": {"lifetime": {}, "current_month": {}}, + "limits": {}, "subscription": null, "key": {"name": "k", "allowance": null} + } + """# + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + + let usage = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: " sk-test ", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"]) + + #expect(usage.creditUsedPercent == 50) + } + + @Test + func `non success fetch throws generic HTTP error`() async throws { + let registered = URLProtocol.registerClass(NeuralWattStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(NeuralWattStubURLProtocol.self) + } + NeuralWattStubURLProtocol.handler = nil + } + + NeuralWattStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse(url: url, body: #"{"detail":"bad key"}"#, statusCode: 500) + } + + do { + _ = try await NeuralWattUsageFetcher.fetchUsage( + apiKey: "sk-test", + environment: [NeuralWattSettingsReader.apiURLEnvironmentKey: "https://api.neuralwatt.test"]) + Issue.record("Expected NeuralWattUsageError.apiError") + } catch let error as NeuralWattUsageError { + guard case let .apiError(message) = error else { + Issue.record("Expected apiError, got \(error)") + return + } + #expect(message == "HTTP 500") + } + } + + 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)) + } +} + +final class NeuralWattStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "api.neuralwatt.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/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 77b4302d01..fae6a42f6c 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -312,6 +312,19 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.elevenLabsToken(environment: env) == "xi-token") } + @Test + func `applies API key override for NeuralWatt`() { + let config = ProviderConfig(id: .neuralwatt, apiKey: "sk-neuralwatt-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .neuralwatt, + config: config) + + #expect(env[NeuralWattSettingsReader.apiKeyEnvironmentKey] == "sk-neuralwatt-config") + #expect(ProviderTokenResolver.neuralWattToken(environment: env) == "sk-neuralwatt-config") + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .neuralwatt)) + } + @Test func `applies API key override for groq`() { let config = ProviderConfig(id: .groq, apiKey: "gsk-token") @@ -587,6 +600,41 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.deepseekToken(environment: env) == nil) } + @Test + func `projects the legacy DeepSeek Platform token and stable profile identifier`() { + let config = ProviderConfig( + id: .deepseek, + apiKey: "legacy-api-key", + cookieHeader: "browser-platform-token", + deepseekProfileID: "/profiles/Profile 2", + deepseekProfileScope: "account-id") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .deepseek, + config: config) + + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == nil) + #expect(env[DeepSeekSettingsReader.platformTokenEnvironmentKey] == "browser-platform-token") + #expect(env[DeepSeekSettingsReader.profileIDEnvironmentKey] == "chrome:Profile 2") + #expect(env[DeepSeekSettingsReader.profileScopeEnvironmentKey] == "account-id") + } + + @Test + func `normalization preserves a legacy DeepSeek browser token and canonicalizes the profile path`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .deepseek, + cookieHeader: "browser-platform-token", + deepseekProfileID: "/profiles/Profile 2", + deepseekProfileScope: " account-id "), + ]).normalized() + let deepseek = try #require(config.providerConfig(for: .deepseek)) + + #expect(deepseek.cookieHeader == "browser-platform-token") + #expect(deepseek.deepseekProfileID == "chrome:Profile 2") + #expect(deepseek.deepseekProfileScope == "account-id") + } + @Test func `applies API key override for kilo`() { let config = ProviderConfig(id: .kilo, apiKey: "kilo-token") diff --git a/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift b/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift index 52bb2fd5cd..604257aea4 100644 --- a/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift +++ b/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift @@ -15,6 +15,18 @@ struct ProviderEnvironmentResolverTests { #expect(environment[ZaiSettingsReader.apiTokenKey] == "account-token") } + @Test + func `NeuralWatt selected API account overrides saved and ambient credentials`() { + let account = Self.account(token: "sk-neuralwatt-account") + let environment = ProviderEnvironmentResolver.resolve( + base: [NeuralWattSettingsReader.apiKeyEnvironmentKey: "ambient-token"], + provider: .neuralwatt, + config: ProviderConfig(id: .neuralwatt, apiKey: "saved-token"), + selectedAccount: account) + + #expect(environment[NeuralWattSettingsReader.apiKeyEnvironmentKey] == "sk-neuralwatt-account") + } + @Test func `OpenAI account removes project scoping from saved config`() { let account = Self.account(token: "sk-admin-account") diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 069f461800..4e0c2c1404 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -14,6 +14,7 @@ struct ProviderIconResourcesTests { let slugs = [ "codex", "claude", + "clinepass", "zai", "minimax", "cursor", @@ -29,6 +30,7 @@ struct ProviderIconResourcesTests { "commandcode", "t3chat", "kimi", + "longcat", "bedrock", "elevenlabs", "groq", @@ -39,6 +41,7 @@ struct ProviderIconResourcesTests { "clawrouter", "sub2api", "wayfinder", + "anyrouter", "zenmux", ] for slug in slugs { diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 120fd77f66..66047b095e 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -333,7 +333,492 @@ struct ProviderSettingsDescriptorTests { #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) } - private func makeSettingsFixture(suite: String) throws -> ProviderSettingsFixture { + @Test + func `deepseek profile picker contains only validated profiles and persists selection`() throws { + let apiKey = "test-deepseek-api-key" + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-profiles", + environmentBase: [DeepSeekSettingsReader.apiKeyEnvironmentKey: apiKey]) + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + let context = fixture.settingsContext(provider: .deepseek) + + let picker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + #expect(picker.options.map(\.id) == ["", "chrome:Default", "chrome:Profile 2"]) + #expect(picker.binding.wrappedValue.isEmpty) + let configRevision = fixture.settings.configRevision + let backgroundWorkRevision = fixture.settings.backgroundWorkSettingsRevision + let providerConfigRevision = fixture.settings.providerConfigRevision(for: .deepseek) + let snapshot = fixture.store.snapshots[.deepseek] + + picker.binding.wrappedValue = "chrome:Profile 2" + #expect(fixture.settings.deepseekProfileID(apiKey: apiKey) == "chrome:Profile 2") + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileID == "chrome:Profile 2") + let expectedScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: nil, + apiKey: apiKey)) + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileScope == expectedScope) + #expect(fixture.settings.configRevision == configRevision) + #expect(fixture.settings.backgroundWorkSettingsRevision == backgroundWorkRevision) + #expect(fixture.settings.providerConfigRevision(for: .deepseek) == providerConfigRevision + 1) + #expect(fixture.store.snapshots[.deepseek]?.updatedAt == snapshot?.updatedAt) + #expect(fixture.store.snapshots[.deepseek]?.deepseekPlatformProfiles.map(\.id) == [ + "chrome:Default", + "chrome:Profile 2", + ]) + } + + @Test + func `deepseek browser only profile selection persists without an API key`() async throws { + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-browser-only-profile") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06 (Paid: $8.06 / Granted: $0.00)"), + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + + let picker = try #require(DeepSeekProviderImplementation() + .settingsPickers(context: fixture.settingsContext(provider: .deepseek)).first) + picker.binding.wrappedValue = "chrome:Profile 2" + + #expect(fixture.settings.deepseekProfileID(apiKey: nil) == "chrome:Profile 2") + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileScope != nil) + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription == "Refreshing") + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .failure(DeepSeekUsageError.networkError("offline")), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription == "Unavailable") + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription?.contains("$8.06") == false) + } + + @Test + func `deepseek profile picker stays visible while switching profiles`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-profile-switch") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + fixture.store.lastKnownResetSnapshots[.deepseek] = snapshot + fixture.store.snapshots.removeValue(forKey: .deepseek) + fixture.store.refreshingProviders.insert(.deepseek) + let context = fixture.settingsContext(provider: .deepseek) + + let picker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + #expect(picker.options.map(\.id) == ["", "chrome:Default", "chrome:Profile 2"]) + #expect(picker.binding.wrappedValue.isEmpty) + #expect(picker.dynamicSubtitle?() == "Refreshing") + #expect(!(picker.isEnabled?() ?? true)) + } + + @Test + func `deepseek browser profile cancellation does not leave refreshing behind`() async throws { + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-browser-cancelled-transition") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition(preservingBalance: false) + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome(result: .failure(CancellationError()), attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription == "Unavailable") + } + + @Test + func `deepseek settings keeps balance while live snapshot is switching`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-balance-switch") + fixture.store.lastKnownResetSnapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$9.32 (Paid: $9.32 / Granted: $0.00)"), + secondary: nil, + updatedAt: Date()) + fixture.store.snapshots.removeValue(forKey: .deepseek) + fixture.store.refreshingProviders.insert(.deepseek) + + let model = ProvidersPane(settings: fixture.settings, store: fixture.store) + ._test_menuCardModel(for: .deepseek) + + let balance = try #require(model.metrics.first) + #expect(balance.title == "Balance") + #expect(balance.statusText == "$9.32 (Paid: $9.32 / Granted: $0.00)") + #expect(model.usageNotes.isEmpty) + #expect(model.inlineUsageDashboard == nil) + #expect(model.placeholder == nil) + } + + @Test + func `deepseek profile transition survives selected api token cache invalidation`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-token-transition") + fixture.settings.addTokenAccount(provider: .deepseek, label: "cv", token: "test-token") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06 (Paid: $8.06 / Granted: $0.00)"), + secondary: nil, + deepseekUsage: DeepSeekUsageSummary( + todayTokens: 100, + currentMonthTokens: 100, + todayCost: 0.1, + currentMonthCost: 0.1, + requestCount: 1, + currentMonthRequestCount: 1, + topModel: "deepseek-chat", + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date()), + deepseekDetailedUsageState: .available, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + fixture.store.snapshots[.deepseek] = snapshot + fixture.store.lastKnownResetSnapshots[.deepseek] = snapshot + let context = fixture.settingsContext(provider: .deepseek) + let picker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + + picker.binding.wrappedValue = "chrome:Profile 2" + fixture.store.refreshingProviders.insert(.deepseek) + fixture.store.reconcileSelectedTokenAccountSnapshotBeforeRefresh( + provider: .deepseek, + accounts: fixture.settings.tokenAccounts(for: .deepseek)) + + #expect(fixture.store.snapshots[.deepseek] == nil) + #expect(fixture.store.lastKnownResetSnapshots[.deepseek] == nil) + let model = ProvidersPane(settings: fixture.settings, store: fixture.store) + ._test_menuCardModel(for: .deepseek) + #expect(model.metrics.first?.statusText == "$8.06 (Paid: $8.06 / Granted: $0.00)") + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes.isEmpty) + #expect(!ProvidersPane(settings: fixture.settings, store: fixture.store) + ._test_providerSubtitle(.deepseek).contains("usage not fetched yet")) + let transitionPicker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + #expect(transitionPicker.options.map(\.id) == ["chrome:Default", "chrome:Profile 2"]) + #expect(!(transitionPicker.isEnabled?() ?? true)) + + fixture.store.refreshingProviders.remove(.deepseek) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.primary != nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.deepseekUsage == nil) + + fixture.store.clearDeepSeekProfileTransition() + #expect(fixture.store.presentationSnapshot(for: .deepseek) == nil) + } + + @Test + func `deepseek selected account success clears its profile transition`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-transition-success") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + let refreshed = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$7.50"), + secondary: nil, + updatedAt: Date()) + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: refreshed, + credits: nil, + dashboard: nil, + sourceLabel: "api", + strategyID: "deepseek.api", + strategyKind: .apiToken)), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot == nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.primary?.resetDescription == "$7.50") + } + + @Test + func `deepseek timeout keeps the validated profile catalog with the refreshed balance`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-timeout-catalog") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + deepseekDetailedUsageState: .available, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + let refreshedBalance = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$7.50"), + secondary: nil, + deepseekDetailedUsageState: .unavailable, + deepseekPlatformProfiles: [], + updatedAt: Date()) + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: refreshedBalance, + credits: nil, + dashboard: nil, + sourceLabel: "api", + strategyID: "deepseek.api", + strategyKind: .apiToken)), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot == nil) + #expect(fixture.store.snapshots[.deepseek]?.primary?.resetDescription == "$7.50") + #expect(fixture.store.snapshots[.deepseek]?.deepseekPlatformProfiles.map(\.id) == [ + "chrome:Default", + "chrome:Profile 2", + ]) + let picker = try #require(DeepSeekProviderImplementation() + .settingsPickers(context: fixture.settingsContext(provider: .deepseek)).first) + #expect(picker.options.map(\.id) == ["", "chrome:Default", "chrome:Profile 2"]) + } + + @Test + func `deepseek selected account failure preserves its balance only transition`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-transition-failure") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + deepseekUsage: DeepSeekUsageSummary( + todayTokens: 100, + currentMonthTokens: 100, + todayCost: nil, + currentMonthCost: nil, + requestCount: 1, + currentMonthRequestCount: 1, + topModel: nil, + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date()), + deepseekDetailedUsageState: .available, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .failure(DeepSeekUsageError.apiError("offline")), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot != nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.primary?.resetDescription == "$8.06") + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.deepseekUsage == nil) + } + + @Test + func `disabling deepseek clears a failed profile transition`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-disable-transition") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .failure(DeepSeekUsageError.apiError("offline")), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek) != nil) + + fixture.store.clearDisabledProviderState(enabledProviders: []) + + #expect(fixture.store.deepseekProfileTransitionSnapshot == nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek) == nil) + } + + @Test + func `deepseek requires explicit replacement when the stored profile expires`() throws { + let apiKey = "test-deepseek-api-key" + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-expired-selection", + environmentBase: [DeepSeekSettingsReader.apiKeyEnvironmentKey: apiKey]) + fixture.settings.setDeepSeekProfileID("chrome:Default", apiKey: apiKey) + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekDetailedUsageState: .profileSelectionRequired, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + + let picker = try #require(DeepSeekProviderImplementation() + .settingsPickers(context: fixture.settingsContext(provider: .deepseek)).first) + #expect(picker.options.map(\.id) == ["", "chrome:Profile 2"]) + #expect(picker.binding.wrappedValue.isEmpty) + } + + @Test + func `deepseek profile transition does not cross api token account selection`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-account-transition") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Personal", token: "token-1") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Work", token: "token-2") + let workAccount = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06 Work"), + secondary: nil, + updatedAt: Date()) + fixture.settings.setDeepSeekProfileID("chrome:Profile 2", apiKey: workAccount.token) + fixture.store.beginDeepSeekProfileTransition() + + fixture.settings.setActiveTokenAccountIndex(0, for: .deepseek) + let personalAccount = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + #expect(personalAccount.id != workAccount.id) + fixture.store.reconcileSelectedTokenAccountSnapshotBeforeRefresh( + provider: .deepseek, + accounts: fixture.settings.tokenAccounts(for: .deepseek)) + + #expect(fixture.settings.deepseekProfileID(apiKey: personalAccount.token).isEmpty) + #expect(fixture.store.deepseekProfileTransitionSnapshot != nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek) == nil) + } + + @Test + func `replacing a deepseek key in the same account clears its profile selection`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-replaced-key") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Account", token: "old-key") + let account = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + fixture.settings.setDeepSeekProfileID("chrome:Default", apiKey: account.token) + #expect(fixture.settings.deepseekProfileID(apiKey: account.token) == "chrome:Default") + + fixture.settings.updateTokenAccount( + provider: .deepseek, + accountID: account.id, + token: "new-key") + + #expect(fixture.settings.deepseekProfileID(apiKey: "new-key").isEmpty) + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileID == "chrome:Default") + } + + @Test + func `deepseek detailed usage runs only for the active api token account`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-account-usage") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Personal", token: "token-1") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Work", token: "token-2") + let accounts = fixture.settings.tokenAccounts(for: .deepseek) + let active = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + let inactive = try #require(accounts.first(where: { $0.id != active.id })) + + #expect(ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: .deepseek, + settings: fixture.settings, + override: TokenAccountOverride(provider: .deepseek, account: active))) + #expect(!ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: .deepseek, + settings: fixture.settings, + override: TokenAccountOverride(provider: .deepseek, account: inactive))) + } + + @Test + func `provider settings labels an empty transition as refreshing`() { + #expect(ProviderMetricsInlineView.placeholderText( + isEnabled: true, + isRefreshing: true, + modelPlaceholder: nil) == "Refreshing") + #expect(ProviderMetricsInlineView.placeholderText( + isEnabled: true, + isRefreshing: false, + modelPlaceholder: nil) == "No usage yet") + } + + @Test + func `deepseek hides profile picker when only one validated profile remains`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-single-profile") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + ], + updatedAt: Date()) + let context = fixture.settingsContext(provider: .deepseek) + + #expect(DeepSeekProviderImplementation().settingsPickers(context: context).isEmpty) + } + + private func makeSettingsFixture( + suite: String, + environmentBase: [String: String] = [:]) throws -> ProviderSettingsFixture + { let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) let settings = SettingsStore( @@ -344,7 +829,8 @@ struct ProviderSettingsDescriptorTests { let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + settings: settings, + environmentBase: environmentBase) return ProviderSettingsFixture(settings: settings, store: store) } diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index cda750da46..c7dc928381 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -261,6 +261,20 @@ struct ProvidersPaneCoverageTests { } } + @Test + func `Neuralwatt menu bar metric picker omits nonexistent spend lane`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-neuralwatt-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .neuralwatt) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + } + } + @Test func `moonshot menu bar metric picker shows balance only copy`() { Self.withEnglishLocalization { diff --git a/Tests/CodexBarTests/UsageStoreNeuralWattAccountRefreshTests.swift b/Tests/CodexBarTests/UsageStoreNeuralWattAccountRefreshTests.swift new file mode 100644 index 0000000000..84ee6ab66a --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreNeuralWattAccountRefreshTests.swift @@ -0,0 +1,121 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +private actor NeuralWattAccountRefreshRecorder { + private(set) var dates: [Date] = [] + private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func record() { + self.dates.append(Date()) + let ready = self.waiters.filter { self.dates.count >= $0.count } + self.waiters.removeAll { self.dates.count >= $0.count } + ready.forEach { $0.continuation.resume() } + } + + func waitForCount(_ count: Int) async { + if self.dates.count >= count { return } + await withCheckedContinuation { continuation in + self.waiters.append((count, continuation)) + } + } +} + +private struct NeuralWattAccountRefreshStrategy: ProviderFetchStrategy { + let recorder: NeuralWattAccountRefreshRecorder + + let id = "neuralwatt-account-refresh-test" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + await self.recorder.record() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + return self.makeResult(usage: snapshot, sourceLabel: self.id) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +@MainActor +@Suite(.serialized) +struct UsageStoreNeuralWattAccountRefreshTests { + @Test + func `multi-account refresh respects Neuralwatt quota rate limit`() async throws { + let recorder = NeuralWattAccountRefreshRecorder() + let store = try Self.makeStore(recorder: recorder) + let accounts = Self.addAccounts(to: store, count: 2) + + await store.refreshTokenAccounts(provider: .neuralwatt, accounts: accounts) + + let dates = await recorder.dates + #expect(dates.count == 2) + #expect(dates[1].timeIntervalSince(dates[0]) >= 0.95) + } + + private static func makeStore(recorder: NeuralWattAccountRefreshRecorder) throws -> UsageStore { + let settings = testSettingsStore( + suiteName: "UsageStoreNeuralWattAccountRefreshTests-\(UUID().uuidString)", + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let baseSpec = try #require(store.providerSpecs[.neuralwatt]) + let baseDescriptor = baseSpec.descriptor + let strategy = NeuralWattAccountRefreshStrategy(recorder: recorder) + store.providerSpecs[.neuralwatt] = ProviderSpec( + style: baseSpec.style, + isEnabled: { true }, + descriptor: ProviderDescriptor( + id: .neuralwatt, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + private static func addAccounts(to store: UsageStore, count: Int) -> [ProviderTokenAccount] { + for index in 0.. UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } +} diff --git a/TestsLinux/ClinePassProviderLinuxTests.swift b/TestsLinux/ClinePassProviderLinuxTests.swift new file mode 100644 index 0000000000..53b982ba83 --- /dev/null +++ b/TestsLinux/ClinePassProviderLinuxTests.swift @@ -0,0 +1,223 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct ClinePassProviderLinuxTests { + @Test + func `parses all rate windows`() throws { + let body = #""" + { + "data": { + "limits": [ + { + "type": "five_hour", + "percentUsed": 12.5, + "resetsAt": "2026-07-16T10:20:30Z" + }, + { + "type": "weekly", + "percentUsed": 34, + "resetsAt": "2026-07-20T00:00:00Z" + }, + { + "type": "monthly", + "percentUsed": 56.75, + "resetsAt": "2026-08-01T00:00:00Z" + } + ] + }, + "success": true + } + """# + let updatedAt = Date(timeIntervalSince1970: 123) + + let snapshot = try ClinePassUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: updatedAt) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 12.5) + #expect(usage.primary?.windowMinutes == 5 * 60) + #expect(usage.primary?.resetsAt == Self.date("2026-07-16T10:20:30Z")) + #expect(usage.secondary?.usedPercent == 34) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + #expect(usage.secondary?.resetsAt == Self.date("2026-07-20T00:00:00Z")) + #expect(usage.tertiary?.usedPercent == 56.75) + #expect(usage.tertiary?.windowMinutes == 30 * 24 * 60) + #expect(usage.tertiary?.resetsAt == Self.date("2026-08-01T00:00:00Z")) + #expect(usage.updatedAt == updatedAt) + #expect(usage.identity?.providerID == .clinepass) + #expect(usage.identity?.loginMethod == "API key") + } + + @Test + func `leaves missing rate windows nil`() throws { + let body = #""" + { + "data": { + "limits": [ + { + "type": "weekly", + "percentUsed": 40 + } + ] + }, + "success": true + } + """# + + let snapshot = try ClinePassUsageFetcher._parseSnapshotForTesting(Data(body.utf8)) + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary?.usedPercent == 40) + #expect(snapshot.secondary?.resetsAt == nil) + #expect(snapshot.tertiary == nil) + } + + @Test + func `rejects malformed payload`() { + let body = #""" + { + "data": { + "limits": [ + { + "type": "weekly", + "percentUsed": "forty" + } + ] + }, + "success": true + } + """# + + #expect { + _ = try ClinePassUsageFetcher._parseSnapshotForTesting(Data(body.utf8)) + } throws: { error in + guard case ClinePassUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `reads both environment keys and config override`() { + #expect(ClinePassSettingsReader.apiKey(environment: [ + ClinePassSettingsReader.apiKeyEnvironmentKey: " primary ", + ClinePassSettingsReader.alternateAPIKeyEnvironmentKey: "alternate", + ]) == "primary") + #expect(ClinePassSettingsReader.apiKey(environment: [ + ClinePassSettingsReader.alternateAPIKeyEnvironmentKey: " alternate ", + ]) == "alternate") + + let config = ProviderConfig(id: .clinepass, apiKey: "config-key") + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .clinepass, + config: config) + + #expect(environment[ClinePassSettingsReader.apiKeyEnvironmentKey] == "config-key") + #expect(ClinePassSettingsReader.apiKey(environment: environment) == "config-key") + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .clinepass)) + } + + @Test + func `registers descriptor and CLI selection`() throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .clinepass) + let selection = try #require(ProviderSelection(argument: "clinepass")) + + #expect(descriptor.metadata.displayName == "ClinePass") + #expect(descriptor.cli.name == "clinepass") + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) + #expect(ProviderDescriptorRegistry.cliNameMap["clinepass"] == .clinepass) + #expect(selection.asList == [.clinepass]) + #expect(ProviderHelp.list.split(separator: "|").contains("clinepass")) + } + + @Test + func `fetches usage with bearer authentication`() async throws { + let body = #""" + { + "data": { + "limits": [ + { + "type": "five_hour", + "percentUsed": 25 + } + ] + }, + "success": true + } + """# + let transport = ProviderHTTPTransportHandler { request in + #expect(request.url?.absoluteString == "https://api.cline.bot/api/v1/users/me/plan/usage-limits") + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-key") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.timeoutInterval == 15) + return try Self.response(for: request, body: body, statusCode: 200) + } + + let snapshot = try await ClinePassUsageFetcher._fetchUsage( + apiKey: " test-key ", + transport: transport, + now: Date(timeIntervalSince1970: 456)) + + #expect(snapshot.primary?.usedPercent == 25) + #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 456)) + } + + @Test + func `requires authentication and rejects unauthorized response`() async { + let unusedTransport = ProviderHTTPTransportHandler { _ in + Issue.record("Transport should not be called without an API key") + throw URLError(.userAuthenticationRequired) + } + + do { + _ = try await ClinePassUsageFetcher._fetchUsage(apiKey: " ", transport: unusedTransport) + Issue.record("Expected missing credentials") + } catch let error as ClinePassUsageError { + #expect(error == .missingCredentials) + } catch { + Issue.record("Unexpected error: \(error)") + } + + let unauthorizedTransport = ProviderHTTPTransportHandler { request in + try Self.response(for: request, body: "{}", statusCode: 401) + } + do { + _ = try await ClinePassUsageFetcher._fetchUsage( + apiKey: "test-key", + transport: unauthorizedTransport) + Issue.record("Expected unauthorized error") + } catch let error as ClinePassUsageError { + #expect(error == .unauthorized) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + private static func date(_ raw: String) -> Date? { + ISO8601DateFormatter().date(from: raw) + } + + private static func response( + for request: URLRequest, + body: String, + statusCode: Int) throws -> (Data, URLResponse) + { + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"]) + else { + throw URLError(.badServerResponse) + } + return (Data(body.utf8), response) + } +} diff --git a/docs/anyrouter.md b/docs/anyrouter.md new file mode 100644 index 0000000000..a25f496740 --- /dev/null +++ b/docs/anyrouter.md @@ -0,0 +1,80 @@ +--- +summary: "AnyRouter provider: API key credits, balance, and lifetime spend." +read_when: + - Debugging AnyRouter credit or balance parsing + - Explaining AnyRouter setup and environment variables +--- + +# AnyRouter Provider + +[AnyRouter](https://anyrouter.dev) is a universal AI model router: one OpenAI-compatible gateway that reaches models +across many upstream providers through a single endpoint, billed from a shared credit balance. + +## Authentication + +AnyRouter uses API key authentication. Create an inference key on the +[AnyRouter dashboard](https://anyrouter.dev/dashboard/keys). Inference keys are prefixed with `sk-ar-v1-`, which +distinguishes them from upstream provider keys. + +### Environment variable + +```bash +export ANYROUTER_API_KEY="sk-ar-v1-..." +``` + +### Settings + +You can also store one or more labeled keys in CodexBar Settings → Providers → AnyRouter. Keys are scoped per +organization, so a separate key per environment (dev, staging, prod) can be added and switched between. + +### CLI config + +```bash +printf '%s' "$ANYROUTER_API_KEY" | codexbar config set-api-key --provider anyrouter --stdin +``` + +## Data source + +The provider reads the credits API (`GET /api/v1/credits`) with your `sk-ar-v1-…` inference key. AnyRouter +authenticates that key on this route directly (`extractApiKey` accepts only `sk-ar-`-prefixed keys, then validates +it), so no separate management key is needed. + +One caveat: a key can carry an endpoint allow-list. If that list omits `/api/v1/credits`, AnyRouter returns +403 `insufficient_scope` and CodexBar tells you to allow the endpoint on the key. Keys with no allow-list — +the default — have full access and just work. + +The response returns the spendable balance, the lifetime spend, and today's spend: + +| Field | Meaning | +|-------|---------| +| `balance` | Total credit available to spend (`monthly_balance` + `topup_balance`) | +| `monthly_balance` | AnyRouter-issued credit: signup bonus, plan grants, referrals. Spent first | +| `topup_balance` | Purchased credit. Spent after monthly credit runs out; never expires | +| `used` | Cumulative lifetime spend | +| `today_cost` | Spend so far today | + +AnyRouter's `/api/v1/key` endpoint needs dashboard session auth (or a Clerk `ak_` management key) rather than an +inference key, so key-scoped rate limits are not available to CodexBar today. + +## Display + +- **Primary meter**: share of granted credit already spent — `used / (used + balance)`. +- **Spend**: lifetime spend against total credit granted. +- **Balance**: shown in the identity section as "Balance: $X.XX". + +Because AnyRouter plan credits regenerate monthly, the meter tracks lifetime spend against everything ever granted; +it is a spend indicator, not a countdown to a hard cap. + +## CLI usage + +```bash +codexbar --provider anyrouter +codexbar -p ar # alias +``` + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `ANYROUTER_API_KEY` | AnyRouter inference key (required) | +| `ANYROUTER_API_URL` | Override the base API URL (optional, defaults to `https://anyrouter.dev/api/v1`). HTTPS only | diff --git a/docs/configuration.md b/docs/configuration.md index c4e58b2a36..9c4e99190a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,6 +110,7 @@ printf '%s' "$LLM_PROXY_API_KEY" | codexbar config set-api-key --provider llmpro printf '%s' "$LITELLM_API_KEY" | codexbar config set-api-key --provider litellm --stdin printf '%s' "$CLAWROUTER_API_KEY" | codexbar config set-api-key --provider clawrouter --stdin printf '%s' "$SUB2API_API_KEY" | codexbar config set-api-key --provider sub2api --stdin +printf '%s' "$ANYROUTER_API_KEY" | codexbar config set-api-key --provider anyrouter --stdin ``` OpenAI API project scoping uses `workspaceID` in config. This maps to `OPENAI_PROJECT_ID` for Admin API usage and is @@ -196,7 +197,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`, `crossmodel`, `clawrouter`, `sub2api`, `wayfinder`, `zenmux`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `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`, `neuralwatt`, `longcat`, `crossmodel`, `clawrouter`, `sub2api`, `wayfinder`, `anyrouter`, `zenmux`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/deepseek.md b/docs/deepseek.md index 1db239ed36..bb84ac6390 100644 --- a/docs/deepseek.md +++ b/docs/deepseek.md @@ -1,30 +1,75 @@ --- -summary: "DeepSeek provider data sources: API key + balance endpoint." +summary: "DeepSeek provider data sources: API key, balance, and optional detailed usage endpoints." read_when: - Adding or tweaking DeepSeek balance parsing + - Adding or tweaking DeepSeek detailed usage parsing - Updating API key handling - Documenting new provider behavior --- # DeepSeek provider -DeepSeek is API-only. Balance is reported by `GET https://api.deepseek.com/user/balance`, -so CodexBar only needs a valid API key to show your remaining credit balance. +CodexBar can use either a DeepSeek API key or a signed-in DeepSeek Platform session for the remaining credit balance. +Detailed cost and token usage comes from the Platform session; an API key cannot authenticate the private dashboard +endpoints. ## Data sources -1. **API key** supplied via `DEEPSEEK_API_KEY` / `DEEPSEEK_KEY`, or selected from DeepSeek token accounts in `~/.codexbar/config.json`. -2. **Balance endpoint** +1. **Optional API key** supplied via `DEEPSEEK_API_KEY` / `DEEPSEEK_KEY`, or selected from DeepSeek token accounts in `~/.codexbar/config.json`. +2. **API-key balance endpoint** - `GET https://api.deepseek.com/user/balance` - Request headers: `Authorization: Bearer `, `Accept: application/json` - Response contains `is_available`, and a `balance_infos` array with per-currency entries (`total_balance`, `granted_balance`, `topped_up_balance`). +3. **Platform-session balance endpoint** + - `GET https://platform.deepseek.com/api/v0/users/get_user_summary` + - Request headers: `Authorization: Bearer `, `Accept: application/json` + - Used as the balance source when no API key is configured. +4. **Optional detailed usage endpoints** + - `GET https://platform.deepseek.com/api/v0/usage/amount?month=&year=` + - `GET https://platform.deepseek.com/api/v0/usage/cost?month=&year=` + - Request headers: `Authorization: Bearer `, `Accept: application/json` + - These are private dashboard endpoints rather than documented public API endpoints and may change without notice. + +## Platform session + +CodexBar resolves the Platform `userToken` in this order: + +1. An explicitly supplied `DEEPSEEK_PLATFORM_TOKEN` / `DEEPSEEK_USER_TOKEN` or a legacy + `providers[].cookieHeader` value preserved from an existing config. +2. A prompt-free read of `userToken` from the `https://platform.deepseek.com` local-storage origin in Chrome. + +The legacy config value remains a compatibility fallback so upgrading cannot silently erase a working session. New +automatic imports are never written back to config. + +CodexBar checks every Chrome profile containing a parseable `userToken` against DeepSeek. Rejected or expired +sessions are omitted. Settings shows a **Chrome profile** picker containing only valid sessions. With no API key, one +valid session is selected automatically; with multiple valid sessions, CodexBar reuses the saved choice or asks once. +When an API key supplies the balance, a new or changed API credential requires an explicit session selection before +website usage is combined with it. CodexBar persists a stable browser/profile identifier, not an absolute home-directory path, and +keeps automatically imported tokens in memory only. The choice is scoped to a non-reversible fingerprint of the +active API credential and saved-account slot, so replacing a key or switching accounts cannot silently reuse an old +browser session. Validation results are cached briefly so normal +refreshes do not probe every profile, and a temporary network failure does not erase a previously validated profile. +If the selected session expires, CodexBar asks before switching to another valid profile. + +If no session is valid, the menu keeps the API-key balance when one exists; otherwise it asks the user to sign in to +DeepSeek Platform in Chrome. Authentication failures returned as top-level or nested DeepSeek codes `40002` and +`40003` are treated as expired sessions. ## Usage details - The menu card shows total balance with the paid vs. granted breakdown: e.g. `$50.00 (Paid: $40.00 / Granted: $10.00)`. - The API separates granted balance from topped-up balance; CodexBar labels these as granted vs. paid credit. +- With optional extra usage enabled, the menu shows today's and the current month's cost and tokens, + request counts, cache/input/output categories, the top model, and a current-month token chart. +- The amount and cost requests run concurrently. After balance arrives, CodexBar waits up to five seconds for + automatic Chrome resolution and detailed usage. The deadline remains bounded even if a local Chrome read does not + respond to cancellation. If the optional work fails or times out, the balance and previously validated profile list + remain available while the menu reports that detailed usage is unavailable. +- With multiple configured API keys, browser-derived detailed usage is shown only with the active API-key account; + other account cards remain balance-only so website usage is never duplicated across accounts. - When multiple currencies are present, USD is shown preferentially. - If total balance is zero, CodexBar shows an add-credits message. If balance is nonzero but `is_available` is false, it shows "Balance unavailable for API calls". - There is no session or weekly window — DeepSeek does not expose per-window quota via API. @@ -34,6 +79,7 @@ so CodexBar only needs a valid API key to show your remaining credit balance. - `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift` (descriptor + fetch strategy) - `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift` (HTTP client + JSON parser) +- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift` (Chrome Platform session import) - `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift` (env var resolution) - `Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift` (provider activation and token-account visibility) - `Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift` (DeepSeek token-account injection) diff --git a/docs/index.html b/docs/index.html index a16f1b9259..7e6808f229 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ CodexBar — every AI coding limit, in your menu bar @@ -37,7 +37,7 @@ @@ -293,7 +293,7 @@

- 60 providers,{mobileBreak}one menu bar + 64 providers,{mobileBreak}one menu bar

Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus. diff --git a/docs/llms.txt b/docs/llms.txt index 9d0f911716..0a6467a4ff 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,9 +1,9 @@ # CodexBar -A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 60 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 64 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Canonical documentation: -- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 60 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 64 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Source: https://github.com/steipete/CodexBar diff --git a/docs/logos/anyrouter.svg b/docs/logos/anyrouter.svg new file mode 100644 index 0000000000..6697ec10e2 --- /dev/null +++ b/docs/logos/anyrouter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/neuralwatt.md b/docs/neuralwatt.md new file mode 100644 index 0000000000..6facf998a8 --- /dev/null +++ b/docs/neuralwatt.md @@ -0,0 +1,79 @@ +--- +summary: "Neuralwatt provider notes: API key setup and quota usage fields." +read_when: + - Adding or modifying the Neuralwatt provider + - Debugging Neuralwatt API keys or quota parsing + - Adjusting Neuralwatt credit labels +--- + +# Neuralwatt Provider + +The Neuralwatt provider reads account quota from the Neuralwatt Cloud API using an API key. +Neuralwatt Cloud is an OpenAI-compatible inference API with energy-based pricing. Prepaid credits +are a deplete-as-you-go USD balance: they do **not** reset on a billing cycle and are refilled by +topping up. Active subscription usage is billed separately against a kWh allowance. The quota +endpoint exposes both surfaces plus current-month spend and optional per-key spending allowances. + +## Features + +- USD credit balance as the primary usage window (`credits_remaining_usd` / `total_credits_usd`). + The bar fills as credits are consumed; there is **no reset date** since credits deplete, not renew. +- Per-key spending allowance (`spent_usd` / `limit_usd`) as an extra rate window when configured. +- Accounting method (`Token` vs `Energy`) shown as the provider identity label. +- Current calendar-month spend is parsed for future/reporting use, but is not shown as a resettable quota window. + +## Setup + +### CLI + +Store the API key without opening Settings: + +```bash +printf '%s' "$NEURALWATT_API_KEY" | codexbar config set-api-key --provider neuralwatt --stdin +``` + +This trims the piped key, writes it to CodexBar's config file (`~/.config/codexbar/config.json` +by default, or the legacy `~/.codexbar/config.json` when already present), and enables Neuralwatt by +default. Use `--no-enable` to save the key without enabling the provider. + +### Settings + +1. Open **Settings → Providers** +2. Enable **Neuralwatt** +3. Open `https://portal.neuralwatt.com/dashboard` +4. Create or copy an API key +5. Paste the key into CodexBar's Neuralwatt provider settings + +### Environment Variables + +CodexBar also accepts these environment variables: + +- `NEURALWATT_API_KEY` + +For tests or self-hosted/proxy setups, override the API base URL with `NEURALWATT_API_URL`. + +## How It Works + +- Endpoint: `GET https://api.neuralwatt.com/v1/quota` +- Auth header: `Authorization: Bearer sk-...` +- Fields used: `balance.credits_remaining_usd`, `balance.total_credits_usd`, + `balance.credits_used_usd`, `balance.accounting_method`, + `usage.current_month.cost_usd`, + `key.allowance.limit_usd`, `key.allowance.spent_usd`, `key.allowance.period` +- `credits_used_usd` is derived as `total_credits_usd − credits_remaining_usd` when the API omits it. +- `subscription` may be `null`. Its separate kWh allowance is not rendered yet; landing that display + requires an explicit product decision because it changes the provider from balance-only to mixed quota semantics. + +## Troubleshooting + +### "Missing Neuralwatt API key" + +Set the key with `codexbar config set-api-key --provider neuralwatt --stdin`, add it in +**Settings → Providers → Neuralwatt**, set `NEURALWATT_API_KEY`, or configure a Neuralwatt token +account in CodexBar. + +### "Neuralwatt API error" + +Confirm the API key is valid and that the current network can reach `api.neuralwatt.com`. The +quota endpoint is rate-limited to 1 request per second per customer; CodexBar refreshes on its +normal cycle so this should not be hit in practice. diff --git a/docs/providers.md b/docs/providers.md index 82730b24b3..777133820d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 60 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 64 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -53,6 +53,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | Ollama | API key verifies Cloud API access (`api`); browser cookies expose Cloud quota windows (`web`). | | Synthetic | API key from config/env → quota API (`api`). | | OpenRouter | API token (config, overrides env) → credits API (`api`). | +| AnyRouter | API key (config, overrides env) → credits API (`api`). | | CrossModel | API key from config/env → credits + usage API (`api`). | | Perplexity | Browser cookies/manual cookie/env session token → credits API (`web`). | | Xiaomi MiMo | Browser cookies → balance/token plan endpoints (`web`). | @@ -66,6 +67,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | Crof | API key from config/env → credit balance + requests quota API (`api`). | | Venice | API key from config/env → DIEM/USD balance API (`api`). | | Command Code | Web billing API via Command Code session cookies (`web`). | +| ClinePass | API key from config/env → 5-hour, weekly, and monthly subscription usage limits (`api`). | | StepFun | Username/password login or manual Oasis token (`web`). | | AWS Bedrock | AWS credentials → Cost Explorer spend/budgets and optional CloudWatch Claude activity (`api`). | | Grok | `grok agent stdio` JSON-RPC `x.ai/billing` (`cli`) → grok.com billing gRPC-web via Chrome session cookies (`web`); local `~/.grok/sessions` signals as fallback. | @@ -76,6 +78,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | LiteLLM | API key + base URL → `/key/info`, then `/user/info` or `/team/info` budget usage (`api`). | | Deepgram | API key → project discovery and usage breakdown API (`api`). | | Chutes | API key from config/env → subscription usage and quota API (`api`). | +| Neuralwatt | API key from config/env → `/v1/quota` credit balance and per-key allowance (`api`). | | ZenMux | Management API key from config/env → five-hour and seven-day quota windows plus PAYG balance (`api`). | | Zed | Zed editor Keychain session → `cloud.zed.dev/client/users/me` for plan and quota data (`local`). | @@ -317,6 +320,13 @@ headers, source selection, provider ordering, and token accounts are stored in ` - Status: `https://status.openrouter.ai` (link only, no auto-polling yet). - Details: `docs/openrouter.md`. +## AnyRouter +- API key from `~/.codexbar/config.json` (`providers[].apiKey`) or `ANYROUTER_API_KEY` env var. +- Reads spendable balance, lifetime spend, and today's spend from the credits API (`/api/v1/credits`). +- Primary meter is lifetime spend against total credit granted; balance is shown in the identity section. +- Override base URL with `ANYROUTER_API_URL` env var (HTTPS only). +- Details: `docs/anyrouter.md`. + ## CrossModel - API key from `~/.codexbar/config.json` (`providers[].apiKey`) or `CROSSMODEL_API_KEY` env var. - Reads wallet balance (`/v1/credits`) and matching-currency UTC day/week/month spend (`/v1/usage`). @@ -415,6 +425,12 @@ headers, source selection, provider ordering, and token accounts are stored in ` - Status: none yet. - Details: `docs/command-code.md`. +## ClinePass +- API key from `~/.codexbar/config.json`, `CLINE_API_KEY`, or `CLINEPASS_API_KEY`. +- Reads 5-hour, weekly, and monthly usage limits from `GET https://api.cline.bot/api/v1/users/me/plan/usage-limits`. +- ClinePass subscription limits are distinct from Cline pay-as-you-go balance and usage. +- Status: none yet. + ## Qoder - Chrome session cookies from automatic import, or a manual `Cookie:` header/cURL capture on macOS or Linux. - Reads big model credit usage from the Qoder account dashboard on `qoder.com` or `qoder.com.cn`. @@ -501,6 +517,13 @@ headers, source selection, provider ordering, and token accounts are stored in ` - Uses Chutes' management API at `https://api.chutes.ai`; `CHUTES_API_URL` can override it with an HTTPS endpoint. - Details: `docs/chutes.md`. +## Neuralwatt +- API key from config or `NEURALWATT_API_KEY`. +- Reads `GET /v1/quota` from `api.neuralwatt.com`; `NEURALWATT_API_URL` can override it with an HTTPS endpoint. +- Shows the USD prepaid-credit balance and an optional per-key spending allowance. +- Active subscription kWh allowance is returned separately by Neuralwatt and is not yet surfaced pending a product decision. +- Details: `docs/neuralwatt.md`. + ## StepFun - Username/password login or manual Oasis-Token. - Reads Step Plan 5-hour and weekly rate-limit windows from `platform.stepfun.com`. diff --git a/docs/site-locales.mjs b/docs/site-locales.mjs index 920fe5436d..a405121774 100644 --- a/docs/site-locales.mjs +++ b/docs/site-locales.mjs @@ -98,8 +98,8 @@ export const localeCatalog = [ export const localeMessages = { "en": { "meta.title": "CodexBar — every AI coding limit in your menu bar", - "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 60 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", - "meta.ogDescription": "Track usage windows, credits, and resets across 60 AI coding providers from your macOS menu bar.", + "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 64 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", + "meta.ogDescription": "Track usage windows, credits, and resets across 64 AI coding providers from your macOS menu bar.", "nav.primary": "Primary", "nav.language": "Language", "nav.docs": "docs", @@ -112,7 +112,7 @@ export const localeMessages = { "hero.description": "CodexBar tracks usage windows, credit balances, and reset countdowns across the providers you actually pay for — one status item each, or merge them into one.", "hero.download": "Download for macOS", "hero.fineprint": "Free & open source · macOS 14+ · Universal via GitHub Releases and Homebrew", - "providers.title": "60 providers,{mobileBreak}one menu bar", + "providers.title": "64 providers,{mobileBreak}one menu bar", "providers.description": "Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus.", "providers.yourProvider": "Your provider", "providers.authoringGuide": "Authoring guide", @@ -239,8 +239,8 @@ export const localeMessages = { }, "zh-CN": { "meta.title": "CodexBar — 菜单栏中的每个 AI 编码限制", - "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 60 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", - "meta.ogDescription": "从您的 macOS 菜单栏跟踪 60 个 AI 编码提供商的使用窗口、积分和重置。", + "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 64 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", + "meta.ogDescription": "从您的 macOS 菜单栏跟踪 64 个 AI 编码提供商的使用窗口、积分和重置。", "nav.primary": "基本的", "nav.language": "语言", "nav.docs": "文档", @@ -253,7 +253,7 @@ export const localeMessages = { "hero.description": "CodexBar 跟踪您实际付费的提供商的使用窗口、信用余额和重置倒计时 - 每个状态项一项,或将它们合并为一项。", "hero.download": "下载macOS", "hero.fineprint": "免费开源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "60 个提供商,{mobileBreak}一个菜单栏", + "providers.title": "64 个提供商,{mobileBreak}一个菜单栏", "providers.description": "受欢迎的提供商成为状态项目,具有自己的使用窗口、重置倒计时、图表和提供商菜单。", "providers.yourProvider": "您的提供商", "providers.authoringGuide": "创作指南", @@ -380,8 +380,8 @@ export const localeMessages = { }, "zh-TW": { "meta.title": "CodexBar — 功能表列中的每個 AI 編碼限制", - "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 60 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", - "meta.ogDescription": "從您的 macOS 功能表列追蹤 60 個 AI 編碼提供者的使用視窗、積分和重設。", + "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 64 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", + "meta.ogDescription": "從您的 macOS 功能表列追蹤 64 個 AI 編碼提供者的使用視窗、積分和重設。", "nav.primary": "基本的", "nav.language": "語言", "nav.docs": "文件", @@ -394,7 +394,7 @@ export const localeMessages = { "hero.description": "CodexBar 追蹤您實際付費的提供者的使用視窗、信用餘額和重設倒數計時 - 每個狀態項一項,或將它們合併為一項。", "hero.download": "下載macOS", "hero.fineprint": "免費開源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "60 個提供者,{mobileBreak}一個選單列", + "providers.title": "64 個提供者,{mobileBreak}一個選單列", "providers.description": "受歡迎的提供者成為狀態項目,具有自己的使用視窗、重置倒數計時、圖表和提供者選單。", "providers.yourProvider": "您的提供者", "providers.authoringGuide": "創作指南", @@ -521,8 +521,8 @@ export const localeMessages = { }, "ja-JP": { "meta.title": "CodexBar — メニュー バーのすべての AI コーディング制限", - "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、60 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", - "meta.ogDescription": "macOS メニュー バーから、60 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", + "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、64 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", + "meta.ogDescription": "macOS メニュー バーから、64 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", "nav.primary": "主要な", "nav.language": "言語", "nav.docs": "ドキュメント", @@ -535,7 +535,7 @@ export const localeMessages = { "hero.description": "CodexBar は、実際に料金を支払っているプロバイダー全体の使用期間、クレジット残高、リセット カウントダウンを追跡します。ステータス項目を 1 つずつ、または 1 つに統合します。", "hero.download": "macOS のダウンロード", "hero.fineprint": "無料・オープンソース · macOS 14+ · GitHub Releases と Homebrew でユニバーサル版を提供", - "providers.title": "60 プロバイダー、{mobileBreak}1つのメニューバー", + "providers.title": "64 プロバイダー、{mobileBreak}1つのメニューバー", "providers.description": "人気のあるプロバイダーは、独自の使用期間、リセット カウントダウン、グラフ、プロバイダー メニューを備えたステータス アイテムになります。", "providers.yourProvider": "あなたのプロバイダー", "providers.authoringGuide": "オーサリングガイド", @@ -662,8 +662,8 @@ export const localeMessages = { }, "es": { "meta.title": "CodexBar: cada límite de codificación de IA en tu barra de menú", - "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 60 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", - "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 60 proveedores de codificación de IA desde su barra de menú macOS.", + "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 64 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", + "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 64 proveedores de codificación de IA desde su barra de menú macOS.", "nav.primary": "Primario", "nav.language": "Idioma", "nav.docs": "Documentos", @@ -676,7 +676,7 @@ export const localeMessages = { "hero.description": "CodexBar realiza un seguimiento de los períodos de uso, los saldos de crédito y restablece las cuentas regresivas de los proveedores por los que realmente paga: un elemento de estado para cada uno o los fusiona en uno solo.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratis y de código abierto · macOS 14+ · Universal mediante GitHub Releases y Homebrew", - "providers.title": "60 proveedores,{mobileBreak}una barra de menús", + "providers.title": "64 proveedores,{mobileBreak}una barra de menús", "providers.description": "Los proveedores populares se convierten en elementos de estado con sus propias ventanas de uso, restablecen cuentas regresivas, gráficos y menús de proveedores.", "providers.yourProvider": "Tu proveedor", "providers.authoringGuide": "guía de autoría", @@ -803,8 +803,8 @@ export const localeMessages = { }, "pt-BR": { "meta.title": "CodexBar — todos os limites de codificação de IA na sua barra de menu", - "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 60 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", - "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 60 provedores de codificação de IA na barra de menu macOS.", + "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 64 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", + "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 64 provedores de codificação de IA na barra de menu macOS.", "nav.primary": "Primário", "nav.language": "Linguagem", "nav.docs": "Documentos", @@ -817,7 +817,7 @@ export const localeMessages = { "hero.description": "CodexBar rastreia janelas de uso, saldos de crédito e reinicia contagens regressivas nos provedores pelos quais você realmente paga - um item de status cada, ou mescla-os em um.", "hero.download": "Baixar para macOS", "hero.fineprint": "Gratuito e de código aberto · macOS 14+ · Universal via GitHub Releases e Homebrew", - "providers.title": "60 provedores,{mobileBreak}uma barra de menu", + "providers.title": "64 provedores,{mobileBreak}uma barra de menu", "providers.description": "Provedores populares tornam-se itens de status com suas próprias janelas de uso, reiniciam contagens regressivas, gráficos e menus de provedores.", "providers.yourProvider": "Seu provedor", "providers.authoringGuide": "Guia de autoria", @@ -944,8 +944,8 @@ export const localeMessages = { }, "ko": { "meta.title": "CodexBar — 메뉴 표시줄의 모든 AI 코딩 제한", - "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 60개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", - "meta.ogDescription": "macOS 메뉴 표시줄에서 60개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", + "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 64개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", + "meta.ogDescription": "macOS 메뉴 표시줄에서 64개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", "nav.primary": "주요한", "nav.language": "언어", "nav.docs": "문서", @@ -958,7 +958,7 @@ export const localeMessages = { "hero.description": "CodexBar는 귀하가 실제로 비용을 지불한 제공업체 전반에 걸쳐 사용 기간, 크레딧 잔액 및 재설정 카운트다운을 추적합니다. 즉, 각각 하나의 상태 항목 또는 하나로 병합됩니다.", "hero.download": "macOS 동안 다운로드", "hero.fineprint": "무료 오픈 소스 · macOS 14+ · GitHub Releases와 Homebrew에서 유니버설 제공", - "providers.title": "60개 제공자,{mobileBreak}하나의 메뉴 막대", + "providers.title": "64개 제공자,{mobileBreak}하나의 메뉴 막대", "providers.description": "인기 있는 제공업체는 자체 사용 창, 재설정 카운트다운, 차트 및 제공업체 메뉴를 갖춘 상태 항목이 됩니다.", "providers.yourProvider": "귀하의 제공자", "providers.authoringGuide": "저작 가이드", @@ -1085,8 +1085,8 @@ export const localeMessages = { }, "de": { "meta.title": "CodexBar – alle Limits Ihrer KI-Coding-Tools in der Menüleiste", - "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 60 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", - "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 60 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", + "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 64 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", + "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 64 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", "nav.primary": "Primär", "nav.language": "Sprache", "nav.docs": "Dokumente", @@ -1099,7 +1099,7 @@ export const localeMessages = { "hero.description": "CodexBar verfolgt Nutzungsfenster, Guthaben und Reset-Countdowns bei den Anbietern, für die Sie tatsächlich bezahlen – jeweils ein Statuselement oder sie werden zu einem zusammengeführt.", "hero.download": "Herunterladen für macOS", "hero.fineprint": "Kostenlos und Open Source · macOS 14+ · Universal über GitHub Releases und Homebrew", - "providers.title": "60 Provider,{mobileBreak}eine Menüleiste", + "providers.title": "64 Provider,{mobileBreak}eine Menüleiste", "providers.description": "Beliebte Anbieter werden zu Statuselementen mit eigenen Nutzungsfenstern, Reset-Countdowns, Diagrammen und Anbietermenüs.", "providers.yourProvider": "Ihr Anbieter", "providers.authoringGuide": "Autorenleitfaden", @@ -1226,8 +1226,8 @@ export const localeMessages = { }, "fr": { "meta.title": "CodexBar — chaque limite de codage IA dans votre barre de menus", - "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 60 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", - "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 60 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", + "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 64 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", + "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 64 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", "nav.primary": "Primaire", "nav.language": "Langue", "nav.docs": "Documents", @@ -1240,7 +1240,7 @@ export const localeMessages = { "hero.description": "CodexBar suit les fenêtres d'utilisation, les soldes créditeurs et réinitialise les comptes à rebours pour les fournisseurs pour lesquels vous payez réellement - un élément de statut chacun, ou les fusionne en un seul.", "hero.download": "Télécharger pour macOS", "hero.fineprint": "Gratuit et open source · macOS 14+ · Universel via GitHub Releases et Homebrew", - "providers.title": "60 fournisseurs,{mobileBreak}une barre des menus", + "providers.title": "64 fournisseurs,{mobileBreak}une barre des menus", "providers.description": "Les fournisseurs populaires deviennent des éléments de statut avec leurs propres fenêtres d'utilisation, réinitialisent les comptes à rebours, les graphiques et les menus des fournisseurs.", "providers.yourProvider": "Votre fournisseur", "providers.authoringGuide": "Guide de création", @@ -1367,8 +1367,8 @@ export const localeMessages = { }, "ar": { "meta.title": "CodexBar — كل حد لترميز الذكاء الاصطناعي في شريط القائمة", - "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 60 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", - "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 60 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", + "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 64 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", + "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 64 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", "nav.primary": "أساسي", "nav.language": "لغة", "nav.docs": "المستندات", @@ -1381,7 +1381,7 @@ export const localeMessages = { "hero.description": "يتتبع CodexBar فترات الاستخدام والأرصدة الائتمانية وإعادة تعيين العد التنازلي عبر مقدمي الخدمة الذين تدفع مقابلهم فعليًا — عنصر حالة واحد لكل منهم، أو دمجهم في عنصر واحد.", "hero.download": "التنزيل لمدة macOS", "hero.fineprint": "مجاني ومفتوح المصدر · macOS 14+ · إصدار شامل عبر GitHub Releases وHomebrew", - "providers.title": "60 مزودًا،{mobileBreak}شريط قوائم واحد", + "providers.title": "64 مزودًا،{mobileBreak}شريط قوائم واحد", "providers.description": "يصبح الموفرون المشهورون عناصر حالة مع نوافذ الاستخدام الخاصة بهم، وعمليات إعادة تعيين العد التنازلي، والمخططات، وقوائم الموفر.", "providers.yourProvider": "المزود الخاص بك", "providers.authoringGuide": "دليل التأليف", @@ -1508,8 +1508,8 @@ export const localeMessages = { }, "it": { "meta.title": "CodexBar: ogni limite di codifica AI nella barra dei menu", - "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 60 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", - "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 60 fornitori di codifica AI dalla barra dei menu macOS.", + "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 64 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", + "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 64 fornitori di codifica AI dalla barra dei menu macOS.", "nav.primary": "Primario", "nav.language": "Lingua", "nav.docs": "Documenti", @@ -1522,7 +1522,7 @@ export const localeMessages = { "hero.description": "CodexBar tiene traccia delle finestre di utilizzo, dei saldi del credito e reimposta i conti alla rovescia tra i fornitori per cui paghi effettivamente: un elemento di stato ciascuno o uniscili in uno solo.", "hero.download": "Scarica per macOS", "hero.fineprint": "Gratuito e open source · macOS 14+ · Universale tramite GitHub Releases e Homebrew", - "providers.title": "60 provider,{mobileBreak}una barra dei menu", + "providers.title": "64 provider,{mobileBreak}una barra dei menu", "providers.description": "I fornitori più popolari diventano elementi di stato con le proprie finestre di utilizzo, reimpostano i conti alla rovescia, i grafici e i menu dei fornitori.", "providers.yourProvider": "Il tuo fornitore", "providers.authoringGuide": "Guida all'autore", @@ -1649,8 +1649,8 @@ export const localeMessages = { }, "vi": { "meta.title": "CodexBar — mọi giới hạn mã hóa AI trong thanh menu của bạn", - "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 60 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", - "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 60 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", + "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 64 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", + "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 64 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", "nav.primary": "Sơ đẳng", "nav.language": "Ngôn ngữ", "nav.docs": "Tài liệu", @@ -1663,7 +1663,7 @@ export const localeMessages = { "hero.description": "CodexBar theo dõi khoảng thời gian sử dụng, số dư tín dụng và đếm ngược đặt lại trên các nhà cung cấp mà bạn thực sự thanh toán — mỗi mục một trạng thái hoặc hợp nhất chúng thành một.", "hero.download": "Tải xuống cho macOS", "hero.fineprint": "Miễn phí và nguồn mở · macOS 14+ · Bản universal qua GitHub Releases và Homebrew", - "providers.title": "60 nhà cung cấp,{mobileBreak}một thanh menu", + "providers.title": "64 nhà cung cấp,{mobileBreak}một thanh menu", "providers.description": "Các nhà cung cấp phổ biến trở thành các mục trạng thái với cửa sổ sử dụng của riêng họ, đặt lại bộ đếm ngược, biểu đồ và menu nhà cung cấp.", "providers.yourProvider": "Nhà cung cấp của bạn", "providers.authoringGuide": "Hướng dẫn soạn thảo", @@ -1790,8 +1790,8 @@ export const localeMessages = { }, "nl": { "meta.title": "CodexBar — elke AI-coderingslimiet in uw menubalk", - "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 60 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", - "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 60 leveranciers van AI-codering via uw macOS-menubalk.", + "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 64 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", + "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 64 leveranciers van AI-codering via uw macOS-menubalk.", "nav.primary": "Primair", "nav.language": "Taal", "nav.docs": "Documenten", @@ -1804,7 +1804,7 @@ export const localeMessages = { "hero.description": "CodexBar houdt gebruiksperioden, tegoeden en reset-countdowns bij voor de providers waarvoor u daadwerkelijk betaalt: elk één statusitem, of u kunt ze samenvoegen tot één statusitem.", "hero.download": "Downloaden voor macOS", "hero.fineprint": "Gratis en open source · macOS 14+ · Universeel via GitHub Releases en Homebrew", - "providers.title": "60 providers,{mobileBreak}één menubalk", + "providers.title": "64 providers,{mobileBreak}één menubalk", "providers.description": "Populaire providers worden statusitems met hun eigen gebruiksvensters, resetcountdowns, grafieken en providermenu's.", "providers.yourProvider": "Uw aanbieder", "providers.authoringGuide": "Handleiding voor het schrijven", @@ -1931,8 +1931,8 @@ export const localeMessages = { }, "tr": { "meta.title": "CodexBar — menü çubuğunuzdaki tüm AI kodlama limitleri", - "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 60 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", - "meta.ogDescription": "macOS menü çubuğunu kullanarak 60 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", + "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 64 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", + "meta.ogDescription": "macOS menü çubuğunu kullanarak 64 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", "nav.primary": "Öncelik", "nav.language": "Dil", "nav.docs": "Dokümanlar", @@ -1945,7 +1945,7 @@ export const localeMessages = { "hero.description": "CodexBar, gerçekte ödeme yaptığınız sağlayıcılar genelinde kullanım pencerelerini, kredi bakiyelerini ve geri sayımları sıfırlar (her biri bir durum öğesi olacak şekilde) izler veya bunları tek bir öğede birleştirir.", "hero.download": "macOS için indirin", "hero.fineprint": "Ücretsiz ve açık kaynak · macOS 14+ · GitHub Releases ve Homebrew ile evrensel sürüm", - "providers.title": "60 sağlayıcı,{mobileBreak}bir menü çubuğu", + "providers.title": "64 sağlayıcı,{mobileBreak}bir menü çubuğu", "providers.description": "Popüler sağlayıcılar, kendi kullanım pencereleri, sıfırlama geri sayımları, çizelgeleri ve sağlayıcı menüleriyle durum öğeleri haline gelir.", "providers.yourProvider": "Sağlayıcınız", "providers.authoringGuide": "Yazma kılavuzu", @@ -2072,8 +2072,8 @@ export const localeMessages = { }, "uk": { "meta.title": "CodexBar — кожне обмеження кодування AI у вашій панелі меню", - "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 60 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", - "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 60 постачальників кодування ШІ за допомогою панелі меню macOS.", + "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 64 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", + "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 64 постачальників кодування ШІ за допомогою панелі меню macOS.", "nav.primary": "Первинний", "nav.language": "Мова", "nav.docs": "документи", @@ -2086,7 +2086,7 @@ export const localeMessages = { "hero.description": "CodexBar відстежує вікна використання, кредитні баланси та скидає зворотний відлік для постачальників, за яких ви фактично платите, — по одному статусу для кожного або об’єднує їх в один.", "hero.download": "Завантажити для macOS", "hero.fineprint": "Безкоштовний і відкритий код · macOS 14+ · Універсальна версія через GitHub Releases і Homebrew", - "providers.title": "60 провайдерів,{mobileBreak}одна панель меню", + "providers.title": "64 провайдерів,{mobileBreak}одна панель меню", "providers.description": "Популярні постачальники стають елементами статусу з власними вікнами використання, скиданням зворотного відліку, діаграмами та меню постачальників.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Авторський посібник", @@ -2213,8 +2213,8 @@ export const localeMessages = { }, "ru": { "meta.title": "CodexBar — все лимиты AI-кодинга в вашей строке меню", - "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 60 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", - "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 60 AI-провайдеров для кодинга прямо из строки меню macOS.", + "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 64 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", + "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 64 AI-провайдеров для кодинга прямо из строки меню macOS.", "nav.primary": "Основная навигация", "nav.language": "Язык", "nav.docs": "Документация", @@ -2227,7 +2227,7 @@ export const localeMessages = { "hero.description": "CodexBar отслеживает окна использования, балансы кредитов и обратный отсчет до сброса у провайдеров, за которых вы действительно платите, — по одному элементу статуса на каждого или все вместе в одном.", "hero.download": "Скачать для macOS", "hero.fineprint": "Бесплатно и с открытым исходным кодом · macOS 14+ · универсальная сборка через GitHub Releases и Homebrew", - "providers.title": "60 провайдеров,{mobileBreak}одна строка меню", + "providers.title": "64 провайдеров,{mobileBreak}одна строка меню", "providers.description": "Популярные провайдеры становятся элементами статуса со своими окнами использования, обратным отсчетом до сброса, графиками и меню провайдера.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Руководство по добавлению", @@ -2354,8 +2354,8 @@ export const localeMessages = { }, "id": { "meta.title": "CodexBar — setiap batas pengkodean AI di bilah menu Anda", - "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 60 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", - "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 60 penyedia pengkodean AI dari bilah menu macOS Anda.", + "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 64 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", + "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 64 penyedia pengkodean AI dari bilah menu macOS Anda.", "nav.primary": "Utama", "nav.language": "Bahasa", "nav.docs": "dokumen", @@ -2368,7 +2368,7 @@ export const localeMessages = { "hero.description": "CodexBar melacak jangka waktu penggunaan, saldo kredit, dan hitungan mundur penyetelan ulang di seluruh penyedia yang sebenarnya Anda bayar — masing-masing satu item status, atau gabungkan menjadi satu.", "hero.download": "Unduh untuk macOS", "hero.fineprint": "Gratis dan sumber terbuka · macOS 14+ · Universal melalui GitHub Releases dan Homebrew", - "providers.title": "60 penyedia,{mobileBreak}satu bilah menu", + "providers.title": "64 penyedia,{mobileBreak}satu bilah menu", "providers.description": "Penyedia populer menjadi item status dengan jendela penggunaannya sendiri, hitung mundur pengaturan ulang, bagan, dan menu penyedia.", "providers.yourProvider": "Penyedia Anda", "providers.authoringGuide": "Panduan penulisan", @@ -2495,8 +2495,8 @@ export const localeMessages = { }, "pl": { "meta.title": "CodexBar — każdy limit kodowania AI na pasku menu", - "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 60 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", - "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 60 dostawców kodowania AI za pomocą paska menu macOS.", + "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 64 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", + "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 64 dostawców kodowania AI za pomocą paska menu macOS.", "nav.primary": "Podstawowy", "nav.language": "Język", "nav.docs": "Dokumenty", @@ -2509,7 +2509,7 @@ export const localeMessages = { "hero.description": "CodexBar śledzi okna użytkowania, salda kredytów i resetuje odliczanie u dostawców, za których faktycznie płacisz — po jednym statusie dla każdego lub połącz je w jeden.", "hero.download": "Pobierz dla macOS", "hero.fineprint": "Darmowe i otwarte oprogramowanie · macOS 14+ · Wersja uniwersalna przez GitHub Releases i Homebrew", - "providers.title": "60 dostawców,{mobileBreak}jeden pasek menu", + "providers.title": "64 dostawców,{mobileBreak}jeden pasek menu", "providers.description": "Popularni dostawcy stają się elementami statusu z własnymi oknami użytkowania, resetowaniem odliczania, wykresami i menu dostawców.", "providers.yourProvider": "Twój dostawca", "providers.authoringGuide": "Przewodnik autorski", @@ -2636,8 +2636,8 @@ export const localeMessages = { }, "fa": { "meta.title": "CodexBar - هر محدودیت کدنویسی هوش مصنوعی در نوار منو شما", - "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 60 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", - "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 60 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", + "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 64 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", + "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 64 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", "nav.primary": "اولیه", "nav.language": "زبان", "nav.docs": "اسناد", @@ -2650,7 +2650,7 @@ export const localeMessages = { "hero.description": "CodexBar پنجره‌های استفاده، مانده اعتبار و بازنشانی شمارش معکوس را در سراسر ارائه‌دهندگانی که واقعاً برایشان پول پرداخت می‌کنید ردیابی می‌کند - هر کدام یک مورد وضعیت، یا آنها را در یکی ادغام کنید.", "hero.download": "دانلود برای macOS", "hero.fineprint": "رایگان و متن‌باز · macOS 14+ · نسخهٔ یونیورسال از GitHub Releases و Homebrew", - "providers.title": "60 ارائه دهنده،{mobileBreak}یک نوار منو", + "providers.title": "64 ارائه دهنده،{mobileBreak}یک نوار منو", "providers.description": "ارائه‌دهندگان محبوب با پنجره‌های استفاده خاص خود، شمارش معکوس، نمودارها و منوهای ارائه‌دهنده را بازنشانی می‌کنند.", "providers.yourProvider": "ارائه دهنده شما", "providers.authoringGuide": "راهنمای نگارش", @@ -2777,8 +2777,8 @@ export const localeMessages = { }, "th": { "meta.title": "CodexBar — ทุกขีดจำกัดการเข้ารหัส AI ในแถบเมนูของคุณ", - "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 60 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", - "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 60 รายจากแถบเมนู macOS", + "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 64 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", + "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 64 รายจากแถบเมนู macOS", "nav.primary": "หลัก", "nav.language": "ภาษา", "nav.docs": "เอกสาร", @@ -2791,7 +2791,7 @@ export const localeMessages = { "hero.description": "CodexBar ติดตามกรอบเวลาการใช้งาน ยอดเครดิต และรีเซ็ตการนับถอยหลังของผู้ให้บริการที่คุณชำระเงินจริง — รายการสถานะแต่ละรายการ หรือรวมรายการเหล่านั้นเป็นรายการเดียว", "hero.download": "ดาวน์โหลดสำหรับ macOS", "hero.fineprint": "ฟรีและโอเพ่นซอร์ส · macOS 14+ · รุ่น Universal ผ่าน GitHub Releases และ Homebrew", - "providers.title": "ผู้ให้บริการ 60 ราย{mobileBreak}หนึ่งแถบเมนู", + "providers.title": "ผู้ให้บริการ 64 ราย{mobileBreak}หนึ่งแถบเมนู", "providers.description": "ผู้ให้บริการยอดนิยมจะกลายเป็นรายการสถานะที่มีหน้าต่างการใช้งานของตนเอง รีเซ็ตการนับถอยหลัง แผนภูมิ และเมนูของผู้ให้บริการ", "providers.yourProvider": "ผู้ให้บริการของคุณ", "providers.authoringGuide": "คู่มือการเขียน", @@ -2918,8 +2918,8 @@ export const localeMessages = { }, "gl": { "meta.title": "CodexBar — todos os límites de programación con IA na túa barra de menús", - "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 60 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", - "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 60 provedores de programación con IA desde a barra de menús de macOS.", + "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 64 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", + "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 64 provedores de programación con IA desde a barra de menús de macOS.", "nav.primary": "Principal", "nav.language": "Idioma", "nav.docs": "Documentación", @@ -2932,7 +2932,7 @@ export const localeMessages = { "hero.description": "CodexBar controla as xanelas de uso, os saldos de crédito e as contas atrás ata o restablecemento dos provedores polos que realmente pagas — un elemento de estado para cada un ou todos combinados nun só.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratuíto e de código aberto · macOS 14+ · Universal mediante GitHub Releases e Homebrew", - "providers.title": "60 provedores,{mobileBreak}unha barra de menús", + "providers.title": "64 provedores,{mobileBreak}unha barra de menús", "providers.description": "Os provedores populares convértense en elementos de estado coas súas propias xanelas de uso, contas atrás de restablecemento, gráficas e menús.", "providers.yourProvider": "O teu provedor", "providers.authoringGuide": "Guía de creación", @@ -3059,8 +3059,8 @@ export const localeMessages = { }, "ca": { "meta.title": "CodexBar: tots els límits de la programació amb IA a la barra de menús", - "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 60 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", - "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 60 proveïdors de programació amb IA des de la barra de menús del macOS.", + "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 64 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", + "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 64 proveïdors de programació amb IA des de la barra de menús del macOS.", "nav.primary": "Navegació principal", "nav.language": "Llengua", "nav.docs": "Documentació", @@ -3073,7 +3073,7 @@ export const localeMessages = { "hero.description": "El CodexBar fa el seguiment de les finestres d'ús, els saldos de crèdit i els comptes enrere fins al restabliment dels proveïdors pels quals realment pagueu: un element d'estat per a cadascun, o combineu-los tots en un de sol.", "hero.download": "Baixeu per al macOS", "hero.fineprint": "Gratuït i de codi obert · macOS 14+ · Universal mitjançant GitHub Releases i Homebrew", - "providers.title": "60 proveïdors,{mobileBreak}una barra de menús", + "providers.title": "64 proveïdors,{mobileBreak}una barra de menús", "providers.description": "Els proveïdors populars es converteixen en elements d'estat amb les seves pròpies finestres d'ús, comptes enrere de restabliment, gràfics i menús de proveïdor.", "providers.yourProvider": "El vostre proveïdor", "providers.authoringGuide": "Guia de creació", @@ -3200,8 +3200,8 @@ export const localeMessages = { }, "sv": { "meta.title": "CodexBar — varje AI-kodningsgräns i din menyrad", - "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 60 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", - "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 60 AI-kodningsleverantörer från din macOS-menyrad.", + "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 64 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", + "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 64 AI-kodningsleverantörer från din macOS-menyrad.", "nav.primary": "Primär", "nav.language": "Språk", "nav.docs": "Dokument", @@ -3214,7 +3214,7 @@ export const localeMessages = { "hero.description": "CodexBar spårar användningsfönster, kreditsaldon och återställningsnedräkningar för de leverantörer du faktiskt betalar för – en statuspost var, eller slå samman dem till en.", "hero.download": "Ladda ner för macOS", "hero.fineprint": "Gratis och öppen källkod · macOS 14+ · Universal via GitHub Releases och Homebrew", - "providers.title": "60 leverantörer,{mobileBreak}en menyrad", + "providers.title": "64 leverantörer,{mobileBreak}en menyrad", "providers.description": "Populära leverantörer blir statusobjekt med sina egna användningsfönster, återställer nedräkningar, diagram och leverantörsmenyer.", "providers.yourProvider": "Din leverantör", "providers.authoringGuide": "Författarguide", diff --git a/docs/social.html b/docs/social.html index 255b501739..c536857fa4 100644 --- a/docs/social.html +++ b/docs/social.html @@ -199,7 +199,7 @@

Every AI coding limit, in your menu bar.

-

60 providers·usage windows, credits, resets·one status item each, or merged.

+

64 providers·usage windows, credits, resets·one status item each, or merged.