diff --git a/CopilotMonitor/CLI/CLIProviderManager.swift b/CopilotMonitor/CLI/CLIProviderManager.swift index a8fbc5b..f101583 100644 --- a/CopilotMonitor/CLI/CLIProviderManager.swift +++ b/CopilotMonitor/CLI/CLIProviderManager.swift @@ -15,6 +15,7 @@ actor CLIProviderManager { static let registeredProviders: [ProviderIdentifier] = [ .claude, .codex, .geminiCLI, .openRouter, .antigravity, .openCodeZen, .kimi, .zaiCodingPlan, + .nanoGpt, .chutes, .copilot, .synthetic ] @@ -22,8 +23,8 @@ actor CLIProviderManager { // MARK: - Initialization init() { - // Initialize all 11 providers - // 10 shared providers (no UI dependencies) + // Initialize all providers + // Shared providers (no UI dependencies) let claudeProvider = ClaudeProvider() let codexProvider = CodexProvider() let geminiCLIProvider = GeminiCLIProvider() @@ -32,6 +33,7 @@ actor CLIProviderManager { let openCodeZenProvider = OpenCodeZenProvider() let kimiProvider = KimiProvider() let zaiCodingPlanProvider = ZaiCodingPlanProvider() + let nanoGptProvider = NanoGptProvider() let chutesProvider = ChutesProvider() let syntheticProvider = SyntheticProvider() @@ -47,6 +49,7 @@ actor CLIProviderManager { openCodeZenProvider, kimiProvider, zaiCodingPlanProvider, + nanoGptProvider, chutesProvider, copilotCLIProvider, syntheticProvider diff --git a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj index bd6300a..7ff8aa9 100644 --- a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj +++ b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj @@ -33,6 +33,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 */; }; + NANOGPTAPP11111111111111 /* NanoGptProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = NANOGPTFILE222222222222 /* NanoGptProvider.swift */; }; A55555555555555555555555 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A66666666666666666666666 /* Assets.xcassets */; }; AD95EBD6AE3134DF4C797577 /* ClaudeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDC1B4DE6B5118CE4AE8F82 /* ClaudeProvider.swift */; }; AM1111111111111111111111 /* AppMigrationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AM2222222222222222222222 /* AppMigrationHelper.swift */; }; @@ -51,6 +52,7 @@ CLIHISTORY11111111111111 /* CopilotHistoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1085A77EF4A58E5B5EC71B /* CopilotHistoryService.swift */; }; CLIKIMI1111111111111111 /* KimiProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = KI2222222222222222222222 /* KimiProvider.swift */; }; CLIZAI11111111111111111 /* ZaiCodingPlanProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A454D8C32F30548900E355E3 /* ZaiCodingPlanProvider.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 */; }; CLIPROVMGR11111111111111 /* CLIProviderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CLIPROVMGR22222222222222 /* CLIProviderManager.swift */; }; @@ -64,6 +66,7 @@ KI1111111111111111111111 /* KimiProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = KI2222222222222222222222 /* KimiProvider.swift */; }; 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 */; }; ME1111111111111111111111 /* MenuEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = ME2222222222222222222222 /* MenuEnums.swift */; }; OC1111111111111111111111 /* OpenCodeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OC2222222222222222222222 /* OpenCodeProvider.swift */; }; OR1111111111111111111111 /* OpenRouterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = OR2222222222222222222222 /* OpenRouterProvider.swift */; }; @@ -142,6 +145,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 = ""; }; + NANOGPTFILE222222222222 /* NanoGptProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NanoGptProvider.swift; sourceTree = ""; }; A66666666666666666666666 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A77777777777777777777777 /* OpenCode Bar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "OpenCode Bar.app"; sourceTree = BUILT_PRODUCTS_DIR; }; A88888888888888888888888 /* CopilotMonitor.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CopilotMonitor.entitlements; sourceTree = ""; }; @@ -182,6 +186,7 @@ TAAAAAAAAAAAAAAAAAAAAAAA /* gemini_response.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = gemini_response.json; sourceTree = ""; }; 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 = ""; }; TDDDDDDDDDDDDDDDDDDDDDD /* CopilotMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CopilotMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -233,6 +238,7 @@ isa = PBXGroup; children = ( A454D8C32F30548900E355E3 /* ZaiCodingPlanProvider.swift */, + NANOGPTFILE222222222222 /* NanoGptProvider.swift */, 06EC3B683EB6892E4F9C8316 /* GeminiCLIProvider.swift */, 4DDC1B4DE6B5118CE4AE8F82 /* ClaudeProvider.swift */, 76783C44AA2329AE3FA7E981 /* CodexProvider.swift */, @@ -388,6 +394,7 @@ C8493409A9188CFF5B73D5B3 /* MenuDesignTokenTests.swift */, 54353FD130DDE0500F6B367F /* MenuResultBuilderTests.swift */, SYNTHTEST1111111111111111 /* SyntheticProviderTests.swift */, + NANOGPTTESTFR1111111111 /* NanoGptProviderTests.swift */, ); path = CopilotMonitorTests; sourceTree = ""; @@ -552,6 +559,7 @@ CLIOPENCZEN1111111111111 /* OpenCodeZenProvider.swift in Sources */, CLIKIMI1111111111111111 /* KimiProvider.swift in Sources */, CLIZAI11111111111111111 /* ZaiCodingPlanProvider.swift in Sources */, + NANOGPTCLI11111111111111 /* NanoGptProvider.swift in Sources */, 283349022F313176004DADE1 /* ChutesProvider.swift in Sources */, CLISYNTHETIC1111111111111 /* SyntheticProvider.swift in Sources */, CLIPROVMGR11111111111111 /* CLIProviderManager.swift in Sources */, @@ -567,8 +575,9 @@ 283348F92F313096004DADE1 /* ProviderResult.swift in Sources */, A33333333333333333333333 /* AppDelegate.swift in Sources */, AM1111111111111111111111 /* AppMigrationHelper.swift in Sources */, - A454D8C42F30548900E355E3 /* ZaiCodingPlanProvider.swift in Sources */, - BCDE4599B74AF7A799CE1D /* StatusBarIconView.swift in Sources */, + A454D8C42F30548900E355E3 /* ZaiCodingPlanProvider.swift in Sources */, + NANOGPTAPP11111111111111 /* NanoGptProvider.swift in Sources */, + BCDE4599B74AF7A799CE1D /* StatusBarIconView.swift in Sources */, ME1111111111111111111111 /* MenuEnums.swift in Sources */, D11111111111111111111111 /* StatusBarController.swift in Sources */, E11111111111111111111111 /* CopilotUsage.swift in Sources */, @@ -612,9 +621,10 @@ OR4444444444444444444444 /* OpenRouterProviderTests.swift in Sources */, 668B6906C95903D51823808A /* DependencyTests.swift in Sources */, A1BED4A5CA1FEC2DADC461C2 /* MenuDesignTokenTests.swift in Sources */, - B58BAD3BFD97973070A2A892 /* MenuResultBuilderTests.swift in Sources */, - SYNTHTEST2222222222222222 /* SyntheticProviderTests.swift in Sources */, - ); + B58BAD3BFD97973070A2A892 /* MenuResultBuilderTests.swift in Sources */, + SYNTHTEST2222222222222222 /* SyntheticProviderTests.swift in Sources */, + NANOGPTTESTBF1111111111 /* NanoGptProviderTests.swift in Sources */, + ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index caad29d..cbf7a21 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -927,6 +927,7 @@ final class StatusBarController: NSObject { .kimi, .codex, .zaiCodingPlan, + .nanoGpt, .antigravity, .chutes, .synthetic @@ -1040,6 +1041,9 @@ final class StatusBarController: NSObject { } else if identifier == .zaiCodingPlan { let percents = [account.details?.tokenUsagePercent, account.details?.mcpUsagePercent].compactMap { $0 } usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents + } else if identifier == .nanoGpt { + let percents = [account.details?.tokenUsagePercent, account.details?.mcpUsagePercent].compactMap { $0 } + usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents } else { usedPercents = [account.usage.usagePercentage] } @@ -1090,6 +1094,9 @@ final class StatusBarController: NSObject { } else if identifier == .zaiCodingPlan { let percents = [result.details?.tokenUsagePercent, result.details?.mcpUsagePercent].compactMap { $0 } usedPercents = percents.isEmpty ? [singlePercent] : percents + } else if identifier == .nanoGpt { + let percents = [result.details?.tokenUsagePercent, result.details?.mcpUsagePercent].compactMap { $0 } + usedPercents = percents.isEmpty ? [singlePercent] : percents } else { usedPercents = [singlePercent] } @@ -1486,6 +1493,8 @@ final class StatusBarController: NSObject { image = NSImage(systemSymbolName: identifier.iconName, accessibilityDescription: identifier.displayName) case .zaiCodingPlan: image = NSImage(named: "ZaiIcon") + case .nanoGpt: + image = NSImage(named: "NanoGptIcon") case .synthetic: image = NSImage(named: "SyntheticIcon") case .chutes: diff --git a/CopilotMonitor/CopilotMonitor/Assets.xcassets/NanoGptIcon.imageset/Contents.json b/CopilotMonitor/CopilotMonitor/Assets.xcassets/NanoGptIcon.imageset/Contents.json new file mode 100644 index 0000000..e34b65a --- /dev/null +++ b/CopilotMonitor/CopilotMonitor/Assets.xcassets/NanoGptIcon.imageset/Contents.json @@ -0,0 +1,17 @@ +{ + "images" : [ + { + "filename" : "nano-gpt-logo.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/CopilotMonitor/CopilotMonitor/Assets.xcassets/NanoGptIcon.imageset/nano-gpt-logo.png b/CopilotMonitor/CopilotMonitor/Assets.xcassets/NanoGptIcon.imageset/nano-gpt-logo.png new file mode 100644 index 0000000..e1896de Binary files /dev/null and b/CopilotMonitor/CopilotMonitor/Assets.xcassets/NanoGptIcon.imageset/nano-gpt-logo.png differ diff --git a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift index 4e0bf55..3810cb5 100644 --- a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift +++ b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift @@ -582,6 +582,59 @@ extension StatusBarController { // === Subscription === addSubscriptionItems(to: submenu, provider: .zaiCodingPlan, accountId: accountId) + case .nanoGpt: + if let dailyUsage = details.tokenUsagePercent { + let rows = createUsageWindowRow( + label: "Daily", + usagePercent: dailyUsage, + resetDate: details.tokenUsageReset, + windowHours: 24 + ) + rows.forEach { submenu.addItem($0) } + } + if let dailyUsed = details.tokenUsageUsed, + let dailyTotal = details.tokenUsageTotal { + let item = createLimitRow(label: "Daily Units", used: Double(dailyUsed), total: Double(dailyTotal)) + submenu.addItem(item) + } + + if details.tokenUsagePercent != nil, details.mcpUsagePercent != nil { + submenu.addItem(NSMenuItem.separator()) + } + + if let monthlyUsage = details.mcpUsagePercent { + let rows = createUsageWindowRow( + label: "Monthly", + usagePercent: monthlyUsage, + resetDate: details.mcpUsageReset, + isMonthly: true + ) + rows.forEach { submenu.addItem($0) } + } + if let monthlyUsed = details.mcpUsageUsed, + let monthlyTotal = details.mcpUsageTotal { + let item = createLimitRow(label: "Monthly Units", used: Double(monthlyUsed), total: Double(monthlyTotal)) + submenu.addItem(item) + } + + if details.creditsBalance != nil || details.totalCredits != nil { + submenu.addItem(NSMenuItem.separator()) + } + + if let usdBalance = details.creditsBalance { + let item = NSMenuItem() + item.view = createDisabledLabelView(text: String(format: "USD Balance: $%.2f", usdBalance)) + submenu.addItem(item) + } + + if let nanoBalance = details.totalCredits { + let item = NSMenuItem() + item.view = createDisabledLabelView(text: String(format: "NANO Balance: %.8f", nanoBalance)) + submenu.addItem(item) + } + + addSubscriptionItems(to: submenu, provider: .nanoGpt, accountId: accountId) + case .chutes: if let daily = details.dailyUsage, let limit = details.limit { diff --git a/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift index d70c758..0c1a672 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift @@ -20,6 +20,7 @@ enum ProviderIdentifier: String, CaseIterable { case openCodeZen = "opencode_zen" case kimi case zaiCodingPlan = "zai_coding_plan" + case nanoGpt = "nano_gpt" case synthetic case chutes @@ -45,6 +46,8 @@ enum ProviderIdentifier: String, CaseIterable { return "Kimi for Coding" case .zaiCodingPlan: return "Z.AI Coding Plan" + case .nanoGpt: + return "Nano-GPT" case .synthetic: return "Synthetic" case .chutes: @@ -74,6 +77,8 @@ enum ProviderIdentifier: String, CaseIterable { return "k.circle" case .zaiCodingPlan: return "globe" + case .nanoGpt: + return "NanoGptIcon" case .synthetic: return "SyntheticIcon" case .chutes: diff --git a/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift b/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift index 03a742f..504af23 100644 --- a/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift +++ b/CopilotMonitor/CopilotMonitor/Models/SubscriptionSettings.swift @@ -151,6 +151,10 @@ struct ProviderSubscriptionPresets { SubscriptionPreset(name: "Pro", cost: 60) ] + static let nanoGpt: [SubscriptionPreset] = [ + SubscriptionPreset(name: "Subscription", cost: 8) + ] + static let openRouter: [SubscriptionPreset] = [] static let openCode: [SubscriptionPreset] = [] static let openCodeZen: [SubscriptionPreset] = [] @@ -177,6 +181,8 @@ struct ProviderSubscriptionPresets { return openCodeZen case .zaiCodingPlan: return zaiCodingPlan + case .nanoGpt: + return nanoGpt case .synthetic: return synthetic case .chutes: diff --git a/CopilotMonitor/CopilotMonitor/Providers/NanoGptProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/NanoGptProvider.swift new file mode 100644 index 0000000..c663ebb --- /dev/null +++ b/CopilotMonitor/CopilotMonitor/Providers/NanoGptProvider.swift @@ -0,0 +1,289 @@ +import Foundation +import os.log + +private let logger = Logger(subsystem: "com.opencodeproviders", category: "NanoGptProvider") + +private struct NanoGptSubscriptionUsageResponse: Decodable { + struct Limits: Decodable { + let daily: Int? + let monthly: Int? + + private enum CodingKeys: String, CodingKey { + case daily + case monthly + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + daily = NanoGptSubscriptionUsageResponse.decodeInt(container, forKey: .daily) + monthly = NanoGptSubscriptionUsageResponse.decodeInt(container, forKey: .monthly) + } + } + + struct WindowUsage: Decodable { + let used: Int? + let remaining: Int? + let percentUsed: Double? + let resetAt: Int64? + + private enum CodingKeys: String, CodingKey { + case used + case remaining + case percentUsed + case resetAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + used = NanoGptSubscriptionUsageResponse.decodeInt(container, forKey: .used) + remaining = NanoGptSubscriptionUsageResponse.decodeInt(container, forKey: .remaining) + percentUsed = NanoGptSubscriptionUsageResponse.decodeDouble(container, forKey: .percentUsed) + resetAt = NanoGptSubscriptionUsageResponse.decodeInt64(container, forKey: .resetAt) + } + } + + struct Period: Decodable { + let currentPeriodEnd: String? + } + + let active: Bool? + let limits: Limits? + let daily: WindowUsage? + let monthly: WindowUsage? + let period: Period? + let state: String? + let graceUntil: String? +} + +private struct NanoGptBalanceResponse: Decodable { + let usdBalance: String? + let nanoBalance: String? + + private enum CodingKeys: String, CodingKey { + case usdBalance = "usd_balance" + case nanoBalance = "nano_balance" + } +} + +private extension NanoGptSubscriptionUsageResponse { + static func decodeInt(_ container: KeyedDecodingContainer, forKey key: Key) -> Int? { + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + return Int(value) + } + return nil + } + + static func decodeInt64(_ container: KeyedDecodingContainer, forKey key: Key) -> Int64? { + if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return Int64(value) + } + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return Int64(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + return Int64(value) + } + return nil + } + + static func decodeDouble(_ container: KeyedDecodingContainer, forKey key: Key) -> Double? { + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + return Double(value) + } + return nil + } +} + +final class NanoGptProvider: ProviderProtocol { + let identifier: ProviderIdentifier = .nanoGpt + let type: ProviderType = .quotaBased + + private let tokenManager: TokenManager + private let session: URLSession + + init(tokenManager: TokenManager = .shared, session: URLSession = .shared) { + self.tokenManager = tokenManager + self.session = session + } + + func fetch() async throws -> ProviderResult { + logger.info("Nano-GPT fetch started") + + guard let apiKey = tokenManager.getNanoGptAPIKey() else { + logger.error("Nano-GPT API key not found") + throw ProviderError.authenticationFailed("Nano-GPT API key not available") + } + + async let usageResponseTask = fetchSubscriptionUsage(apiKey: apiKey) + async let balanceResponseTask = fetchBalance(apiKey: apiKey) + + let usageResponse = try await usageResponseTask + let balanceResponse = try? await balanceResponseTask + + guard let monthlyLimit = usageResponse.limits?.monthly, + monthlyLimit > 0 else { + logger.error("Nano-GPT monthly limit missing") + throw ProviderError.decodingError("Missing Nano-GPT monthly limit") + } + + let monthlyUsed = usageResponse.monthly?.used ?? 0 + let monthlyRemaining = usageResponse.monthly?.remaining ?? max(0, monthlyLimit - monthlyUsed) + let monthlyPercentUsed = normalizedPercent( + usageResponse.monthly?.percentUsed, + used: monthlyUsed, + total: monthlyLimit + ) + + let usage = ProviderUsage.quotaBased( + remaining: max(0, monthlyRemaining), + entitlement: monthlyLimit, + overagePermitted: false + ) + + let dailyLimit = usageResponse.limits?.daily + let dailyUsed = usageResponse.daily?.used + let dailyPercentUsed = normalizedPercent( + usageResponse.daily?.percentUsed, + used: dailyUsed, + total: dailyLimit + ) + + let details = DetailedUsage( + totalCredits: parseDouble(balanceResponse?.nanoBalance), + resetPeriod: formatISO8601(usageResponse.period?.currentPeriodEnd), + creditsBalance: parseDouble(balanceResponse?.usdBalance), + authSource: tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json", + tokenUsagePercent: dailyPercentUsed, + tokenUsageReset: dateFromMilliseconds(usageResponse.daily?.resetAt), + tokenUsageUsed: dailyUsed, + tokenUsageTotal: dailyLimit, + mcpUsagePercent: monthlyPercentUsed, + mcpUsageReset: dateFromMilliseconds(usageResponse.monthly?.resetAt), + mcpUsageUsed: monthlyUsed, + mcpUsageTotal: monthlyLimit + ) + + logger.info( + "Nano-GPT usage fetched: daily=\(dailyPercentUsed?.description ?? "n/a")% used, monthly=\(monthlyPercentUsed?.description ?? "n/a")% used" + ) + + return ProviderResult(usage: usage, details: details) + } + + private func fetchSubscriptionUsage(apiKey: String) async throws -> NanoGptSubscriptionUsageResponse { + guard let url = URL(string: "https://nano-gpt.com/api/subscription/v1/usage") else { + throw ProviderError.networkError("Invalid Nano-GPT usage endpoint") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + try validateHTTP(response: response, data: data) + + do { + return try JSONDecoder().decode(NanoGptSubscriptionUsageResponse.self, from: data) + } catch { + logger.error("Failed to decode Nano-GPT usage: \(error.localizedDescription)") + throw ProviderError.decodingError("Invalid Nano-GPT usage response") + } + } + + private func fetchBalance(apiKey: String) async throws -> NanoGptBalanceResponse { + guard let url = URL(string: "https://nano-gpt.com/api/check-balance") else { + throw ProviderError.networkError("Invalid Nano-GPT balance endpoint") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + try validateHTTP(response: response, data: data) + + do { + return try JSONDecoder().decode(NanoGptBalanceResponse.self, from: data) + } catch { + logger.error("Failed to decode Nano-GPT balance: \(error.localizedDescription)") + throw ProviderError.decodingError("Invalid Nano-GPT balance response") + } + } + + private func validateHTTP(response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw ProviderError.networkError("Invalid response type") + } + + if httpResponse.statusCode == 401 { + throw ProviderError.authenticationFailed("Invalid Nano-GPT API key") + } + + guard (200...299).contains(httpResponse.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + logger.error("Nano-GPT HTTP \(httpResponse.statusCode): \(body, privacy: .public)") + throw ProviderError.networkError("HTTP \(httpResponse.statusCode)") + } + } + + private func normalizedPercent(_ percentValue: Double?, used: Int?, total: Int?) -> Double? { + if let percentValue { + if percentValue <= 1.0 { + return min(max(percentValue * 100.0, 0), 100) + } + return min(max(percentValue, 0), 100) + } + + guard let used, let total, total > 0 else { + return nil + } + + return min(max((Double(used) / Double(total)) * 100.0, 0), 100) + } + + private func dateFromMilliseconds(_ milliseconds: Int64?) -> Date? { + guard let milliseconds else { return nil } + return Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000.0) + } + + private func formatISO8601(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + + let formatterWithFractional = ISO8601DateFormatter() + formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let formatterWithoutFractional = ISO8601DateFormatter() + formatterWithoutFractional.formatOptions = [.withInternetDateTime] + + let date = formatterWithFractional.date(from: value) ?? formatterWithoutFractional.date(from: value) + guard let date else { return nil } + + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "yyyy-MM-dd HH:mm z" + displayFormatter.timeZone = TimeZone.current + return displayFormatter.string(from: date) + } + + private func parseDouble(_ value: String?) -> Double? { + guard let value, !value.isEmpty else { return nil } + return Double(value) + } +} diff --git a/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift b/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift index 677224f..5f86c2c 100644 --- a/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift @@ -32,6 +32,7 @@ actor ProviderManager { CodexProvider(), GeminiCLIProvider(), ZaiCodingPlanProvider(), + NanoGptProvider(), OpenRouterProvider(), AntigravityProvider(), OpenCodeZenProvider(), diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index b22272f..9fb9b42 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -174,6 +174,7 @@ struct OpenCodeAuth: Codable { let opencode: APIKey? let kimiForCoding: APIKey? let zaiCodingPlan: APIKey? + let nanoGpt: APIKey? let synthetic: APIKey? let chutes: APIKey? @@ -182,6 +183,7 @@ struct OpenCodeAuth: Codable { case githubCopilot = "github-copilot" case kimiForCoding = "kimi-for-coding" case zaiCodingPlan = "zai-coding-plan" + case nanoGpt = "nano-gpt" } init( @@ -192,6 +194,7 @@ struct OpenCodeAuth: Codable { opencode: APIKey?, kimiForCoding: APIKey?, zaiCodingPlan: APIKey?, + nanoGpt: APIKey?, synthetic: APIKey?, chutes: APIKey? = nil ) { @@ -202,6 +205,7 @@ struct OpenCodeAuth: Codable { self.opencode = opencode self.kimiForCoding = kimiForCoding self.zaiCodingPlan = zaiCodingPlan + self.nanoGpt = nanoGpt self.synthetic = synthetic self.chutes = chutes } @@ -215,6 +219,7 @@ struct OpenCodeAuth: Codable { opencode = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .opencode) kimiForCoding = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .kimiForCoding) zaiCodingPlan = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .zaiCodingPlan) + nanoGpt = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .nanoGpt) synthetic = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .synthetic) chutes = Self.decodeLossyIfPresent(APIKey.self, from: container, forKey: .chutes) @@ -225,6 +230,7 @@ struct OpenCodeAuth: Codable { opencode == nil, kimiForCoding == nil, zaiCodingPlan == nil, + nanoGpt == nil, synthetic == nil, chutes == nil { throw DecodingError.dataCorrupted( @@ -257,6 +263,7 @@ struct OpenCodeAuth: Codable { try container.encodeIfPresent(opencode, forKey: .opencode) try container.encodeIfPresent(kimiForCoding, forKey: .kimiForCoding) try container.encodeIfPresent(zaiCodingPlan, forKey: .zaiCodingPlan) + try container.encodeIfPresent(nanoGpt, forKey: .nanoGpt) try container.encodeIfPresent(synthetic, forKey: .synthetic) try container.encodeIfPresent(chutes, forKey: .chutes) } @@ -2098,6 +2105,11 @@ final class TokenManager: @unchecked Sendable { return auth.zaiCodingPlan?.key } + func getNanoGptAPIKey() -> String? { + guard let auth = readOpenCodeAuth() else { return nil } + return auth.nanoGpt?.key + } + func getSyntheticAPIKey() -> String? { guard let auth = readOpenCodeAuth() else { return nil } return auth.synthetic?.key @@ -2669,6 +2681,7 @@ final class TokenManager: @unchecked Sendable { debugLines.append(" [OpenCode] \(auth.opencode != nil ? "CONFIGURED" : "NOT CONFIGURED")") debugLines.append(" [Kimi] \(auth.kimiForCoding != nil ? "CONFIGURED" : "NOT CONFIGURED")") debugLines.append(" [Z.AI Coding Plan] \(auth.zaiCodingPlan != nil ? "CONFIGURED" : "NOT CONFIGURED")") + debugLines.append(" [Nano-GPT] \(auth.nanoGpt != nil ? "CONFIGURED" : "NOT CONFIGURED")") } else { debugLines.append(" [auth.json] PARSE FAILED or NOT FOUND") } @@ -2969,6 +2982,14 @@ final class TokenManager: @unchecked Sendable { } else { debugLines.append("[Z.AI Coding Plan] NOT CONFIGURED") } + + if let nanoGpt = auth.nanoGpt { + debugLines.append("[Nano-GPT] API Key Present") + debugLines.append(" - Key Length: \(nanoGpt.key.count) chars") + debugLines.append(" - Key Preview: \(maskToken(nanoGpt.key))") + } else { + debugLines.append("[Nano-GPT] NOT CONFIGURED") + } } else { debugLines.append("[auth.json] PARSE FAILED or NOT FOUND") } diff --git a/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift b/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift index 86b6364..ce77997 100644 --- a/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift +++ b/CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift @@ -150,6 +150,8 @@ final class MultiProviderStatusBarIconView: NSView { iconName = "k.circle" case .zaiCodingPlan: iconName = "ZaiIcon" + case .nanoGpt: + iconName = "NanoGptIcon" case .synthetic: iconName = "SyntheticIcon" case .chutes: diff --git a/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift b/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift index b198586..874c43e 100644 --- a/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift +++ b/CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift @@ -96,6 +96,7 @@ struct SwiftUIProviderAlertView: View { case .antigravity: return "arrow.up.circle" case .kimi: return "k.circle" case .zaiCodingPlan: return "globe" + case .nanoGpt: return "n.circle" case .synthetic: return "diamond" case .chutes: return "c.circle" } diff --git a/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift b/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift index a893a00..e178893 100644 --- a/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift @@ -14,6 +14,7 @@ final class CLIFormatterTests: XCTestCase { XCTAssertEqual(ProviderIdentifier.kimi.rawValue, "kimi") XCTAssertEqual(ProviderIdentifier.antigravity.rawValue, "antigravity") XCTAssertEqual(ProviderIdentifier.copilot.rawValue, "copilot") + XCTAssertEqual(ProviderIdentifier.nanoGpt.rawValue, "nano_gpt") } func testProviderIdentifierDisplayNames() { @@ -22,6 +23,7 @@ final class CLIFormatterTests: XCTestCase { XCTAssertEqual(ProviderIdentifier.geminiCLI.displayName, "Gemini CLI") XCTAssertEqual(ProviderIdentifier.claude.displayName, "Claude") XCTAssertEqual(ProviderIdentifier.kimi.displayName, "Kimi for Coding") + XCTAssertEqual(ProviderIdentifier.nanoGpt.displayName, "Nano-GPT") } // MARK: - ProviderUsage Tests diff --git a/CopilotMonitor/CopilotMonitorTests/NanoGptProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/NanoGptProviderTests.swift new file mode 100644 index 0000000..8c979bb --- /dev/null +++ b/CopilotMonitor/CopilotMonitorTests/NanoGptProviderTests.swift @@ -0,0 +1,192 @@ +import XCTest +@testable import OpenCode_Bar + +final class NanoGptProviderTests: 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() + } + + func testProviderIdentifier() { + let provider = NanoGptProvider() + XCTAssertEqual(provider.identifier, .nanoGpt) + } + + func testProviderType() { + let provider = NanoGptProvider() + XCTAssertEqual(provider.type, .quotaBased) + } + + func testFetchSuccessCreatesProviderResult() async throws { + guard TokenManager.shared.getNanoGptAPIKey() != nil else { + throw XCTSkip("Nano-GPT API key not available; skipping fetch test.") + } + + let session = makeSession() + let provider = NanoGptProvider(tokenManager: .shared, session: session) + + let usageJSON = """ + { + "active": true, + "limits": { "daily": 5000, "monthly": 60000 }, + "daily": { "used": 5, "remaining": 4995, "percentUsed": 0.001, "resetAt": 1738540800000 }, + "monthly": { "used": 45, "remaining": 59955, "percentUsed": 0.00075, "resetAt": 1739404800000 }, + "period": { "currentPeriodEnd": "2025-02-13T23:59:59.000Z" } + } + """ + + let balanceJSON = """ + { + "usd_balance": "129.46956147", + "nano_balance": "26.71801147" + } + """ + + MockURLProtocol.requestHandler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + + if url.path == "/api/subscription/v1/usage" { + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data(usageJSON.utf8)) + } + + if url.path == "/api/check-balance" { + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data(balanceJSON.utf8)) + } + + let response = HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)! + return (response, Data()) + } + + let result = try await provider.fetch() + + switch result.usage { + case .quotaBased(let remaining, let entitlement, let overagePermitted): + XCTAssertEqual(remaining, 59955) + XCTAssertEqual(entitlement, 60000) + XCTAssertFalse(overagePermitted) + default: + XCTFail("Expected quota-based usage") + } + + XCTAssertEqual(result.details?.tokenUsageUsed, 5) + XCTAssertEqual(result.details?.tokenUsageTotal, 5000) + XCTAssertEqual(result.details?.mcpUsageUsed, 45) + XCTAssertEqual(result.details?.mcpUsageTotal, 60000) + XCTAssertEqual(result.details?.tokenUsagePercent ?? -1, 0.1, accuracy: 0.001) + XCTAssertEqual(result.details?.mcpUsagePercent ?? -1, 0.075, accuracy: 0.001) + XCTAssertEqual(result.details?.creditsBalance ?? -1, 129.46956147, accuracy: 0.0000001) + XCTAssertEqual(result.details?.totalCredits ?? -1, 26.71801147, accuracy: 0.0000001) + XCTAssertNotNil(result.details?.mcpUsageReset) + } + + func testFetchReturnsAuthenticationErrorOn401() async throws { + guard TokenManager.shared.getNanoGptAPIKey() != nil else { + throw XCTSkip("Nano-GPT API key not available; skipping fetch test.") + } + + let session = makeSession() + let provider = NanoGptProvider(tokenManager: .shared, session: session) + + MockURLProtocol.requestHandler = { request in + let url = request.url ?? URL(string: "https://nano-gpt.com")! + let response = HTTPURLResponse(url: url, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (response, Data("{}".utf8)) + } + + do { + _ = try await provider.fetch() + XCTFail("Expected authentication failure") + } catch let error as ProviderError { + switch error { + case .authenticationFailed: + break + default: + XCTFail("Unexpected error: \(error)") + } + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testFetchSucceedsWhenBalanceEndpointFails() async throws { + guard TokenManager.shared.getNanoGptAPIKey() != nil else { + throw XCTSkip("Nano-GPT API key not available; skipping fetch test.") + } + + let session = makeSession() + let provider = NanoGptProvider(tokenManager: .shared, session: session) + + let usageJSON = """ + { + "limits": { "daily": 5000, "monthly": 60000 }, + "daily": { "used": 10, "remaining": 4990, "percentUsed": 0.002, "resetAt": 1738540800000 }, + "monthly": { "used": 100, "remaining": 59900, "percentUsed": 0.001666, "resetAt": 1739404800000 } + } + """ + + MockURLProtocol.requestHandler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + + if url.path == "/api/subscription/v1/usage" { + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data(usageJSON.utf8)) + } + + let response = HTTPURLResponse(url: url, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (response, Data("{}".utf8)) + } + + let result = try await provider.fetch() + switch result.usage { + case .quotaBased(let remaining, let entitlement, _): + XCTAssertEqual(remaining, 59900) + XCTAssertEqual(entitlement, 60000) + default: + XCTFail("Expected quota-based usage") + } + XCTAssertNil(result.details?.creditsBalance) + XCTAssertNil(result.details?.totalCredits) + } +} diff --git a/README.md b/README.md index afd3021..43e23cb 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi | **Claude** | Quota-based | 5h/7d usage windows, Sonnet/Opus breakdown | | **Codex** | Quota-based | Primary/Secondary quotas, plan type | | **Gemini CLI** | Quota-based | Per-model quotas, multi-account support with email labels and account ID details | +| **Nano-GPT** | Quota-based | Daily/monthly unit quotas, USD/NANO balance | | **Kimi for Coding (Kimi K2.5)** | Quota-based | Usage limits, membership level, reset time | | **Z.AI Coding Plan** | Quota-based | Token/MCP quotas, model usage, tool usage (24h) | | **Synthetic** | Quota-based | 5h usage limit, request limits, reset time | diff --git a/docs/AI_USAGE_API_REFERENCE.md b/docs/AI_USAGE_API_REFERENCE.md index e412c6d..a40b845 100644 --- a/docs/AI_USAGE_API_REFERENCE.md +++ b/docs/AI_USAGE_API_REFERENCE.md @@ -6,7 +6,7 @@ | Provider | Token File | |----------|-----------| -| Claude, Codex, Copilot | `~/.local/share/opencode/auth.json` | +| Claude, Codex, Copilot, Nano-GPT | `~/.local/share/opencode/auth.json` | | Antigravity (Gemini) | `~/.config/opencode/antigravity-accounts.json` | --- @@ -145,7 +145,54 @@ curl -s "https://api.github.com/copilot_internal/user" \ --- -## 4. Antigravity (Dual Quota System) +## 4. Nano-GPT + +**Endpoints:** +- `GET https://nano-gpt.com/api/subscription/v1/usage` +- `POST https://nano-gpt.com/api/check-balance` + +```bash +API_KEY=$(jq -r '."nano-gpt".key' ~/.local/share/opencode/auth.json) + +curl -s "https://nano-gpt.com/api/subscription/v1/usage" \ + -H "Authorization: Bearer $API_KEY" \ + -H "x-api-key: $API_KEY" + +curl -s -X POST "https://nano-gpt.com/api/check-balance" \ + -H "x-api-key: $API_KEY" +``` + +**Response (usage):** +```json +{ + "active": true, + "limits": { "daily": 5000, "monthly": 60000 }, + "daily": { "used": 5, "remaining": 4995, "percentUsed": 0.001, "resetAt": 1738540800000 }, + "monthly": { "used": 45, "remaining": 59955, "percentUsed": 0.00075, "resetAt": 1739404800000 }, + "period": { "currentPeriodEnd": "2025-02-13T23:59:59.000Z" } +} +``` + +**Response (balance):** +```json +{ + "usd_balance": "129.46956147", + "nano_balance": "26.71801147" +} +``` + +| Field | Description | +|-------|-------------| +| `limits.daily`, `limits.monthly` | Daily/monthly allowance | +| `daily.percentUsed`, `monthly.percentUsed` | Fraction (0..1) of limit used | +| `daily.resetAt`, `monthly.resetAt` | Reset time in epoch milliseconds | +| `period.currentPeriodEnd` | End of current billing period (ISO 8601) | +| `usd_balance` | USD balance string | +| `nano_balance` | NANO balance string | + +--- + +## 5. Antigravity (Dual Quota System) Antigravity has **two independent quota systems**: