diff --git a/CopilotMonitor/CLI/CLIProviderManager.swift b/CopilotMonitor/CLI/CLIProviderManager.swift index af89f8c1..64600ac8 100644 --- a/CopilotMonitor/CLI/CLIProviderManager.swift +++ b/CopilotMonitor/CLI/CLIProviderManager.swift @@ -17,7 +17,8 @@ actor CLIProviderManager { .antigravity, .openCodeZen, .openCodeGo, .kiro, .grok, .kimi, .minimaxCodingPlan, .zaiCodingPlan, .nanoGpt, .chutes, .copilot, - .synthetic + .synthetic, + .deepSeek ] // MARK: - Initialization @@ -42,6 +43,7 @@ actor CLIProviderManager { let nanoGptProvider = NanoGptProvider() let chutesProvider = ChutesProvider() let syntheticProvider = SyntheticProvider() + let deepSeekProvider = DeepSeekProvider() // 1 CLI-specific provider (uses browser cookies instead of WebView) let copilotCLIProvider = CopilotCLIProvider() @@ -64,7 +66,8 @@ actor CLIProviderManager { nanoGptProvider, chutesProvider, copilotCLIProvider, - syntheticProvider + syntheticProvider, + deepSeekProvider ] let providerCount = providers.count diff --git a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj index b9619f34..31fea153 100644 --- a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj +++ b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ A33333333333333333333333 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A44444444444444444444444 /* AppDelegate.swift */; }; A454D8C22F30544900E355E3 /* MenuBarExtraAccess in Frameworks */ = {isa = PBXBuildFile; productRef = MBA2222222222222222222222 /* MenuBarExtraAccess */; }; A454D8C42F30548900E355E3 /* ZaiCodingPlanProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A454D8C32F30548900E355E3 /* ZaiCodingPlanProvider.swift */; }; + DE5E1B4A2C3D4E5F6A7B8C9DF /* DeepSeekProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE5E1B4A2C3D4E5F6A7B8C9DE /* DeepSeekProvider.swift */; }; MINIMAXAPP11111111111111 /* MiniMaxProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = MINIMAXFILE2222222222222 /* MiniMaxProvider.swift */; }; OCGOAPP11111111111111 /* OpenCodeGoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OCGOFILE2222222222222 /* OpenCodeGoProvider.swift */; }; GROKAPP11111111111111 /* GrokProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = GROKFILE2222222222222 /* GrokProvider.swift */; }; @@ -66,6 +67,7 @@ GROKCLI11111111111111 /* GrokProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = GROKFILE2222222222222 /* GrokProvider.swift */; }; KIROCLI11111111111111 /* KiroProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = KIROFILE2222222222222 /* KiroProvider.swift */; }; CLIZAI11111111111111111 /* ZaiCodingPlanProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A454D8C32F30548900E355E3 /* ZaiCodingPlanProvider.swift */; }; + CLIDEEPSEEK11111111111 /* DeepSeekProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE5E1B4A2C3D4E5F6A7B8C9DE /* DeepSeekProvider.swift */; }; NANOGPTCLI11111111111111 /* NanoGptProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = NANOGPTFILE222222222222 /* NanoGptProvider.swift */; }; CLIOPENCZEN1111111111111 /* OpenCodeZenProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OZ2222222222222222222222 /* OpenCodeZenProvider.swift */; }; CLIOPENROUTER111111111 /* OpenRouterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OR2222222222222222222222 /* OpenRouterProvider.swift */; }; @@ -90,6 +92,7 @@ SYNTHETIC1111111111111111 /* SyntheticProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = SYNTHETIC2222222222222222 /* SyntheticProvider.swift */; }; SYNTHTEST2222222222222222 /* SyntheticProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SYNTHTEST1111111111111111 /* SyntheticProviderTests.swift */; }; NANOGPTTESTBF1111111111 /* NanoGptProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NANOGPTTESTFR1111111111 /* NanoGptProviderTests.swift */; }; + DEEPSKIPTESTBF1111111 /* DeepSeekProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEEPSKIPTESTFR1111111 /* DeepSeekProviderTests.swift */; }; TOKENTESTBF1111111111111 /* TokenManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TOKENTESTFR1111111111111 /* TokenManagerTests.swift */; }; CODEXTESTBF111111111111 /* CodexProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CODEXTESTFR111111111111 /* CodexProviderTests.swift */; }; OCAUTHTESTBF11111111111 /* OpenCodeAuthDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OCAUTHTESTFR11111111111 /* OpenCodeAuthDecodingTests.swift */; }; @@ -175,6 +178,7 @@ 9B1085A77EF4A58E5B5EC71B /* CopilotHistoryService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CopilotHistoryService.swift; sourceTree = ""; }; A44444444444444444444444 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A454D8C32F30548900E355E3 /* ZaiCodingPlanProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZaiCodingPlanProvider.swift; sourceTree = ""; }; + DE5E1B4A2C3D4E5F6A7B8C9DE /* DeepSeekProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekProvider.swift; sourceTree = ""; }; MINIMAXFILE2222222222222 /* MiniMaxProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniMaxProvider.swift; sourceTree = ""; }; OCGOFILE2222222222222 /* OpenCodeGoProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeGoProvider.swift; sourceTree = ""; }; GROKFILE2222222222222 /* GrokProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrokProvider.swift; sourceTree = ""; }; @@ -233,6 +237,7 @@ SYNTHETIC2222222222222222 /* SyntheticProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntheticProvider.swift; sourceTree = ""; }; SYNTHTEST1111111111111111 /* SyntheticProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntheticProviderTests.swift; sourceTree = ""; }; NANOGPTTESTFR1111111111 /* NanoGptProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NanoGptProviderTests.swift; sourceTree = ""; }; + DEEPSKIPTESTFR1111111 /* DeepSeekProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekProviderTests.swift; sourceTree = ""; }; TOKENTESTFR1111111111111 /* TokenManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManagerTests.swift; sourceTree = ""; }; CODEXTESTFR111111111111 /* CodexProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexProviderTests.swift; sourceTree = ""; }; OCAUTHTESTFR11111111111 /* OpenCodeAuthDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeAuthDecodingTests.swift; sourceTree = ""; }; @@ -289,6 +294,7 @@ isa = PBXGroup; children = ( A454D8C32F30548900E355E3 /* ZaiCodingPlanProvider.swift */, + DE5E1B4A2C3D4E5F6A7B8C9DE /* DeepSeekProvider.swift */, NANOGPTFILE222222222222 /* NanoGptProvider.swift */, 06EC3B683EB6892E4F9C8316 /* GeminiCLIProvider.swift */, 4DDC1B4DE6B5118CE4AE8F82 /* ClaudeProvider.swift */, @@ -455,6 +461,7 @@ 54353FD130DDE0500F6B367F /* MenuResultBuilderTests.swift */, SYNTHTEST1111111111111111 /* SyntheticProviderTests.swift */, NANOGPTTESTFR1111111111 /* NanoGptProviderTests.swift */, + DEEPSKIPTESTFR1111111 /* DeepSeekProviderTests.swift */, MINIMAXTESTFR11111111111 /* MiniMaxProviderTests.swift */, OCGOTESTFR11111111111 /* OpenCodeGoProviderTests.swift */, GROKTESTFR11111111111 /* GrokProviderTests.swift */, @@ -638,6 +645,7 @@ GROKCLI11111111111111 /* GrokProvider.swift in Sources */, KIROCLI11111111111111 /* KiroProvider.swift in Sources */, CLIZAI11111111111111111 /* ZaiCodingPlanProvider.swift in Sources */, + CLIDEEPSEEK11111111111 /* DeepSeekProvider.swift in Sources */, NANOGPTCLI11111111111111 /* NanoGptProvider.swift in Sources */, 283349022F313176004DADE1 /* ChutesProvider.swift in Sources */, CLITAVILY111111111111111 /* TavilySearchProvider.swift in Sources */, @@ -658,6 +666,7 @@ A33333333333333333333333 /* AppDelegate.swift in Sources */, AM1111111111111111111111 /* AppMigrationHelper.swift in Sources */, A454D8C42F30548900E355E3 /* ZaiCodingPlanProvider.swift in Sources */, + DE5E1B4A2C3D4E5F6A7B8C9DF /* DeepSeekProvider.swift in Sources */, NANOGPTAPP11111111111111 /* NanoGptProvider.swift in Sources */, BCDE4599B74AF7A799CE1D /* StatusBarIconView.swift in Sources */, ME1111111111111111111111 /* MenuEnums.swift in Sources */, @@ -715,6 +724,7 @@ B58BAD3BFD97973070A2A892 /* MenuResultBuilderTests.swift in Sources */, SYNTHTEST2222222222222222 /* SyntheticProviderTests.swift in Sources */, NANOGPTTESTBF1111111111 /* NanoGptProviderTests.swift in Sources */, + DEEPSKIPTESTBF1111111 /* DeepSeekProviderTests.swift in Sources */, MINIMAXTESTBF11111111111 /* MiniMaxProviderTests.swift in Sources */, OCGOTESTBF11111111111 /* OpenCodeGoProviderTests.swift in Sources */, GROKTESTBF11111111111 /* GrokProviderTests.swift in Sources */, diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index 765f0221..7b4f7dbc 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -917,12 +917,12 @@ final class StatusBarController: NSObject { return ProviderIdentifier.allCases.first(where: { isProviderEnabled($0) }) } - private func normalizedUsagePercent(_ percent: Double?) -> Double? { + private static func normalizedUsagePercent(_ percent: Double?) -> Double? { guard let percent, percent.isFinite else { return nil } return min(max(percent, 0), 999) } - private func dailyPercentFromDetails(_ details: DetailedUsage?) -> Double? { + private static func dailyPercentFromDetails(_ details: DetailedUsage?) -> Double? { guard let details else { return nil } if let limit = details.limit, limit > 0, let used = details.dailyUsage { return (used / limit) * 100.0 @@ -930,7 +930,7 @@ final class StatusBarController: NSObject { return details.dailyUsage } - private func priorityForWindowHours( + private static func priorityForWindowHours( _ hours: Int?, fallback: UsageDisplayWindowPriority ) -> UsageDisplayWindowPriority { @@ -941,7 +941,7 @@ final class StatusBarController: NSObject { return .hourly } - private func chutesMonthlyPercentFromDetails(_ details: DetailedUsage?) -> Double? { + private static func chutesMonthlyPercentFromDetails(_ details: DetailedUsage?) -> Double? { guard let details else { return nil } let configuredPlan = SubscriptionSettingsManager.shared.getPlan(for: .chutes) @@ -966,7 +966,7 @@ final class StatusBarController: NSObject { ) -> [UsagePercentCandidate] { var candidates: [UsagePercentCandidate] = [] func add(_ percent: Double?, priority: UsageDisplayWindowPriority) { - guard let normalized = normalizedUsagePercent(percent) else { return } + guard let normalized = Self.normalizedUsagePercent(percent) else { return } candidates.append(UsagePercentCandidate(percent: normalized, priority: priority)) } @@ -995,19 +995,19 @@ final class StatusBarController: NSObject { case .codex: add( details?.secondaryUsage, - priority: priorityForWindowHours(details?.codexSecondaryWindowHours, fallback: .weekly) + priority: Self.priorityForWindowHours(details?.codexSecondaryWindowHours, fallback: .weekly) ) add( details?.sparkSecondaryUsage, - priority: priorityForWindowHours(details?.sparkSecondaryWindowHours, fallback: .weekly) + priority: Self.priorityForWindowHours(details?.sparkSecondaryWindowHours, fallback: .weekly) ) add( - dailyPercentFromDetails(details), - priority: priorityForWindowHours(details?.codexPrimaryWindowHours, fallback: .daily) + Self.dailyPercentFromDetails(details), + priority: Self.priorityForWindowHours(details?.codexPrimaryWindowHours, fallback: .daily) ) add( details?.sparkUsage, - priority: priorityForWindowHours(details?.sparkPrimaryWindowHours, fallback: .hourly) + priority: Self.priorityForWindowHours(details?.sparkPrimaryWindowHours, fallback: .hourly) ) case .commandCode: add(usage.usagePercentage, priority: .monthly) @@ -1027,13 +1027,13 @@ final class StatusBarController: NSObject { case .nanoGpt: add(details?.sevenDayUsage, priority: .weekly) case .chutes: - add(chutesMonthlyPercentFromDetails(details), priority: .monthly) - add(dailyPercentFromDetails(details), priority: .daily) + add(Self.chutesMonthlyPercentFromDetails(details), priority: .monthly) + add(Self.dailyPercentFromDetails(details), priority: .daily) case .synthetic: add(details?.fiveHourUsage, priority: .hourly) case .tavilySearch, .braveSearch: add(details?.mcpUsagePercent, priority: .monthly) - case .antigravity, .geminiCLI, .openRouter, .openCode, .openCodeZen: + case .antigravity, .geminiCLI, .openRouter, .openCode, .openCodeZen, .deepSeek: break } @@ -1085,7 +1085,7 @@ final class StatusBarController: NSObject { // Gemini CLI special case: add as fallback priority since these don't have window metadata if identifier == .geminiCLI, let geminiAccounts = result.details?.geminiAccounts { for account in geminiAccounts { - if let normalized = normalizedUsagePercent(100.0 - account.remainingPercentage) { + if let normalized = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) { allCandidates.append(UsagePercentCandidate(percent: normalized, priority: .fallback)) } } @@ -1108,7 +1108,7 @@ final class StatusBarController: NSObject { func appendMetrics(usage: ProviderUsage, details: DetailedUsage?) { guard case .quotaBased = usage else { return } - if let percent = normalizedUsagePercent(usage.usagePercentage) { + if let percent = Self.normalizedUsagePercent(usage.usagePercentage) { usedPercents.append(percent) } @@ -1129,7 +1129,7 @@ final class StatusBarController: NSObject { details.openCodeGoMonthlyUsage ] for percent in extraPercents { - if let normalized = normalizedUsagePercent(percent) { + if let normalized = Self.normalizedUsagePercent(percent) { usedPercents.append(normalized) } } @@ -1146,7 +1146,7 @@ final class StatusBarController: NSObject { if identifier == .geminiCLI, let geminiAccounts = result.details?.geminiAccounts { for account in geminiAccounts { - if let percent = normalizedUsagePercent(100.0 - account.remainingPercentage) { + if let percent = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) { usedPercents.append(percent) } } @@ -1659,7 +1659,7 @@ final class StatusBarController: NSObject { var hasPayAsYouGo = false - let payAsYouGoOrder: [ProviderIdentifier] = [.openRouter, .openCodeZen] + let payAsYouGoOrder: [ProviderIdentifier] = [.openRouter, .openCodeZen, .deepSeek] for identifier in payAsYouGoOrder { guard isProviderEnabled(identifier) else { continue } @@ -1677,9 +1677,17 @@ final class StatusBarController: NSObject { } else if let result { if case .payAsYouGo(_, let cost, _) = result.usage { hasPayAsYouGo = true - let costValue = cost ?? 0.0 + // Balance-style providers (DeepSeek) leave `cost` nil and + // surface the remaining balance through details. + let costValue = cost ?? result.details?.creditsBalance ?? 0.0 + let title: String + if let symbol = result.details?.balanceCurrencySymbol, !symbol.isEmpty { + title = String(format: "%@ (%@%.2f)", identifier.displayName, symbol, costValue) + } else { + title = String(format: "%@ ($%.2f)", identifier.displayName, costValue) + } let item = NSMenuItem( - title: String(format: "%@ ($%.2f)", identifier.displayName, costValue), + title: title, action: nil, keyEquivalent: "" ) item.image = iconForProvider(identifier) @@ -2139,7 +2147,7 @@ final class StatusBarController: NSObject { let percents = [account.details?.tokenUsagePercent, account.details?.mcpUsagePercent].compactMap { $0 } usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents } else if identifier == .chutes { - let percents = [dailyPercentFromDetails(account.details), chutesMonthlyPercentFromDetails(account.details)].compactMap { $0 } + let percents = [Self.dailyPercentFromDetails(account.details), Self.chutesMonthlyPercentFromDetails(account.details)].compactMap { $0 } usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents } else if identifier == .nanoGpt { let percents = [ @@ -2225,7 +2233,7 @@ final class StatusBarController: NSObject { let percents = [result.details?.tokenUsagePercent, result.details?.mcpUsagePercent].compactMap { $0 } usedPercents = percents.isEmpty ? [singlePercent] : percents } else if identifier == .chutes { - let percents = [dailyPercentFromDetails(result.details), chutesMonthlyPercentFromDetails(result.details)].compactMap { $0 } + let percents = [Self.dailyPercentFromDetails(result.details), Self.chutesMonthlyPercentFromDetails(result.details)].compactMap { $0 } usedPercents = percents.isEmpty ? [singlePercent] : percents } else if identifier == .nanoGpt { let percents = [ @@ -2304,7 +2312,7 @@ final class StatusBarController: NSObject { for account in geminiAccounts { hasQuota = true let accountNumber = account.accountIndex + 1 - let usedPercent = normalizedUsagePercent(100.0 - account.remainingPercentage) ?? 0.0 + let usedPercent = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) ?? 0.0 // Gemini account rows should represent Gemini quota only. // Antigravity has its own provider row and should not be duplicated here. let usedPercents: [Double] = [usedPercent] @@ -3081,6 +3089,8 @@ final class StatusBarController: NSObject { image = NSImage(named: "TavilyIcon") case .braveSearch: image = NSImage(named: "BraveSearchIcon") + case .deepSeek: + image = NSImage(systemSymbolName: identifier.iconName, accessibilityDescription: identifier.displayName) } // Keep consistent icon sizing and make Gemini slightly larger. diff --git a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift index 3565a076..54af183d 100644 --- a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift +++ b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift @@ -67,6 +67,13 @@ extension StatusBarController { let subscriptionAccountId = resolvedSubscriptionAccountId(details: details, fallback: accountId) switch identifier { + case .deepSeek: + for (label, value) in Self.deepSeekBalanceRows(details: details) { + let item = NSMenuItem() + item.view = createDisabledLabelView(text: String(format: "%@: %@%.2f", label, details.balanceCurrencySymbol, value)) + submenu.addItem(item) + } + case .openRouter: if let remaining = details.creditsRemaining { let item = NSMenuItem() @@ -1020,6 +1027,20 @@ extension StatusBarController { return submenu } + /// Label/value pairs for the DeepSeek balance detail rows, in display + /// order. Extracted as a pure function so the menu rows are testable + /// without instantiating StatusBarController. + static func deepSeekBalanceRows(details: DetailedUsage) -> [(label: String, value: Double)] { + let rows: [(String, Double?)] = [ + ("Balance", details.creditsBalance), + ("Topped-up", details.balanceToppedUp), + ("Granted", details.balanceGranted) + ] + return rows.compactMap { label, value -> (label: String, value: Double)? in + value.map { (label: label, value: $0) } + } + } + private func resolvedSubscriptionAccountId(details: DetailedUsage, fallback accountId: String?) -> String? { if let email = details.email?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !email.isEmpty { diff --git a/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift index fd656f12..bf20cf27 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift @@ -31,6 +31,7 @@ enum ProviderIdentifier: String, CaseIterable { case chutes case tavilySearch = "tavily_search" case braveSearch = "brave_search" + case deepSeek = "deepseek" var displayName: String { switch self { @@ -76,6 +77,8 @@ enum ProviderIdentifier: String, CaseIterable { return "Tavily" case .braveSearch: return "Brave Search" + case .deepSeek: + return "DeepSeek" } } @@ -123,6 +126,8 @@ enum ProviderIdentifier: String, CaseIterable { return "Tavily" case .braveSearch: return "Brave" + case .deepSeek: + return "DeepSeek" } } @@ -170,6 +175,8 @@ enum ProviderIdentifier: String, CaseIterable { return "TavilyIcon" case .braveSearch: return "BraveSearchIcon" + case .deepSeek: + return "network" } } } diff --git a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift index dc4e6777..79d66fc5 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift @@ -157,6 +157,22 @@ struct DetailedUsage { let creditsBalance: Double? let planType: String? + // DeepSeek balance details (pay-as-you-go CNY balance) + let balanceCurrency: String? + let balanceGranted: Double? + let balanceToppedUp: Double? + + /// Currency symbol for `balanceCurrency` (e.g. "USD" -> "$", "CNY" -> "¥"). + /// Falls back to the raw code with a trailing space for unknown currencies. + var balanceCurrencySymbol: String { + switch balanceCurrency?.uppercased() { + case "USD", "US": return "$" + case "CNY", "RMB": return "¥" + case let code? where !code.isEmpty: return code + " " + default: return "" + } + } + // Chutes-specific value cap tracking let chutesMonthlyValueCapUSD: Double? let chutesMonthlyValueUsedUSD: Double? @@ -265,6 +281,9 @@ struct DetailedUsage { sparkSecondaryWindowHours: Int? = nil, creditsBalance: Double? = nil, planType: String? = nil, + balanceCurrency: String? = nil, + balanceGranted: Double? = nil, + balanceToppedUp: Double? = nil, chutesMonthlyValueCapUSD: Double? = nil, chutesMonthlyValueUsedUSD: Double? = nil, chutesMonthlyValueUsedPercent: Double? = nil, @@ -348,6 +367,9 @@ struct DetailedUsage { self.sparkSecondaryWindowHours = sparkSecondaryWindowHours self.creditsBalance = creditsBalance self.planType = planType + self.balanceCurrency = balanceCurrency + self.balanceGranted = balanceGranted + self.balanceToppedUp = balanceToppedUp self.chutesMonthlyValueCapUSD = chutesMonthlyValueCapUSD self.chutesMonthlyValueUsedUSD = chutesMonthlyValueUsedUSD self.chutesMonthlyValueUsedPercent = chutesMonthlyValueUsedPercent @@ -405,7 +427,7 @@ extension DetailedUsage: Codable { case codexPrimaryWindowLabel, codexPrimaryWindowHours, codexSecondaryWindowLabel, codexSecondaryWindowHours case sparkUsage, sparkReset, sparkSecondaryUsage, sparkSecondaryReset, sparkWindowLabel case sparkPrimaryWindowLabel, sparkPrimaryWindowHours, sparkSecondaryWindowLabel, sparkSecondaryWindowHours - case creditsBalance, planType + case creditsBalance, planType, balanceCurrency, balanceGranted, balanceToppedUp case chutesMonthlyValueCapUSD, chutesMonthlyValueUsedUSD, chutesMonthlyValueUsedPercent case openCodeGoMonthlyUsage, openCodeGoMonthlyReset, openCodeGoModelCount case extraUsageEnabled @@ -461,6 +483,9 @@ extension DetailedUsage: Codable { sparkSecondaryWindowHours = try container.decodeIfPresent(Int.self, forKey: .sparkSecondaryWindowHours) creditsBalance = try container.decodeIfPresent(Double.self, forKey: .creditsBalance) planType = try container.decodeIfPresent(String.self, forKey: .planType) + balanceCurrency = try container.decodeIfPresent(String.self, forKey: .balanceCurrency) + balanceGranted = try container.decodeIfPresent(Double.self, forKey: .balanceGranted) + balanceToppedUp = try container.decodeIfPresent(Double.self, forKey: .balanceToppedUp) chutesMonthlyValueCapUSD = try container.decodeIfPresent(Double.self, forKey: .chutesMonthlyValueCapUSD) chutesMonthlyValueUsedUSD = try container.decodeIfPresent(Double.self, forKey: .chutesMonthlyValueUsedUSD) chutesMonthlyValueUsedPercent = try container.decodeIfPresent(Double.self, forKey: .chutesMonthlyValueUsedPercent) @@ -547,6 +572,9 @@ extension DetailedUsage: Codable { try container.encodeIfPresent(sparkSecondaryWindowHours, forKey: .sparkSecondaryWindowHours) try container.encodeIfPresent(creditsBalance, forKey: .creditsBalance) try container.encodeIfPresent(planType, forKey: .planType) + try container.encodeIfPresent(balanceCurrency, forKey: .balanceCurrency) + try container.encodeIfPresent(balanceGranted, forKey: .balanceGranted) + try container.encodeIfPresent(balanceToppedUp, forKey: .balanceToppedUp) try container.encodeIfPresent(chutesMonthlyValueCapUSD, forKey: .chutesMonthlyValueCapUSD) try container.encodeIfPresent(chutesMonthlyValueUsedUSD, forKey: .chutesMonthlyValueUsedUSD) try container.encodeIfPresent(chutesMonthlyValueUsedPercent, forKey: .chutesMonthlyValueUsedPercent) @@ -625,6 +653,22 @@ struct JSONFormatter { let formatter = ISO8601DateFormatter() providerDict["resetsAt"] = formatter.string(from: resetsAt) } + // Balance-style providers (cost nil) expose the remaining + // balance from details instead. + if cost == nil, let details = result.details { + if let balance = details.creditsBalance { + providerDict["balance"] = balance + } + if let currency = details.balanceCurrency { + providerDict["currency"] = currency + } + if let granted = details.balanceGranted { + providerDict["grantedBalance"] = granted + } + if let toppedUp = details.balanceToppedUp { + providerDict["toppedUpBalance"] = toppedUp + } + } case .quotaBased(let remaining, let entitlement, let overagePermitted): providerDict["type"] = "quota-based" @@ -1075,6 +1119,9 @@ struct TableFormatter { if let cost = cost { metrics += String(format: "$%.2f spent", cost) + } else if let balance = result.details?.creditsBalance { + // Balance-style providers (cost nil): show remaining balance. + metrics += String(format: "%@%.2f remaining", result.details?.balanceCurrencySymbol ?? "", balance) } else { metrics += "Cost unavailable" } diff --git a/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift b/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift index 62b89dd3..5d750984 100644 --- a/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift +++ b/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift @@ -195,6 +195,7 @@ struct ProviderSubscriptionPresets { ] static let tavilySearch: [SubscriptionPreset] = [] static let braveSearch: [SubscriptionPreset] = [] + static let deepSeek: [SubscriptionPreset] = [] static func presets(for provider: ProviderIdentifier) -> [SubscriptionPreset] { switch provider { @@ -240,6 +241,8 @@ struct ProviderSubscriptionPresets { return synthetic case .chutes: return chutes + case .deepSeek: + return deepSeek } } } diff --git a/CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift new file mode 100644 index 00000000..18d8dd03 --- /dev/null +++ b/CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift @@ -0,0 +1,158 @@ +import Foundation +import os.log + +private let logger = Logger(subsystem: "com.opencodeproviders", category: "DeepSeekProvider") + +/// Provider for DeepSeek pay-as-you-go balance tracking. +/// +/// DeepSeek is billed as prepaid credit: the account carries a CNY balance +/// (split into topped-up and granted parts) reported by the official +/// `GET https://api.deepseek.com/user/balance` endpoint. There is no quota +/// window or utilization percentage — the balance is the only metric, so it +/// is surfaced through `DetailedUsage.creditsBalance` (plus +/// `balanceCurrency` / `balanceGranted` / `balanceToppedUp`) while +/// `payAsYouGo.cost` stays nil: cost means money spent, and a remaining +/// balance must never count toward the aggregate spend total. +final class DeepSeekProvider: ProviderProtocol { + let identifier: ProviderIdentifier = .deepSeek + let type: ProviderType = .payAsYouGo + + private let tokenManager: TokenManager + private let session: URLSession + /// Optional injected API key for tests; falls back to the credential store. + private let apiKeyOverride: String? + + init(tokenManager: TokenManager = .shared, session: URLSession = .shared, apiKey: String? = nil) { + self.tokenManager = tokenManager + self.session = session + self.apiKeyOverride = apiKey + } + + // MARK: - API Response Structures + + /// Response structure for /user/balance + struct BalanceResponse: Decodable { + let isAvailable: Bool? + let balanceInfos: [BalanceInfo]? + + enum CodingKeys: String, CodingKey { + case isAvailable = "is_available" + case balanceInfos = "balance_infos" + } + } + + struct BalanceInfo: Decodable { + let currency: String? + let totalBalance: String? + let grantedBalance: String? + let toppedUpBalance: String? + + enum CodingKeys: String, CodingKey { + case currency + case totalBalance = "total_balance" + case grantedBalance = "granted_balance" + case toppedUpBalance = "topped_up_balance" + } + } + + // MARK: - ProviderProtocol + + func fetch() async throws -> ProviderResult { + logger.info("DeepSeek balance fetch started") + + guard let apiKey = apiKeyOverride ?? tokenManager.getDeepSeekAPIKey() else { + logger.error("DeepSeek API key not found") + throw ProviderError.authenticationFailed("DeepSeek API key not available") + } + + let balanceResponse = try await fetchBalance(apiKey: apiKey) + + // `is_available=false` means the account currently has no usable + // balance for API calls; surface it as a warning for diagnostics. + // balance_infos is still rendered (a frozen/zero balance is informative). + if balanceResponse.isAvailable == false { + logger.warning("DeepSeek reports balance is not currently available for API calls") + } + + guard let balanceInfos = balanceResponse.balanceInfos, !balanceInfos.isEmpty else { + logger.error("DeepSeek balance response missing balance_infos") + throw ProviderError.decodingError("Missing balance_infos") + } + + // Explicit currency policy: prefer CNY (the account currency), fall + // back to USD; anything else is unsupported. Never pick `.first` — + // balance_infos order is not guaranteed. + let info = balanceInfos.first { $0.currency?.uppercased() == "CNY" } + ?? balanceInfos.first { $0.currency?.uppercased() == "USD" } + guard let info else { + let currencies = balanceInfos.compactMap { $0.currency }.joined(separator: ", ") + logger.error("DeepSeek balance response has no supported currency (CNY/USD), got: \(currencies)") + throw ProviderError.decodingError("Unsupported balance currency") + } + + guard let totalBalance = Double(info.totalBalance ?? "") else { + logger.error("DeepSeek balance response has unparseable total_balance: \(info.totalBalance ?? "nil")") + throw ProviderError.decodingError("Invalid total_balance") + } + // Granted/topped-up are secondary details; tolerate unparseable values. + let grantedBalance = Double(info.grantedBalance ?? "") ?? 0.0 + let toppedUpBalance = Double(info.toppedUpBalance ?? "") ?? 0.0 + let currency = info.currency ?? "CNY" + + logger.info("DeepSeek balance fetched: \(currency) \(String(format: "%.2f", totalBalance)) (granted: \(String(format: "%.2f", grantedBalance)), topped-up: \(String(format: "%.2f", toppedUpBalance)))") + + let details = DetailedUsage( + creditsBalance: totalBalance, + balanceCurrency: currency, + balanceGranted: grantedBalance, + balanceToppedUp: toppedUpBalance, + authSource: tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + ) + + // `cost` stays nil: it represents money already spent, while DeepSeek + // reports money remaining. The menu row reads the balance from details + // and the aggregate spend total never counts this provider. + return ProviderResult( + usage: .payAsYouGo(utilization: 0, cost: nil, resetsAt: nil), + details: details + ) + } + + // MARK: - Private API Methods + + /// Fetches the account balance from the DeepSeek API + /// - Parameter apiKey: DeepSeek API key + /// - Returns: BalanceResponse containing balance_infos + private func fetchBalance(apiKey: String) async throws -> BalanceResponse { + let endpoint = "https://api.deepseek.com/user/balance" + + guard let url = URL(string: endpoint) else { + logger.error("Invalid balance endpoint URL") + throw ProviderError.networkError("Invalid endpoint URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid response type from balance API") + throw ProviderError.networkError("Invalid response type") + } + + guard httpResponse.statusCode == 200 else { + logger.error("Balance API request failed with status code: \(httpResponse.statusCode)") + throw ProviderError.networkError("HTTP \(httpResponse.statusCode)") + } + + do { + return try JSONDecoder().decode(BalanceResponse.self, from: data) + } catch { + logger.error("Failed to decode balance response: \(error.localizedDescription)") + throw ProviderError.decodingError(error.localizedDescription) + } + } +} diff --git a/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift b/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift index 064fbcc2..88387378 100644 --- a/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift @@ -46,7 +46,8 @@ actor ProviderManager { ChutesProvider(), SyntheticProvider(), TavilySearchProvider(), - BraveSearchProvider() + BraveSearchProvider(), + DeepSeekProvider() ] } diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index 68b6027f..0e97f556 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -217,6 +217,7 @@ struct OpenCodeAuth: Codable { let openaiAPIKey: APIKey? let githubCopilot: OAuth? let openrouter: APIKey? + let deepseek: APIKey? let opencode: APIKey? let openCodeGo: APIKey? let kimiForCoding: APIKey? @@ -227,7 +228,7 @@ struct OpenCodeAuth: Codable { let chutes: APIKey? enum CodingKeys: String, CodingKey { - case anthropic, openai, openrouter, opencode, synthetic, chutes + case anthropic, openai, openrouter, opencode, synthetic, chutes, deepseek case openCodeGo = "opencode-go" case githubCopilot = "github-copilot" case kimiForCoding = "kimi-for-coding" @@ -242,6 +243,7 @@ struct OpenCodeAuth: Codable { openaiAPIKey: APIKey?, githubCopilot: OAuth?, openrouter: APIKey?, + deepseek: APIKey?, opencode: APIKey?, openCodeGo: APIKey?, kimiForCoding: APIKey?, @@ -256,6 +258,7 @@ struct OpenCodeAuth: Codable { self.openaiAPIKey = openaiAPIKey self.githubCopilot = githubCopilot self.openrouter = openrouter + self.deepseek = deepseek self.opencode = opencode self.openCodeGo = openCodeGo self.kimiForCoding = kimiForCoding @@ -276,6 +279,7 @@ struct OpenCodeAuth: Codable { : nil githubCopilot = Self.decodeLossyIfPresent(OAuth.self, from: container, forKey: .githubCopilot) openrouter = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .openrouter) + deepseek = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .deepseek) opencode = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .opencode) openCodeGo = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .openCodeGo) kimiForCoding = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .kimiForCoding) @@ -290,6 +294,7 @@ struct OpenCodeAuth: Codable { openaiAPIKey == nil, githubCopilot == nil, openrouter == nil, + deepseek == nil, opencode == nil, openCodeGo == nil, kimiForCoding == nil, @@ -327,6 +332,7 @@ struct OpenCodeAuth: Codable { if openai == nil { try container.encodeIfPresent(openaiAPIKey, forKey: .openai) } try container.encodeIfPresent(githubCopilot, forKey: .githubCopilot) try container.encodeIfPresent(openrouter, forKey: .openrouter) + try container.encodeIfPresent(deepseek, forKey: .deepseek) try container.encodeIfPresent(opencode, forKey: .opencode) try container.encodeIfPresent(openCodeGo, forKey: .openCodeGo) try container.encodeIfPresent(kimiForCoding, forKey: .kimiForCoding) @@ -4235,6 +4241,13 @@ final class TokenManager: @unchecked Sendable { return auth.openrouter?.key } + /// Gets DeepSeek API key from OpenCode auth + /// - Returns: API key string if available, nil otherwise + func getDeepSeekAPIKey() -> String? { + guard let auth = readOpenCodeAuth() else { return nil } + return auth.deepseek?.key + } + func getOpenCodeAPIKey() -> String? { guard let auth = readOpenCodeAuth() else { return nil } return auth.opencode?.key diff --git a/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift b/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift index fcaa4e8d..fc064044 100644 --- a/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift +++ b/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift @@ -172,6 +172,8 @@ final class MultiProviderStatusBarIconView: NSView { iconName = "TavilyIcon" case .braveSearch: iconName = "BraveSearchIcon" + case .deepSeek: + iconName = "dollarsign.circle" } let icon: NSImage diff --git a/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift b/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift index 868e2f87..538429d2 100644 --- a/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift +++ b/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift @@ -152,7 +152,7 @@ struct SwiftUIProviderAlertView: View { case .nanoGpt: return "n.circle" case .synthetic: return "diamond" case .chutes: return "c.circle" - case .tavilySearch, .braveSearch, .minimaxCodingPlan: return nil + case .tavilySearch, .braveSearch, .minimaxCodingPlan, .deepSeek: return nil } } } diff --git a/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift b/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift index f9bde6c0..977ea9b6 100644 --- a/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift @@ -428,4 +428,54 @@ final class CLIFormatterTests: XCTestCase { "Separator must be at least as wide as every data row. Row: \(row)") } } + // MARK: - Balance-style pay-as-you-go formatter tests (DeepSeek) + + /// Table metrics must show the remaining balance (CNY) instead of + /// "Cost unavailable" when cost is nil and details carry a balance. + func testDeepSeekTableShowsRemainingBalance() { + let details = DetailedUsage( + creditsBalance: 103.49, + balanceCurrency: "CNY", + balanceGranted: 0.0, + balanceToppedUp: 103.49 + ) + let usage = ProviderUsage.payAsYouGo(utilization: 0, cost: nil, resetsAt: nil) + let result = ProviderResult(usage: usage, details: details) + + let output = TableFormatter.format([.deepSeek: result]) + XCTAssertTrue(output.contains("¥103.49 remaining"), "Table should show CNY remaining balance, got:\n\(output)") + XCTAssertFalse(output.contains("Cost unavailable"), "Balance must not be reported as unavailable:\n\(output)") + } + + /// JSON must emit balance/currency/granted/topped-up for balance-style + /// pay-as-you-go providers and omit "cost". + func testDeepSeekJSONIncludesBalanceFields() throws { + let details = DetailedUsage( + creditsBalance: 103.49, + balanceCurrency: "CNY", + balanceGranted: 0.0, + balanceToppedUp: 103.49 + ) + let usage = ProviderUsage.payAsYouGo(utilization: 0, cost: nil, resetsAt: nil) + let result = ProviderResult(usage: usage, details: details) + + let json = try JSONFormatter.format([.deepSeek: result]) + XCTAssertTrue(json.contains("\"balance\" : 103.49"), "Missing balance in:\n\(json)") + XCTAssertTrue(json.contains("\"currency\" : \"CNY\""), "Missing currency in:\n\(json)") + XCTAssertTrue(json.contains("\"grantedBalance\" : 0"), "Missing grantedBalance in:\n\(json)") + XCTAssertTrue(json.contains("\"toppedUpBalance\" : 103.49"), "Missing toppedUpBalance in:\n\(json)") + XCTAssertFalse(json.contains("\"cost\""), "cost must stay nil for balance-style providers:\n\(json)") + } + + /// Providers with a real cost keep the existing "$x spent" rendering. + func testPayAsYouGoWithCostKeepsSpentRendering() throws { + let usage = ProviderUsage.payAsYouGo(utilization: 0, cost: 12.34, resetsAt: nil) + let result = ProviderResult(usage: usage, details: nil) + + let table = TableFormatter.format([.openRouter: result]) + XCTAssertTrue(table.contains("$12.34 spent"), "Table should show spent cost, got:\n\(table)") + + let json = try JSONFormatter.format([.openRouter: result]) + XCTAssertTrue(json.contains("\"cost\" : 12.34"), "JSON should keep cost, got:\n\(json)") + } } diff --git a/CopilotMonitor/CopilotMonitorTests/DeepSeekProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/DeepSeekProviderTests.swift new file mode 100644 index 00000000..f815fbfd --- /dev/null +++ b/CopilotMonitor/CopilotMonitorTests/DeepSeekProviderTests.swift @@ -0,0 +1,259 @@ +import XCTest +@testable import OpenCode_Bar + +final class DeepSeekProviderTests: XCTestCase { + private final class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + } + + private func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: configuration) + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + super.tearDown() + } + + /// Provider with an injected fake API key so tests run without the + /// credential store (and therefore execute in CI). + private func makeProvider(statusCode: Int = 200, body: String) -> DeepSeekProvider { + let session = makeSession() + let provider = DeepSeekProvider(tokenManager: .shared, session: session, apiKey: "sk-test-fake") + + MockURLProtocol.requestHandler = { request in + // Assert the request shape so endpoint/method/auth regressions fail. + let url = try XCTUnwrap(request.url) + XCTAssertEqual(url.absoluteString, "https://api.deepseek.com/user/balance") + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer sk-test-fake") + + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + return (response, Data(body.utf8)) + } + return provider + } + + // MARK: - Identity + + func testProviderIdentifier() { + let provider = DeepSeekProvider() + XCTAssertEqual(provider.identifier, .deepSeek) + } + + func testProviderType() { + let provider = DeepSeekProvider() + XCTAssertEqual(provider.type, .payAsYouGo) + } + + // MARK: - Response decoding + + /// Real /user/balance response shape (string amounts, CNY). + private let balanceJSON = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "103.49", + "granted_balance": "0.00", + "topped_up_balance": "103.49" + } + ] + } + """ + + func testBalanceResponseDecodesStringAmounts() throws { + let response = try JSONDecoder().decode( + DeepSeekProvider.BalanceResponse.self, + from: balanceJSON.data(using: .utf8)! + ) + let info = try XCTUnwrap(response.balanceInfos?.first) + XCTAssertEqual(info.currency, "CNY") + XCTAssertEqual(info.totalBalance, "103.49") + XCTAssertEqual(info.grantedBalance, "0.00") + XCTAssertEqual(info.toppedUpBalance, "103.49") + XCTAssertEqual(response.isAvailable, true) + } + + // MARK: - Fetch + + func testFetchSuccessSurfacesBalanceInDetails() async throws { + let result = try await makeProvider(body: balanceJSON).fetch() + + guard case .payAsYouGo(let utilization, let cost, _) = result.usage else { + return XCTFail("Expected payAsYouGo usage") + } + XCTAssertEqual(utilization, 0) + // Balance is not spend: cost must stay nil so the aggregate total is unaffected. + XCTAssertNil(cost) + + let details = try XCTUnwrap(result.details) + XCTAssertEqual(details.creditsBalance, 103.49) + XCTAssertEqual(details.balanceCurrency, "CNY") + XCTAssertEqual(details.balanceCurrencySymbol, "¥") + XCTAssertEqual(details.balanceGranted, 0.0) + XCTAssertEqual(details.balanceToppedUp, 103.49) + } + + func testFetchUnavailableBalanceStillReturnsData() async throws { + // is_available=false is a diagnostic signal; balance_infos may still be + // present (zeroed/frozen) and should still be rendered. + let unavailableJSON = """ + { + "is_available": false, + "balance_infos": [ + {"currency": "CNY", "total_balance": "0.00", "granted_balance": "0.00", "topped_up_balance": "0.00"} + ] + } + """ + let result = try await makeProvider(body: unavailableJSON).fetch() + let details = try XCTUnwrap(result.details) + XCTAssertEqual(details.creditsBalance, 0.0) + XCTAssertEqual(details.balanceCurrency, "CNY") + } + + func testFetchUnparseableTotalBalanceThrowsDecodingError() async throws { + let badJSON = """ + { + "is_available": true, + "balance_infos": [ + {"currency": "CNY", "total_balance": "not-a-number", "granted_balance": "0.00", "topped_up_balance": "0.00"} + ] + } + """ + do { + _ = try await makeProvider(body: badJSON).fetch() + XCTFail("Expected decodingError for unparseable total_balance") + } catch let error as ProviderError { + guard case .decodingError = error else { + return XCTFail("Expected decodingError, got \(error)") + } + } + } + + func testFetchPropagatesHTTPError() async throws { + do { + _ = try await makeProvider(statusCode: 401, body: "").fetch() + XCTFail("Expected network error for 401") + } catch let error as ProviderError { + guard case .networkError = error else { + return XCTFail("Expected networkError, got \(error)") + } + } + } + + // MARK: - Multi-currency policy + + /// CNY must win over USD regardless of array order. + func testMultiCurrencyPrefersCNYRegardlessOfOrder() async throws { + let usdFirst = """ + {"is_available": true, "balance_infos": [ + {"currency": "USD", "total_balance": "12.00", "granted_balance": "0.00", "topped_up_balance": "12.00"}, + {"currency": "CNY", "total_balance": "88.88", "granted_balance": "0.00", "topped_up_balance": "88.88"} + ]} + """ + let cnyFirst = """ + {"is_available": true, "balance_infos": [ + {"currency": "CNY", "total_balance": "88.88", "granted_balance": "0.00", "topped_up_balance": "88.88"}, + {"currency": "USD", "total_balance": "12.00", "granted_balance": "0.00", "topped_up_balance": "12.00"} + ]} + """ + for body in [usdFirst, cnyFirst] { + let result = try await makeProvider(body: body).fetch() + let details = try XCTUnwrap(result.details) + XCTAssertEqual(details.balanceCurrency, "CNY", "CNY must win regardless of array order") + XCTAssertEqual(details.creditsBalance, 88.88) + XCTAssertEqual(details.balanceCurrencySymbol, "¥") + } + } + + /// USD-only accounts must render with the dollar symbol. + func testUSDBalanceSurfacesWithDollarSymbol() async throws { + let usdOnly = """ + {"is_available": true, "balance_infos": [ + {"currency": "USD", "total_balance": "12.00", "granted_balance": "2.00", "topped_up_balance": "10.00"} + ]} + """ + let result = try await makeProvider(body: usdOnly).fetch() + let details = try XCTUnwrap(result.details) + XCTAssertEqual(details.balanceCurrency, "USD") + XCTAssertEqual(details.creditsBalance, 12.0) + XCTAssertEqual(details.balanceCurrencySymbol, "$") + } + + /// Unsupported-only balances must fail loudly instead of picking an arbitrary entry. + func testUnsupportedCurrencyThrowsDecodingError() async throws { + let eurOnly = """ + {"is_available": true, "balance_infos": [ + {"currency": "EUR", "total_balance": "12.00", "granted_balance": "0.00", "topped_up_balance": "12.00"} + ]} + """ + do { + _ = try await makeProvider(body: eurOnly).fetch() + XCTFail("Expected decodingError for unsupported currency") + } catch let error as ProviderError { + guard case .decodingError = error else { + return XCTFail("Expected decodingError, got \(error)") + } + } + } + + // MARK: - Detail menu rows + + /// The DeepSeek detail submenu rows: Balance/Topped-up/Granted in display + /// order, formatted with the currency symbol. + @MainActor + func testDeepSeekBalanceRowsOrderValuesAndCurrency() { + let details = DetailedUsage( + creditsBalance: 103.49, + balanceCurrency: "CNY", + balanceGranted: 0.0, + balanceToppedUp: 103.49 + ) + + let rows = StatusBarController.deepSeekBalanceRows(details: details) + XCTAssertEqual(rows.map(\.label), ["Balance", "Topped-up", "Granted"]) + XCTAssertEqual(rows[0].value, 103.49) + XCTAssertEqual(rows[1].value, 103.49) + XCTAssertEqual(rows[2].value, 0.0) + + // The menu renders "label: ". + let rendered = rows.map { String(format: "%@: %@%.2f", $0.label, details.balanceCurrencySymbol, $0.value) } + XCTAssertEqual(rendered, ["Balance: ¥103.49", "Topped-up: ¥103.49", "Granted: ¥0.00"]) + } + +}