From 1c4a1e6dc19625ef0470be9b55f53d45c5672106 Mon Sep 17 00:00:00 2001 From: butterrobot Date: Fri, 11 Sep 2026 06:21:57 +0000 Subject: [PATCH 1/3] Add ChatGPT as a third usage provider Adds a ChatGPT provider alongside Cursor and Claude, following the same provider/auth/metric structure: - Auto mode reads the Codex CLI session from ~/.codex/auth.json (CODEX_HOME aware) and queries the ChatGPT usage endpoint for the 5-hour and weekly rate-limit windows. - Manual mode accepts a pasted ChatGPT access token; the account id and plan are recovered from the token's own claims. - Admin mode reports 7-day input/output tokens and cost from the OpenAI organization usage and costs APIs. Supporting changes: - Extract base64url JWT payload decoding into a shared JWT helper, reused by CursorAuthService. - Replace the duplicated provider lookup in UsageRefreshService and SettingsView with ProviderID.provider. - Build the Settings provider tabs from ProviderID.allCases. - Providers missing from previously saved settings now start disabled, so the upgrade does not add an unconfigured indicator to an existing menu bar. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 9 + AIMenubarUsage.xcodeproj/project.pbxproj | 32 ++ AIMenubarUsage/Models/AppSettings.swift | 16 +- AIMenubarUsage/Models/ProviderID.swift | 3 + AIMenubarUsage/Models/UsageMetric.swift | 37 ++ .../Providers/ChatGPTProvider.swift | 398 ++++++++++++++++++ AIMenubarUsage/Providers/UsageProvider.swift | 12 + .../ProviderChatGPT.imageset/Contents.json | 32 ++ .../ProviderChatGPT.svg | 1 + .../ProviderChatGPTDark.svg | 1 + .../Services/ChatGPTAuthService.swift | 90 ++++ .../Services/CursorAuthService.swift | 19 +- AIMenubarUsage/Services/JWTDecoder.swift | 25 ++ .../Services/UsageRefreshService.swift | 9 +- AIMenubarUsage/Views/ProviderIcon.swift | 1 + AIMenubarUsage/Views/SettingsView.swift | 47 ++- AIMenubarUsageTests/ChatGPTAuthTests.swift | 73 ++++ .../ChatGPTProviderTests.swift | 73 ++++ .../Fixtures/chatgpt-admin-costs.json | 39 ++ .../Fixtures/chatgpt-admin-usage.json | 33 ++ .../Fixtures/chatgpt-codex-usage.json | 15 + README.md | 19 +- 22 files changed, 938 insertions(+), 46 deletions(-) create mode 100644 AIMenubarUsage/Providers/ChatGPTProvider.swift create mode 100644 AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/Contents.json create mode 100644 AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPT.svg create mode 100644 AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPTDark.svg create mode 100644 AIMenubarUsage/Services/ChatGPTAuthService.swift create mode 100644 AIMenubarUsage/Services/JWTDecoder.swift create mode 100644 AIMenubarUsageTests/ChatGPTAuthTests.swift create mode 100644 AIMenubarUsageTests/ChatGPTProviderTests.swift create mode 100644 AIMenubarUsageTests/Fixtures/chatgpt-admin-costs.json create mode 100644 AIMenubarUsageTests/Fixtures/chatgpt-admin-usage.json create mode 100644 AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json diff --git a/.env.example b/.env.example index 6ac1bbf..d485263 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,12 @@ # Claude manual OAuth access token # CLAUDE_OAUTH_TOKEN= + +# Codex CLI home directory (default: ~/.codex) +# CODEX_HOME=/Users/you/.codex + +# OpenAI Admin API key (Organization) — sk-admin-... +# OPENAI_ADMIN_API_KEY= + +# ChatGPT manual access token (tokens.access_token in ~/.codex/auth.json) +# CHATGPT_ACCESS_TOKEN= diff --git a/AIMenubarUsage.xcodeproj/project.pbxproj b/AIMenubarUsage.xcodeproj/project.pbxproj index 81ab3a3..b8a24a0 100644 --- a/AIMenubarUsage.xcodeproj/project.pbxproj +++ b/AIMenubarUsage.xcodeproj/project.pbxproj @@ -8,11 +8,13 @@ /* Begin PBXBuildFile section */ 020588A97FA4F49E70833A88 /* CursorAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 952E850263D70B58A8622D84 /* CursorAuthService.swift */; }; + 0807BE7F8F9390502E045CF9 /* ChatGPTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD09FAB7724B27BF96DA124 /* ChatGPTAuthService.swift */; }; 0FDD856746B2EDD9346A380D /* ClaudeAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA51A975D1EFCACAD5591EF2 /* ClaudeAuthTests.swift */; }; 14C74ABCE2855AE766FC199F /* UsageRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E6D85517D87583670D54A8A /* UsageRefreshService.swift */; }; 14EEEE6F7825B2B65AE18FC4 /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5BC8D498AD23776B7EF6D7 /* MenuBarController.swift */; }; 350A49467C190655A44A1B72 /* cursor-usage-summary.json in Resources */ = {isa = PBXBuildFile; fileRef = A5CAC3724673DBDC415CB9C1 /* cursor-usage-summary.json */; }; 474CD6506A1028547A88F63E /* ProviderUsageSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A929A7D2E06CA26E801EE5 /* ProviderUsageSnapshot.swift */; }; + 605967B2F70578393027499F /* ChatGPTProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E870E40099D74E699A3A31 /* ChatGPTProviderTests.swift */; }; 692ED3CCC92E543EEF808F06 /* PopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12BF3837FE5F27BCB9F05825 /* PopoverView.swift */; }; 6AA6002791A3C8146BE308C8 /* CursorProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E90D01F19914785954D4054 /* CursorProviderTests.swift */; }; 80377D42C74851D9C7CF9DDC /* ClaudeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BB9DEC72FBE9B5E3D71AE7 /* ClaudeProvider.swift */; }; @@ -27,14 +29,20 @@ B9BA0C825207364FD8309DC7 /* UsageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61452F7607C80021EAE24E6C /* UsageProvider.swift */; }; BDF64716296F11F448D0E604 /* UsageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E57FF9519DD9DADBAA366503 /* UsageCache.swift */; }; C5715AEDEB19F7B36298B0FC /* ClaudeProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3717BFFDC22E029B1CF81C65 /* ClaudeProviderTests.swift */; }; + C5DCA64163D93004462D13C3 /* ChatGPTProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF55E59C2BD1F3154CAFCDC0 /* ChatGPTProvider.swift */; }; + C78BB52FBE17E8147941FDD5 /* chatgpt-admin-costs.json in Resources */ = {isa = PBXBuildFile; fileRef = 00DE34258D967308EA9D72DE /* chatgpt-admin-costs.json */; }; + C8B9E8E4054E852342319653 /* chatgpt-admin-usage.json in Resources */ = {isa = PBXBuildFile; fileRef = 39A0875ED003CF3D095951A5 /* chatgpt-admin-usage.json */; }; C940AAD549BFBF0DBCBB89C6 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0F6E6ACBDD186D4701E4B63 /* SettingsView.swift */; }; C9CCA6B2E3EF24D42A7F96C2 /* ClaudeCLIUsageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BC68BB05A29514C7775589 /* ClaudeCLIUsageService.swift */; }; + D4A241051CBA23EBF22E7B33 /* chatgpt-codex-usage.json in Resources */ = {isa = PBXBuildFile; fileRef = 42E5EC443BF74037D24F7578 /* chatgpt-codex-usage.json */; }; DACE19613E28C972D18338B9 /* ProviderCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8B7EBB88662A7526DC0DAC /* ProviderCardView.swift */; }; DBB881F7D74D8AF0A56987F3 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CAAFDF70292C8F5EBF0B950 /* AppSettings.swift */; }; + DF725056BCEAA825B29A2822 /* JWTDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10122384CF19ED1198A65825 /* JWTDecoder.swift */; }; E4C0E55E8FE83106E2ED480B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 62783B05EB9FEC95006D048D /* Assets.xcassets */; }; E80856A5BCA4400F795AB66F /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E6E438C251CE8879D29C39 /* KeychainService.swift */; }; E86C79AD8EB57748E7383CC1 /* ProviderID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D1EC45CD4695E90D8F58C5 /* ProviderID.swift */; }; E95AA83AAF822E74E8CD6027 /* LaunchAtLoginService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8658C9C077212181A65D761F /* LaunchAtLoginService.swift */; }; + E9F00C84985C06ACF7D8BDF3 /* ChatGPTAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C1C21ADABE13AEA9F0A2F1 /* ChatGPTAuthTests.swift */; }; EE77658E03D7D2B29ECEE8E2 /* AIMenubarUsageApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C6925E3576963CFDCD8FDB /* AIMenubarUsageApp.swift */; }; /* End PBXBuildFile section */ @@ -49,6 +57,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 00DE34258D967308EA9D72DE /* chatgpt-admin-costs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "chatgpt-admin-costs.json"; sourceTree = ""; }; + 10122384CF19ED1198A65825 /* JWTDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTDecoder.swift; sourceTree = ""; }; 12BF3837FE5F27BCB9F05825 /* PopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoverView.swift; sourceTree = ""; }; 15C24ACF2657E390A6F1EE20 /* UsageMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageMetric.swift; sourceTree = ""; }; 17BC6C419B57B4E3AB287296 /* AIMenubarUsage.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AIMenubarUsage.entitlements; sourceTree = ""; }; @@ -57,12 +67,16 @@ 2709A3685926703B69983D6E /* MenuBarLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarLabelView.swift; sourceTree = ""; }; 27BB9DEC72FBE9B5E3D71AE7 /* ClaudeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProvider.swift; sourceTree = ""; }; 2D5BC8D498AD23776B7EF6D7 /* MenuBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarController.swift; sourceTree = ""; }; + 34C1C21ADABE13AEA9F0A2F1 /* ChatGPTAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTAuthTests.swift; sourceTree = ""; }; 3717BFFDC22E029B1CF81C65 /* ClaudeProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProviderTests.swift; sourceTree = ""; }; + 39A0875ED003CF3D095951A5 /* chatgpt-admin-usage.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "chatgpt-admin-usage.json"; sourceTree = ""; }; 3B500F187AFF84E5C8BC212C /* AIMenubarUsage.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AIMenubarUsage.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3CA37F8450D730231198E183 /* ProviderIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderIcon.swift; sourceTree = ""; }; + 42E5EC443BF74037D24F7578 /* chatgpt-codex-usage.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "chatgpt-codex-usage.json"; sourceTree = ""; }; 4416B8C95B50BCEDF54FA130 /* AIMenubarUsageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AIMenubarUsageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5AA4B4C369488D4FA6312484 /* CursorProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CursorProvider.swift; sourceTree = ""; }; 5CAAFDF70292C8F5EBF0B950 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + 5DD09FAB7724B27BF96DA124 /* ChatGPTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTAuthService.swift; sourceTree = ""; }; 61452F7607C80021EAE24E6C /* UsageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageProvider.swift; sourceTree = ""; }; 62783B05EB9FEC95006D048D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 65D1EC45CD4695E90D8F58C5 /* ProviderID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderID.swift; sourceTree = ""; }; @@ -72,6 +86,7 @@ 93B331C96A907C7CB9172650 /* MenuBarDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarDisplayTests.swift; sourceTree = ""; }; 952E850263D70B58A8622D84 /* CursorAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CursorAuthService.swift; sourceTree = ""; }; A5CAC3724673DBDC415CB9C1 /* cursor-usage-summary.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "cursor-usage-summary.json"; sourceTree = ""; }; + A6E870E40099D74E699A3A31 /* ChatGPTProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTProviderTests.swift; sourceTree = ""; }; AA51A975D1EFCACAD5591EF2 /* ClaudeAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeAuthTests.swift; sourceTree = ""; }; B0F6E6ACBDD186D4701E4B63 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; B1A929A7D2E06CA26E801EE5 /* ProviderUsageSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderUsageSnapshot.swift; sourceTree = ""; }; @@ -82,12 +97,15 @@ E0F49A20AA99ADBF5FA32D5A /* ClaudeCLIParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCLIParserTests.swift; sourceTree = ""; }; E57FF9519DD9DADBAA366503 /* UsageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageCache.swift; sourceTree = ""; }; F0E6E438C251CE8879D29C39 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; + FF55E59C2BD1F3154CAFCDC0 /* ChatGPTProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTProvider.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ 78437CF65E89E1611E94DB24 /* AIMenubarUsageTests */ = { isa = PBXGroup; children = ( + 34C1C21ADABE13AEA9F0A2F1 /* ChatGPTAuthTests.swift */, + A6E870E40099D74E699A3A31 /* ChatGPTProviderTests.swift */, AA51A975D1EFCACAD5591EF2 /* ClaudeAuthTests.swift */, E0F49A20AA99ADBF5FA32D5A /* ClaudeCLIParserTests.swift */, 3717BFFDC22E029B1CF81C65 /* ClaudeProviderTests.swift */, @@ -101,9 +119,11 @@ 91A35F24DF9917494BB27990 /* Services */ = { isa = PBXGroup; children = ( + 5DD09FAB7724B27BF96DA124 /* ChatGPTAuthService.swift */, 7FCE5EFE595B010E1E54D98C /* ClaudeAuthService.swift */, B8BC68BB05A29514C7775589 /* ClaudeCLIUsageService.swift */, 952E850263D70B58A8622D84 /* CursorAuthService.swift */, + 10122384CF19ED1198A65825 /* JWTDecoder.swift */, F0E6E438C251CE8879D29C39 /* KeychainService.swift */, 8658C9C077212181A65D761F /* LaunchAtLoginService.swift */, 2D5BC8D498AD23776B7EF6D7 /* MenuBarController.swift */, @@ -141,6 +161,9 @@ AB234C351A82D7F8C29D59FC /* Fixtures */ = { isa = PBXGroup; children = ( + 00DE34258D967308EA9D72DE /* chatgpt-admin-costs.json */, + 39A0875ED003CF3D095951A5 /* chatgpt-admin-usage.json */, + 42E5EC443BF74037D24F7578 /* chatgpt-codex-usage.json */, C2DDCF0D74548E0E3EE8BBC2 /* claude-oauth-usage.json */, A5CAC3724673DBDC415CB9C1 /* cursor-usage-summary.json */, ); @@ -171,6 +194,7 @@ E9887E1F5E66E9471D6346EB /* Providers */ = { isa = PBXGroup; children = ( + FF55E59C2BD1F3154CAFCDC0 /* ChatGPTProvider.swift */, 27BB9DEC72FBE9B5E3D71AE7 /* ClaudeProvider.swift */, 5AA4B4C369488D4FA6312484 /* CursorProvider.swift */, 61452F7607C80021EAE24E6C /* UsageProvider.swift */, @@ -272,6 +296,9 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + C78BB52FBE17E8147941FDD5 /* chatgpt-admin-costs.json in Resources */, + C8B9E8E4054E852342319653 /* chatgpt-admin-usage.json in Resources */, + D4A241051CBA23EBF22E7B33 /* chatgpt-codex-usage.json in Resources */, 9ADC813DA1DC6C9F6AF03D96 /* claude-oauth-usage.json in Resources */, 350A49467C190655A44A1B72 /* cursor-usage-summary.json in Resources */, ); @@ -294,11 +321,14 @@ files = ( EE77658E03D7D2B29ECEE8E2 /* AIMenubarUsageApp.swift in Sources */, DBB881F7D74D8AF0A56987F3 /* AppSettings.swift in Sources */, + 0807BE7F8F9390502E045CF9 /* ChatGPTAuthService.swift in Sources */, + C5DCA64163D93004462D13C3 /* ChatGPTProvider.swift in Sources */, 84E50E6BE603EFED0E82E96A /* ClaudeAuthService.swift in Sources */, C9CCA6B2E3EF24D42A7F96C2 /* ClaudeCLIUsageService.swift in Sources */, 80377D42C74851D9C7CF9DDC /* ClaudeProvider.swift in Sources */, 020588A97FA4F49E70833A88 /* CursorAuthService.swift in Sources */, 9226F7526F0F46F2265AE091 /* CursorProvider.swift in Sources */, + DF725056BCEAA825B29A2822 /* JWTDecoder.swift in Sources */, E80856A5BCA4400F795AB66F /* KeychainService.swift in Sources */, E95AA83AAF822E74E8CD6027 /* LaunchAtLoginService.swift in Sources */, 14EEEE6F7825B2B65AE18FC4 /* MenuBarController.swift in Sources */, @@ -320,6 +350,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E9F00C84985C06ACF7D8BDF3 /* ChatGPTAuthTests.swift in Sources */, + 605967B2F70578393027499F /* ChatGPTProviderTests.swift in Sources */, 0FDD856746B2EDD9346A380D /* ClaudeAuthTests.swift in Sources */, 9C03E35D6CC3AE953BEB3EF7 /* ClaudeCLIParserTests.swift in Sources */, C5715AEDEB19F7B36298B0FC /* ClaudeProviderTests.swift in Sources */, diff --git a/AIMenubarUsage/Models/AppSettings.swift b/AIMenubarUsage/Models/AppSettings.swift index a85d94b..bd818c8 100644 --- a/AIMenubarUsage/Models/AppSettings.swift +++ b/AIMenubarUsage/Models/AppSettings.swift @@ -23,6 +23,8 @@ enum AuthMode: String, Codable, CaseIterable, Identifiable, Sendable { "Claude Code CLI" case (.auto, .cursor): "Auto-detect (Cursor IDE)" + case (.auto, .chatgpt): + "Auto-detect (Codex CLI)" default: displayName } @@ -127,7 +129,7 @@ final class AppSettings: ObservableObject { private init() { if let data = UserDefaults.standard.data(forKey: Keys.providerSettings), let decoded = try? JSONDecoder().decode([ProviderID: ProviderSettings].self, from: data) { - providerSettings = decoded + providerSettings = Self.addingProvidersMissing(from: decoded) } else { providerSettings = Dictionary( uniqueKeysWithValues: ProviderID.allCases.map { ($0, ProviderSettings.defaults(for: $0)) } @@ -199,6 +201,18 @@ final class AppSettings: ObservableObject { return enabledCount == 0 } + /// Providers introduced after a user's settings were saved start disabled, so upgrading + /// never adds an unconfigured indicator to an existing menu bar. + private static func addingProvidersMissing(from stored: [ProviderID: ProviderSettings]) -> [ProviderID: ProviderSettings] { + var merged = stored + for providerID in ProviderID.allCases where merged[providerID] == nil { + var defaults = ProviderSettings.defaults(for: providerID) + defaults.enabled = false + merged[providerID] = defaults + } + return merged + } + private func ensureAtLeastOneProviderEnabled() { guard enabledProviders.isEmpty else { return } diff --git a/AIMenubarUsage/Models/ProviderID.swift b/AIMenubarUsage/Models/ProviderID.swift index 7217b0d..d48352b 100644 --- a/AIMenubarUsage/Models/ProviderID.swift +++ b/AIMenubarUsage/Models/ProviderID.swift @@ -3,6 +3,7 @@ import Foundation enum ProviderID: String, Codable, CaseIterable, Identifiable, Sendable { case cursor case claude + case chatgpt var id: String { rawValue } @@ -10,6 +11,7 @@ enum ProviderID: String, Codable, CaseIterable, Identifiable, Sendable { switch self { case .cursor: "Cursor" case .claude: "Claude" + case .chatgpt: "ChatGPT" } } @@ -17,6 +19,7 @@ enum ProviderID: String, Codable, CaseIterable, Identifiable, Sendable { switch self { case .cursor: "Cu" case .claude: "C" + case .chatgpt: "GPT" } } } diff --git a/AIMenubarUsage/Models/UsageMetric.swift b/AIMenubarUsage/Models/UsageMetric.swift index b178cb7..b1e6d15 100644 --- a/AIMenubarUsage/Models/UsageMetric.swift +++ b/AIMenubarUsage/Models/UsageMetric.swift @@ -31,6 +31,18 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { case cursorAdminSpendCents case cursorAdminMemberCount + // ChatGPT (Codex CLI session) + case chatgptFiveHourUtilization + case chatgptFiveHourResetsAt + case chatgptWeeklyUtilization + case chatgptWeeklyResetsAt + case chatgptPlanType + + // OpenAI Admin API + case chatgptAdminInputTokens + case chatgptAdminOutputTokens + case chatgptAdminCostUSD + var id: String { rawValue } var providerID: ProviderID { @@ -45,6 +57,10 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { .claudeSevenDaySonnetUtilization, .claudeAdminInputTokens, .claudeAdminOutputTokens, .claudeAdminCostUSD: .claude + case .chatgptFiveHourUtilization, .chatgptFiveHourResetsAt, .chatgptWeeklyUtilization, + .chatgptWeeklyResetsAt, .chatgptPlanType, .chatgptAdminInputTokens, + .chatgptAdminOutputTokens, .chatgptAdminCostUSD: + .chatgpt } } @@ -72,6 +88,14 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { case .claudeAdminCostUSD: "Cost USD (7d)" case .cursorAdminSpendCents: "Team spend (cycle)" case .cursorAdminMemberCount: "Team members" + case .chatgptFiveHourUtilization: "5-hour session %" + case .chatgptFiveHourResetsAt: "5-hour reset" + case .chatgptWeeklyUtilization: "Weekly usage %" + case .chatgptWeeklyResetsAt: "Weekly reset" + case .chatgptPlanType: "Plan" + case .chatgptAdminInputTokens: "Input tokens (7d)" + case .chatgptAdminOutputTokens: "Output tokens (7d)" + case .chatgptAdminCostUSD: "Cost USD (7d)" } } @@ -98,6 +122,15 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { .claudeSevenDayUtilization, .claudeSevenDayResetsAt, ] + case (.chatgpt, .menuBar): + return [.chatgptFiveHourUtilization] + case (.chatgpt, .popover): + return [ + .chatgptFiveHourUtilization, + .chatgptFiveHourResetsAt, + .chatgptWeeklyUtilization, + .chatgptWeeklyResetsAt, + ] } } @@ -111,6 +144,10 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { return [.claudeAdminInputTokens, .claudeAdminOutputTokens, .claudeAdminCostUSD] case (.claude, _): return allCases.filter { $0.providerID == .claude && !$0.rawValue.hasPrefix("claudeAdmin") } + case (.chatgpt, .adminAPI): + return [.chatgptAdminInputTokens, .chatgptAdminOutputTokens, .chatgptAdminCostUSD] + case (.chatgpt, _): + return allCases.filter { $0.providerID == .chatgpt && !$0.rawValue.hasPrefix("chatgptAdmin") } } } } diff --git a/AIMenubarUsage/Providers/ChatGPTProvider.swift b/AIMenubarUsage/Providers/ChatGPTProvider.swift new file mode 100644 index 0000000..4e8ab44 --- /dev/null +++ b/AIMenubarUsage/Providers/ChatGPTProvider.swift @@ -0,0 +1,398 @@ +import Foundation + +enum ChatGPTAPIError: Error, LocalizedError { + case notAuthenticated + case invalidResponse + case rateLimited + case httpError(statusCode: Int) + + var errorDescription: String? { + switch self { + case .notAuthenticated: + "Not authenticated. Run `codex login` or add credentials in Settings." + case .invalidResponse: + "Could not parse usage data from ChatGPT." + case .rateLimited: + "ChatGPT usage API rate limited. Showing cached data if available." + case .httpError(let statusCode): + "ChatGPT API returned HTTP \(statusCode)." + } + } +} + +struct ChatGPTRateLimitWindow: Codable, Sendable { + let usedPercent: Double? + let windowMinutes: Int? + let resetsInSeconds: Int? + + enum CodingKeys: String, CodingKey { + case usedPercent = "used_percent" + case windowMinutes = "window_minutes" + case resetsInSeconds = "resets_in_seconds" + } +} + +struct ChatGPTRateLimits: Codable, Sendable { + let primary: ChatGPTRateLimitWindow? + let secondary: ChatGPTRateLimitWindow? +} + +struct ChatGPTUsageResponse: Codable, Sendable { + let rateLimits: ChatGPTRateLimits? + let planType: String? + + enum CodingKeys: String, CodingKey { + case rateLimits = "rate_limits" + case planType = "plan_type" + } + + /// The endpoint is unofficial: accept the windows wrapped in `rate_limits` or inlined + /// at the root, which is how Codex reports the same snapshot elsewhere. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + planType = try container.decodeIfPresent(String.self, forKey: .planType) + if let wrapped = try container.decodeIfPresent(ChatGPTRateLimits.self, forKey: .rateLimits) { + rateLimits = wrapped + } else { + rateLimits = try? ChatGPTRateLimits(from: decoder) + } + } +} + +struct ChatGPTAdminUsageResult: Decodable, Sendable { + let inputTokens: Int? + let outputTokens: Int? + + enum CodingKeys: String, CodingKey { + case inputTokens = "input_tokens" + case outputTokens = "output_tokens" + } +} + +struct ChatGPTAdminUsageBucket: Decodable, Sendable { + let results: [ChatGPTAdminUsageResult]? +} + +struct ChatGPTAdminUsageResponse: Decodable, Sendable { + let data: [ChatGPTAdminUsageBucket]? +} + +struct ChatGPTAdminCostAmount: Decodable, Sendable { + let value: Double? +} + +struct ChatGPTAdminCostResult: Decodable, Sendable { + let amount: ChatGPTAdminCostAmount? +} + +struct ChatGPTAdminCostBucket: Decodable, Sendable { + let results: [ChatGPTAdminCostResult]? +} + +struct ChatGPTAdminCostResponse: Decodable, Sendable { + let data: [ChatGPTAdminCostBucket]? +} + +enum ChatGPTAPI { + private static let usageURL = URL(string: "https://chatgpt.com/backend-api/codex/usage")! + private static let adminUsageURL = URL(string: "https://api.openai.com/v1/organization/usage/completions")! + private static let adminCostURL = URL(string: "https://api.openai.com/v1/organization/costs")! + private static let adminReportDays = 7 + + static func fetchUsage(accessToken: String, accountID: String?) async throws -> ChatGPTUsageResponse { + var request = URLRequest(url: usageURL) + request.httpMethod = "GET" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("codex_cli_rs", forHTTPHeaderField: "originator") + if let accountID { + request.setValue(accountID, forHTTPHeaderField: "chatgpt-account-id") + } + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw ChatGPTAPIError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + break + case 401, 403: + throw ChatGPTAPIError.notAuthenticated + case 429: + throw ChatGPTAPIError.rateLimited + default: + throw ChatGPTAPIError.httpError(statusCode: httpResponse.statusCode) + } + + do { + return try JSONDecoder().decode(ChatGPTUsageResponse.self, from: data) + } catch { + throw ChatGPTAPIError.invalidResponse + } + } + + static func fetchAdminUsage(apiKey: String) async throws -> (inputTokens: Int, outputTokens: Int, costUSD: Double) { + let startTime = Calendar.current.date(byAdding: .day, value: -adminReportDays, to: Date()) ?? Date() + let window = [ + URLQueryItem(name: "start_time", value: String(Int(startTime.timeIntervalSince1970))), + URLQueryItem(name: "bucket_width", value: "1d"), + URLQueryItem(name: "limit", value: String(adminReportDays)), + ] + + var usageComponents = URLComponents(url: adminUsageURL, resolvingAgainstBaseURL: false)! + usageComponents.queryItems = window + + var costComponents = URLComponents(url: adminCostURL, resolvingAgainstBaseURL: false)! + costComponents.queryItems = window + + async let usageData = adminRequest(url: usageComponents.url!, apiKey: apiKey) + async let costData = adminRequest(url: costComponents.url!, apiKey: apiKey) + + let (usageRaw, costRaw) = try await (usageData, costData) + + do { + let usage = try JSONDecoder().decode(ChatGPTAdminUsageResponse.self, from: usageRaw) + let cost = try JSONDecoder().decode(ChatGPTAdminCostResponse.self, from: costRaw) + return totals(usage: usage, cost: cost) + } catch { + throw ChatGPTAPIError.invalidResponse + } + } + + static func totals( + usage: ChatGPTAdminUsageResponse, + cost: ChatGPTAdminCostResponse + ) -> (inputTokens: Int, outputTokens: Int, costUSD: Double) { + var inputTokens = 0 + var outputTokens = 0 + for bucket in usage.data ?? [] { + for result in bucket.results ?? [] { + inputTokens += result.inputTokens ?? 0 + outputTokens += result.outputTokens ?? 0 + } + } + + var costUSD = 0.0 + for bucket in cost.data ?? [] { + for result in bucket.results ?? [] { + costUSD += result.amount?.value ?? 0 + } + } + + return (inputTokens, outputTokens, costUSD) + } + + private static func adminRequest(url: URL, apiKey: String) async throws -> Data { + 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 URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw ChatGPTAPIError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + return data + case 401, 403: + throw ChatGPTAPIError.notAuthenticated + case 429: + throw ChatGPTAPIError.rateLimited + default: + throw ChatGPTAPIError.httpError(statusCode: httpResponse.statusCode) + } + } + + /// `planType` comes from the local token claims and is used when the response omits it. + static func snapshot( + from usage: ChatGPTUsageResponse, + planType: String?, + fetchedAt: Date, + isStale: Bool + ) -> ProviderUsageSnapshot { + var values: [UsageMetric: MetricValue] = [:] + + func apply(_ window: ChatGPTRateLimitWindow?, percent: UsageMetric, reset: UsageMetric) { + guard let window else { return } + if let usedPercent = window.usedPercent { + values[percent] = .percent(usedPercent) + } + // The API reports a countdown, so the absolute reset stays correct for cached snapshots. + if let resetsInSeconds = window.resetsInSeconds { + values[reset] = .date(fetchedAt.addingTimeInterval(TimeInterval(resetsInSeconds))) + } + } + + apply( + usage.rateLimits?.primary, + percent: .chatgptFiveHourUtilization, + reset: .chatgptFiveHourResetsAt + ) + apply( + usage.rateLimits?.secondary, + percent: .chatgptWeeklyUtilization, + reset: .chatgptWeeklyResetsAt + ) + + if let plan = usage.planType ?? planType, !plan.isEmpty { + values[.chatgptPlanType] = .text(plan) + } + + return ProviderUsageSnapshot( + providerID: .chatgpt, + fetchedAt: fetchedAt, + values: values, + isStale: isStale + ) + } + + static func adminSnapshot( + inputTokens: Int, + outputTokens: Int, + costUSD: Double, + fetchedAt: Date, + isStale: Bool + ) -> ProviderUsageSnapshot { + ProviderUsageSnapshot( + providerID: .chatgpt, + fetchedAt: fetchedAt, + values: [ + .chatgptAdminInputTokens: .integer(inputTokens), + .chatgptAdminOutputTokens: .integer(outputTokens), + .chatgptAdminCostUSD: .currency(costUSD), + ], + isStale: isStale + ) + } +} + +struct ChatGPTProvider: UsageProvider { + let id: ProviderID = .chatgpt + let displayName = "ChatGPT" + + func resolveCredentials(authMode: AuthMode) throws -> AuthCredentials { + switch authMode { + case .auto: + let session = try ChatGPTAuthService.loadSessionCredentials() + return .chatgptSession(session) + case .manualSession: + let account = KeychainService.keychainAccount(provider: .chatgpt, authMode: .manualSession) + guard let token = KeychainService.load(account: account), !token.isEmpty else { + throw ChatGPTAPIError.notAuthenticated + } + return .chatgptSession(ChatGPTAuthService.sessionCredentials(accessToken: token)) + case .adminAPI: + let account = KeychainService.keychainAccount(provider: .chatgpt, authMode: .adminAPI) + guard let apiKey = KeychainService.load(account: account), !apiKey.isEmpty else { + throw ChatGPTAPIError.notAuthenticated + } + return .chatgptAdminAPI(apiKey: apiKey) + } + } + + func fetchUsage(credentials: AuthCredentials, cache: UsageCache) async throws -> ProviderUsageSnapshot { + switch credentials { + case .chatgptSession(let session): + return try await fetchSessionUsage(session: session, cache: cache) + case .chatgptAdminAPI(let apiKey): + return try await fetchAdminUsage(apiKey: apiKey, cache: cache) + default: + throw ChatGPTAPIError.invalidResponse + } + } + + private func fetchSessionUsage( + session: ChatGPTSessionCredentials, + cache: UsageCache + ) async throws -> ProviderUsageSnapshot { + if let cached = await cache.load(provider: .chatgpt, maxAge: 60), + let usage = try? JSONDecoder().decode(ChatGPTUsageResponse.self, from: cached.payload) { + return ChatGPTAPI.snapshot( + from: usage, + planType: session.planType, + fetchedAt: cached.fetchedAt, + isStale: false + ) + } + + do { + let usage = try await ChatGPTAPI.fetchUsage( + accessToken: session.accessToken, + accountID: session.accountID + ) + if let data = try? JSONEncoder().encode(usage) { + await cache.save(provider: .chatgpt, payload: data) + } + return ChatGPTAPI.snapshot( + from: usage, + planType: session.planType, + fetchedAt: Date(), + isStale: false + ) + } catch { + if let stale = await cache.loadStale(provider: .chatgpt), + let usage = try? JSONDecoder().decode(ChatGPTUsageResponse.self, from: stale.payload) { + return ChatGPTAPI.snapshot( + from: usage, + planType: session.planType, + fetchedAt: stale.fetchedAt, + isStale: true + ) + } + throw error + } + } + + private func fetchAdminUsage(apiKey: String, cache: UsageCache) async throws -> ProviderUsageSnapshot { + struct AdminPayload: Codable { + let inputTokens: Int + let outputTokens: Int + let costUSD: Double + } + + if let cached = await cache.load(provider: .chatgpt, maxAge: 300), + let payload = try? JSONDecoder().decode(AdminPayload.self, from: cached.payload) { + return ChatGPTAPI.adminSnapshot( + inputTokens: payload.inputTokens, + outputTokens: payload.outputTokens, + costUSD: payload.costUSD, + fetchedAt: cached.fetchedAt, + isStale: false + ) + } + + do { + let result = try await ChatGPTAPI.fetchAdminUsage(apiKey: apiKey) + let payload = AdminPayload( + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + costUSD: result.costUSD + ) + if let data = try? JSONEncoder().encode(payload) { + await cache.save(provider: .chatgpt, payload: data) + } + return ChatGPTAPI.adminSnapshot( + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + costUSD: result.costUSD, + fetchedAt: Date(), + isStale: false + ) + } catch { + if let stale = await cache.loadStale(provider: .chatgpt), + let payload = try? JSONDecoder().decode(AdminPayload.self, from: stale.payload) { + return ChatGPTAPI.adminSnapshot( + inputTokens: payload.inputTokens, + outputTokens: payload.outputTokens, + costUSD: payload.costUSD, + fetchedAt: stale.fetchedAt, + isStale: true + ) + } + throw error + } + } +} diff --git a/AIMenubarUsage/Providers/UsageProvider.swift b/AIMenubarUsage/Providers/UsageProvider.swift index a13af6f..31a7d36 100644 --- a/AIMenubarUsage/Providers/UsageProvider.swift +++ b/AIMenubarUsage/Providers/UsageProvider.swift @@ -6,6 +6,8 @@ enum AuthCredentials: Sendable { case claudeCLI case claudeOAuth(accessToken: String) case claudeAdminAPI(apiKey: String) + case chatgptSession(ChatGPTSessionCredentials) + case chatgptAdminAPI(apiKey: String) } protocol UsageProvider: Sendable { @@ -14,3 +16,13 @@ protocol UsageProvider: Sendable { func fetchUsage(credentials: AuthCredentials, cache: UsageCache) async throws -> ProviderUsageSnapshot func resolveCredentials(authMode: AuthMode) throws -> AuthCredentials } + +extension ProviderID { + var provider: any UsageProvider { + switch self { + case .cursor: return CursorProvider() + case .claude: return ClaudeProvider() + case .chatgpt: return ChatGPTProvider() + } + } +} diff --git a/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/Contents.json b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/Contents.json new file mode 100644 index 0000000..bdd1452 --- /dev/null +++ b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/Contents.json @@ -0,0 +1,32 @@ +{ + "images": [ + { + "appearances": [ + { + "appearance": "luminosity", + "value": "light" + } + ], + "filename": "ProviderChatGPT.svg", + "idiom": "mac" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "ProviderChatGPTDark.svg", + "idiom": "mac" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "original" + } +} diff --git a/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPT.svg b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPT.svg new file mode 100644 index 0000000..785e0c0 --- /dev/null +++ b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPT.svg @@ -0,0 +1 @@ +ChatGPT \ No newline at end of file diff --git a/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPTDark.svg b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPTDark.svg new file mode 100644 index 0000000..6d5184d --- /dev/null +++ b/AIMenubarUsage/Resources/Assets.xcassets/ProviderChatGPT.imageset/ProviderChatGPTDark.svg @@ -0,0 +1 @@ +ChatGPT \ No newline at end of file diff --git a/AIMenubarUsage/Services/ChatGPTAuthService.swift b/AIMenubarUsage/Services/ChatGPTAuthService.swift new file mode 100644 index 0000000..2c22722 --- /dev/null +++ b/AIMenubarUsage/Services/ChatGPTAuthService.swift @@ -0,0 +1,90 @@ +import Foundation + +enum ChatGPTAuthError: Error, LocalizedError { + case credentialsNotFound + case tokenNotFound + + var errorDescription: String? { + switch self { + case .credentialsNotFound: + "Codex CLI credentials not found. Run `codex login` or add a token in Settings." + case .tokenNotFound: + "No ChatGPT access token in the Codex CLI credentials. Run `codex login` again." + } + } +} + +struct ChatGPTSessionCredentials: Sendable { + let accessToken: String + let accountID: String? + let planType: String? +} + +enum ChatGPTAuthService { + /// Namespace of the ChatGPT claims Codex embeds in its OAuth tokens. + private static let authClaimKey = "https://api.openai.com/auth" + + /// Codex CLI keeps its OAuth tokens here; `CODEX_HOME` relocates the whole directory. + static var credentialsURL: URL { + if let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"], !codexHome.isEmpty { + return URL(fileURLWithPath: codexHome, isDirectory: true).appendingPathComponent("auth.json") + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex/auth.json") + } + + static func loadSessionCredentials() throws -> ChatGPTSessionCredentials { + guard let data = try? Data(contentsOf: credentialsURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw ChatGPTAuthError.credentialsNotFound + } + return try parseAuthJSON(json) + } + + static func parseAuthJSON(_ json: [String: Any]) throws -> ChatGPTSessionCredentials { + guard let tokens = json["tokens"] as? [String: Any], + let accessToken = tokens["access_token"] as? String, + !accessToken.isEmpty + else { + throw ChatGPTAuthError.tokenNotFound + } + + var claims = authClaims(in: tokens["id_token"] as? String) + if claims.isEmpty { + claims = authClaims(in: accessToken) + } + + let accountID = [tokens["account_id"] as? String, claims["chatgpt_account_id"] as? String] + .compactMap { $0 } + .first { !$0.isEmpty } + + return ChatGPTSessionCredentials( + accessToken: accessToken, + accountID: accountID, + planType: claims["chatgpt_plan_type"] as? String + ) + } + + /// A manually pasted token is the same ChatGPT access token Codex stores, so the account + /// and plan can still be read from its claims. + static func sessionCredentials(accessToken: String) -> ChatGPTSessionCredentials { + let claims = authClaims(in: accessToken) + let accountID = claims["chatgpt_account_id"] as? String + return ChatGPTSessionCredentials( + accessToken: accessToken, + accountID: accountID?.isEmpty == true ? nil : accountID, + planType: claims["chatgpt_plan_type"] as? String + ) + } + + private static func authClaims(in token: String?) -> [String: Any] { + guard let token, + let payload = JWT.payload(of: token), + let claims = payload[authClaimKey] as? [String: Any] + else { + return [:] + } + return claims + } +} diff --git a/AIMenubarUsage/Services/CursorAuthService.swift b/AIMenubarUsage/Services/CursorAuthService.swift index 80a5291..e9c7d3f 100644 --- a/AIMenubarUsage/Services/CursorAuthService.swift +++ b/AIMenubarUsage/Services/CursorAuthService.swift @@ -57,23 +57,8 @@ enum CursorAuthService { } private static func extractUserID(from jwt: String) throws -> String { - let parts = jwt.split(separator: ".") - guard parts.count >= 2 else { - throw CursorAuthError.invalidToken - } - - var payload = String(parts[1]) - let padding = payload.count % 4 - if padding > 0 { - payload += String(repeating: "=", count: 4 - padding) - } - payload = payload - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - - guard let data = Data(base64Encoded: payload), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let subject = json["sub"] as? String + guard let payload = JWT.payload(of: jwt), + let subject = payload["sub"] as? String else { throw CursorAuthError.invalidToken } diff --git a/AIMenubarUsage/Services/JWTDecoder.swift b/AIMenubarUsage/Services/JWTDecoder.swift new file mode 100644 index 0000000..a16347a --- /dev/null +++ b/AIMenubarUsage/Services/JWTDecoder.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Reads the unverified claims of a JWT. Provider tokens carry account identifiers and +/// plan information in their payload; nothing here is trusted for authorization. +enum JWT { + static func payload(of token: String) -> [String: Any]? { + let parts = token.split(separator: ".") + guard parts.count >= 2 else { return nil } + + var encoded = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = encoded.count % 4 + if padding > 0 { + encoded += String(repeating: "=", count: 4 - padding) + } + + guard let data = Data(base64Encoded: encoded), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return nil + } + return json + } +} diff --git a/AIMenubarUsage/Services/UsageRefreshService.swift b/AIMenubarUsage/Services/UsageRefreshService.swift index 46806aa..73bd5e2 100644 --- a/AIMenubarUsage/Services/UsageRefreshService.swift +++ b/AIMenubarUsage/Services/UsageRefreshService.swift @@ -11,10 +11,6 @@ final class UsageRefreshService: ObservableObject { private let settings = AppSettings.shared private let cache = UsageCache.shared - private let providers: [ProviderID: any UsageProvider] = [ - .cursor: CursorProvider(), - .claude: ClaudeProvider(), - ] private var refreshTask: Task? private var timerCancellable: AnyCancellable? @@ -65,10 +61,7 @@ final class UsageRefreshService: ObservableObject { } private func fetchProvider(_ providerID: ProviderID) async -> (ProviderID, ProviderDisplayState) { - guard let provider = providers[providerID] else { - return (providerID, ProviderDisplayState.failure(providerID, message: "Unknown provider")) - } - + let provider = providerID.provider let providerSettings = settings.settings(for: providerID) do { diff --git a/AIMenubarUsage/Views/ProviderIcon.swift b/AIMenubarUsage/Views/ProviderIcon.swift index 787720c..4a1893d 100644 --- a/AIMenubarUsage/Views/ProviderIcon.swift +++ b/AIMenubarUsage/Views/ProviderIcon.swift @@ -6,6 +6,7 @@ enum ProviderIconAsset { switch provider { case .cursor: "ProviderCursor" case .claude: "ProviderClaude" + case .chatgpt: "ProviderChatGPT" } } } diff --git a/AIMenubarUsage/Views/SettingsView.swift b/AIMenubarUsage/Views/SettingsView.swift index db88660..9b0bcc7 100644 --- a/AIMenubarUsage/Views/SettingsView.swift +++ b/AIMenubarUsage/Views/SettingsView.swift @@ -37,15 +37,15 @@ struct ProviderSettingsTab: View { } } - if providerID == .claude, providerSettings.authMode == .auto { - Text("Runs `claude -p \"/usage\"` so the CLI handles authentication. No Keychain access from this app.") + if providerSettings.authMode == .auto, let autoModeHelp { + Text(autoModeHelp) .font(.caption) .foregroundStyle(.secondary) } if providerSettings.authMode == .manualSession { SecureField("Session token", text: $manualToken) - Text("For Cursor: WorkosCursorSessionToken value. For Claude: OAuth access token.") + Text(manualTokenHelp) .font(.caption) .foregroundStyle(.secondary) Button("Save Token") { @@ -106,12 +106,36 @@ struct ProviderSettingsTab: View { AuthMode.allCases } + private var autoModeHelp: String? { + switch providerID { + case .cursor: + return nil + case .claude: + return "Runs `claude -p \"/usage\"` so the CLI handles authentication. No Keychain access from this app." + case .chatgpt: + return "Reads the Codex CLI session from ~/.codex/auth.json. Run `codex login` if it is missing." + } + } + + private var manualTokenHelp: String { + switch providerID { + case .cursor: + "WorkosCursorSessionToken cookie value from cursor.com." + case .claude: + "Claude OAuth access token." + case .chatgpt: + "ChatGPT access token — the `tokens.access_token` value in ~/.codex/auth.json." + } + } + private var adminAPIHelp: String { switch providerID { case .cursor: "Cursor Admin API key with admin:* scope from cursor.com/dashboard/api" case .claude: "Anthropic Admin API key (sk-ant-admin01-...) from console.anthropic.com" + case .chatgpt: + "OpenAI Admin API key (sk-admin-...) from platform.openai.com organization settings" } } @@ -191,7 +215,7 @@ struct ProviderSettingsTab: View { connectionMessage = nil defer { isTesting = false } - let provider: any UsageProvider = providerID == .cursor ? CursorProvider() : ClaudeProvider() + let provider = providerID.provider do { let credentials = try provider.resolveCredentials(authMode: providerSettings.authMode) _ = try await provider.fetchUsage(credentials: credentials, cache: UsageCache.shared) @@ -250,16 +274,16 @@ struct AboutSettingsTab: View { Text("AI Menubar Usage") .font(.title2) .fontWeight(.semibold) - Text("Monitor Claude and Cursor usage from your menu bar.") + Text("Monitor Cursor, Claude and ChatGPT usage from your menu bar.") .foregroundStyle(.secondary) Text("Authentication") .font(.headline) - Text("Claude auto mode shells out to the Claude Code CLI (`claude -p \"/usage\"`), which handles login itself. Cursor auto mode reads the local Cursor IDE session. Manual tokens and Admin API keys are stored in this app's Keychain.") + Text("Claude auto mode shells out to the Claude Code CLI (`claude -p \"/usage\"`), which handles login itself. Cursor auto mode reads the local Cursor IDE session, and ChatGPT auto mode reads the Codex CLI session from ~/.codex/auth.json. Manual tokens and Admin API keys are stored in this app's Keychain.") Text("Unofficial APIs") .font(.headline) - Text("Cursor dashboard and Claude OAuth usage endpoints are unofficial and may change without notice. Admin APIs require organization or team accounts.") + Text("The Cursor dashboard, Claude OAuth and ChatGPT usage endpoints are unofficial and may change without notice. Admin APIs require organization or team accounts.") Spacer() } @@ -277,11 +301,10 @@ struct SettingsView: View { GeneralSettingsTab(settings: settings, refreshService: refreshService) .tabItem { Label("General", systemImage: "gearshape") } - ProviderSettingsTab(providerID: .cursor, settings: settings, refreshService: refreshService) - .tabItem { Label("Cursor", image: ProviderIconAsset.name(for: .cursor)) } - - ProviderSettingsTab(providerID: .claude, settings: settings, refreshService: refreshService) - .tabItem { Label("Claude", image: ProviderIconAsset.name(for: .claude)) } + ForEach(ProviderID.allCases) { providerID in + ProviderSettingsTab(providerID: providerID, settings: settings, refreshService: refreshService) + .tabItem { Label(providerID.displayName, image: ProviderIconAsset.name(for: providerID)) } + } AboutSettingsTab() .tabItem { Label("About", systemImage: "info.circle") } diff --git a/AIMenubarUsageTests/ChatGPTAuthTests.swift b/AIMenubarUsageTests/ChatGPTAuthTests.swift new file mode 100644 index 0000000..8c0f111 --- /dev/null +++ b/AIMenubarUsageTests/ChatGPTAuthTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import AIMenubarUsage + +final class ChatGPTAuthTests: XCTestCase { + func testParseAuthJSONFromCodexCredentials() throws { + let idToken = try makeToken(accountID: "acct_from_claims", planType: "pro") + let json: [String: Any] = [ + "OPENAI_API_KEY": NSNull(), + "tokens": [ + "id_token": idToken, + "access_token": "chatgpt-access-token", + "refresh_token": "chatgpt-refresh-token", + "account_id": "acct_from_file", + ], + "last_refresh": "2026-01-01T00:00:00Z", + ] + + let credentials = try ChatGPTAuthService.parseAuthJSON(json) + XCTAssertEqual(credentials.accessToken, "chatgpt-access-token") + XCTAssertEqual(credentials.accountID, "acct_from_file") + XCTAssertEqual(credentials.planType, "pro") + } + + func testParseAuthJSONFallsBackToTokenClaimsForAccountID() throws { + let idToken = try makeToken(accountID: "acct_from_claims", planType: "plus") + let json: [String: Any] = [ + "tokens": [ + "id_token": idToken, + "access_token": "chatgpt-access-token", + ], + ] + + let credentials = try ChatGPTAuthService.parseAuthJSON(json) + XCTAssertEqual(credentials.accountID, "acct_from_claims") + XCTAssertEqual(credentials.planType, "plus") + } + + func testParseAuthJSONWithoutAccessTokenThrows() { + XCTAssertThrowsError(try ChatGPTAuthService.parseAuthJSON(["tokens": ["access_token": ""]])) + XCTAssertThrowsError(try ChatGPTAuthService.parseAuthJSON([:])) + } + + func testSessionCredentialsFromManualAccessToken() throws { + let token = try makeToken(accountID: "acct_manual", planType: "team") + + let credentials = ChatGPTAuthService.sessionCredentials(accessToken: token) + XCTAssertEqual(credentials.accessToken, token) + XCTAssertEqual(credentials.accountID, "acct_manual") + XCTAssertEqual(credentials.planType, "team") + } + + func testSessionCredentialsFromOpaqueTokenHasNoClaims() { + let credentials = ChatGPTAuthService.sessionCredentials(accessToken: "not-a-jwt") + + XCTAssertEqual(credentials.accessToken, "not-a-jwt") + XCTAssertNil(credentials.accountID) + XCTAssertNil(credentials.planType) + } + + private func makeToken(accountID: String, planType: String) throws -> String { + let payload = try JSONSerialization.data(withJSONObject: [ + "https://api.openai.com/auth": [ + "chatgpt_account_id": accountID, + "chatgpt_plan_type": planType, + ], + ]) + let encoded = payload.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "header.\(encoded).signature" + } +} diff --git a/AIMenubarUsageTests/ChatGPTProviderTests.swift b/AIMenubarUsageTests/ChatGPTProviderTests.swift new file mode 100644 index 0000000..92e7dbe --- /dev/null +++ b/AIMenubarUsageTests/ChatGPTProviderTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import AIMenubarUsage + +final class ChatGPTProviderTests: XCTestCase { + func testDecodeCodexUsageFixture() throws { + let usage = try decodeFixture(ChatGPTUsageResponse.self, named: "chatgpt-codex-usage") + + XCTAssertEqual(usage.planType, "plus") + XCTAssertEqual(usage.rateLimits?.primary?.usedPercent ?? 0, 42, accuracy: 0.001) + XCTAssertEqual(usage.rateLimits?.primary?.windowMinutes, 300) + XCTAssertEqual(usage.rateLimits?.secondary?.resetsInSeconds, 86_400) + } + + func testSnapshotMapping() throws { + let usage = try decodeFixture(ChatGPTUsageResponse.self, named: "chatgpt-codex-usage") + let fetchedAt = Date(timeIntervalSince1970: 1_735_689_600) + let snapshot = ChatGPTAPI.snapshot(from: usage, planType: nil, fetchedAt: fetchedAt, isStale: false) + + XCTAssertEqual(snapshot.providerID, .chatgpt) + XCTAssertEqual(snapshot.formatted(.chatgptFiveHourUtilization), "42%") + XCTAssertEqual(snapshot.formatted(.chatgptWeeklyUtilization), "15%") + XCTAssertEqual(snapshot.formatted(.chatgptPlanType), "plus") + XCTAssertEqual(snapshot.value(for: .chatgptFiveHourResetsAt), MetricValue.date(fetchedAt.addingTimeInterval(3600))) + XCTAssertEqual(snapshot.value(for: .chatgptWeeklyResetsAt), MetricValue.date(fetchedAt.addingTimeInterval(86_400))) + } + + func testDecodeRateLimitsInlinedAtRoot() throws { + let json = Data(""" + {"primary": {"used_percent": 7, "window_minutes": 300, "resets_in_seconds": 60}} + """.utf8) + + let usage = try JSONDecoder().decode(ChatGPTUsageResponse.self, from: json) + let snapshot = ChatGPTAPI.snapshot(from: usage, planType: nil, fetchedAt: Date(), isStale: false) + + XCTAssertEqual(snapshot.formatted(.chatgptFiveHourUtilization), "7%") + XCTAssertNil(snapshot.value(for: .chatgptWeeklyUtilization)) + } + + func testSnapshotFallsBackToLocalPlanType() throws { + let json = Data(#"{"rate_limits": {}}"#.utf8) + let usage = try JSONDecoder().decode(ChatGPTUsageResponse.self, from: json) + let snapshot = ChatGPTAPI.snapshot(from: usage, planType: "pro", fetchedAt: Date(), isStale: false) + + XCTAssertEqual(snapshot.formatted(.chatgptPlanType), "pro") + } + + func testAdminTotalsAcrossBuckets() throws { + let usage = try decodeFixture(ChatGPTAdminUsageResponse.self, named: "chatgpt-admin-usage") + let cost = try decodeFixture(ChatGPTAdminCostResponse.self, named: "chatgpt-admin-costs") + + let totals = ChatGPTAPI.totals(usage: usage, cost: cost) + XCTAssertEqual(totals.inputTokens, 2000) + XCTAssertEqual(totals.outputTokens, 500) + XCTAssertEqual(totals.costUSD, 1.75, accuracy: 0.001) + + let snapshot = ChatGPTAPI.adminSnapshot( + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + costUSD: totals.costUSD, + fetchedAt: Date(), + isStale: false + ) + XCTAssertEqual(snapshot.formatted(.chatgptAdminCostUSD), "$1.75") + } + + private func decodeFixture(_ type: T.Type, named name: String) throws -> T { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures") + .appendingPathComponent("\(name).json") + return try JSONDecoder().decode(type, from: Data(contentsOf: url)) + } +} diff --git a/AIMenubarUsageTests/Fixtures/chatgpt-admin-costs.json b/AIMenubarUsageTests/Fixtures/chatgpt-admin-costs.json new file mode 100644 index 0000000..a3008d4 --- /dev/null +++ b/AIMenubarUsageTests/Fixtures/chatgpt-admin-costs.json @@ -0,0 +1,39 @@ +{ + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1735689600, + "end_time": 1735776000, + "results": [ + { + "object": "organization.costs.result", + "amount": { + "value": 0.5, + "currency": "usd" + }, + "line_item": null, + "project_id": null + } + ] + }, + { + "object": "bucket", + "start_time": 1735776000, + "end_time": 1735862400, + "results": [ + { + "object": "organization.costs.result", + "amount": { + "value": 1.25, + "currency": "usd" + }, + "line_item": null, + "project_id": null + } + ] + } + ], + "has_more": false, + "next_page": null +} diff --git a/AIMenubarUsageTests/Fixtures/chatgpt-admin-usage.json b/AIMenubarUsageTests/Fixtures/chatgpt-admin-usage.json new file mode 100644 index 0000000..d7b27b0 --- /dev/null +++ b/AIMenubarUsageTests/Fixtures/chatgpt-admin-usage.json @@ -0,0 +1,33 @@ +{ + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1735689600, + "end_time": 1735776000, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 1200, + "output_tokens": 340, + "num_model_requests": 8 + } + ] + }, + { + "object": "bucket", + "start_time": 1735776000, + "end_time": 1735862400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 800, + "output_tokens": 160, + "num_model_requests": 5 + } + ] + } + ], + "has_more": false, + "next_page": null +} diff --git a/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json b/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json new file mode 100644 index 0000000..fdccba1 --- /dev/null +++ b/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json @@ -0,0 +1,15 @@ +{ + "plan_type": "plus", + "rate_limits": { + "primary": { + "used_percent": 42, + "window_minutes": 300, + "resets_in_seconds": 3600 + }, + "secondary": { + "used_percent": 15, + "window_minutes": 10080, + "resets_in_seconds": 86400 + } + } +} diff --git a/README.md b/README.md index d3de8d5..4a628f4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ AI Menubar Usage in the macOS menu bar showing Cursor and Claude usage -A native macOS menu bar app for **Cursor** and **Claude** usage at a glance. +A native macOS menu bar app for **Cursor**, **Claude** and **ChatGPT** usage at a glance. @@ -12,7 +12,7 @@ A native macOS menu bar app for **Cursor** and **Claude** usage at a glance. - Per-provider menu bar indicators with usage percentages - Popover with a detailed breakdown per provider -- Auto-detect auth from Cursor IDE and Claude Code CLI +- Auto-detect auth from Cursor IDE, Claude Code CLI and Codex CLI - Manual tokens or Admin API keys (Keychain) - Configurable metrics, refresh interval, and appearance @@ -20,7 +20,7 @@ A native macOS menu bar app for **Cursor** and **Claude** usage at a glance. - macOS 14+ - Xcode 15+ -- [Cursor](https://cursor.com) signed in, and/or [Claude Code CLI](https://claude.ai/code) with `claude auth login` +- Any of: [Cursor](https://cursor.com) signed in, [Claude Code CLI](https://claude.ai/code) with `claude auth login`, [Codex CLI](https://developers.openai.com/codex/cli) with `codex login` ## Build & Run @@ -34,14 +34,17 @@ First launch from an unsigned build: right-click the app → **Open**, or `xattr ## Authentication -| Provider | Auto | Manual | -| ---------- | ---------------------------------------- | ------------------------------ | -| **Cursor** | Session from local Cursor IDE database | Session token or Admin API key | -| **Claude** | `claude -p "/usage"` via Claude Code CLI | OAuth token or Admin API key | +| Provider | Auto | Manual | +| ----------- | ---------------------------------------- | ------------------------------- | +| **Cursor** | Session from local Cursor IDE database | Session token or Admin API key | +| **Claude** | `claude -p "/usage"` via Claude Code CLI | OAuth token or Admin API key | +| **ChatGPT** | Codex CLI session from `~/.codex/auth.json` | Access token or Admin API key | + +Providers added in an app update start disabled — enable them in **Settings → \**. ## Disclaimer -Cursor and Claude usage endpoints used in auto mode are unofficial and may change. Not affiliated with Anthropic or Cursor. +The Cursor, Claude and ChatGPT usage endpoints used in auto mode are unofficial and may change. Not affiliated with Anthropic, Cursor or OpenAI. ## License -- 2.52.0 From 3c7cd8e2185d300480498371d2eebffff9b1fee4 Mon Sep 17 00:00:00 2001 From: butterrobot Date: Fri, 11 Sep 2026 06:41:59 +0000 Subject: [PATCH 2/3] Read ChatGPT usage through the Codex CLI only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ChatGPT provider no longer touches credentials at all. Usage now comes from `codex app-server`, the CLI's stdio JSON-RPC interface: handshake, then `account/rateLimits/read`. Codex owns authentication end to end, so this works wherever it keeps its credentials — file, keyring or ephemeral — and the app never reads, stores or forwards a token. Removed accordingly: - Reading ~/.codex/auth.json and the CODEX_HOME environment variable. - The manual-token and Admin API key modes for this provider, along with the OpenAI organization usage/cost metrics. ChatGPT now offers Auto only, and Settings no longer queries the Keychain for a provider that cannot use it. - The shared JWT helper, which existed only to read claims out of Codex tokens; CursorAuthService is back to its original form. This also drops the HTTP path, whose endpoint was wrong: the earlier commit used /backend-api/codex/usage, while Codex actually polls /backend-api/wham/usage with a different response shape. The app-server command is documented as experimental and `account/rateLimits/read` is undocumented, so the decoder accepts the windows wrapped in `rateLimits` or inlined at the root, and an absolute `resetsAt` or a `resetsInSeconds` countdown. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 9 - AIMenubarUsage.xcodeproj/project.pbxproj | 24 +- AIMenubarUsage/Models/ProviderID.swift | 8 + AIMenubarUsage/Models/UsageMetric.swift | 17 +- .../Providers/ChatGPTProvider.swift | 384 +----------------- AIMenubarUsage/Providers/UsageProvider.swift | 3 +- .../Services/ChatGPTAuthService.swift | 90 ---- .../Services/CodexCLIUsageService.swift | 256 ++++++++++++ .../Services/CursorAuthService.swift | 19 +- AIMenubarUsage/Services/JWTDecoder.swift | 25 -- AIMenubarUsage/Views/SettingsView.swift | 35 +- AIMenubarUsageTests/ChatGPTAuthTests.swift | 73 ---- .../ChatGPTProviderTests.swift | 109 ++--- .../Fixtures/chatgpt-admin-costs.json | 39 -- .../Fixtures/chatgpt-admin-usage.json | 33 -- .../Fixtures/chatgpt-codex-usage.json | 26 +- README.md | 7 +- 17 files changed, 403 insertions(+), 754 deletions(-) delete mode 100644 AIMenubarUsage/Services/ChatGPTAuthService.swift create mode 100644 AIMenubarUsage/Services/CodexCLIUsageService.swift delete mode 100644 AIMenubarUsage/Services/JWTDecoder.swift delete mode 100644 AIMenubarUsageTests/ChatGPTAuthTests.swift delete mode 100644 AIMenubarUsageTests/Fixtures/chatgpt-admin-costs.json delete mode 100644 AIMenubarUsageTests/Fixtures/chatgpt-admin-usage.json diff --git a/.env.example b/.env.example index d485263..6ac1bbf 100644 --- a/.env.example +++ b/.env.example @@ -13,12 +13,3 @@ # Claude manual OAuth access token # CLAUDE_OAUTH_TOKEN= - -# Codex CLI home directory (default: ~/.codex) -# CODEX_HOME=/Users/you/.codex - -# OpenAI Admin API key (Organization) — sk-admin-... -# OPENAI_ADMIN_API_KEY= - -# ChatGPT manual access token (tokens.access_token in ~/.codex/auth.json) -# CHATGPT_ACCESS_TOKEN= diff --git a/AIMenubarUsage.xcodeproj/project.pbxproj b/AIMenubarUsage.xcodeproj/project.pbxproj index b8a24a0..1c7e00e 100644 --- a/AIMenubarUsage.xcodeproj/project.pbxproj +++ b/AIMenubarUsage.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 020588A97FA4F49E70833A88 /* CursorAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 952E850263D70B58A8622D84 /* CursorAuthService.swift */; }; - 0807BE7F8F9390502E045CF9 /* ChatGPTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD09FAB7724B27BF96DA124 /* ChatGPTAuthService.swift */; }; 0FDD856746B2EDD9346A380D /* ClaudeAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA51A975D1EFCACAD5591EF2 /* ClaudeAuthTests.swift */; }; 14C74ABCE2855AE766FC199F /* UsageRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E6D85517D87583670D54A8A /* UsageRefreshService.swift */; }; 14EEEE6F7825B2B65AE18FC4 /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5BC8D498AD23776B7EF6D7 /* MenuBarController.swift */; }; @@ -25,24 +24,21 @@ 9ADC813DA1DC6C9F6AF03D96 /* claude-oauth-usage.json in Resources */ = {isa = PBXBuildFile; fileRef = C2DDCF0D74548E0E3EE8BBC2 /* claude-oauth-usage.json */; }; 9C03E35D6CC3AE953BEB3EF7 /* ClaudeCLIParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0F49A20AA99ADBF5FA32D5A /* ClaudeCLIParserTests.swift */; }; B00F7D3788898A4FBDAA205D /* ProviderIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA37F8450D730231198E183 /* ProviderIcon.swift */; }; + B3CC65806679B0201BD2CAFA /* CodexCLIUsageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE841AA7311B637A59E5893 /* CodexCLIUsageService.swift */; }; B53990EEE51F907CCD4B5822 /* UsageMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15C24ACF2657E390A6F1EE20 /* UsageMetric.swift */; }; B9BA0C825207364FD8309DC7 /* UsageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61452F7607C80021EAE24E6C /* UsageProvider.swift */; }; BDF64716296F11F448D0E604 /* UsageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E57FF9519DD9DADBAA366503 /* UsageCache.swift */; }; C5715AEDEB19F7B36298B0FC /* ClaudeProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3717BFFDC22E029B1CF81C65 /* ClaudeProviderTests.swift */; }; C5DCA64163D93004462D13C3 /* ChatGPTProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF55E59C2BD1F3154CAFCDC0 /* ChatGPTProvider.swift */; }; - C78BB52FBE17E8147941FDD5 /* chatgpt-admin-costs.json in Resources */ = {isa = PBXBuildFile; fileRef = 00DE34258D967308EA9D72DE /* chatgpt-admin-costs.json */; }; - C8B9E8E4054E852342319653 /* chatgpt-admin-usage.json in Resources */ = {isa = PBXBuildFile; fileRef = 39A0875ED003CF3D095951A5 /* chatgpt-admin-usage.json */; }; C940AAD549BFBF0DBCBB89C6 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0F6E6ACBDD186D4701E4B63 /* SettingsView.swift */; }; C9CCA6B2E3EF24D42A7F96C2 /* ClaudeCLIUsageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BC68BB05A29514C7775589 /* ClaudeCLIUsageService.swift */; }; D4A241051CBA23EBF22E7B33 /* chatgpt-codex-usage.json in Resources */ = {isa = PBXBuildFile; fileRef = 42E5EC443BF74037D24F7578 /* chatgpt-codex-usage.json */; }; DACE19613E28C972D18338B9 /* ProviderCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8B7EBB88662A7526DC0DAC /* ProviderCardView.swift */; }; DBB881F7D74D8AF0A56987F3 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CAAFDF70292C8F5EBF0B950 /* AppSettings.swift */; }; - DF725056BCEAA825B29A2822 /* JWTDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10122384CF19ED1198A65825 /* JWTDecoder.swift */; }; E4C0E55E8FE83106E2ED480B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 62783B05EB9FEC95006D048D /* Assets.xcassets */; }; E80856A5BCA4400F795AB66F /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E6E438C251CE8879D29C39 /* KeychainService.swift */; }; E86C79AD8EB57748E7383CC1 /* ProviderID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D1EC45CD4695E90D8F58C5 /* ProviderID.swift */; }; E95AA83AAF822E74E8CD6027 /* LaunchAtLoginService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8658C9C077212181A65D761F /* LaunchAtLoginService.swift */; }; - E9F00C84985C06ACF7D8BDF3 /* ChatGPTAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C1C21ADABE13AEA9F0A2F1 /* ChatGPTAuthTests.swift */; }; EE77658E03D7D2B29ECEE8E2 /* AIMenubarUsageApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C6925E3576963CFDCD8FDB /* AIMenubarUsageApp.swift */; }; /* End PBXBuildFile section */ @@ -57,8 +53,6 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 00DE34258D967308EA9D72DE /* chatgpt-admin-costs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "chatgpt-admin-costs.json"; sourceTree = ""; }; - 10122384CF19ED1198A65825 /* JWTDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTDecoder.swift; sourceTree = ""; }; 12BF3837FE5F27BCB9F05825 /* PopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoverView.swift; sourceTree = ""; }; 15C24ACF2657E390A6F1EE20 /* UsageMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageMetric.swift; sourceTree = ""; }; 17BC6C419B57B4E3AB287296 /* AIMenubarUsage.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AIMenubarUsage.entitlements; sourceTree = ""; }; @@ -67,16 +61,13 @@ 2709A3685926703B69983D6E /* MenuBarLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarLabelView.swift; sourceTree = ""; }; 27BB9DEC72FBE9B5E3D71AE7 /* ClaudeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProvider.swift; sourceTree = ""; }; 2D5BC8D498AD23776B7EF6D7 /* MenuBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarController.swift; sourceTree = ""; }; - 34C1C21ADABE13AEA9F0A2F1 /* ChatGPTAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTAuthTests.swift; sourceTree = ""; }; 3717BFFDC22E029B1CF81C65 /* ClaudeProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProviderTests.swift; sourceTree = ""; }; - 39A0875ED003CF3D095951A5 /* chatgpt-admin-usage.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "chatgpt-admin-usage.json"; sourceTree = ""; }; 3B500F187AFF84E5C8BC212C /* AIMenubarUsage.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AIMenubarUsage.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3CA37F8450D730231198E183 /* ProviderIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderIcon.swift; sourceTree = ""; }; 42E5EC443BF74037D24F7578 /* chatgpt-codex-usage.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "chatgpt-codex-usage.json"; sourceTree = ""; }; 4416B8C95B50BCEDF54FA130 /* AIMenubarUsageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AIMenubarUsageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5AA4B4C369488D4FA6312484 /* CursorProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CursorProvider.swift; sourceTree = ""; }; 5CAAFDF70292C8F5EBF0B950 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; - 5DD09FAB7724B27BF96DA124 /* ChatGPTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTAuthService.swift; sourceTree = ""; }; 61452F7607C80021EAE24E6C /* UsageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageProvider.swift; sourceTree = ""; }; 62783B05EB9FEC95006D048D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 65D1EC45CD4695E90D8F58C5 /* ProviderID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderID.swift; sourceTree = ""; }; @@ -96,6 +87,7 @@ DE93F3CBB065D23A36D4BE3D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; E0F49A20AA99ADBF5FA32D5A /* ClaudeCLIParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCLIParserTests.swift; sourceTree = ""; }; E57FF9519DD9DADBAA366503 /* UsageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageCache.swift; sourceTree = ""; }; + EBE841AA7311B637A59E5893 /* CodexCLIUsageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexCLIUsageService.swift; sourceTree = ""; }; F0E6E438C251CE8879D29C39 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; FF55E59C2BD1F3154CAFCDC0 /* ChatGPTProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatGPTProvider.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -104,7 +96,6 @@ 78437CF65E89E1611E94DB24 /* AIMenubarUsageTests */ = { isa = PBXGroup; children = ( - 34C1C21ADABE13AEA9F0A2F1 /* ChatGPTAuthTests.swift */, A6E870E40099D74E699A3A31 /* ChatGPTProviderTests.swift */, AA51A975D1EFCACAD5591EF2 /* ClaudeAuthTests.swift */, E0F49A20AA99ADBF5FA32D5A /* ClaudeCLIParserTests.swift */, @@ -119,11 +110,10 @@ 91A35F24DF9917494BB27990 /* Services */ = { isa = PBXGroup; children = ( - 5DD09FAB7724B27BF96DA124 /* ChatGPTAuthService.swift */, 7FCE5EFE595B010E1E54D98C /* ClaudeAuthService.swift */, B8BC68BB05A29514C7775589 /* ClaudeCLIUsageService.swift */, + EBE841AA7311B637A59E5893 /* CodexCLIUsageService.swift */, 952E850263D70B58A8622D84 /* CursorAuthService.swift */, - 10122384CF19ED1198A65825 /* JWTDecoder.swift */, F0E6E438C251CE8879D29C39 /* KeychainService.swift */, 8658C9C077212181A65D761F /* LaunchAtLoginService.swift */, 2D5BC8D498AD23776B7EF6D7 /* MenuBarController.swift */, @@ -161,8 +151,6 @@ AB234C351A82D7F8C29D59FC /* Fixtures */ = { isa = PBXGroup; children = ( - 00DE34258D967308EA9D72DE /* chatgpt-admin-costs.json */, - 39A0875ED003CF3D095951A5 /* chatgpt-admin-usage.json */, 42E5EC443BF74037D24F7578 /* chatgpt-codex-usage.json */, C2DDCF0D74548E0E3EE8BBC2 /* claude-oauth-usage.json */, A5CAC3724673DBDC415CB9C1 /* cursor-usage-summary.json */, @@ -296,8 +284,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - C78BB52FBE17E8147941FDD5 /* chatgpt-admin-costs.json in Resources */, - C8B9E8E4054E852342319653 /* chatgpt-admin-usage.json in Resources */, D4A241051CBA23EBF22E7B33 /* chatgpt-codex-usage.json in Resources */, 9ADC813DA1DC6C9F6AF03D96 /* claude-oauth-usage.json in Resources */, 350A49467C190655A44A1B72 /* cursor-usage-summary.json in Resources */, @@ -321,14 +307,13 @@ files = ( EE77658E03D7D2B29ECEE8E2 /* AIMenubarUsageApp.swift in Sources */, DBB881F7D74D8AF0A56987F3 /* AppSettings.swift in Sources */, - 0807BE7F8F9390502E045CF9 /* ChatGPTAuthService.swift in Sources */, C5DCA64163D93004462D13C3 /* ChatGPTProvider.swift in Sources */, 84E50E6BE603EFED0E82E96A /* ClaudeAuthService.swift in Sources */, C9CCA6B2E3EF24D42A7F96C2 /* ClaudeCLIUsageService.swift in Sources */, 80377D42C74851D9C7CF9DDC /* ClaudeProvider.swift in Sources */, + B3CC65806679B0201BD2CAFA /* CodexCLIUsageService.swift in Sources */, 020588A97FA4F49E70833A88 /* CursorAuthService.swift in Sources */, 9226F7526F0F46F2265AE091 /* CursorProvider.swift in Sources */, - DF725056BCEAA825B29A2822 /* JWTDecoder.swift in Sources */, E80856A5BCA4400F795AB66F /* KeychainService.swift in Sources */, E95AA83AAF822E74E8CD6027 /* LaunchAtLoginService.swift in Sources */, 14EEEE6F7825B2B65AE18FC4 /* MenuBarController.swift in Sources */, @@ -350,7 +335,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - E9F00C84985C06ACF7D8BDF3 /* ChatGPTAuthTests.swift in Sources */, 605967B2F70578393027499F /* ChatGPTProviderTests.swift in Sources */, 0FDD856746B2EDD9346A380D /* ClaudeAuthTests.swift in Sources */, 9C03E35D6CC3AE953BEB3EF7 /* ClaudeCLIParserTests.swift in Sources */, diff --git a/AIMenubarUsage/Models/ProviderID.swift b/AIMenubarUsage/Models/ProviderID.swift index d48352b..9c51869 100644 --- a/AIMenubarUsage/Models/ProviderID.swift +++ b/AIMenubarUsage/Models/ProviderID.swift @@ -15,6 +15,14 @@ enum ProviderID: String, Codable, CaseIterable, Identifiable, Sendable { } } + /// Codex owns its own credentials, so ChatGPT has nothing for this app to store. + var supportedAuthModes: [AuthMode] { + switch self { + case .cursor, .claude: AuthMode.allCases + case .chatgpt: [.auto] + } + } + var defaultMenuBarLabel: String { switch self { case .cursor: "Cu" diff --git a/AIMenubarUsage/Models/UsageMetric.swift b/AIMenubarUsage/Models/UsageMetric.swift index b1e6d15..f4bce13 100644 --- a/AIMenubarUsage/Models/UsageMetric.swift +++ b/AIMenubarUsage/Models/UsageMetric.swift @@ -31,18 +31,13 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { case cursorAdminSpendCents case cursorAdminMemberCount - // ChatGPT (Codex CLI session) + // ChatGPT (Codex CLI) case chatgptFiveHourUtilization case chatgptFiveHourResetsAt case chatgptWeeklyUtilization case chatgptWeeklyResetsAt case chatgptPlanType - // OpenAI Admin API - case chatgptAdminInputTokens - case chatgptAdminOutputTokens - case chatgptAdminCostUSD - var id: String { rawValue } var providerID: ProviderID { @@ -58,8 +53,7 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { .claudeAdminCostUSD: .claude case .chatgptFiveHourUtilization, .chatgptFiveHourResetsAt, .chatgptWeeklyUtilization, - .chatgptWeeklyResetsAt, .chatgptPlanType, .chatgptAdminInputTokens, - .chatgptAdminOutputTokens, .chatgptAdminCostUSD: + .chatgptWeeklyResetsAt, .chatgptPlanType: .chatgpt } } @@ -93,9 +87,6 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { case .chatgptWeeklyUtilization: "Weekly usage %" case .chatgptWeeklyResetsAt: "Weekly reset" case .chatgptPlanType: "Plan" - case .chatgptAdminInputTokens: "Input tokens (7d)" - case .chatgptAdminOutputTokens: "Output tokens (7d)" - case .chatgptAdminCostUSD: "Cost USD (7d)" } } @@ -144,10 +135,8 @@ enum UsageMetric: String, Codable, CaseIterable, Identifiable, Sendable { return [.claudeAdminInputTokens, .claudeAdminOutputTokens, .claudeAdminCostUSD] case (.claude, _): return allCases.filter { $0.providerID == .claude && !$0.rawValue.hasPrefix("claudeAdmin") } - case (.chatgpt, .adminAPI): - return [.chatgptAdminInputTokens, .chatgptAdminOutputTokens, .chatgptAdminCostUSD] case (.chatgpt, _): - return allCases.filter { $0.providerID == .chatgpt && !$0.rawValue.hasPrefix("chatgptAdmin") } + return allCases.filter { $0.providerID == .chatgpt } } } } diff --git a/AIMenubarUsage/Providers/ChatGPTProvider.swift b/AIMenubarUsage/Providers/ChatGPTProvider.swift index 4e8ab44..80b8231 100644 --- a/AIMenubarUsage/Providers/ChatGPTProvider.swift +++ b/AIMenubarUsage/Providers/ChatGPTProvider.swift @@ -1,396 +1,34 @@ import Foundation -enum ChatGPTAPIError: Error, LocalizedError { - case notAuthenticated - case invalidResponse - case rateLimited - case httpError(statusCode: Int) - - var errorDescription: String? { - switch self { - case .notAuthenticated: - "Not authenticated. Run `codex login` or add credentials in Settings." - case .invalidResponse: - "Could not parse usage data from ChatGPT." - case .rateLimited: - "ChatGPT usage API rate limited. Showing cached data if available." - case .httpError(let statusCode): - "ChatGPT API returned HTTP \(statusCode)." - } - } -} - -struct ChatGPTRateLimitWindow: Codable, Sendable { - let usedPercent: Double? - let windowMinutes: Int? - let resetsInSeconds: Int? - - enum CodingKeys: String, CodingKey { - case usedPercent = "used_percent" - case windowMinutes = "window_minutes" - case resetsInSeconds = "resets_in_seconds" - } -} - -struct ChatGPTRateLimits: Codable, Sendable { - let primary: ChatGPTRateLimitWindow? - let secondary: ChatGPTRateLimitWindow? -} - -struct ChatGPTUsageResponse: Codable, Sendable { - let rateLimits: ChatGPTRateLimits? - let planType: String? - - enum CodingKeys: String, CodingKey { - case rateLimits = "rate_limits" - case planType = "plan_type" - } - - /// The endpoint is unofficial: accept the windows wrapped in `rate_limits` or inlined - /// at the root, which is how Codex reports the same snapshot elsewhere. - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - planType = try container.decodeIfPresent(String.self, forKey: .planType) - if let wrapped = try container.decodeIfPresent(ChatGPTRateLimits.self, forKey: .rateLimits) { - rateLimits = wrapped - } else { - rateLimits = try? ChatGPTRateLimits(from: decoder) - } - } -} - -struct ChatGPTAdminUsageResult: Decodable, Sendable { - let inputTokens: Int? - let outputTokens: Int? - - enum CodingKeys: String, CodingKey { - case inputTokens = "input_tokens" - case outputTokens = "output_tokens" - } -} - -struct ChatGPTAdminUsageBucket: Decodable, Sendable { - let results: [ChatGPTAdminUsageResult]? -} - -struct ChatGPTAdminUsageResponse: Decodable, Sendable { - let data: [ChatGPTAdminUsageBucket]? -} - -struct ChatGPTAdminCostAmount: Decodable, Sendable { - let value: Double? -} - -struct ChatGPTAdminCostResult: Decodable, Sendable { - let amount: ChatGPTAdminCostAmount? -} - -struct ChatGPTAdminCostBucket: Decodable, Sendable { - let results: [ChatGPTAdminCostResult]? -} - -struct ChatGPTAdminCostResponse: Decodable, Sendable { - let data: [ChatGPTAdminCostBucket]? -} - -enum ChatGPTAPI { - private static let usageURL = URL(string: "https://chatgpt.com/backend-api/codex/usage")! - private static let adminUsageURL = URL(string: "https://api.openai.com/v1/organization/usage/completions")! - private static let adminCostURL = URL(string: "https://api.openai.com/v1/organization/costs")! - private static let adminReportDays = 7 - - static func fetchUsage(accessToken: String, accountID: String?) async throws -> ChatGPTUsageResponse { - var request = URLRequest(url: usageURL) - request.httpMethod = "GET" - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("codex_cli_rs", forHTTPHeaderField: "originator") - if let accountID { - request.setValue(accountID, forHTTPHeaderField: "chatgpt-account-id") - } - - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw ChatGPTAPIError.invalidResponse - } - - switch httpResponse.statusCode { - case 200: - break - case 401, 403: - throw ChatGPTAPIError.notAuthenticated - case 429: - throw ChatGPTAPIError.rateLimited - default: - throw ChatGPTAPIError.httpError(statusCode: httpResponse.statusCode) - } - - do { - return try JSONDecoder().decode(ChatGPTUsageResponse.self, from: data) - } catch { - throw ChatGPTAPIError.invalidResponse - } - } - - static func fetchAdminUsage(apiKey: String) async throws -> (inputTokens: Int, outputTokens: Int, costUSD: Double) { - let startTime = Calendar.current.date(byAdding: .day, value: -adminReportDays, to: Date()) ?? Date() - let window = [ - URLQueryItem(name: "start_time", value: String(Int(startTime.timeIntervalSince1970))), - URLQueryItem(name: "bucket_width", value: "1d"), - URLQueryItem(name: "limit", value: String(adminReportDays)), - ] - - var usageComponents = URLComponents(url: adminUsageURL, resolvingAgainstBaseURL: false)! - usageComponents.queryItems = window - - var costComponents = URLComponents(url: adminCostURL, resolvingAgainstBaseURL: false)! - costComponents.queryItems = window - - async let usageData = adminRequest(url: usageComponents.url!, apiKey: apiKey) - async let costData = adminRequest(url: costComponents.url!, apiKey: apiKey) - - let (usageRaw, costRaw) = try await (usageData, costData) - - do { - let usage = try JSONDecoder().decode(ChatGPTAdminUsageResponse.self, from: usageRaw) - let cost = try JSONDecoder().decode(ChatGPTAdminCostResponse.self, from: costRaw) - return totals(usage: usage, cost: cost) - } catch { - throw ChatGPTAPIError.invalidResponse - } - } - - static func totals( - usage: ChatGPTAdminUsageResponse, - cost: ChatGPTAdminCostResponse - ) -> (inputTokens: Int, outputTokens: Int, costUSD: Double) { - var inputTokens = 0 - var outputTokens = 0 - for bucket in usage.data ?? [] { - for result in bucket.results ?? [] { - inputTokens += result.inputTokens ?? 0 - outputTokens += result.outputTokens ?? 0 - } - } - - var costUSD = 0.0 - for bucket in cost.data ?? [] { - for result in bucket.results ?? [] { - costUSD += result.amount?.value ?? 0 - } - } - - return (inputTokens, outputTokens, costUSD) - } - - private static func adminRequest(url: URL, apiKey: String) async throws -> Data { - 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 URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw ChatGPTAPIError.invalidResponse - } - - switch httpResponse.statusCode { - case 200: - return data - case 401, 403: - throw ChatGPTAPIError.notAuthenticated - case 429: - throw ChatGPTAPIError.rateLimited - default: - throw ChatGPTAPIError.httpError(statusCode: httpResponse.statusCode) - } - } - - /// `planType` comes from the local token claims and is used when the response omits it. - static func snapshot( - from usage: ChatGPTUsageResponse, - planType: String?, - fetchedAt: Date, - isStale: Bool - ) -> ProviderUsageSnapshot { - var values: [UsageMetric: MetricValue] = [:] - - func apply(_ window: ChatGPTRateLimitWindow?, percent: UsageMetric, reset: UsageMetric) { - guard let window else { return } - if let usedPercent = window.usedPercent { - values[percent] = .percent(usedPercent) - } - // The API reports a countdown, so the absolute reset stays correct for cached snapshots. - if let resetsInSeconds = window.resetsInSeconds { - values[reset] = .date(fetchedAt.addingTimeInterval(TimeInterval(resetsInSeconds))) - } - } - - apply( - usage.rateLimits?.primary, - percent: .chatgptFiveHourUtilization, - reset: .chatgptFiveHourResetsAt - ) - apply( - usage.rateLimits?.secondary, - percent: .chatgptWeeklyUtilization, - reset: .chatgptWeeklyResetsAt - ) - - if let plan = usage.planType ?? planType, !plan.isEmpty { - values[.chatgptPlanType] = .text(plan) - } - - return ProviderUsageSnapshot( - providerID: .chatgpt, - fetchedAt: fetchedAt, - values: values, - isStale: isStale - ) - } - - static func adminSnapshot( - inputTokens: Int, - outputTokens: Int, - costUSD: Double, - fetchedAt: Date, - isStale: Bool - ) -> ProviderUsageSnapshot { - ProviderUsageSnapshot( - providerID: .chatgpt, - fetchedAt: fetchedAt, - values: [ - .chatgptAdminInputTokens: .integer(inputTokens), - .chatgptAdminOutputTokens: .integer(outputTokens), - .chatgptAdminCostUSD: .currency(costUSD), - ], - isStale: isStale - ) - } -} - struct ChatGPTProvider: UsageProvider { let id: ProviderID = .chatgpt let displayName = "ChatGPT" func resolveCredentials(authMode: AuthMode) throws -> AuthCredentials { - switch authMode { - case .auto: - let session = try ChatGPTAuthService.loadSessionCredentials() - return .chatgptSession(session) - case .manualSession: - let account = KeychainService.keychainAccount(provider: .chatgpt, authMode: .manualSession) - guard let token = KeychainService.load(account: account), !token.isEmpty else { - throw ChatGPTAPIError.notAuthenticated - } - return .chatgptSession(ChatGPTAuthService.sessionCredentials(accessToken: token)) - case .adminAPI: - let account = KeychainService.keychainAccount(provider: .chatgpt, authMode: .adminAPI) - guard let apiKey = KeychainService.load(account: account), !apiKey.isEmpty else { - throw ChatGPTAPIError.notAuthenticated - } - return .chatgptAdminAPI(apiKey: apiKey) - } + .codexCLI } func fetchUsage(credentials: AuthCredentials, cache: UsageCache) async throws -> ProviderUsageSnapshot { - switch credentials { - case .chatgptSession(let session): - return try await fetchSessionUsage(session: session, cache: cache) - case .chatgptAdminAPI(let apiKey): - return try await fetchAdminUsage(apiKey: apiKey, cache: cache) - default: - throw ChatGPTAPIError.invalidResponse - } - } - - private func fetchSessionUsage( - session: ChatGPTSessionCredentials, - cache: UsageCache - ) async throws -> ProviderUsageSnapshot { - if let cached = await cache.load(provider: .chatgpt, maxAge: 60), - let usage = try? JSONDecoder().decode(ChatGPTUsageResponse.self, from: cached.payload) { - return ChatGPTAPI.snapshot( - from: usage, - planType: session.planType, - fetchedAt: cached.fetchedAt, - isStale: false - ) - } - - do { - let usage = try await ChatGPTAPI.fetchUsage( - accessToken: session.accessToken, - accountID: session.accountID - ) - if let data = try? JSONEncoder().encode(usage) { - await cache.save(provider: .chatgpt, payload: data) - } - return ChatGPTAPI.snapshot( - from: usage, - planType: session.planType, - fetchedAt: Date(), - isStale: false - ) - } catch { - if let stale = await cache.loadStale(provider: .chatgpt), - let usage = try? JSONDecoder().decode(ChatGPTUsageResponse.self, from: stale.payload) { - return ChatGPTAPI.snapshot( - from: usage, - planType: session.planType, - fetchedAt: stale.fetchedAt, - isStale: true - ) - } - throw error - } - } - - private func fetchAdminUsage(apiKey: String, cache: UsageCache) async throws -> ProviderUsageSnapshot { - struct AdminPayload: Codable { - let inputTokens: Int - let outputTokens: Int - let costUSD: Double + guard case .codexCLI = credentials else { + throw CodexCLIError.invalidResponse } if let cached = await cache.load(provider: .chatgpt, maxAge: 300), - let payload = try? JSONDecoder().decode(AdminPayload.self, from: cached.payload) { - return ChatGPTAPI.adminSnapshot( - inputTokens: payload.inputTokens, - outputTokens: payload.outputTokens, - costUSD: payload.costUSD, - fetchedAt: cached.fetchedAt, - isStale: false - ) + let parsed = try? JSONDecoder().decode(CodexRateLimitSnapshot.self, from: cached.payload) { + return CodexCLIUsageService.snapshot(from: parsed, fetchedAt: cached.fetchedAt, isStale: false) } do { - let result = try await ChatGPTAPI.fetchAdminUsage(apiKey: apiKey) - let payload = AdminPayload( - inputTokens: result.inputTokens, - outputTokens: result.outputTokens, - costUSD: result.costUSD - ) - if let data = try? JSONEncoder().encode(payload) { + let output = try await CodexCLIUsageService.runRateLimitsRequest() + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) + if let data = try? JSONEncoder().encode(parsed) { await cache.save(provider: .chatgpt, payload: data) } - return ChatGPTAPI.adminSnapshot( - inputTokens: result.inputTokens, - outputTokens: result.outputTokens, - costUSD: result.costUSD, - fetchedAt: Date(), - isStale: false - ) + return CodexCLIUsageService.snapshot(from: parsed, fetchedAt: Date(), isStale: false) } catch { if let stale = await cache.loadStale(provider: .chatgpt), - let payload = try? JSONDecoder().decode(AdminPayload.self, from: stale.payload) { - return ChatGPTAPI.adminSnapshot( - inputTokens: payload.inputTokens, - outputTokens: payload.outputTokens, - costUSD: payload.costUSD, - fetchedAt: stale.fetchedAt, - isStale: true - ) + let parsed = try? JSONDecoder().decode(CodexRateLimitSnapshot.self, from: stale.payload) { + return CodexCLIUsageService.snapshot(from: parsed, fetchedAt: stale.fetchedAt, isStale: true) } throw error } diff --git a/AIMenubarUsage/Providers/UsageProvider.swift b/AIMenubarUsage/Providers/UsageProvider.swift index 31a7d36..dae6b54 100644 --- a/AIMenubarUsage/Providers/UsageProvider.swift +++ b/AIMenubarUsage/Providers/UsageProvider.swift @@ -6,8 +6,7 @@ enum AuthCredentials: Sendable { case claudeCLI case claudeOAuth(accessToken: String) case claudeAdminAPI(apiKey: String) - case chatgptSession(ChatGPTSessionCredentials) - case chatgptAdminAPI(apiKey: String) + case codexCLI } protocol UsageProvider: Sendable { diff --git a/AIMenubarUsage/Services/ChatGPTAuthService.swift b/AIMenubarUsage/Services/ChatGPTAuthService.swift deleted file mode 100644 index 2c22722..0000000 --- a/AIMenubarUsage/Services/ChatGPTAuthService.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation - -enum ChatGPTAuthError: Error, LocalizedError { - case credentialsNotFound - case tokenNotFound - - var errorDescription: String? { - switch self { - case .credentialsNotFound: - "Codex CLI credentials not found. Run `codex login` or add a token in Settings." - case .tokenNotFound: - "No ChatGPT access token in the Codex CLI credentials. Run `codex login` again." - } - } -} - -struct ChatGPTSessionCredentials: Sendable { - let accessToken: String - let accountID: String? - let planType: String? -} - -enum ChatGPTAuthService { - /// Namespace of the ChatGPT claims Codex embeds in its OAuth tokens. - private static let authClaimKey = "https://api.openai.com/auth" - - /// Codex CLI keeps its OAuth tokens here; `CODEX_HOME` relocates the whole directory. - static var credentialsURL: URL { - if let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"], !codexHome.isEmpty { - return URL(fileURLWithPath: codexHome, isDirectory: true).appendingPathComponent("auth.json") - } - return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".codex/auth.json") - } - - static func loadSessionCredentials() throws -> ChatGPTSessionCredentials { - guard let data = try? Data(contentsOf: credentialsURL), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { - throw ChatGPTAuthError.credentialsNotFound - } - return try parseAuthJSON(json) - } - - static func parseAuthJSON(_ json: [String: Any]) throws -> ChatGPTSessionCredentials { - guard let tokens = json["tokens"] as? [String: Any], - let accessToken = tokens["access_token"] as? String, - !accessToken.isEmpty - else { - throw ChatGPTAuthError.tokenNotFound - } - - var claims = authClaims(in: tokens["id_token"] as? String) - if claims.isEmpty { - claims = authClaims(in: accessToken) - } - - let accountID = [tokens["account_id"] as? String, claims["chatgpt_account_id"] as? String] - .compactMap { $0 } - .first { !$0.isEmpty } - - return ChatGPTSessionCredentials( - accessToken: accessToken, - accountID: accountID, - planType: claims["chatgpt_plan_type"] as? String - ) - } - - /// A manually pasted token is the same ChatGPT access token Codex stores, so the account - /// and plan can still be read from its claims. - static func sessionCredentials(accessToken: String) -> ChatGPTSessionCredentials { - let claims = authClaims(in: accessToken) - let accountID = claims["chatgpt_account_id"] as? String - return ChatGPTSessionCredentials( - accessToken: accessToken, - accountID: accountID?.isEmpty == true ? nil : accountID, - planType: claims["chatgpt_plan_type"] as? String - ) - } - - private static func authClaims(in token: String?) -> [String: Any] { - guard let token, - let payload = JWT.payload(of: token), - let claims = payload[authClaimKey] as? [String: Any] - else { - return [:] - } - return claims - } -} diff --git a/AIMenubarUsage/Services/CodexCLIUsageService.swift b/AIMenubarUsage/Services/CodexCLIUsageService.swift new file mode 100644 index 0000000..0a29271 --- /dev/null +++ b/AIMenubarUsage/Services/CodexCLIUsageService.swift @@ -0,0 +1,256 @@ +import Foundation + +enum CodexCLIError: Error, LocalizedError, Equatable { + case cliNotFound + case commandFailed(String) + case invalidResponse + case notAuthenticated + + var errorDescription: String? { + switch self { + case .cliNotFound: + "Codex CLI not found. Install it from developers.openai.com/codex." + case .commandFailed(let details): + "Codex CLI failed: \(details)" + case .invalidResponse: + "Could not parse usage output from Codex CLI." + case .notAuthenticated: + "Codex CLI is not signed in. Run `codex login` in Terminal." + } + } +} + +struct CodexRateLimitWindow: Codable, Sendable { + let usedPercent: Double? + let windowDurationMins: Int? + /// Absolute reset, Unix seconds. Newer CLIs send this; older ones send a countdown instead. + let resetsAt: Double? + let resetsInSeconds: Double? + + func resetDate(relativeTo fetchedAt: Date) -> Date? { + if let resetsAt, resetsAt > 0 { + return Date(timeIntervalSince1970: resetsAt) + } + if let resetsInSeconds { + return fetchedAt.addingTimeInterval(resetsInSeconds) + } + return nil + } +} + +struct CodexRateLimits: Codable, Sendable { + let primary: CodexRateLimitWindow? + let secondary: CodexRateLimitWindow? +} + +struct CodexRateLimitSnapshot: Codable, Sendable { + let rateLimits: CodexRateLimits? + let planType: String? + + enum CodingKeys: String, CodingKey { + case rateLimits + case planType + } + + /// `account/rateLimits/read` is undocumented and its envelope has moved between CLI + /// releases — accept the windows wrapped in `rateLimits` or inlined at the root. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + planType = try container.decodeIfPresent(String.self, forKey: .planType) + if let wrapped = try container.decodeIfPresent(CodexRateLimits.self, forKey: .rateLimits) { + rateLimits = wrapped + } else { + rateLimits = try? CodexRateLimits(from: decoder) + } + } +} + +/// Reads Codex usage through `codex app-server`, the CLI's stdio JSON-RPC interface, so the +/// CLI owns authentication end to end. This app never reads, stores or forwards Codex +/// credentials — which also means it works regardless of where Codex keeps them. +enum CodexCLIUsageService { + private static let timeoutSeconds: TimeInterval = 30 + private static let rateLimitsRequestID = 1 + + static func resolveCodexExecutable() -> String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let candidates = [ + "\(home)/.local/bin/codex", + "/opt/homebrew/bin/codex", + "/usr/local/bin/codex", + ] + + for candidate in candidates where FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + + return "codex" + } + + static func fetchUsage() async throws -> ProviderUsageSnapshot { + let output = try await runRateLimitsRequest() + let parsed = try parseRateLimitsResponse(output) + return snapshot(from: parsed, fetchedAt: Date(), isStale: false) + } + + static func runRateLimitsRequest() async throws -> String { + let codexPath = resolveCodexExecutable() + let process = Process() + process.executableURL = URL(fileURLWithPath: codexPath) + // Read-only with approvals off: this call must never be able to touch the workspace. + process.arguments = ["-s", "read-only", "-a", "never", "app-server"] + + var environment = ProcessInfo.processInfo.environment + let home = FileManager.default.homeDirectoryForCurrentUser.path + let extraPaths = [ + URL(fileURLWithPath: codexPath).deletingLastPathComponent().path, + "\(home)/.local/bin", + "/opt/homebrew/bin", + "/usr/local/bin", + ] + let existingPath = environment["PATH"] ?? "/usr/bin:/bin" + environment["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") + process.environment = environment + + let input = Pipe() + let stdout = Pipe() + let stderr = Pipe() + process.standardInput = input + process.standardOutput = stdout + process.standardError = stderr + + do { + try process.run() + } catch { + throw CodexCLIError.cliNotFound + } + + write(handshakeMessages, to: input) + // The server drops requests that arrive before the handshake settles. + try? await Task.sleep(nanoseconds: 500_000_000) + write([rateLimitsMessage], to: input) + try? input.fileHandleForWriting.close() + + let deadline = Date().addingTimeInterval(timeoutSeconds) + while process.isRunning, Date() < deadline { + try await Task.sleep(nanoseconds: 100_000_000) + } + + let timedOut = process.isRunning + if timedOut { + process.terminate() + } + + let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let errorOutput = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + // A server that never closes stdin still answered, so prefer its output over the timeout. + if output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let details = errorOutput.trimmingCharacters(in: .whitespacesAndNewlines) + if timedOut { + throw CodexCLIError.commandFailed("timed out after \(Int(timeoutSeconds))s") + } + if details.localizedCaseInsensitiveContains("not logged in") + || details.localizedCaseInsensitiveContains("not authenticated") + || details.localizedCaseInsensitiveContains("login") { + throw CodexCLIError.notAuthenticated + } + if codexPath == "codex", details.contains("not found") || details.contains("No such file") { + throw CodexCLIError.cliNotFound + } + throw CodexCLIError.commandFailed(details.isEmpty ? "no response" : details) + } + + return output + } + + static func parseRateLimitsResponse(_ output: String) throws -> CodexRateLimitSnapshot { + for line in output.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, + let data = trimmed.data(using: .utf8), + let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + intValue(message["id"]) == rateLimitsRequestID + else { + continue + } + + if let rpcError = message["error"] as? [String: Any] { + let details = rpcError["message"] as? String ?? "unknown app-server error" + if details.localizedCaseInsensitiveContains("auth") + || details.localizedCaseInsensitiveContains("login") { + throw CodexCLIError.notAuthenticated + } + throw CodexCLIError.commandFailed(details) + } + + guard let result = message["result"], + let resultData = try? JSONSerialization.data(withJSONObject: result), + let parsed = try? JSONDecoder().decode(CodexRateLimitSnapshot.self, from: resultData) + else { + throw CodexCLIError.invalidResponse + } + return parsed + } + + throw CodexCLIError.invalidResponse + } + + static func snapshot(from parsed: CodexRateLimitSnapshot, fetchedAt: Date, isStale: Bool) -> ProviderUsageSnapshot { + var values: [UsageMetric: MetricValue] = [:] + + func apply(_ window: CodexRateLimitWindow?, percent: UsageMetric, reset: UsageMetric) { + guard let window else { return } + if let usedPercent = window.usedPercent { + values[percent] = .percent(usedPercent) + } + if let resetDate = window.resetDate(relativeTo: fetchedAt) { + values[reset] = .date(resetDate) + } + } + + apply( + parsed.rateLimits?.primary, + percent: .chatgptFiveHourUtilization, + reset: .chatgptFiveHourResetsAt + ) + apply( + parsed.rateLimits?.secondary, + percent: .chatgptWeeklyUtilization, + reset: .chatgptWeeklyResetsAt + ) + + if let planType = parsed.planType, !planType.isEmpty { + values[.chatgptPlanType] = .text(planType) + } + + return ProviderUsageSnapshot( + providerID: .chatgpt, + fetchedAt: fetchedAt, + values: values, + isStale: isStale + ) + } + + private static var handshakeMessages: [String] { + [ + #"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"clientInfo":{"name":"ai-menubar-usage","title":"AI Menubar Usage","version":"1.0.0"}}}"#, + #"{"jsonrpc":"2.0","method":"initialized","params":{}}"#, + ] + } + + private static var rateLimitsMessage: String { + #"{"jsonrpc":"2.0","id":\#(rateLimitsRequestID),"method":"account/rateLimits/read","params":{}}"# + } + + private static func write(_ messages: [String], to pipe: Pipe) { + let payload = messages.map { $0 + "\n" }.joined() + try? pipe.fileHandleForWriting.write(contentsOf: Data(payload.utf8)) + } + + private static func intValue(_ value: Any?) -> Int? { + if let number = value as? NSNumber { return number.intValue } + if let string = value as? String { return Int(string) } + return nil + } +} diff --git a/AIMenubarUsage/Services/CursorAuthService.swift b/AIMenubarUsage/Services/CursorAuthService.swift index e9c7d3f..80a5291 100644 --- a/AIMenubarUsage/Services/CursorAuthService.swift +++ b/AIMenubarUsage/Services/CursorAuthService.swift @@ -57,8 +57,23 @@ enum CursorAuthService { } private static func extractUserID(from jwt: String) throws -> String { - guard let payload = JWT.payload(of: jwt), - let subject = payload["sub"] as? String + let parts = jwt.split(separator: ".") + guard parts.count >= 2 else { + throw CursorAuthError.invalidToken + } + + var payload = String(parts[1]) + let padding = payload.count % 4 + if padding > 0 { + payload += String(repeating: "=", count: 4 - padding) + } + payload = payload + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + guard let data = Data(base64Encoded: payload), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let subject = json["sub"] as? String else { throw CursorAuthError.invalidToken } diff --git a/AIMenubarUsage/Services/JWTDecoder.swift b/AIMenubarUsage/Services/JWTDecoder.swift deleted file mode 100644 index a16347a..0000000 --- a/AIMenubarUsage/Services/JWTDecoder.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -/// Reads the unverified claims of a JWT. Provider tokens carry account identifiers and -/// plan information in their payload; nothing here is trusted for authorization. -enum JWT { - static func payload(of token: String) -> [String: Any]? { - let parts = token.split(separator: ".") - guard parts.count >= 2 else { return nil } - - var encoded = String(parts[1]) - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let padding = encoded.count % 4 - if padding > 0 { - encoded += String(repeating: "=", count: 4 - padding) - } - - guard let data = Data(base64Encoded: encoded), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { - return nil - } - return json - } -} diff --git a/AIMenubarUsage/Views/SettingsView.swift b/AIMenubarUsage/Views/SettingsView.swift index 9b0bcc7..4f91bb6 100644 --- a/AIMenubarUsage/Views/SettingsView.swift +++ b/AIMenubarUsage/Views/SettingsView.swift @@ -43,7 +43,7 @@ struct ProviderSettingsTab: View { .foregroundStyle(.secondary) } - if providerSettings.authMode == .manualSession { + if providerSettings.authMode == .manualSession, let manualTokenHelp { SecureField("Session token", text: $manualToken) Text(manualTokenHelp) .font(.caption) @@ -53,7 +53,7 @@ struct ProviderSettingsTab: View { } } - if providerSettings.authMode == .adminAPI { + if providerSettings.authMode == .adminAPI, let adminAPIHelp { SecureField("Admin API key", text: $adminAPIKey) Text(adminAPIHelp) .font(.caption) @@ -103,7 +103,7 @@ struct ProviderSettingsTab: View { } private var availableAuthModes: [AuthMode] { - AuthMode.allCases + providerID.supportedAuthModes } private var autoModeHelp: String? { @@ -113,29 +113,29 @@ struct ProviderSettingsTab: View { case .claude: return "Runs `claude -p \"/usage\"` so the CLI handles authentication. No Keychain access from this app." case .chatgpt: - return "Reads the Codex CLI session from ~/.codex/auth.json. Run `codex login` if it is missing." + return "Runs `codex app-server` so the CLI handles authentication. No Keychain access from this app." } } - private var manualTokenHelp: String { + private var manualTokenHelp: String? { switch providerID { case .cursor: - "WorkosCursorSessionToken cookie value from cursor.com." + return "WorkosCursorSessionToken cookie value from cursor.com." case .claude: - "Claude OAuth access token." + return "Claude OAuth access token." case .chatgpt: - "ChatGPT access token — the `tokens.access_token` value in ~/.codex/auth.json." + return nil } } - private var adminAPIHelp: String { + private var adminAPIHelp: String? { switch providerID { case .cursor: - "Cursor Admin API key with admin:* scope from cursor.com/dashboard/api" + return "Cursor Admin API key with admin:* scope from cursor.com/dashboard/api" case .claude: - "Anthropic Admin API key (sk-ant-admin01-...) from console.anthropic.com" + return "Anthropic Admin API key (sk-ant-admin01-...) from console.anthropic.com" case .chatgpt: - "OpenAI Admin API key (sk-admin-...) from platform.openai.com organization settings" + return nil } } @@ -180,7 +180,14 @@ struct ProviderSettingsTab: View { } } + /// ChatGPT never stores credentials here, so it must not even query the Keychain. + private var usesKeychain: Bool { + providerID.supportedAuthModes.contains { $0 != .auto } + } + private func loadKeychainValues() { + guard usesKeychain else { return } + let manualAccount = KeychainService.keychainAccount(provider: providerID, authMode: .manualSession) manualToken = KeychainService.load(account: manualAccount) ?? "" @@ -279,11 +286,11 @@ struct AboutSettingsTab: View { Text("Authentication") .font(.headline) - Text("Claude auto mode shells out to the Claude Code CLI (`claude -p \"/usage\"`), which handles login itself. Cursor auto mode reads the local Cursor IDE session, and ChatGPT auto mode reads the Codex CLI session from ~/.codex/auth.json. Manual tokens and Admin API keys are stored in this app's Keychain.") + Text("Claude and ChatGPT shell out to their own CLIs — `claude -p \"/usage\"` and `codex app-server` — which handle login themselves, so this app never sees their credentials. Cursor auto mode reads the local Cursor IDE session. Cursor and Claude manual tokens and Admin API keys are stored in this app's Keychain.") Text("Unofficial APIs") .font(.headline) - Text("The Cursor dashboard, Claude OAuth and ChatGPT usage endpoints are unofficial and may change without notice. Admin APIs require organization or team accounts.") + Text("The Cursor dashboard and Claude OAuth usage endpoints are unofficial and may change without notice. Codex's `app-server` interface is experimental and its fields may change between CLI releases. Admin APIs require organization or team accounts.") Spacer() } diff --git a/AIMenubarUsageTests/ChatGPTAuthTests.swift b/AIMenubarUsageTests/ChatGPTAuthTests.swift deleted file mode 100644 index 8c0f111..0000000 --- a/AIMenubarUsageTests/ChatGPTAuthTests.swift +++ /dev/null @@ -1,73 +0,0 @@ -import XCTest -@testable import AIMenubarUsage - -final class ChatGPTAuthTests: XCTestCase { - func testParseAuthJSONFromCodexCredentials() throws { - let idToken = try makeToken(accountID: "acct_from_claims", planType: "pro") - let json: [String: Any] = [ - "OPENAI_API_KEY": NSNull(), - "tokens": [ - "id_token": idToken, - "access_token": "chatgpt-access-token", - "refresh_token": "chatgpt-refresh-token", - "account_id": "acct_from_file", - ], - "last_refresh": "2026-01-01T00:00:00Z", - ] - - let credentials = try ChatGPTAuthService.parseAuthJSON(json) - XCTAssertEqual(credentials.accessToken, "chatgpt-access-token") - XCTAssertEqual(credentials.accountID, "acct_from_file") - XCTAssertEqual(credentials.planType, "pro") - } - - func testParseAuthJSONFallsBackToTokenClaimsForAccountID() throws { - let idToken = try makeToken(accountID: "acct_from_claims", planType: "plus") - let json: [String: Any] = [ - "tokens": [ - "id_token": idToken, - "access_token": "chatgpt-access-token", - ], - ] - - let credentials = try ChatGPTAuthService.parseAuthJSON(json) - XCTAssertEqual(credentials.accountID, "acct_from_claims") - XCTAssertEqual(credentials.planType, "plus") - } - - func testParseAuthJSONWithoutAccessTokenThrows() { - XCTAssertThrowsError(try ChatGPTAuthService.parseAuthJSON(["tokens": ["access_token": ""]])) - XCTAssertThrowsError(try ChatGPTAuthService.parseAuthJSON([:])) - } - - func testSessionCredentialsFromManualAccessToken() throws { - let token = try makeToken(accountID: "acct_manual", planType: "team") - - let credentials = ChatGPTAuthService.sessionCredentials(accessToken: token) - XCTAssertEqual(credentials.accessToken, token) - XCTAssertEqual(credentials.accountID, "acct_manual") - XCTAssertEqual(credentials.planType, "team") - } - - func testSessionCredentialsFromOpaqueTokenHasNoClaims() { - let credentials = ChatGPTAuthService.sessionCredentials(accessToken: "not-a-jwt") - - XCTAssertEqual(credentials.accessToken, "not-a-jwt") - XCTAssertNil(credentials.accountID) - XCTAssertNil(credentials.planType) - } - - private func makeToken(accountID: String, planType: String) throws -> String { - let payload = try JSONSerialization.data(withJSONObject: [ - "https://api.openai.com/auth": [ - "chatgpt_account_id": accountID, - "chatgpt_plan_type": planType, - ], - ]) - let encoded = payload.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - return "header.\(encoded).signature" - } -} diff --git a/AIMenubarUsageTests/ChatGPTProviderTests.swift b/AIMenubarUsageTests/ChatGPTProviderTests.swift index 92e7dbe..e56b674 100644 --- a/AIMenubarUsageTests/ChatGPTProviderTests.swift +++ b/AIMenubarUsageTests/ChatGPTProviderTests.swift @@ -2,72 +2,87 @@ import XCTest @testable import AIMenubarUsage final class ChatGPTProviderTests: XCTestCase { - func testDecodeCodexUsageFixture() throws { - let usage = try decodeFixture(ChatGPTUsageResponse.self, named: "chatgpt-codex-usage") + func testParseRateLimitsResponseFromAppServerOutput() throws { + let output = try appServerOutput() + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) - XCTAssertEqual(usage.planType, "plus") - XCTAssertEqual(usage.rateLimits?.primary?.usedPercent ?? 0, 42, accuracy: 0.001) - XCTAssertEqual(usage.rateLimits?.primary?.windowMinutes, 300) - XCTAssertEqual(usage.rateLimits?.secondary?.resetsInSeconds, 86_400) + XCTAssertEqual(parsed.planType, "plus") + XCTAssertEqual(parsed.rateLimits?.primary?.usedPercent ?? 0, 42, accuracy: 0.001) + XCTAssertEqual(parsed.rateLimits?.primary?.windowDurationMins, 300) + XCTAssertEqual(parsed.rateLimits?.secondary?.windowDurationMins, 10_080) + } + + func testParseSkipsHandshakeAndNotificationLines() throws { + let noise = """ + {"jsonrpc":"2.0","id":0,"result":{"userAgent":"codex"}} + {"jsonrpc":"2.0","method":"someNotification","params":{}} + """ + let output = try appServerOutput() + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(noise + "\n" + output) + + XCTAssertEqual(parsed.planType, "plus") } func testSnapshotMapping() throws { - let usage = try decodeFixture(ChatGPTUsageResponse.self, named: "chatgpt-codex-usage") - let fetchedAt = Date(timeIntervalSince1970: 1_735_689_600) - let snapshot = ChatGPTAPI.snapshot(from: usage, planType: nil, fetchedAt: fetchedAt, isStale: false) + let output = try appServerOutput() + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) + let snapshot = CodexCLIUsageService.snapshot(from: parsed, fetchedAt: Date(), isStale: false) XCTAssertEqual(snapshot.providerID, .chatgpt) XCTAssertEqual(snapshot.formatted(.chatgptFiveHourUtilization), "42%") XCTAssertEqual(snapshot.formatted(.chatgptWeeklyUtilization), "15%") XCTAssertEqual(snapshot.formatted(.chatgptPlanType), "plus") - XCTAssertEqual(snapshot.value(for: .chatgptFiveHourResetsAt), MetricValue.date(fetchedAt.addingTimeInterval(3600))) - XCTAssertEqual(snapshot.value(for: .chatgptWeeklyResetsAt), MetricValue.date(fetchedAt.addingTimeInterval(86_400))) + XCTAssertEqual( + snapshot.value(for: .chatgptFiveHourResetsAt), + MetricValue.date(Date(timeIntervalSince1970: 1_788_265_323)) + ) } - func testDecodeRateLimitsInlinedAtRoot() throws { - let json = Data(""" - {"primary": {"used_percent": 7, "window_minutes": 300, "resets_in_seconds": 60}} - """.utf8) + func testCountdownResetIsResolvedAgainstFetchTime() throws { + let output = #"{"jsonrpc":"2.0","id":1,"result":{"rateLimits":{"primary":{"usedPercent":7,"resetsInSeconds":3600}}}}"# + let fetchedAt = Date(timeIntervalSince1970: 1_700_000_000) - let usage = try JSONDecoder().decode(ChatGPTUsageResponse.self, from: json) - let snapshot = ChatGPTAPI.snapshot(from: usage, planType: nil, fetchedAt: Date(), isStale: false) + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) + let snapshot = CodexCLIUsageService.snapshot(from: parsed, fetchedAt: fetchedAt, isStale: false) XCTAssertEqual(snapshot.formatted(.chatgptFiveHourUtilization), "7%") - XCTAssertNil(snapshot.value(for: .chatgptWeeklyUtilization)) - } - - func testSnapshotFallsBackToLocalPlanType() throws { - let json = Data(#"{"rate_limits": {}}"#.utf8) - let usage = try JSONDecoder().decode(ChatGPTUsageResponse.self, from: json) - let snapshot = ChatGPTAPI.snapshot(from: usage, planType: "pro", fetchedAt: Date(), isStale: false) - - XCTAssertEqual(snapshot.formatted(.chatgptPlanType), "pro") - } - - func testAdminTotalsAcrossBuckets() throws { - let usage = try decodeFixture(ChatGPTAdminUsageResponse.self, named: "chatgpt-admin-usage") - let cost = try decodeFixture(ChatGPTAdminCostResponse.self, named: "chatgpt-admin-costs") - - let totals = ChatGPTAPI.totals(usage: usage, cost: cost) - XCTAssertEqual(totals.inputTokens, 2000) - XCTAssertEqual(totals.outputTokens, 500) - XCTAssertEqual(totals.costUSD, 1.75, accuracy: 0.001) - - let snapshot = ChatGPTAPI.adminSnapshot( - inputTokens: totals.inputTokens, - outputTokens: totals.outputTokens, - costUSD: totals.costUSD, - fetchedAt: Date(), - isStale: false + XCTAssertEqual( + snapshot.value(for: .chatgptFiveHourResetsAt), + MetricValue.date(fetchedAt.addingTimeInterval(3600)) ) - XCTAssertEqual(snapshot.formatted(.chatgptAdminCostUSD), "$1.75") } - private func decodeFixture(_ type: T.Type, named name: String) throws -> T { + func testParseAcceptsWindowsInlinedAtRoot() throws { + let output = #"{"jsonrpc":"2.0","id":1,"result":{"primary":{"usedPercent":20},"secondary":{"usedPercent":55}}}"# + + let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) + let snapshot = CodexCLIUsageService.snapshot(from: parsed, fetchedAt: Date(), isStale: false) + + XCTAssertEqual(snapshot.formatted(.chatgptFiveHourUtilization), "20%") + XCTAssertEqual(snapshot.formatted(.chatgptWeeklyUtilization), "55%") + XCTAssertNil(snapshot.value(for: .chatgptPlanType)) + } + + func testRPCErrorAboutLoginSurfacesAsNotAuthenticated() { + let output = #"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"not logged in; run codex login"}}"# + + XCTAssertThrowsError(try CodexCLIUsageService.parseRateLimitsResponse(output)) { error in + XCTAssertEqual(error as? CodexCLIError, CodexCLIError.notAuthenticated) + } + } + + func testMissingResponseThrows() { + XCTAssertThrowsError(try CodexCLIUsageService.parseRateLimitsResponse("not json\n")) + } + + /// The fixture is pretty-printed for review; app-server emits one JSON object per line. + private func appServerOutput() throws -> String { let url = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .appendingPathComponent("Fixtures") - .appendingPathComponent("\(name).json") - return try JSONDecoder().decode(type, from: Data(contentsOf: url)) + .appendingPathComponent("chatgpt-codex-usage.json") + let object = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) + let compact = try JSONSerialization.data(withJSONObject: object) + return try XCTUnwrap(String(data: compact, encoding: .utf8)) } } diff --git a/AIMenubarUsageTests/Fixtures/chatgpt-admin-costs.json b/AIMenubarUsageTests/Fixtures/chatgpt-admin-costs.json deleted file mode 100644 index a3008d4..0000000 --- a/AIMenubarUsageTests/Fixtures/chatgpt-admin-costs.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "object": "page", - "data": [ - { - "object": "bucket", - "start_time": 1735689600, - "end_time": 1735776000, - "results": [ - { - "object": "organization.costs.result", - "amount": { - "value": 0.5, - "currency": "usd" - }, - "line_item": null, - "project_id": null - } - ] - }, - { - "object": "bucket", - "start_time": 1735776000, - "end_time": 1735862400, - "results": [ - { - "object": "organization.costs.result", - "amount": { - "value": 1.25, - "currency": "usd" - }, - "line_item": null, - "project_id": null - } - ] - } - ], - "has_more": false, - "next_page": null -} diff --git a/AIMenubarUsageTests/Fixtures/chatgpt-admin-usage.json b/AIMenubarUsageTests/Fixtures/chatgpt-admin-usage.json deleted file mode 100644 index d7b27b0..0000000 --- a/AIMenubarUsageTests/Fixtures/chatgpt-admin-usage.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "object": "page", - "data": [ - { - "object": "bucket", - "start_time": 1735689600, - "end_time": 1735776000, - "results": [ - { - "object": "organization.usage.completions.result", - "input_tokens": 1200, - "output_tokens": 340, - "num_model_requests": 8 - } - ] - }, - { - "object": "bucket", - "start_time": 1735776000, - "end_time": 1735862400, - "results": [ - { - "object": "organization.usage.completions.result", - "input_tokens": 800, - "output_tokens": 160, - "num_model_requests": 5 - } - ] - } - ], - "has_more": false, - "next_page": null -} diff --git a/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json b/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json index fdccba1..0ebe338 100644 --- a/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json +++ b/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json @@ -1,15 +1,19 @@ { - "plan_type": "plus", - "rate_limits": { - "primary": { - "used_percent": 42, - "window_minutes": 300, - "resets_in_seconds": 3600 - }, - "secondary": { - "used_percent": 15, - "window_minutes": 10080, - "resets_in_seconds": 86400 + "jsonrpc": "2.0", + "id": 1, + "result": { + "planType": "plus", + "rateLimits": { + "primary": { + "usedPercent": 42, + "windowDurationMins": 300, + "resetsAt": 1788265323 + }, + "secondary": { + "usedPercent": 15, + "windowDurationMins": 10080, + "resetsAt": 1788765541 + } } } } diff --git a/README.md b/README.md index 4a628f4..3ef6258 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,16 @@ First launch from an unsigned build: right-click the app → **Open**, or `xattr | ----------- | ---------------------------------------- | ------------------------------- | | **Cursor** | Session from local Cursor IDE database | Session token or Admin API key | | **Claude** | `claude -p "/usage"` via Claude Code CLI | OAuth token or Admin API key | -| **ChatGPT** | Codex CLI session from `~/.codex/auth.json` | Access token or Admin API key | +| **ChatGPT** | `codex app-server` via Codex CLI | — (Codex owns its credentials) | + +ChatGPT usage comes entirely from the Codex CLI: the app shells out to `codex app-server` and never reads, +stores or forwards Codex credentials, so it works wherever Codex keeps them. Providers added in an app update start disabled — enable them in **Settings → \**. ## Disclaimer -The Cursor, Claude and ChatGPT usage endpoints used in auto mode are unofficial and may change. Not affiliated with Anthropic, Cursor or OpenAI. +The Cursor and Claude usage endpoints used in auto mode are unofficial and may change. Codex's `app-server` interface is experimental and its fields may change between CLI releases. Not affiliated with Anthropic, Cursor or OpenAI. ## License -- 2.52.0 From 1478565262cef5ff1005cfb1cf83a0e3323ca56a Mon Sep 17 00:00:00 2001 From: butterrobot Date: Fri, 11 Sep 2026 09:03:59 +0000 Subject: [PATCH 3/3] Fix the app-server handshake against the documented protocol The previous commit's messages were rejected by Codex. Checked against the app-server documentation rather than guessing again: - app-server omits the `"jsonrpc": "2.0"` header on the wire; we were sending it on every message. - `initialize` takes `params.capabilities.experimentalApi`, which part of the server's surface is gated behind. We sent only `clientInfo`. - The documented invocation is a bare `codex app-server`. The `-s read-only -a never` prefix was defensive but undocumented, and nothing here starts a thread or runs a turn for it to constrain. Also fixed alongside: - A PATH-only install was unreachable: `URL(fileURLWithPath: "codex")` resolves against the working directory, so the fallback now goes through `/usr/bin/env`. - stdout and stderr are drained as they fill instead of after exit, which removes both the pipe-buffer deadlock and the full timeout wait for a server that outlives its stdin. The request returns as soon as its response lands. - Failures now carry the exit status and the stderr tail instead of a bare "could not parse", so the next report says what actually happened. Co-Authored-By: Claude Opus 5 (1M context) --- .../Services/CodexCLIUsageService.swift | 203 ++++++++++++------ .../ChatGPTProviderTests.swift | 10 +- .../Fixtures/chatgpt-codex-usage.json | 1 - 3 files changed, 142 insertions(+), 72 deletions(-) diff --git a/AIMenubarUsage/Services/CodexCLIUsageService.swift b/AIMenubarUsage/Services/CodexCLIUsageService.swift index 0a29271..16877c1 100644 --- a/AIMenubarUsage/Services/CodexCLIUsageService.swift +++ b/AIMenubarUsage/Services/CodexCLIUsageService.swift @@ -52,8 +52,8 @@ struct CodexRateLimitSnapshot: Codable, Sendable { case planType } - /// `account/rateLimits/read` is undocumented and its envelope has moved between CLI - /// releases — accept the windows wrapped in `rateLimits` or inlined at the root. + /// app-server is documented as experimental, so tolerate envelope drift: accept the + /// windows wrapped in `rateLimits` or inlined at the root. init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) planType = try container.decodeIfPresent(String.self, forKey: .planType) @@ -96,28 +96,31 @@ enum CodexCLIUsageService { static func runRateLimitsRequest() async throws -> String { let codexPath = resolveCodexExecutable() let process = Process() - process.executableURL = URL(fileURLWithPath: codexPath) - // Read-only with approvals off: this call must never be able to touch the workspace. - process.arguments = ["-s", "read-only", "-a", "never", "app-server"] - - var environment = ProcessInfo.processInfo.environment - let home = FileManager.default.homeDirectoryForCurrentUser.path - let extraPaths = [ - URL(fileURLWithPath: codexPath).deletingLastPathComponent().path, - "\(home)/.local/bin", - "/opt/homebrew/bin", - "/usr/local/bin", - ] - let existingPath = environment["PATH"] ?? "/usr/bin:/bin" - environment["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") - process.environment = environment + // A bare name has to go through `env`; a file URL built from it would resolve + // against the working directory instead of PATH. + let needsPathLookup = !codexPath.hasPrefix("/") + process.executableURL = URL(fileURLWithPath: needsPathLookup ? "/usr/bin/env" : codexPath) + process.arguments = needsPathLookup ? [codexPath, "app-server"] : ["app-server"] + process.environment = environmentWithCLIPaths(for: codexPath) let input = Pipe() - let stdout = Pipe() - let stderr = Pipe() + let outputPipe = Pipe() + let errorPipe = Pipe() process.standardInput = input - process.standardOutput = stdout - process.standardError = stderr + process.standardOutput = outputPipe + process.standardError = errorPipe + + // Drain both pipes as they fill: a stdio server blocks once a pipe buffer is full, + // and reading only after exit would deadlock a server that outlives its stdin. + let stdout = OutputCollector() + let stderr = OutputCollector() + outputPipe.fileHandleForReading.readabilityHandler = { stdout.append($0.availableData) } + errorPipe.fileHandleForReading.readabilityHandler = { stderr.append($0.availableData) } + defer { + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + if process.isRunning { process.terminate() } + } do { try process.run() @@ -132,68 +135,115 @@ enum CodexCLIUsageService { try? input.fileHandleForWriting.close() let deadline = Date().addingTimeInterval(timeoutSeconds) - while process.isRunning, Date() < deadline { + while true { + let text = stdout.text + if containsRateLimitsResponse(text) { return text } + if !process.isRunning || Date() >= deadline { break } try await Task.sleep(nanoseconds: 100_000_000) } - let timedOut = process.isRunning - if timedOut { - process.terminate() + // Give the readers a moment to drain whatever landed just before the process exited. + try? await Task.sleep(nanoseconds: 200_000_000) + let text = stdout.text + if containsRateLimitsResponse(text) { return text } + + throw failure(stdout: text, stderr: stderr.text, process: process, codexPath: codexPath) + } + + private static func environmentWithCLIPaths(for codexPath: String) -> [String: String] { + var environment = ProcessInfo.processInfo.environment + let home = FileManager.default.homeDirectoryForCurrentUser.path + var extraPaths = [ + "\(home)/.local/bin", + "/opt/homebrew/bin", + "/usr/local/bin", + ] + if codexPath.hasPrefix("/") { + extraPaths.insert(URL(fileURLWithPath: codexPath).deletingLastPathComponent().path, at: 0) } + let existingPath = environment["PATH"] ?? "/usr/bin:/bin" + environment["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") + return environment + } - let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - let errorOutput = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + private static func failure( + stdout: String, + stderr: String, + process: Process, + codexPath: String + ) -> CodexCLIError { + let details = stderr.trimmingCharacters(in: .whitespacesAndNewlines) - // A server that never closes stdin still answered, so prefer its output over the timeout. - if output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - let details = errorOutput.trimmingCharacters(in: .whitespacesAndNewlines) - if timedOut { - throw CodexCLIError.commandFailed("timed out after \(Int(timeoutSeconds))s") - } - if details.localizedCaseInsensitiveContains("not logged in") - || details.localizedCaseInsensitiveContains("not authenticated") - || details.localizedCaseInsensitiveContains("login") { - throw CodexCLIError.notAuthenticated - } - if codexPath == "codex", details.contains("not found") || details.contains("No such file") { - throw CodexCLIError.cliNotFound - } - throw CodexCLIError.commandFailed(details.isEmpty ? "no response" : details) + if details.localizedCaseInsensitiveContains("not logged in") + || details.localizedCaseInsensitiveContains("not authenticated") + || details.localizedCaseInsensitiveContains("codex login") { + return .notAuthenticated } + if codexPath == "codex", + details.contains("not found") || details.contains("No such file") { + return .cliNotFound + } + if process.isRunning { + return .commandFailed("no response after \(Int(timeoutSeconds))s — \(diagnostic(stdout: stdout, stderr: details))") + } + return .commandFailed("exit \(process.terminationStatus) — \(diagnostic(stdout: stdout, stderr: details))") + } - return output + /// Shown in the popover, so keep it short — but specific enough to act on in a bug report. + private static func diagnostic(stdout: String, stderr: String) -> String { + let lastLine = stdout + .components(separatedBy: .newlines) + .last { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + + if !stderr.isEmpty { + return String(stderr.suffix(200)) + } + if let lastLine { + return "last app-server line: \(lastLine.prefix(200))" + } + return "no output from `codex app-server`" + } + + private static func containsRateLimitsResponse(_ output: String) -> Bool { + message(withID: rateLimitsRequestID, in: output) != nil } static func parseRateLimitsResponse(_ output: String) throws -> CodexRateLimitSnapshot { + guard let message = message(withID: rateLimitsRequestID, in: output) else { + throw CodexCLIError.invalidResponse + } + + if let rpcError = message["error"] as? [String: Any] { + let details = rpcError["message"] as? String ?? "unknown app-server error" + if details.localizedCaseInsensitiveContains("auth") + || details.localizedCaseInsensitiveContains("login") { + throw CodexCLIError.notAuthenticated + } + throw CodexCLIError.commandFailed(details) + } + + guard let result = message["result"], + let resultData = try? JSONSerialization.data(withJSONObject: result), + let parsed = try? JSONDecoder().decode(CodexRateLimitSnapshot.self, from: resultData) + else { + throw CodexCLIError.invalidResponse + } + return parsed + } + + private static func message(withID id: Int, in output: String) -> [String: Any]? { for line in output.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8), let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - intValue(message["id"]) == rateLimitsRequestID + intValue(message["id"]) == id else { continue } - - if let rpcError = message["error"] as? [String: Any] { - let details = rpcError["message"] as? String ?? "unknown app-server error" - if details.localizedCaseInsensitiveContains("auth") - || details.localizedCaseInsensitiveContains("login") { - throw CodexCLIError.notAuthenticated - } - throw CodexCLIError.commandFailed(details) - } - - guard let result = message["result"], - let resultData = try? JSONSerialization.data(withJSONObject: result), - let parsed = try? JSONDecoder().decode(CodexRateLimitSnapshot.self, from: resultData) - else { - throw CodexCLIError.invalidResponse - } - return parsed + return message } - - throw CodexCLIError.invalidResponse + return nil } static func snapshot(from parsed: CodexRateLimitSnapshot, fetchedAt: Date, isStale: Bool) -> ProviderUsageSnapshot { @@ -232,15 +282,17 @@ enum CodexCLIUsageService { ) } + /// app-server omits the `"jsonrpc": "2.0"` header on the wire, and gates part of its + /// surface behind the `experimentalApi` capability. private static var handshakeMessages: [String] { [ - #"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"clientInfo":{"name":"ai-menubar-usage","title":"AI Menubar Usage","version":"1.0.0"}}}"#, - #"{"jsonrpc":"2.0","method":"initialized","params":{}}"#, + #"{"id":0,"method":"initialize","params":{"clientInfo":{"name":"ai-menubar-usage","title":"AI Menubar Usage","version":"1.0.0"},"capabilities":{"experimentalApi":true}}}"#, + #"{"method":"initialized","params":{}}"#, ] } private static var rateLimitsMessage: String { - #"{"jsonrpc":"2.0","id":\#(rateLimitsRequestID),"method":"account/rateLimits/read","params":{}}"# + #"{"id":\#(rateLimitsRequestID),"method":"account/rateLimits/read","params":{}}"# } private static func write(_ messages: [String], to pipe: Pipe) { @@ -254,3 +306,22 @@ enum CodexCLIUsageService { return nil } } + +/// `readabilityHandler` fires on a background queue, so the buffer needs its own lock. +private final class OutputCollector: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ chunk: Data) { + guard !chunk.isEmpty else { return } + lock.lock() + data.append(chunk) + lock.unlock() + } + + var text: String { + lock.lock() + defer { lock.unlock() } + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/AIMenubarUsageTests/ChatGPTProviderTests.swift b/AIMenubarUsageTests/ChatGPTProviderTests.swift index e56b674..db0d6f8 100644 --- a/AIMenubarUsageTests/ChatGPTProviderTests.swift +++ b/AIMenubarUsageTests/ChatGPTProviderTests.swift @@ -14,8 +14,8 @@ final class ChatGPTProviderTests: XCTestCase { func testParseSkipsHandshakeAndNotificationLines() throws { let noise = """ - {"jsonrpc":"2.0","id":0,"result":{"userAgent":"codex"}} - {"jsonrpc":"2.0","method":"someNotification","params":{}} + {"id":0,"result":{"userAgent":"codex"}} + {"method":"someNotification","params":{}} """ let output = try appServerOutput() let parsed = try CodexCLIUsageService.parseRateLimitsResponse(noise + "\n" + output) @@ -39,7 +39,7 @@ final class ChatGPTProviderTests: XCTestCase { } func testCountdownResetIsResolvedAgainstFetchTime() throws { - let output = #"{"jsonrpc":"2.0","id":1,"result":{"rateLimits":{"primary":{"usedPercent":7,"resetsInSeconds":3600}}}}"# + let output = #"{"id":1,"result":{"rateLimits":{"primary":{"usedPercent":7,"resetsInSeconds":3600}}}}"# let fetchedAt = Date(timeIntervalSince1970: 1_700_000_000) let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) @@ -53,7 +53,7 @@ final class ChatGPTProviderTests: XCTestCase { } func testParseAcceptsWindowsInlinedAtRoot() throws { - let output = #"{"jsonrpc":"2.0","id":1,"result":{"primary":{"usedPercent":20},"secondary":{"usedPercent":55}}}"# + let output = #"{"id":1,"result":{"primary":{"usedPercent":20},"secondary":{"usedPercent":55}}}"# let parsed = try CodexCLIUsageService.parseRateLimitsResponse(output) let snapshot = CodexCLIUsageService.snapshot(from: parsed, fetchedAt: Date(), isStale: false) @@ -64,7 +64,7 @@ final class ChatGPTProviderTests: XCTestCase { } func testRPCErrorAboutLoginSurfacesAsNotAuthenticated() { - let output = #"{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"not logged in; run codex login"}}"# + let output = #"{"id":1,"error":{"code":-32000,"message":"not logged in; run codex login"}}"# XCTAssertThrowsError(try CodexCLIUsageService.parseRateLimitsResponse(output)) { error in XCTAssertEqual(error as? CodexCLIError, CodexCLIError.notAuthenticated) diff --git a/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json b/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json index 0ebe338..7d60898 100644 --- a/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json +++ b/AIMenubarUsageTests/Fixtures/chatgpt-codex-usage.json @@ -1,5 +1,4 @@ { - "jsonrpc": "2.0", "id": 1, "result": { "planType": "plus", -- 2.52.0