diff --git a/CHANGELOG.md b/CHANGELOG.md index 1da968e..df4eacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,17 @@ follows [Semantic Versioning](https://semver.org/). ## Unreleased +### Fixed + +- Disconnect cancels queued saved-key recovery for OpenAI and Anthropic API + connections, so a pending restore cannot reconnect with a launcher key. + ### Changed - Clarify that this package requires macOS for build and test checks. Replace the private SSH alias in public agent guidance with a generic macOS SSH route. +- Preserve accent-themed notch rings and Codex pricing details when combining + them with API spend displays and saved connections. ## [0.2.40] - 2026-09-29 diff --git a/README.md b/README.md index 0046e5b..61109de 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ Toggle providers on or off, choose refresh cadence, and switch themes and the ac | Provider | Live Cloud Quota | Local Activity & Tokens | Reset Countdowns | 26-Week Heatmap | Source Mechanism | |---|:---:|:---:|:---:|:---:|---| | **Codex** | ✅ | ✅ | ✅ | ✅ | Local JSON-RPC via `codex app-server --stdio` | +| **OpenAI API** | N/A | API totals | N/A | N/A | Organization Usage and Costs APIs; Admin key required | +| **Anthropic API** | N/A | API totals | N/A | N/A | Organization Usage and Cost Admin APIs; Admin key required | | **Antigravity** | ✅ | ✅ | ✅ | — | CLI `/usage` & local conversation SQLite | | **Claude Code** | Optional* | ✅ | ✅* | ✅ | Local session JSONL streams (`limits[]` file optional) | | **OpenRouter** | ✅ | ✅ | ✅ | — | Public account API & `/api/v1/activity` telemetry | @@ -112,6 +114,93 @@ Toggle providers on or off, choose refresh cadence, and switch themes and the ac * **Opt-In Pacing Alerts** — Native macOS notifications when an active window crosses critical burn velocity or drops below 30 minutes to empty. Pace alerts fire only on a current burn; threshold alerts (80%/95%) remain state-based. * **Share screenshot**: The share button on each provider's usage card (side notch panel detail card) shares a sharp 2x image of the panel through macOS share services, or saves it for X and other apps. +### OpenAI API usage + +Enable **Settings → Providers → OpenAI API** to show organization spend and +completion tokens and requests for today and the last 30 calendar days, +including today. Day boundaries use UTC. Spend comes from OpenAI's Costs API; +it is not calculated from the app's price table. Token totals cover the +completions usage endpoint, not every OpenAI product. Reporting can lag. + +Select **Connect** below the provider toggle, enter an +[organization Admin key](https://platform.openai.com/settings/organization/admin-keys) +in the masked field, and select **Test connection**. The test reads both usage +and costs. A successful reading shows **Connected** and its update time. +A regular project key or Codex subscription login does not provide this access. + +MeterUsage saves entered keys in your Mac's Keychain, so connections survive +restarts and app updates. **Disconnect** removes the saved key and clears the +displayed reading. Closing Settings clears unfinished key entry. Keys never +appear in preferences, plaintext files, logs, or diagnostics. + +macOS may ask you to allow Keychain access after an update, especially for +ad-hoc signed builds. If access is denied, Settings shows an error and +**Restore saved connection** retries access to that key without opening a +new-key field. Updates do not require another API-key entry or a new key from +your organization. + +If saving an entered key fails, **Retry saving key** reuses the value held in +memory. Keep the app open until saving succeeds. A failed replacement leaves +the previous connection intact. Disconnect clears pending entry as well as +the saved connection. + +For an existing secure launcher, `OPENAI_ADMIN_KEY` is also supported at +launch when no saved key exists. Saved keys take precedence; launcher keys +are not copied into Keychain automatically. Run the app executable from that environment: + +```sh +/Applications/MeterUsage.app/Contents/MacOS/meterusage +``` + +Finder launches do not inherit terminal variables. Quit any running copy first. +Missing access, offline requests, and incomplete responses show an unavailable +reading. Disconnect also suppresses a launcher-provided key for the rest of +that app session. + +This monitor appears in the popover's Usage card and the side notch. Enable +the side notch in Settings and use **Notch** beside OpenAI API to show or hide +its entry. The strip shows reported spend for the last 30 UTC calendar days. +Hover its mark for today's and 30-day spend, completion tokens, requests, and +the reading's update time. Missing access shows **N/A**, with connection +guidance in the detail card. No quota percentage, reset countdown, or pace +alert is inferred from spend. Organization usage stays separate from Codex limits +and the local coding summary to avoid counting the same work twice. The quota +JSON CLI does not include this usage-only provider. + +See [OpenAI's Usage and Costs example](https://developers.openai.com/cookbook/examples/completions_usage_api) +and [the privacy boundary](docs/PRIVACY.md). + +### Anthropic API usage + +Enable **Settings → Providers → Anthropic API** to show reported organization +spend and Messages API tokens for today and the last 30 UTC calendar days, +including today. Token totals include uncached input, output, cache reads, and +cache creation. Anthropic reports cost amounts in cents; meterusage converts +them to USD. The cost report excludes Priority Tier charges and can lag. +The API does not supply a total request count, so the card omits that count. + +Select **Connect**, enter a Console organization Admin key in the masked field, +and select **Test connection**. Key handling and Disconnect work as described +under [OpenAI API usage](#openai-api-usage). `ANTHROPIC_ADMIN_KEY` is also +supported through an existing secure launcher. Workspace keys and Claude +subscription logins do not provide this access. Anthropic also documents organization-level keys and +`org:admin` OAuth credentials; this app provides key entry, with no OAuth login. + +**Individual Anthropic accounts cannot connect this monitor.** Anthropic's +[Admin API documentation](https://platform.claude.com/docs/en/manage-claude/admin-api) +states that the Admin API is unavailable for individual accounts. An Admin +role alone does not establish an eligible organization account. If your account +is individual, use the [Claude Console usage page](https://platform.claude.com/usage) +to check usage. MeterUsage cannot sync that account's history through this API. +A missing Admin keys page does not, by itself, confirm the account type. + +This monitor appears in the popover's Usage card, separate from Claude Code +activity and subscription quota. It has no quota ring, countdown, or pace +alert, and does not contribute to local coding totals or the quota JSON CLI. +Missing access, offline requests, and incomplete responses show an unavailable +reading. See [Anthropic's Usage and Cost API guide](https://platform.claude.com/docs/en/manage-claude/usage-cost-api) +and [the privacy boundary](docs/PRIVACY.md). + ### Second accounts Two or more accounts with the same tool are separate budgets, so meterusage diff --git a/Scripts/check-api-keychain.swift b/Scripts/check-api-keychain.swift new file mode 100644 index 0000000..b83c5e0 --- /dev/null +++ b/Scripts/check-api-keychain.swift @@ -0,0 +1,104 @@ +// Opt-in native smoke. Compile with Core/APIKeyStore.swift and Core/APIKeySession.swift. +// Runs only against a caller-chosen, isolated synthetic service. No provider requests. +import Foundation +import Security + +// The production files only need this provider identity contract. +enum Provider: String { case openAI, anthropic + var displayName: String { rawValue } +} + +@main +struct KeychainSmoke { + private static let authorizationNeededStatuses: Set = [ + errSecAuthFailed, + errSecInteractionNotAllowed, + errSecUserCanceled, + ] + + private static func metadata(for provider: Provider, service: String, keychain: SecKeychain) -> OSStatus { + let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: provider.rawValue, + kSecMatchSearchList as String: [keychain], + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne] + var attributes: CFTypeRef? + return SecItemCopyMatching(query as CFDictionary, &attributes) + } + + static func main() throws { + let args = CommandLine.arguments + guard args.count == 3, args[1].hasPrefix("com.meterusage.tests.") else { + fatalError("Usage: keychain-smoke com.meterusage.tests. save|read|read-v1|locked|replace|remove|empty|update-v2") + } + // A denied read must return an error, never leave unattended tests at a password prompt. + SecKeychainSetUserInteractionAllowed(false) + // An isolated test Keychain avoids unlocking or reading the user's login Keychain. + let path = NSTemporaryDirectory() + args[1] + ".keychain" + let password = "synthetic-keychain-password" + var keychain: SecKeychain? + if args[2] == "save" { + precondition(!FileManager.default.fileExists(atPath: path)) + precondition(SecKeychainCreate(path, UInt32(password.utf8.count), password, false, nil, &keychain) == errSecSuccess) + } else { + precondition(SecKeychainOpen(path, &keychain) == errSecSuccess) + if args[2] != "locked" { + let unlockStatus = SecKeychainUnlock(keychain, UInt32(password.utf8.count), password, true) + precondition(unlockStatus == errSecSuccess, "unlock status=\(unlockStatus)") + } else { + precondition(SecKeychainLock(keychain) == errSecSuccess) + } + } + let isolatedKeychain = keychain! + let store = KeychainAPIKeyStore(service: args[1], keychain: isolatedKeychain) + let keys = APIKeySession(environment: [:], store: store) + for provider in [Provider.openAI, .anthropic] { + switch args[2] { + case "save": + let existing = try store.read(provider) + precondition(existing == nil) + try keys.set("fixture-original", for: provider) + case "read", "read-v1": + precondition(keys.key(for: provider) == "fixture-original") + case "locked": + precondition(keys.restoreErrors[provider] != nil) + do { + _ = try store.read(provider) + preconditionFailure("locked read unexpectedly succeeded") + } catch let error as APIKeyStoreError { + precondition(Self.authorizationNeededStatuses.contains(error.status)) + print("\(provider.rawValue): locked read status=\(error.status) expected=true") + } + precondition(Self.metadata(for: provider, service: args[1], keychain: isolatedKeychain) == errSecSuccess) + case "replace": + try keys.set("fixture-replacement", for: provider) + let replacement = try store.read(provider) + precondition(replacement == "fixture-replacement") + case "remove": + try keys.set(nil, for: provider) + case "empty": + let remaining = try store.read(provider) + precondition(remaining == nil) + case "update-v2": + // A rebuilt ad-hoc binary can need approval, but its item must still exist. + precondition(Self.metadata(for: provider, service: args[1], keychain: isolatedKeychain) == errSecSuccess) + do { + _ = try store.read(provider) + precondition(keys.restoreErrors[provider] == nil) + precondition(keys.key(for: provider) == "fixture-original") + print("\(provider.rawValue): saved item readable by rebuilt binary") + } catch let error as APIKeyStoreError { + precondition(Self.authorizationNeededStatuses.contains(error.status)) + precondition(keys.restoreErrors[provider] != nil) + print("\(provider.rawValue): saved item retained; authorization status=\(error.status) expected=true") + } catch { + preconditionFailure("unexpected Keychain error") + } + default: fatalError("Unknown smoke action") + } + } + if args[2] == "empty" { precondition(SecKeychainDelete(isolatedKeychain) == errSecSuccess) } + print("PASS: \(args[2])") + } +} diff --git a/Sources/MeterUsage/App/AppDelegate.swift b/Sources/MeterUsage/App/AppDelegate.swift index f58c3b2..7c41a2f 100644 --- a/Sources/MeterUsage/App/AppDelegate.swift +++ b/Sources/MeterUsage/App/AppDelegate.swift @@ -29,18 +29,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { let preferences = Preferences() + let apiKeys = Composition.isDemoMode + ? APIKeySession(environment: [:]) + : APIKeySession(store: KeychainAPIKeyStore()) let quotaSources = Composition.quotaSources() let coordinator = AppCoordinator( preferences: preferences, isDemoMode: Composition.isDemoMode, quotaSources: quotaSources, activitySources: Composition.activitySources(), - usageSources: Composition.usageSources(), + usageSources: Composition.usageSources(apiKeys: apiKeys), statusSources: Composition.statusSources(), planSources: Composition.planSources(), // Same factory, so "clear cache" can rebuild the activity sources // and get a genuinely cold scan rather than a re-warmed one. - activitySourceFactory: Composition.activitySources + activitySourceFactory: Composition.activitySources, + apiKeys: apiKeys ) self.preferences = preferences self.coordinator = coordinator @@ -381,20 +385,24 @@ enum Composition { .appendingPathComponent("MeterUsage", isDirectory: true) } - static func usageSources() -> [UsageSource] { + static func usageSources(apiKeys: APIKeySession = APIKeySession()) -> [UsageSource] { if isDemoMode { return [ DemoAntigravityUsageSource(), DemoOpenCodeGoUsageSource(), DemoGrokUsageSource(), - DemoOpenRouterUsageSource() + DemoOpenRouterUsageSource(), + DemoOpenAIUsageSource(), + DemoAnthropicUsageSource() ] } return [ AntigravityUsageSource(), OpenCodeGoUsageSource(), GrokUsageSource(), - OpenRouterUsageSource() + OpenRouterUsageSource(), + OpenAIUsageSource(adminKey: { apiKeys.key(for: .openAI) }), + AnthropicUsageSource(adminKey: { apiKeys.key(for: .anthropic) }) ] } diff --git a/Sources/MeterUsage/Core/APIKeySession.swift b/Sources/MeterUsage/Core/APIKeySession.swift new file mode 100644 index 0000000..57f2732 --- /dev/null +++ b/Sources/MeterUsage/Core/APIKeySession.swift @@ -0,0 +1,53 @@ +import Foundation + +/// In-memory keys backed by the app's Keychain store in normal app launches. +final class APIKeySession: @unchecked Sendable { + private let lock = NSLock() + private let store: APIKeyStore? + private let environment: [String: String] + private var keys: [Provider: String] = [:] + private var revisions: [Provider: Int] = [:] + private(set) var restoreErrors: [Provider: String] = [:] + + init(environment: [String: String] = ProcessInfo.processInfo.environment, store: APIKeyStore? = nil) { + self.environment = environment + self.store = store + for provider in [Provider.openAI, .anthropic] { + do { try restore(provider) } + catch { restoreErrors[provider] = Self.message(for: error) } + } + } + + func key(for provider: Provider) -> String? { + lock.withLock { keys[provider] } + } + + func revision(for provider: Provider) -> Int { + lock.withLock { revisions[provider, default: 0] } + } + + func set(_ key: String?, for provider: Provider) throws { + try lock.withLock { + let trimmed = key?.trimmingCharacters(in: .whitespacesAndNewlines) + let value = trimmed?.isEmpty == false ? trimmed : nil + // Save/delete first. A denied write must not forget the current key. + try store?.write(value, for: provider) + keys[provider] = value + revisions[provider, default: 0] += 1 + } + } + + func restore(_ provider: Provider) throws { + try lock.withLock { + let variable = provider == .openAI ? "OPENAI_ADMIN_KEY" : "ANTHROPIC_ADMIN_KEY" + let value = try store?.read(provider) ?? environment[variable] + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + keys[provider] = trimmed?.isEmpty == false ? trimmed : nil + revisions[provider, default: 0] += 1 + } + } + + static func message(for error: Error) -> String { + (error as? APIKeyStoreError)?.errorDescription ?? "Could not access the API key in macOS Keychain. Try again." + } +} diff --git a/Sources/MeterUsage/Core/APIKeyStore.swift b/Sources/MeterUsage/Core/APIKeyStore.swift new file mode 100644 index 0000000..1a543f7 --- /dev/null +++ b/Sources/MeterUsage/Core/APIKeyStore.swift @@ -0,0 +1,73 @@ +import Foundation +import Security + +/// Injected in tests so normal test runs never open the user's Keychain. +protocol APIKeyStore { + func read(_ provider: Provider) throws -> String? + func write(_ key: String?, for provider: Provider) throws +} + +struct KeychainAPIKeyStore: APIKeyStore { + let service: String + private let keychain: SecKeychain? + + init(service: String = "com.meterusage.api-keys", keychain: SecKeychain? = nil) { + self.service = service + self.keychain = keychain + } + + private func query(_ provider: Provider) -> [String: Any] { + var query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: provider.rawValue, + kSecAttrSynchronizable as String: false] + if let keychain { query[kSecMatchSearchList as String] = [keychain] } + return query + } + + func read(_ provider: Provider) throws -> String? { + var query = query(provider) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw APIKeyStoreError(operation: "read", status: status) } + guard let data = result as? Data, let key = String(data: data, encoding: .utf8), !key.isEmpty else { + throw APIKeyStoreError(operation: "read", status: errSecDecode) + } + return key + } + + func write(_ key: String?, for provider: Provider) throws { + let query = query(provider) + guard let key else { + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw APIKeyStoreError(operation: "remove", status: status) + } + return + } + let attributes = [kSecValueData as String: Data(key.utf8)] + var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var item = query + item[kSecMatchSearchList as String] = nil + if let keychain { item[kSecUseKeychain as String] = keychain } + item[kSecValueData as String] = Data(key.utf8) + item[kSecAttrLabel as String] = "MeterUsage \(provider.displayName)" + // Keep macOS's default application access control. Never allow all apps. + status = SecItemAdd(item as CFDictionary, nil) + } + guard status == errSecSuccess else { throw APIKeyStoreError(operation: "save", status: status) } + } +} + +struct APIKeyStoreError: LocalizedError { + let operation: String + let status: OSStatus + + var errorDescription: String? { + "Could not \(operation) the API key in macOS Keychain (\(status)). Allow MeterUsage access in Keychain and try again." + } +} diff --git a/Sources/MeterUsage/Core/AppCoordinator.swift b/Sources/MeterUsage/Core/AppCoordinator.swift index 697c707..ed0fb7d 100644 --- a/Sources/MeterUsage/Core/AppCoordinator.swift +++ b/Sources/MeterUsage/Core/AppCoordinator.swift @@ -2,6 +2,13 @@ import Foundation import Combine import AppKit +enum APIConnectionAction: Equatable { + case connect + case replaceKey + case restoreSavedConnection + case retrySavingKey +} + /// Outcome of one source poll: either data, or a reason there is none. /// /// Modelled explicitly rather than as `T?` because *why* a value is missing is @@ -38,6 +45,11 @@ final class AppCoordinator: ObservableObject { @Published private(set) var quotas: [ProviderSlot: Loaded] = [:] @Published private(set) var activities: [ProviderSlot: Loaded] = [:] @Published private(set) var usages: [ProviderSlot: Loaded] = [:] + @Published private(set) var testingAPIProviders: Set = [] + @Published private(set) var apiConnectionErrors: [Provider: String] = [:] + private let apiKeys: APIKeySession + private var apiKeyRestoreErrors: Set + private var pendingAPIKeys: [Provider: String] = [:] @Published private(set) var statuses: [Provider: Loaded] = [:] /// Subscription tier per provider. Kept in its own map rather than folded /// into `quotas` because a plan is read from a different place than the @@ -162,7 +174,8 @@ final class AppCoordinator: ObservableObject { planSources: [PlanSource] = [], activitySourceFactory: (() -> [LocalActivitySource])? = nil, quotaArchiveURL: URL? = nil, - accountHome: ((ManagedAccount) -> URL?)? = nil + accountHome: ((ManagedAccount) -> URL?)? = nil, + apiKeys: APIKeySession = APIKeySession() ) { self.preferences = preferences self.isDemoMode = isDemoMode @@ -186,6 +199,9 @@ final class AppCoordinator: ObservableObject { self.activitySources = activitySources self.activitySourceFactory = activitySourceFactory self.usageSources = usageSources + self.apiKeys = apiKeys + self.apiConnectionErrors = apiKeys.restoreErrors + self.apiKeyRestoreErrors = Set(apiKeys.restoreErrors.keys) self.statusSources = statusSources self.planSources = planSources let archiveURL = quotaArchiveURL ?? QuotaArchive.defaultURL @@ -310,6 +326,80 @@ final class AppCoordinator: ObservableObject { // MARK: Refresh + func hasAPIKey(for provider: Provider) -> Bool { + apiKeys.key(for: provider) != nil + } + + func apiConnectionAction(for provider: Provider) -> APIConnectionAction { + if pendingAPIKeys[provider] != nil { return .retrySavingKey } + if hasAPIKey(for: provider) { return .replaceKey } + if apiKeyRestoreErrors.contains(provider) { return .restoreSavedConnection } + return .connect + } + + /// Test both reporting endpoints using the same source as scheduled refreshes. + func connectAPI(_ provider: Provider, key: String) async { + guard provider.isOrganizationAPI, !isDemoMode, + !testingAPIProviders.contains(provider), + let source = usageSources.first(where: { $0.provider == provider }) else { return } + guard !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + do { try apiKeys.set(key, for: provider) } + catch { + pendingAPIKeys[provider] = key + apiConnectionErrors[provider] = APIKeySession.message(for: error) + return + } + pendingAPIKeys.removeValue(forKey: provider) + apiKeyRestoreErrors.remove(provider) + apiConnectionErrors[provider] = nil + await testAPIConnection(source) + } + + func disconnectAPI(_ provider: Provider) { + guard provider.isOrganizationAPI, !isDemoMode else { return } + do { try apiKeys.set(nil, for: provider) } + catch { + apiConnectionErrors[provider] = APIKeySession.message(for: error) + return + } + apiConnectionErrors[provider] = nil + pendingAPIKeys.removeValue(forKey: provider) + apiKeyRestoreErrors.remove(provider) + testingAPIProviders.remove(provider) + usages[.primary(provider)] = .missing(.dataNotFound("API connection")) + } + + func retrySavedAPIKey(_ provider: Provider) async { + guard provider.isOrganizationAPI, !isDemoMode, + !testingAPIProviders.contains(provider), + let source = usageSources.first(where: { $0.provider == provider }) else { return } + if let pendingKey = pendingAPIKeys[provider] { + await connectAPI(provider, key: pendingKey) + return + } + guard apiKeyRestoreErrors.contains(provider) else { return } + do { try apiKeys.restore(provider) } + catch { + apiKeyRestoreErrors.insert(provider) + apiConnectionErrors[provider] = APIKeySession.message(for: error) + return + } + apiKeyRestoreErrors.remove(provider) + apiConnectionErrors[provider] = nil + await testAPIConnection(source) + } + + private func testAPIConnection(_ source: UsageSource) async { + let provider = source.provider + let revision = apiKeys.revision(for: provider) + usages[.primary(provider)] = .idle + testingAPIProviders.insert(provider) + await load(usage: source) + guard apiKeys.revision(for: provider) == revision else { return } + testingAPIProviders.remove(provider) + clock = Date() + } + /// Kicks off a refresh, coalescing with one already in flight. /// /// Wake, timer and popover-open can all fire within the same second; running @@ -610,12 +700,15 @@ final class AppCoordinator: ObservableObject { } private func load(usage source: UsageSource) async { + let revision = apiKeys.revision(for: source.provider) let result: Loaded do { result = .value(try await source.fetchUsage()) } catch { result = .missing(Self.reason(for: error, provider: source.slot.provider)) } + // A disconnected or replaced key must not publish a late account reading. + guard apiKeys.revision(for: source.provider) == revision else { return } usages[source.slot] = result record(kind: "usage", slot: source.slot, result: result.unavailable) } @@ -751,17 +844,17 @@ final class AppCoordinator: ObservableObject { } /// Enabled slots the user also chose to show in the menu bar, in the - /// stable display order. OpenRouter is pay-as-you-go (no quota), so it is - /// always excluded from the tray regardless of the stored preference. + /// stable display order. OpenRouter and OpenAI API stay out of the tray. var menuBarSlots: [ProviderSlot] { - visibleSlots.filter { preferences.showsInMenuBar($0.provider) && $0.provider != .openRouter } + visibleSlots.filter { + preferences.showsInMenuBar($0.provider) && $0.provider != .openRouter && $0.provider != .openAI + } } /// Enabled slots the user also chose to show in the side notch panel, /// in the stable display order. The tray and the notch are independent - /// surfaces (see `menuBarSlots`): the tray keeps OpenRouter out, while - /// the notch honors its toggle — a configured key limit yields a real - /// quota window for the ring to render. + /// surfaces (see `menuBarSlots`). OpenAI API shows reported spend without + /// a quota ring; OpenRouter can show a key limit or account balance. var sideNotchSlots: [ProviderSlot] { visibleSlots.filter { preferences.showsInMenuBar($0.provider) } } diff --git a/Sources/MeterUsage/Core/Preferences.swift b/Sources/MeterUsage/Core/Preferences.swift index c5ba15a..354e289 100644 --- a/Sources/MeterUsage/Core/Preferences.swift +++ b/Sources/MeterUsage/Core/Preferences.swift @@ -20,6 +20,8 @@ enum PrefKey { static let refreshInterval = "refreshIntervalSeconds" static let showClaude = "showProviderClaude" static let showCodex = "showProviderCodex" + static let showOpenAI = "showProviderOpenAI" + static let showAnthropic = "showProviderAnthropic" /// User-configured additional accounts (Claude, Codex). Encoded /// `[ManagedAccount]` JSON data; empty/absent means "no additional /// accounts", which is the single-account default install. @@ -33,6 +35,7 @@ enum PrefKey { static let showGemini = "showProviderGemini" static let menuBarClaude = "menuBarProviderClaude" static let menuBarCodex = "menuBarProviderCodex" + static let menuBarOpenAI = "menuBarProviderOpenAI" static let menuBarAntigravity = "menuBarProviderAntigravity" static let menuBarGrok = "menuBarProviderGrok" static let menuBarOpenCodeGo = "menuBarProviderOpenCodeGo" @@ -159,6 +162,8 @@ final class Preferences: ObservableObject { // normal use. PrefKey.showClaude: false, PrefKey.showCodex: true, + PrefKey.showOpenAI: false, + PrefKey.showAnthropic: false, PrefKey.showAntigravity: false, PrefKey.showGrok: false, PrefKey.showOpenCodeGo: true, @@ -170,6 +175,7 @@ final class Preferences: ObservableObject { // down to the ones they glance at. PrefKey.menuBarClaude: true, PrefKey.menuBarCodex: true, + PrefKey.menuBarOpenAI: true, PrefKey.menuBarAntigravity: true, PrefKey.menuBarGrok: true, PrefKey.menuBarOpenCodeGo: true, @@ -218,6 +224,8 @@ final class Preferences: ObservableObject { var providers = Set() if defaults.bool(forKey: PrefKey.showCodex) { providers.insert(.codex) } + if defaults.bool(forKey: PrefKey.showOpenAI) { providers.insert(.openAI) } + if defaults.bool(forKey: PrefKey.showAnthropic) { providers.insert(.anthropic) } if defaults.bool(forKey: PrefKey.showAntigravity) { providers.insert(.antigravity) } if defaults.bool(forKey: PrefKey.showGrok) { providers.insert(.grok) } if defaults.bool(forKey: PrefKey.showOpenCodeGo) { providers.insert(.openCodeGo) } @@ -230,6 +238,7 @@ final class Preferences: ObservableObject { var menuBar = Set() if defaults.bool(forKey: PrefKey.menuBarCodex) { menuBar.insert(.codex) } + if defaults.bool(forKey: PrefKey.menuBarOpenAI) { menuBar.insert(.openAI) } if defaults.bool(forKey: PrefKey.menuBarAntigravity) { menuBar.insert(.antigravity) } if defaults.bool(forKey: PrefKey.menuBarGrok) { menuBar.insert(.grok) } if defaults.bool(forKey: PrefKey.menuBarOpenCodeGo) { menuBar.insert(.openCodeGo) } diff --git a/Sources/MeterUsage/Models/UsageModels.swift b/Sources/MeterUsage/Models/UsageModels.swift index b8d7b78..d42987b 100644 --- a/Sources/MeterUsage/Models/UsageModels.swift +++ b/Sources/MeterUsage/Models/UsageModels.swift @@ -426,6 +426,8 @@ public struct ProviderQuota: Equatable, Sendable { public enum Provider: String, CaseIterable, Codable, Sendable { case codex + case openAI + case anthropic case antigravity case grok case openCodeGo @@ -438,6 +440,8 @@ public enum Provider: String, CaseIterable, Codable, Sendable { public var displayName: String { switch self { case .codex: return "Codex" + case .openAI: return "OpenAI API" + case .anthropic: return "Anthropic API" case .antigravity: return "Antigravity" case .grok: return "Grok" case .openCodeGo: return "OpenCode Go" @@ -449,6 +453,9 @@ public enum Provider: String, CaseIterable, Codable, Sendable { } } + /// Organization billing totals must stay separate from local coding activity. + public var isOrganizationAPI: Bool { self == .openAI || self == .anthropic } + /// Tools that support additional, separately-configured accounts. Each /// additional account is a `ProviderSlot` on one of these providers, /// backed by its own config directory (see `ManagedAccount`). @@ -490,6 +497,8 @@ public enum Provider: String, CaseIterable, Codable, Sendable { return session } return windows.count == 1 ? windows[0] : nil + case .openAI, .anthropic: + return nil case .claude: if let session = windows.first(where: { $0.isSessionWindow }) { return session @@ -513,6 +522,8 @@ public enum Provider: String, CaseIterable, Codable, Sendable { public var sourceLabel: String { switch self { case .codex: return "Codex CLI" + case .openAI: return "OpenAI Admin API" + case .anthropic: return "Anthropic Admin API" case .claude: return "Claude Code" case .antigravity: return "agy CLI" case .grok: return "Grok CLI" @@ -529,9 +540,9 @@ public enum Provider: String, CaseIterable, Codable, Sendable { /// than sending the user to a guessed or unrelated page. public var statusPageURL: URL? { switch self { - case .codex: + case .codex, .openAI: return URL(string: "https://status.openai.com/") - case .claude: + case .claude, .anthropic: return URL(string: "https://status.claude.com/") case .cursor: return URL(string: "https://status.cursor.com/") diff --git a/Sources/MeterUsage/Services/AnthropicUsageSource.swift b/Sources/MeterUsage/Services/AnthropicUsageSource.swift new file mode 100644 index 0000000..f3a285e --- /dev/null +++ b/Sources/MeterUsage/Services/AnthropicUsageSource.swift @@ -0,0 +1,168 @@ +import Foundation + +/// Organization totals from Anthropic's documented Usage and Cost Admin APIs. +/// Only an explicitly supplied Admin key is used; Claude Code credentials are never read. +public struct AnthropicUsageSource: UsageSource { + public let provider: Provider = .anthropic + private let adminKey: @Sendable () -> String? + private let session: URLSession + private let now: @Sendable () -> Date + + public init( + adminKey: @escaping @Sendable () -> String? = { ProcessInfo.processInfo.environment["ANTHROPIC_ADMIN_KEY"] }, + session: URLSession = OpenAIUsageSource.defaultSession(), + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.adminKey = adminKey + self.session = session + self.now = now + } + + static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + public func fetchUsage() async throws -> ProviderUsage { + guard let key = adminKey()?.trimmingCharacters(in: .whitespacesAndNewlines), + !key.isEmpty, !key.contains("\r"), !key.contains("\n") else { + throw SourceUnavailable.dataNotFound("Anthropic Admin API key") + } + let date = now() + let today = Self.utcCalendar.startOfDay(for: date) + let start = Self.utcCalendar.date(byAdding: .day, value: -29, to: today)! + let end = Self.utcCalendar.date(byAdding: .day, value: 1, to: today)! + do { + let usage: [Bucket] = try await buckets(path: "usage_report/messages", key: key, start: start, end: end) + let costs: [Bucket] = try await buckets(path: "cost_report", key: key, start: start, end: end) + return try Self.summarize(usage: usage, costs: costs, start: start, now: date) + } catch let reason as SourceUnavailable { + throw reason + } catch let error as URLError where [.notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .dataNotAllowed].contains(error.code) { + throw SourceUnavailable.offline + } catch { + // Provider responses and transport errors can contain secrets or identifiers. + throw SourceUnavailable.failed(.anthropic) + } + } + + private func buckets(path: String, key: String, start: Date, end: Date) async throws -> [Bucket] { + var buckets: [Bucket] = [] + var cursor: String? + var seen = Set() + // Thirty daily buckets normally fit one page. Bound unexpected pagination. + for _ in 0..<5 { + try Task.checkCancellation() + var url = URLComponents(string: "https://api.anthropic.com/v1/organizations/\(path)")! + url.queryItems = [ + URLQueryItem(name: "starting_at", value: ISO8601DateFormatter().string(from: start)), + URLQueryItem(name: "ending_at", value: ISO8601DateFormatter().string(from: end)), + URLQueryItem(name: "bucket_width", value: "1d"), + URLQueryItem(name: "limit", value: "30") + ] + if let cursor { url.queryItems?.append(URLQueryItem(name: "page", value: cursor)) } + var request = URLRequest(url: url.url!) + request.setValue(key, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.setValue("meterusage/\(AppInfo.version) (https://github.com/pekth/meterusage)", forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { throw SourceUnavailable.failed(.anthropic) } + switch response.statusCode { + case 401, 403: throw SourceUnavailable.dataNotFound("Anthropic Admin API key with usage access") + case 200: break + default: throw SourceUnavailable.failed(.anthropic) + } + let page = try JSONDecoder().decode(Page.self, from: data) + buckets.append(contentsOf: page.data) + if !page.has_more { return buckets } + guard let next = page.next_page, !next.isEmpty, seen.insert(next).inserted else { + throw SourceUnavailable.failed(.anthropic) + } + cursor = next + } + throw SourceUnavailable.failed(.anthropic) + } + + private static func summarize(usage: [Bucket], costs: [Bucket], start: Date, now: Date) throws -> ProviderUsage { + let today = utcCalendar.startOfDay(for: now) + let formatter = ISO8601DateFormatter() + let fractional = ISO8601DateFormatter() + fractional.formatOptions.insert(.withFractionalSeconds) + func day(_ timestamp: String) throws -> Date { + guard let date = formatter.date(from: timestamp) ?? fractional.date(from: timestamp) else { + throw SourceUnavailable.failed(.anthropic) + } + return utcCalendar.startOfDay(for: date) + } + var tokens: [Date: TokenTotals] = [:] + var spend: [Date: Double] = [:] + for bucket in usage { + let date = try day(bucket.starting_at) + guard date >= start, date <= today else { continue } + for result in bucket.results { + let creation = result.cache_creation + guard [result.uncached_input_tokens, result.output_tokens, result.cache_read_input_tokens, + creation.ephemeral_1h_input_tokens, creation.ephemeral_5m_input_tokens].allSatisfy({ $0 >= 0 }) else { + throw SourceUnavailable.failed(.anthropic) + } + tokens[date, default: TokenTotals()] = tokens[date, default: TokenTotals()] + TokenTotals( + input: result.uncached_input_tokens, output: result.output_tokens, + cacheRead: result.cache_read_input_tokens, + cacheWrite: creation.ephemeral_1h_input_tokens + creation.ephemeral_5m_input_tokens + ) + } + } + for bucket in costs { + let date = try day(bucket.starting_at) + guard date >= start, date <= today else { continue } + for result in bucket.results { + guard result.currency.lowercased() == "usd", let cents = Double(result.amount), cents.isFinite else { + throw SourceUnavailable.failed(.anthropic) + } + // The report uses decimal cents, not dollars. Preserve adjustments. + spend[date, default: 0] += cents / 100 + } + } + let total = tokens.values.reduce(TokenTotals(), +) + let todayTokens = tokens[today] ?? TokenTotals() + let totalCost = spend.values.reduce(0, +) + // Anthropic reports token totals and tool-use counts, not a total request count. + return ProviderUsage( + provider: .anthropic, sessionCount: 0, messageCount: 0, + tokens: total, estimatedCostUSD: totalCost, todayTokens: todayTokens, + todayCostUSD: spend[today] ?? 0, + usageWindows: [ + UsageWindow(label: "Today (UTC)", sessionCount: 0, messageCount: 0, + tokens: todayTokens, estimatedCostUSD: spend[today] ?? 0), + UsageWindow(label: "last 30d", sessionCount: 0, messageCount: 0, + tokens: total, estimatedCostUSD: totalCost) + ], capturedAt: now + ) + } + + private struct Page: Decodable { + let data: [Bucket] + let has_more: Bool + let next_page: String? + } + private struct Bucket: Decodable { + let starting_at: String + let results: [Result] + } + private struct MessageUsage: Decodable { + let uncached_input_tokens: Int + let output_tokens: Int + let cache_read_input_tokens: Int + let cache_creation: CacheCreation + struct CacheCreation: Decodable { + let ephemeral_1h_input_tokens: Int + let ephemeral_5m_input_tokens: Int + } + } + private struct Cost: Decodable { + let amount: String + let currency: String + } +} diff --git a/Sources/MeterUsage/Services/BurnAttributionCalculator.swift b/Sources/MeterUsage/Services/BurnAttributionCalculator.swift index 13e5750..93bb79e 100644 --- a/Sources/MeterUsage/Services/BurnAttributionCalculator.swift +++ b/Sources/MeterUsage/Services/BurnAttributionCalculator.swift @@ -21,7 +21,8 @@ public enum BurnAttributionCalculator { } } for (slot, state) in usages.sorted(by: { $0.key < $1.key }) { - guard let usage = state.value else { continue } + // Organization totals are not this machine's coding sessions. + guard let usage = state.value, !usage.provider.isOrganizationAPI else { continue } if let breakdown = usage.projectBreakdown, !breakdown.isEmpty { for split in breakdown { result.append( diff --git a/Sources/MeterUsage/Services/DataSource.swift b/Sources/MeterUsage/Services/DataSource.swift index 73433a5..5d36e0a 100644 --- a/Sources/MeterUsage/Services/DataSource.swift +++ b/Sources/MeterUsage/Services/DataSource.swift @@ -43,9 +43,8 @@ extension LocalActivitySource { public var slot: ProviderSlot { .primary(provider) } } -/// A local usage source for providers whose native history is not Claude's -/// token transcript format. Sources may report sessions/messages only when -/// token or cost data is unavailable. +/// Aggregate usage from local history or a documented provider usage API. +/// Sources may report counts only when token or cost data is unavailable. public protocol UsageSource: Sendable { /// The tool this source meters (see `QuotaSource.provider`). var provider: Provider { get } diff --git a/Sources/MeterUsage/Services/DemoSources.swift b/Sources/MeterUsage/Services/DemoSources.swift index 46ef5b8..106cc2a 100644 --- a/Sources/MeterUsage/Services/DemoSources.swift +++ b/Sources/MeterUsage/Services/DemoSources.swift @@ -1,5 +1,42 @@ import Foundation +/// Synthetic Anthropic API totals; no Claude Code account is consulted. +struct DemoAnthropicUsageSource: UsageSource { + let provider: Provider = .anthropic + + func fetchUsage() async throws -> ProviderUsage { + let today = TokenTotals(input: 8_000, output: 2_000, cacheRead: 6_000, cacheWrite: 4_000) + let total = TokenTotals(input: 120_000, output: 30_000, cacheRead: 90_000, cacheWrite: 60_000) + return ProviderUsage( + provider: .anthropic, sessionCount: 0, messageCount: 0, + tokens: total, estimatedCostUSD: 18.75, todayTokens: today, todayCostUSD: 1.25, + usageWindows: [ + UsageWindow(label: "Today (UTC)", sessionCount: 0, messageCount: 0, tokens: today, estimatedCostUSD: 1.25), + UsageWindow(label: "last 30d", sessionCount: 0, messageCount: 0, tokens: total, estimatedCostUSD: 18.75) + ], capturedAt: Date() + ) + } +} + +/// Synthetic API usage, separate from the Codex subscription fixture. +struct DemoOpenAIUsageSource: UsageSource { + let provider: Provider = .openAI + + func fetchUsage() async throws -> ProviderUsage { + let today = TokenTotals(input: 12_000, output: 3_000, cacheRead: 5_000) + let total = TokenTotals(input: 180_000, output: 45_000, cacheRead: 75_000) + return ProviderUsage( + provider: .openAI, sessionCount: 0, messageCount: 900, + tokens: total, estimatedCostUSD: 12.50, todayMessageCount: 60, + todayTokens: today, weekTokens: today, todayCostUSD: 0.80, + usageWindows: [ + UsageWindow(label: "Today (UTC)", sessionCount: 0, messageCount: 60, tokens: today, estimatedCostUSD: 0.80), + UsageWindow(label: "last 30d", sessionCount: 0, messageCount: 900, tokens: total, estimatedCostUSD: 12.50) + ], capturedAt: Date() + ) + } +} + // MARK: - Demo mode // // Demo mode swaps the *data sources* and nothing else. The real views, the real diff --git a/Sources/MeterUsage/Services/OpenAIUsageSource.swift b/Sources/MeterUsage/Services/OpenAIUsageSource.swift new file mode 100644 index 0000000..2c6c56e --- /dev/null +++ b/Sources/MeterUsage/Services/OpenAIUsageSource.swift @@ -0,0 +1,175 @@ +import Foundation + +/// Organization totals from OpenAI's documented Usage and Costs APIs. +/// Only an explicitly supplied Admin key is used; Codex credentials are never read. +public struct OpenAIUsageSource: UsageSource { + public let provider: Provider = .openAI + private let adminKey: @Sendable () -> String? + private let session: URLSession + private let now: @Sendable () -> Date + + public init( + adminKey: @escaping @Sendable () -> String? = { ProcessInfo.processInfo.environment["OPENAI_ADMIN_KEY"] }, + session: URLSession = OpenAIUsageSource.defaultSession(), + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.adminKey = adminKey + self.session = session + self.now = now + } + + static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + public func fetchUsage() async throws -> ProviderUsage { + guard let key = adminKey()?.trimmingCharacters(in: .whitespacesAndNewlines), + !key.isEmpty, !key.contains("\r"), !key.contains("\n") else { + throw SourceUnavailable.dataNotFound("OpenAI Admin API key") + } + let date = now() + let today = Self.utcCalendar.startOfDay(for: date) + let start = Self.utcCalendar.date(byAdding: .day, value: -29, to: today)! + do { + let usage: [Bucket] = try await buckets(path: "usage/completions", key: key, start: start, end: date) + let costs: [Bucket] = try await buckets(path: "costs", key: key, start: start, end: date) + return try Self.summarize(usage: usage, costs: costs, start: start, now: date) + } catch let reason as SourceUnavailable { + throw reason + } catch let error as URLError where [.notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .dataNotAllowed].contains(error.code) { + throw SourceUnavailable.offline + } catch { + // Provider responses and transport errors can contain secrets or identifiers. + throw SourceUnavailable.failed(.openAI) + } + } + + private func buckets(path: String, key: String, start: Date, end: Date) async throws -> [Bucket] { + var buckets: [Bucket] = [] + var cursor: String? + var seen = Set() + // Thirty daily buckets normally fit one page. Bound unexpected pagination. + for _ in 0..<5 { + try Task.checkCancellation() + var url = URLComponents(string: "https://api.openai.com/v1/organization/\(path)")! + url.queryItems = [ + URLQueryItem(name: "start_time", value: String(Int(start.timeIntervalSince1970))), + URLQueryItem(name: "end_time", value: String(Int(end.timeIntervalSince1970))), + URLQueryItem(name: "bucket_width", value: "1d"), + URLQueryItem(name: "limit", value: "30") + ] + if let cursor { url.queryItems?.append(URLQueryItem(name: "page", value: cursor)) } + var request = URLRequest(url: url.url!) + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { throw SourceUnavailable.failed(.openAI) } + switch response.statusCode { + case 401, 403: throw SourceUnavailable.dataNotFound("OpenAI Admin API key with usage access") + case 200: break + default: throw SourceUnavailable.failed(.openAI) + } + let page = try JSONDecoder().decode(Page.self, from: data) + buckets.append(contentsOf: page.data) + if !page.has_more { return buckets } + guard let next = page.next_page, !next.isEmpty, seen.insert(next).inserted else { + throw SourceUnavailable.failed(.openAI) + } + cursor = next + } + throw SourceUnavailable.failed(.openAI) + } + + private static func summarize(usage: [Bucket], costs: [Bucket], start: Date, now: Date) throws -> ProviderUsage { + let today = utcCalendar.startOfDay(for: now) + let weekStart = utcCalendar.date(byAdding: .day, value: -6, to: today)! + var tokens: [Date: TokenTotals] = [:] + var requests: [Date: Int] = [:] + var spend: [Date: Double] = [:] + for bucket in usage { + let day = utcCalendar.startOfDay(for: Date(timeIntervalSince1970: bucket.start_time)) + guard day >= start, day <= today else { continue } + for result in bucket.results { + let cached = result.input_cached_tokens ?? 0 + guard result.input_tokens >= 0, result.output_tokens >= 0, + result.num_model_requests >= 0, cached >= 0, cached <= result.input_tokens else { + throw SourceUnavailable.failed(.openAI) + } + // OpenAI includes cached input in input_tokens; TokenTotals adds it separately. + tokens[day, default: TokenTotals()] = tokens[day, default: TokenTotals()] + TokenTotals( + input: result.input_tokens - cached, output: result.output_tokens, cacheRead: cached + ) + requests[day, default: 0] += result.num_model_requests + } + } + for bucket in costs { + let day = utcCalendar.startOfDay(for: Date(timeIntervalSince1970: bucket.start_time)) + guard day >= start, day <= today else { continue } + for result in bucket.results { + guard result.amount.currency.lowercased() == "usd", result.amount.value.isFinite else { + throw SourceUnavailable.failed(.openAI) + } + // Preserve provider adjustments, including negative amounts. + spend[day, default: 0] += result.amount.value + } + } + let total = tokens.values.reduce(TokenTotals(), +) + let todayTokens = tokens[today] ?? TokenTotals() + let totalRequests = requests.values.reduce(0, +) + let totalCost = spend.values.reduce(0, +) + let windows = [ + UsageWindow(label: "Today (UTC)", sessionCount: 0, messageCount: requests[today] ?? 0, + tokens: todayTokens, estimatedCostUSD: spend[today] ?? 0), + UsageWindow(label: "last 30d", sessionCount: 0, messageCount: totalRequests, + tokens: total, estimatedCostUSD: totalCost) + ] + return ProviderUsage( + provider: .openAI, sessionCount: 0, messageCount: totalRequests, + tokens: total, estimatedCostUSD: totalCost, + todayMessageCount: requests[today] ?? 0, todayTokens: todayTokens, + weekTokens: tokens.filter { $0.key >= weekStart }.values.reduce(TokenTotals(), +), + todayCostUSD: spend[today] ?? 0, usageWindows: windows, capturedAt: now + ) + } + + public static func defaultSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 10 + config.timeoutIntervalForResource = 15 + config.urlCache = nil + config.httpCookieStorage = nil + config.httpShouldSetCookies = false + return URLSession(configuration: config, delegate: NoRedirects(), delegateQueue: nil) + } + + private struct Page: Decodable { + let data: [Bucket] + let has_more: Bool + let next_page: String? + } + private struct Bucket: Decodable { + let start_time: TimeInterval + let results: [Result] + } + private struct Completion: Decodable { + let input_tokens: Int + let output_tokens: Int + let input_cached_tokens: Int? + let num_model_requests: Int + } + private struct Cost: Decodable { + let amount: Amount + struct Amount: Decodable { + let value: Double + let currency: String + } + } + private final class NoRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + completionHandler(nil) + } + } +} diff --git a/Sources/MeterUsage/Views/APIConnectionControls.swift b/Sources/MeterUsage/Views/APIConnectionControls.swift new file mode 100644 index 0000000..ea51b5f --- /dev/null +++ b/Sources/MeterUsage/Views/APIConnectionControls.swift @@ -0,0 +1,115 @@ +import SwiftUI + +/// Never bind a secret to AppStorage or restore a saved key into a field. +struct APIConnectionControls: View { + let provider: Provider + @ObservedObject var coordinator: AppCoordinator + @State private var editing = false + @State private var key = "" + + private var testing: Bool { coordinator.testingAPIProviders.contains(provider) } + private var hasKey: Bool { coordinator.hasAPIKey(for: provider) } + private var connectionAction: APIConnectionAction { coordinator.apiConnectionAction(for: provider) } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if coordinator.isDemoMode { + Text("Demo data. No connection needed.") + } else { + status + if editing { + Text(provider == .openAI + ? "Use an organization Admin key with usage access." + : "Use a Console organization Admin key. Individual accounts do not have reporting access.") + .fixedSize(horizontal: false, vertical: true) + Link("Create an Admin key", destination: URL(string: provider == .openAI + ? "https://platform.openai.com/settings/organization/admin-keys" + : "https://platform.claude.com/docs/en/manage-claude/admin-api-keys")!) + SecureField("Admin API key", text: $key) + .textFieldStyle(.roundedBorder) + .privacySensitive() + .accessibilityLabel("\(provider.displayName) Admin key") + .onSubmit(connect) + HStack { + Button("Test connection", action: connect) + .disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || testing) + Button("Cancel") { key = ""; editing = false } + } + } else { + HStack { + Button { + switch connectionAction { + case .restoreSavedConnection, .retrySavingKey: + Task { await coordinator.retrySavedAPIKey(provider) } + case .connect, .replaceKey: + editing = true + } + } label: { + Text(connectionAction == .restoreSavedConnection ? "Restore saved connection" : + connectionAction == .retrySavingKey ? "Retry saving key" : + connectionAction == .replaceKey ? "Replace key" : "Connect") + } + .disabled(testing) + if hasKey || coordinator.apiConnectionErrors[provider] != nil { + Button("Disconnect") { + key = "" + coordinator.disconnectAPI(provider) + } + } + } + } + if coordinator.apiConnectionErrors[provider] != nil && + connectionAction != .restoreSavedConnection && connectionAction != .retrySavingKey { + Button("Retry saved key") { + Task { await coordinator.retrySavedAPIKey(provider) } + } + .disabled(testing) + } + Text(connectionAction == .retrySavingKey + ? "This key is not saved yet. Keep MeterUsage open and retry saving it." + : "Keys entered here are saved in macOS Keychain across restarts and updates. Disconnect removes the saved key.") + .foregroundColor(MU.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + .font(.muCaption) + .foregroundColor(MU.textSecondary) + .controlSize(.small) + .padding(.leading, 21) + .padding(.bottom, 4) + .onDisappear { key = ""; editing = false } + } + + @ViewBuilder + private var status: some View { + if let error = coordinator.apiConnectionErrors[provider] { + Text(error) + .foregroundColor(MU.warn) + .fixedSize(horizontal: false, vertical: true) + } else if testing { + Text("Testing usage and cost access…") + } else if !hasKey { + Text("Not connected") + } else { + switch coordinator.usages[.primary(provider)] ?? .idle { + case .value(let usage): + Text("Connected · Updated \(Fmt.timeSince(usage.capturedAt, now: coordinator.clock))") + .foregroundColor(MU.calm) + case .missing(let reason): + Text(reason.userFacingMessage) + .foregroundColor(MU.warn) + .fixedSize(horizontal: false, vertical: true) + case .idle: + Text("Waiting for a usage reading…") + } + } + } + + private func connect() { + guard !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !testing else { return } + let enteredKey = key + key = "" + editing = false + Task { await coordinator.connectAPI(provider, key: enteredKey) } + } +} diff --git a/Sources/MeterUsage/Views/MenuBarLabel.swift b/Sources/MeterUsage/Views/MenuBarLabel.swift index 0ef191d..081b9ff 100644 --- a/Sources/MeterUsage/Views/MenuBarLabel.swift +++ b/Sources/MeterUsage/Views/MenuBarLabel.swift @@ -281,9 +281,9 @@ struct ProviderMark: View { var body: some View { Group { switch provider { - case .codex, .grok, .openCodeGo, .antigravity: + case .codex, .openAI, .grok, .openCodeGo, .antigravity: bundledMark(named: Self.resourceName(for: provider)) - case .claude: + case .claude, .anthropic: ClaudeMascotShape() .fill(tint, style: FillStyle(eoFill: true)) case .openRouter, .cursor, .copilot, .gemini: @@ -323,17 +323,17 @@ struct ProviderMark: View { /// asset; `nil` would mean "no logo" but callers guard by provider first. private static func resourceName(for provider: Provider) -> String { switch provider { - case .codex: return "codex-logo" + case .codex, .openAI: return "codex-logo" case .grok: return "grok-logo" case .openCodeGo: return "opencode-logo" case .antigravity:return "antigravity-logo" - case .openRouter, .claude, .cursor, .copilot, .gemini: return "" + case .openRouter, .claude, .anthropic, .cursor, .copilot, .gemini: return "" } } static func symbol(for provider: Provider) -> String { switch provider { - case .codex: return "sparkle" + case .codex, .openAI: return "sparkle" case .antigravity:return "sparkles" case .grok: return "eye" // A real SF Symbol name: an invalid name renders as nothing, which @@ -341,7 +341,7 @@ struct ProviderMark: View { // exists (e.g. a bare debug binary). case .openCodeGo: return "arrow.up.left.and.arrow.down.right" case .openRouter: return "arrow.triangle.branch" - case .claude: return "sparkles" + case .claude, .anthropic: return "sparkles" case .cursor: return "cursorarrow.rays" case .copilot: return "terminal" case .gemini: return "diamond" diff --git a/Sources/MeterUsage/Views/PopoverRoot.swift b/Sources/MeterUsage/Views/PopoverRoot.swift index 48fc28a..520bbfe 100644 --- a/Sources/MeterUsage/Views/PopoverRoot.swift +++ b/Sources/MeterUsage/Views/PopoverRoot.swift @@ -58,7 +58,8 @@ struct StripTotals { .reduce(0) { $0 + $1.tokens.total } } } - for usage in usages { + // Organization-wide API usage can overlap local CLI activity. + for usage in usages where !usage.provider.isOrganizationAPI { if let t = usage.todayTokens { todayTokens += t.total } if let w = usage.weekTokens { weekTokens += w.total } if let c = usage.todayCostUSD { todayCost += c } diff --git a/Sources/MeterUsage/Views/ProviderUsageSection.swift b/Sources/MeterUsage/Views/ProviderUsageSection.swift index a8dad2e..ec8c2d4 100644 --- a/Sources/MeterUsage/Views/ProviderUsageSection.swift +++ b/Sources/MeterUsage/Views/ProviderUsageSection.swift @@ -34,7 +34,7 @@ struct ProviderUsageSection: View { } } -private struct ProviderUsageRow: View { +struct ProviderUsageRow: View { let provider: Provider let state: Loaded let now: Date @@ -63,19 +63,21 @@ private struct ProviderUsageRow: View { private var trailingValue: some View { switch state { case .value(let usage): - Text(Fmt.count(usage.messageCount)) - .font(.muNumber) - .foregroundColor(providerColor(provider)) + if provider != .anthropic { + Text(provider == .openAI ? "\(Fmt.count(usage.messageCount)) requests" : Fmt.count(usage.messageCount)) + .font(.muNumber) + .foregroundColor(providerColor(provider)) + } case .idle, .missing: EmptyView() } } @ViewBuilder - private var detail: some View { + var detail: some View { switch state { case .idle: - Text("Checking local history…") + Text(provider.isOrganizationAPI ? "Checking API usage…" : "Checking local history…") .font(.muCaption) .foregroundColor(MU.textTertiary) case .missing(let reason): @@ -89,7 +91,9 @@ private struct ProviderUsageRow: View { .fixedSize(horizontal: false, vertical: true) } case .value(let usage): - if let windows = usage.usageWindows, !windows.isEmpty { + if provider.isOrganizationAPI { + APIUsageDetails(usage: usage, now: now) + } else if let windows = usage.usageWindows, !windows.isEmpty { // Rolling-window view (OpenCode Go): one row per window with a // share-of-30d bar + percentage, then counts, then a caption. UsageWindowBars(windows: windows, provider: provider, now: now) @@ -138,10 +142,16 @@ private struct ProviderUsageRow: View { private var accessibilityLabel: String { switch state { case .idle: - return "\(provider.displayName): checking local history" + return "\(provider.displayName): checking usage" case .missing(let reason): return "\(provider.displayName): \(reason.userFacingMessage)" case .value(let usage): + if provider == .anthropic { + return "Anthropic API, last 30 days, \(usage.tokens?.total ?? 0) tokens, reported spend \(Fmt.usd(usage.estimatedCostUSD ?? 0)), excludes Priority Tier costs" + } + if provider == .openAI { + return "OpenAI API, last 30 days, \(usage.messageCount) completion requests, \(usage.tokens?.total ?? 0) tokens, reported spend \(Fmt.usd(usage.estimatedCostUSD ?? 0))" + } var parts = [ provider.displayName, "\(usage.sessionCount) sessions", @@ -159,6 +169,17 @@ private struct ProviderUsageRow: View { } private func hint(for reason: SourceUnavailable) -> String { + if provider.isOrganizationAPI { + switch reason { + case .dataNotFound, .notSignedIn: + if provider == .anthropic { + return "Connect in Settings → Providers → Anthropic API with a Console organization Admin key. Individual accounts, workspace keys, and Claude subscription logins cannot supply this reading." + } + return "Connect in Settings → Providers → OpenAI API with an organization Admin key that has usage access. A project API key or Codex login cannot supply this reading." + case .offline: return "Connect to the internet, then refresh API usage." + default: return "API usage is unavailable. Will retry on the next refresh." + } + } switch reason { case .cliNotFound(let name): return "Install \(name) to read local usage." case .dataNotFound: return "Enable this provider after its local history is available." @@ -170,6 +191,45 @@ private struct ProviderUsageRow: View { } } +/// Organization spend and endpoint-specific tokens, without invented request counts. +private struct APIUsageDetails: View { + let usage: ProviderUsage + let now: Date + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(usage.usageWindows ?? [], id: \.label) { window in + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(window.label == "last 30d" ? "Last 30 days (UTC)" : window.label) + Spacer(minLength: 4) + Text(Fmt.usd(window.estimatedCostUSD)).monospacedDigit() + } + .font(.muBody) + .foregroundColor(MU.text) + Text(usage.provider == .anthropic + ? "\(Fmt.compactCount(window.tokens.total)) tokens" + : "\(Fmt.compactCount(window.tokens.total)) tokens · \(Fmt.count(window.messageCount)) completion requests") + .font(.muCaption) + .foregroundColor(MU.textSecondary) + } + } + Text(usage.provider == .anthropic + ? "Reported API spend excludes Priority Tier. Tokens cover Messages API. Reporting can lag." + : "Organization spend reported by OpenAI. Tokens cover completions only. Reporting can lag.") + .font(.muCaption) + .foregroundColor(MU.textTertiary) + .fixedSize(horizontal: false, vertical: true) + Text("Updated \(Fmt.timeSince(usage.capturedAt, now: now))") + .font(.muCaption) + .foregroundColor(MU.textTertiary) + Link("Open usage dashboard", destination: URL(string: usage.provider == .anthropic + ? "https://platform.claude.com/usage" : "https://platform.openai.com/usage")!) + .font(.muCaption) + } + } +} + /// Rolling usage windows as bars. /// /// For providers without quota limits (like OpenCode Go), percentages reflect diff --git a/Sources/MeterUsage/Views/SettingsView.swift b/Sources/MeterUsage/Views/SettingsView.swift index 82444aa..05dfc2a 100644 --- a/Sources/MeterUsage/Views/SettingsView.swift +++ b/Sources/MeterUsage/Views/SettingsView.swift @@ -7,7 +7,8 @@ import AppKit /// toggles, and a menu-bar app that opens windows loses its "glance and /// dismiss" quality. /// -/// Every control binds `@AppStorage` directly. `Preferences` observes the same +/// Saved controls bind `@AppStorage` directly. API keys stay in memory. +/// `Preferences` observes the same /// keys and republishes, so changing the interval here restarts the coordinator's /// timer with no explicit plumbing between the two. struct SettingsView: View { @@ -21,6 +22,8 @@ struct SettingsView: View { @AppStorage(PrefKey.refreshInterval) private var refreshInterval: Double = Preferences.defaultRefreshInterval @AppStorage(PrefKey.showClaude) private var showClaude: Bool = false @AppStorage(PrefKey.showCodex) private var showCodex: Bool = true + @AppStorage(PrefKey.showOpenAI) private var showOpenAI: Bool = false + @AppStorage(PrefKey.showAnthropic) private var showAnthropic: Bool = false @AppStorage(PrefKey.showAntigravity) private var showAntigravity: Bool = false @AppStorage(PrefKey.showGrok) private var showGrok: Bool = false @AppStorage(PrefKey.showOpenCodeGo) private var showOpenCodeGo: Bool = true @@ -30,6 +33,7 @@ struct SettingsView: View { @AppStorage(PrefKey.showGemini) private var showGemini: Bool = true @AppStorage(PrefKey.menuBarClaude) private var menuBarClaude: Bool = true @AppStorage(PrefKey.menuBarCodex) private var menuBarCodex: Bool = true + @AppStorage(PrefKey.menuBarOpenAI) private var menuBarOpenAI: Bool = true @AppStorage(PrefKey.menuBarAntigravity) private var menuBarAntigravity: Bool = true @AppStorage(PrefKey.menuBarGrok) private var menuBarGrok: Bool = true @AppStorage(PrefKey.menuBarOpenCodeGo) private var menuBarOpenCodeGo: Bool = true @@ -88,6 +92,25 @@ struct SettingsView: View { menuBarIsOn: $menuBarCodex ) Divider().overlay(MU.hairline) + ProviderRow( + provider: .openAI, + subtitle: "Organization spend and tokens", + isOn: $showOpenAI, + menuBarIsOn: $menuBarOpenAI + ) + if showOpenAI { + APIConnectionControls(provider: .openAI, coordinator: coordinator) + } + Divider().overlay(MU.hairline) + ProviderRow( + provider: .anthropic, + subtitle: "Organization spend and tokens", + isOn: $showAnthropic + ) + if showAnthropic { + APIConnectionControls(provider: .anthropic, coordinator: coordinator) + } + Divider().overlay(MU.hairline) ProviderRow( provider: .antigravity, subtitle: "Local sessions and messages", @@ -208,18 +231,15 @@ struct SettingsView: View { Group { SectionHeader("Accounts") Card(padding: 10) { - // Whose readings these are. This app signs in nowhere: every - // number is borrowed from a credential a tool on this Mac - // already holds, so each row names the owning tool and the - // plan it reports. No address, no account id — a plan tier - // is context for the percentages, never an identity. + // Local tools own these credentials. Organization API + // connections have their own controls above. // // Only shown slots are listed: a hidden provider or a // removed second account is not read at all, so showing // its row would present a credential that is currently // doing nothing. if visibleAccounts.isEmpty { - Text("All providers are hidden. Turn one on in Providers above.") + Text("No local provider accounts enabled. API connections are managed above.") .font(.muBody) .foregroundColor(MU.textSecondary) } else { @@ -451,7 +471,7 @@ struct SettingsView: View { // The coordinator's visibility, not the raw preference: an // additional account with no config directory is not an account, // so it gets no row. - coordinator.visibleSlots + coordinator.visibleSlots.filter { !$0.provider.isOrganizationAPI } } private static func intervalLabel(_ seconds: Double) -> String { @@ -626,7 +646,11 @@ private struct ProviderRow: View { ) } .buttonStyle(.plain) - .help(menuBarIsOn.wrappedValue + .help(provider == .openAI + ? (menuBarIsOn.wrappedValue + ? "Hide OpenAI API from the side notch" + : "Show OpenAI API in the side notch") + : menuBarIsOn.wrappedValue ? "Hide \(provider.displayName) from the side notch and menu bar" : "Show \(provider.displayName) in the side notch and menu bar") .accessibilityLabel(menuBarIsOn.wrappedValue @@ -722,4 +746,3 @@ private struct AccountRow: View { .accessibilityLabel("\(account.name), \(account.plan ?? "plan unknown"), via \(account.via)") } } - diff --git a/Sources/MeterUsage/Views/SharedComponents.swift b/Sources/MeterUsage/Views/SharedComponents.swift index 54eb223..95f1cfa 100644 --- a/Sources/MeterUsage/Views/SharedComponents.swift +++ b/Sources/MeterUsage/Views/SharedComponents.swift @@ -192,12 +192,12 @@ func severityColor(_ severity: Severity) -> Color { /// identity colour: the tool is the identity, the account is named in text. func providerColor(_ provider: Provider) -> Color { switch provider { - case .codex: return MU.accent + case .codex, .openAI: return MU.accent case .antigravity:return MU.antigravity case .grok: return MU.grok case .openCodeGo: return MU.openCodeGo case .openRouter: return MU.openRouter - case .claude: return MU.calm + case .claude, .anthropic: return MU.calm case .cursor: return Color(red: 0.1, green: 0.7, blue: 0.9) case .copilot: return Color(red: 0.4, green: 0.6, blue: 1.0) case .gemini: return Color(red: 0.3, green: 0.5, blue: 0.95) diff --git a/Sources/MeterUsage/Views/SideNotchPanelView.swift b/Sources/MeterUsage/Views/SideNotchPanelView.swift index 0be6de3..7d8befc 100644 --- a/Sources/MeterUsage/Views/SideNotchPanelView.swift +++ b/Sources/MeterUsage/Views/SideNotchPanelView.swift @@ -519,6 +519,15 @@ struct SideNotchPanelView: View { // MARK: - Strip + static func stripReading(for entry: Entry) -> some View { + Text(entry.primaryText) + .font(.system(size: 9, weight: .semibold).monospacedDigit()) + .foregroundColor(Notch.text) + .lineLimit(1) + .minimumScaleFactor(entry.usedPercent == nil ? 0.6 : 1) + .frame(maxWidth: SideNotchPanelLayout.stripWidth - 12) + } + private var strip: some View { VStack(spacing: 3) { if entries.isEmpty { @@ -537,9 +546,7 @@ struct SideNotchPanelView: View { accent: preferences.accentTheme, reduceMotion: reduceMotion ) - Text(Fmt.percent(entry.usedPercent)) - .font(.system(size: 9, weight: .semibold).monospacedDigit()) - .foregroundColor(Notch.text) + Self.stripReading(for: entry) if let eta = entry.etaText { Text(eta) .font(.system(size: 7.5, weight: .bold).monospacedDigit()) @@ -556,7 +563,8 @@ struct SideNotchPanelView: View { // explicit activation wins, and a later mouse enter still // re-asserts, so the two inputs never fight. .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityRingText(for: entry)) + .accessibilityLabel(entry.accessibilityText) + .help(entry.accessibilityText) .accessibilityAction(named: "Show details") { cancelFold() isHoveringPanel = true @@ -620,7 +628,7 @@ struct SideNotchPanelView: View { // MARK: - Detail card for hovered provider - private func detailCard(for slot: ProviderSlot) -> some View { + func detailCard(for slot: ProviderSlot) -> some View { // The card follows the ring: live reading when present, otherwise // the archived last-good reading, dated as such. let display = coordinator.displayQuota(for: slot) @@ -662,6 +670,12 @@ struct SideNotchPanelView: View { StatusBadge(severity: status.severity) } + if slot.provider == .openAI { + ProviderUsageRow(provider: .openAI, state: coordinator.usages[slot] ?? .idle, now: coordinator.clock) + .detail + .environment(\.colorScheme, .dark) + } + // Ambient Time-To-Empty banner. It reports the window's own pace: // a deficit empties before reset at that shape even when the burn // has since gone quiet. Only the present-tense "burning fast" @@ -738,7 +752,7 @@ struct SideNotchPanelView: View { // Activity Telemetry 2-column grid if showActivityTelemetry, let tel = telemetry(for: slot) { telemetryView(tel: tel) - } else if let tokenUsage = tokenUsage(for: slot), + } else if slot.provider != .openAI, let tokenUsage = tokenUsage(for: slot), (tokenUsage.todayText != nil || tokenUsage.last30DaysText != nil) { VStack(alignment: .leading, spacing: 5) { Text("Token usage") @@ -855,7 +869,7 @@ struct SideNotchPanelView: View { Text("Last reading \(Fmt.timeSince(quota.capturedAt, now: coordinator.clock))") .font(.system(size: 10, weight: .regular)) .foregroundColor(Notch.subtext) - } else if let last = coordinator.lastRefreshedAt { + } else if slot.provider != .openAI, let last = coordinator.lastRefreshedAt { Text("Updated \(Fmt.timeSince(last, now: coordinator.clock))") .font(.system(size: 10, weight: .regular)) .foregroundColor(Notch.subtext) @@ -1409,8 +1423,9 @@ struct SideNotchPanelView: View { /// The slot digit shown for additional accounts ("2", "3", …); nil /// for primary slots. let digit: String? - let usedPercent: Double - let fraction: Double + let usedPercent: Double? + let fraction: Double? + let spendUSD: Double? let ringTint: Color let markTint: Color let resetsAt: Date? @@ -1422,22 +1437,39 @@ struct SideNotchPanelView: View { var id: String { slot.key } + var primaryText: String { + if let usedPercent { return Fmt.percent(usedPercent) } + return spendUSD.map(Fmt.usd) ?? "N/A" + } + + var accessibilityText: String { + if let usedPercent { + return "\(slot.displayName) \(Fmt.percent(usedPercent)) used\(isStale ? ", last known reading" : "")" + } + if let spendUSD { + return "\(slot.displayName), last 30 days (UTC), reported spend \(Fmt.usd(spendUSD))" + } + return "\(slot.displayName), usage unavailable" + } + init( slot: ProviderSlot, digit: String? = nil, - usedPercent: Double, - fraction: Double, + usedPercent: Double?, + fraction: Double?, ringTint: Color, markTint: Color, resetsAt: Date?, isStale: Bool, etaText: String? = nil, - isDeficit: Bool = false + isDeficit: Bool = false, + spendUSD: Double? = nil ) { self.slot = slot self.digit = digit self.usedPercent = usedPercent self.fraction = fraction + self.spendUSD = spendUSD self.ringTint = ringTint self.markTint = markTint self.resetsAt = resetsAt @@ -1460,6 +1492,7 @@ struct SideNotchPanelView: View { menuBarSlots: [ProviderSlot], quotas: [ProviderSlot: Loaded], statuses: [Provider: Loaded], + usages: [ProviderSlot: Loaded] = [:], archivedQuotas: [ProviderSlot: ProviderQuota] = [:], now: Date = Date() ) -> [Entry] { @@ -1467,6 +1500,14 @@ struct SideNotchPanelView: View { // display order, so their rings stay tellable from the primary's. var familyCounts: [Provider: Int] = [:] return menuBarSlots.compactMap { slot in + if slot.provider == .openAI { + return Entry( + slot: slot, usedPercent: nil, fraction: nil, + ringTint: providerColor(.openAI), markTint: providerColor(.openAI), + resetsAt: nil, isStale: false, + spendUSD: usages[slot]?.value?.estimatedCostUSD + ) + } let live = quotas[slot]?.value let quota = live ?? archivedQuotas[slot] let windows = effectiveWindows(for: slot.provider, quota: quota) @@ -1514,6 +1555,7 @@ struct SideNotchPanelView: View { menuBarSlots: coordinator.sideNotchSlots, quotas: coordinator.quotas, statuses: coordinator.statuses, + usages: coordinator.usages, archivedQuotas: coordinator.archivedQuotas, now: coordinator.clock ) @@ -1568,16 +1610,9 @@ struct SideNotchPanelView: View { return .remaining(percent: max(100 - window.usedPercent, 0)) } - private func accessibilityRingText(for entry: Entry) -> String { - "\(entry.slot.displayName) \(Fmt.percent(entry.usedPercent)) used\(entry.isStale ? ", last known reading" : "")" - } - private var accessibilityText: String { if entries.isEmpty { return "Usage unavailable" } - return entries.map { - "\($0.slot.displayName) \(Fmt.percent($0.usedPercent)) used\($0.isStale ? ", last known" : "")" - } - .joined(separator: ", ") + return entries.map(\.accessibilityText).joined(separator: ", ") } } @@ -1648,7 +1683,7 @@ private struct HoverSensor: NSViewRepresentable { // MARK: - Ring private struct QuotaRing: View { - let fraction: Double + let fraction: Double? let tint: Color let slot: ProviderSlot /// The slot digit for an additional account, drawn as a tiny chip so two @@ -1669,15 +1704,17 @@ private struct QuotaRing: View { .fill(Notch.disc[accentIndex]) Circle() .stroke(Notch.track[accentIndex], lineWidth: 2.5) - Circle() - .trim(from: 0, to: fraction.muClamped(to: 0...1)) - .stroke(tint, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) - .rotationEffect(.degrees(-90)) + if let fraction { + Circle() + .trim(from: 0, to: fraction.muClamped(to: 0...1)) + .stroke(tint, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } ProviderMark(provider: slot.provider, tint: markTint) .frame(width: 9, height: 9) // A hit limit dims the glyph: the full orange ring already // carries the state, and the mark steps back. - .opacity(fraction >= 1 ? 0.5 : 1.0) + .opacity((fraction ?? 0) >= 1 ? 0.5 : 1.0) if let digit { Text(digit) .font(.system(size: 6.5, weight: .bold)) diff --git a/Tests/MeterUsageTests/APIConnectionTests.swift b/Tests/MeterUsageTests/APIConnectionTests.swift new file mode 100644 index 0000000..f3a7a01 --- /dev/null +++ b/Tests/MeterUsageTests/APIConnectionTests.swift @@ -0,0 +1,334 @@ +import XCTest +@testable import MeterUsage + +@MainActor +final class APIConnectionTests: XCTestCase { + func testSessionRecreationRetainsEnteredKey() throws { + let store = MemoryAPIKeyStore() + for provider in [Provider.openAI, .anthropic] { + let firstLaunch = APIKeySession(environment: [:], store: store) + try firstLaunch.set(" fixture-persistent-key\n", for: provider) + let nextLaunch = APIKeySession(environment: ["OPENAI_ADMIN_KEY": "fixture-env", + "ANTHROPIC_ADMIN_KEY": "fixture-env"], store: store) + XCTAssertTrue(nextLaunch.key(for: provider) == "fixture-persistent-key", + "A saved key must survive restart and take precedence over the launcher") + try nextLaunch.set("fixture-replacement", for: provider) + let updatedLaunch = APIKeySession(environment: [:], store: store) + XCTAssertTrue(updatedLaunch.key(for: provider) == "fixture-replacement") + try updatedLaunch.set(nil, for: provider) + XCTAssertNil(APIKeySession(environment: [:], store: store).key(for: provider)) + } + } + + func testConnectReadsBothReportsAndDisconnectForgetsEachKey() async throws { + let store = MemoryAPIKeyStore() + let keys = APIKeySession(environment: [:], store: store) + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ConnectionProtocol.self] + let session = URLSession(configuration: config) + let name = "MeterUsageTests-" + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) + defer { + defaults.removePersistentDomain(forName: name) + ConnectionProtocol.handler = nil + } + let preferences = Preferences(defaults: defaults) + let before = defaults.persistentDomain(forName: name) + let coordinator = AppCoordinator(preferences: preferences, usageSources: [ + OpenAIUsageSource(adminKey: { keys.key(for: .openAI) }, session: session), + AnthropicUsageSource(adminKey: { keys.key(for: .anthropic) }, session: session) + ], apiKeys: keys) + + for provider in [Provider.openAI, .anthropic] { + var calls = 0 + ConnectionProtocol.handler = { request in + calls += 1 + let header = provider == .openAI ? "Authorization" : "x-api-key" + XCTAssertEqual(request.value(forHTTPHeaderField: header), + provider == .openAI ? "Bearer fixture-api-key" : "fixture-api-key") + return 200 + } + await coordinator.connectAPI(provider, key: "fixture-api-key") + XCTAssertEqual(calls, 2, "Connect must verify usage and cost access") + XCTAssertEqual(coordinator.usages[.primary(provider)]?.value?.tokens?.total, 0) + XCTAssertTrue(coordinator.hasAPIKey(for: provider)) + XCTAssertTrue(APIKeySession(environment: [:], store: store).key(for: provider) == "fixture-api-key") + XCTAssertFalse(coordinator.testingAPIProviders.contains(provider)) + XCTAssertFalse(coordinator.diagnosticsText().contains("fixture-api-key")) + + coordinator.disconnectAPI(provider) + XCTAssertNil(keys.key(for: provider)) + XCTAssertNil(coordinator.usages[.primary(provider)]?.value) + XCTAssertFalse(coordinator.hasAPIKey(for: provider)) + XCTAssertNil(APIKeySession(environment: [:], store: store).key(for: provider)) + } + XCTAssertEqual(defaults.persistentDomain(forName: name) as NSDictionary?, before as NSDictionary?) + XCTAssertNil(APIKeySession(environment: [:]).key(for: .openAI)) + } + + func testRejectedCredentialNeverShowsConnectedOrRawError() async throws { + let store = MemoryAPIKeyStore() + let keys = APIKeySession(environment: [:], store: store) + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ConnectionProtocol.self] + defer { ConnectionProtocol.handler = nil } + ConnectionProtocol.handler = { _ in 403 } + let coordinator = AppCoordinator(preferences: Preferences(), usageSources: [ + OpenAIUsageSource(adminKey: { keys.key(for: .openAI) }, session: URLSession(configuration: config)) + ], apiKeys: keys) + await coordinator.connectAPI(.openAI, key: "fixture-rejected-key") + XCTAssertNil(coordinator.usages[.primary(.openAI)]?.value) + XCTAssertEqual(coordinator.usages[.primary(.openAI)]?.unavailable, + .dataNotFound("OpenAI Admin API key with usage access")) + XCTAssertFalse(coordinator.testingAPIProviders.contains(.openAI)) + XCTAssertFalse(coordinator.diagnosticsText().contains("private-error")) + XCTAssertTrue(APIKeySession(environment: [:], store: store).key(for: .openAI) == "fixture-rejected-key", + "A provider error must not delete the saved credential") + } + + func testDisconnectSuppressesEnvironmentKeyAndLateResponse() async throws { + let keys = APIKeySession(environment: ["OPENAI_ADMIN_KEY": "fixture-environment-key"]) + let started = expectation(description: "usage request started") + let source = SuspendedConnectionSource(started: started) + let coordinator = AppCoordinator(preferences: Preferences(), usageSources: [source], apiKeys: keys) + let connecting = Task { await coordinator.connectAPI(.openAI, key: "fixture-session-key") } + await fulfillment(of: [started], timeout: 2) + coordinator.disconnectAPI(.openAI) + await source.finish() + await connecting.value + XCTAssertNil(keys.key(for: .openAI), "Disconnect must not fall back to the environment key") + XCTAssertNil(coordinator.usages[.primary(.openAI)]?.value, "Discard a disconnected account's late response") + XCTAssertFalse(coordinator.testingAPIProviders.contains(.openAI)) + } + + func testFailedSaveAndDeletePreserveExistingConnection() async throws { + let store = MemoryAPIKeyStore() + let keys = APIKeySession(environment: [:], store: store) + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ConnectionProtocol.self] + var calls = 0 + ConnectionProtocol.handler = { _ in calls += 1; return 200 } + defer { ConnectionProtocol.handler = nil } + let coordinator = AppCoordinator(preferences: Preferences(), usageSources: [ + OpenAIUsageSource(adminKey: { keys.key(for: .openAI) }, session: URLSession(configuration: config)) + ], apiKeys: keys) + await coordinator.connectAPI(.openAI, key: "fixture-original") + let revision = keys.revision(for: .openAI) + store.writeError = NSError(domain: "private-storage-error", code: 1) + await coordinator.connectAPI(.openAI, key: "fixture-replacement") + XCTAssertEqual(calls, 2, "A failed save must not test a different or unsaved credential") + XCTAssertNotNil(coordinator.apiConnectionErrors[.openAI]) + XCTAssertEqual(coordinator.apiConnectionAction(for: .openAI), .retrySavingKey) + XCTAssertFalse(coordinator.apiConnectionErrors[.openAI]!.contains("private-storage-error")) + XCTAssertFalse(coordinator.diagnosticsText().contains("fixture-replacement")) + XCTAssertTrue(keys.key(for: .openAI) == "fixture-original") + store.writeError = nil + await coordinator.retrySavedAPIKey(.openAI) + XCTAssertEqual(calls, 4, "Retry must test the retained replacement key after saving it") + XCTAssertEqual(coordinator.apiConnectionAction(for: .openAI), .replaceKey) + XCTAssertTrue(keys.key(for: .openAI) == "fixture-replacement") + store.writeError = NSError(domain: "private-storage-error", code: 1) + await coordinator.connectAPI(.openAI, key: "fixture-second-replacement") + XCTAssertEqual(coordinator.apiConnectionAction(for: .openAI), .retrySavingKey) + XCTAssertTrue(keys.key(for: .openAI) == "fixture-replacement") + coordinator.disconnectAPI(.openAI) + XCTAssertNotNil(coordinator.apiConnectionErrors[.openAI]) + XCTAssertTrue(keys.key(for: .openAI) == "fixture-replacement") + XCTAssertTrue(store.values[.openAI] == "fixture-replacement") + XCTAssertEqual(keys.revision(for: .openAI), revision + 1) + XCTAssertNotNil(coordinator.usages[.primary(.openAI)]?.value) + store.writeError = nil + coordinator.disconnectAPI(.openAI) + XCTAssertNil(coordinator.apiConnectionErrors[.openAI]) + XCTAssertEqual(coordinator.apiConnectionAction(for: .openAI), .connect) + XCTAssertNil(APIKeySession(environment: [:], store: store).key(for: .openAI)) + } + + func testFailedInitialSaveRetainsKeyForRetryAndDisconnectForgetsIt() async throws { + let store = MemoryAPIKeyStore() + let keys = APIKeySession(environment: [:], store: store) + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ConnectionProtocol.self] + ConnectionProtocol.handler = { _ in 200 } + defer { ConnectionProtocol.handler = nil } + let coordinator = AppCoordinator(preferences: Preferences(), usageSources: [ + OpenAIUsageSource(adminKey: { keys.key(for: .openAI) }, session: URLSession(configuration: config)) + ], apiKeys: keys) + + store.writeError = NSError(domain: "private-storage-error", code: 1) + await coordinator.connectAPI(.openAI, key: "fixture-initial") + XCTAssertEqual(coordinator.apiConnectionAction(for: .openAI), .retrySavingKey) + XCTAssertFalse(coordinator.diagnosticsText().contains("fixture-initial")) + XCTAssertNil(keys.key(for: .openAI)) + + coordinator.disconnectAPI(.openAI) + XCTAssertEqual(coordinator.apiConnectionAction(for: .openAI), .retrySavingKey) + store.writeError = nil + await coordinator.retrySavedAPIKey(.openAI) + XCTAssertEqual(coordinator.apiConnectionAction(for: .openAI), .replaceKey) + XCTAssertTrue(keys.key(for: .openAI) == "fixture-initial") + + coordinator.disconnectAPI(.openAI) + XCTAssertEqual(coordinator.apiConnectionAction(for: .openAI), .connect) + XCTAssertNil(keys.key(for: .openAI)) + } + + func testDeniedReadCanRetrySavedKeyWithoutNewEntry() async throws { + let store = MemoryAPIKeyStore() + store.values[.openAI] = "fixture-saved" + store.readError = NSError(domain: "private-read-error", code: 1) + let keys = APIKeySession(environment: ["OPENAI_ADMIN_KEY": "fixture-different-account"], store: store) + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ConnectionProtocol.self] + ConnectionProtocol.handler = { request in + XCTAssertTrue(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-saved") + return 200 + } + defer { ConnectionProtocol.handler = nil } + let coordinator = AppCoordinator(preferences: Preferences(), usageSources: [ + OpenAIUsageSource(adminKey: { keys.key(for: .openAI) }, session: URLSession(configuration: config)) + ], apiKeys: keys) + XCTAssertFalse(coordinator.hasAPIKey(for: .openAI), "Denied access must not switch to another account") + XCTAssertNotNil(coordinator.apiConnectionErrors[.openAI]) + store.readError = nil + await coordinator.retrySavedAPIKey(.openAI) + XCTAssertNil(coordinator.apiConnectionErrors[.openAI]) + XCTAssertNotNil(coordinator.usages[.primary(.openAI)]?.value) + XCTAssertTrue(coordinator.hasAPIKey(for: .openAI)) + } + + func testConnectionActionRecoversDeniedSavedKeysForBothProviders() async throws { + let store = MemoryAPIKeyStore() + store.values[.openAI] = "fixture-openai-saved" + store.values[.anthropic] = "fixture-anthropic-saved" + store.readError = NSError(domain: "private-read-error", code: 1) + let keys = APIKeySession(environment: [:], store: store) + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ConnectionProtocol.self] + ConnectionProtocol.handler = { _ in 200 } + defer { ConnectionProtocol.handler = nil } + let coordinator = AppCoordinator(preferences: Preferences(), usageSources: [ + OpenAIUsageSource(adminKey: { keys.key(for: .openAI) }, session: URLSession(configuration: config)), + AnthropicUsageSource(adminKey: { keys.key(for: .anthropic) }, session: URLSession(configuration: config)) + ], apiKeys: keys) + + for provider in [Provider.openAI, .anthropic] { + XCTAssertEqual(coordinator.apiConnectionAction(for: provider), .restoreSavedConnection) + store.readError = nil + await coordinator.retrySavedAPIKey(provider) + XCTAssertEqual(coordinator.apiConnectionAction(for: provider), .replaceKey) + XCTAssertNotNil(coordinator.usages[.primary(provider)]?.value) + coordinator.disconnectAPI(provider) + XCTAssertEqual(coordinator.apiConnectionAction(for: provider), .connect) + } + } + + func testDisconnectInvalidatesQueuedSavedKeyRecoveryForBothProviders() async throws { + for provider in [Provider.openAI, .anthropic] { + let store = MemoryAPIKeyStore() + store.values[provider] = "fixture-saved" + store.readError = NSError(domain: "private-read-error", code: 1) + let keys = APIKeySession(environment: ["OPENAI_ADMIN_KEY": "fixture-env", + "ANTHROPIC_ADMIN_KEY": "fixture-env"], store: store) + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ConnectionProtocol.self] + let session = URLSession(configuration: config) + var calls = 0 + ConnectionProtocol.handler = { _ in calls += 1; return 200 } + defer { ConnectionProtocol.handler = nil } + let coordinator = AppCoordinator(preferences: Preferences(), usageSources: [ + OpenAIUsageSource(adminKey: { keys.key(for: .openAI) }, session: session), + AnthropicUsageSource(adminKey: { keys.key(for: .anthropic) }, session: session) + ], apiKeys: keys) + + XCTAssertEqual(coordinator.apiConnectionAction(for: provider), .restoreSavedConnection) + store.readError = nil + let restoring = Task { await coordinator.retrySavedAPIKey(provider) } + coordinator.disconnectAPI(provider) + await restoring.value + + XCTAssertNil(keys.key(for: provider), "Queued recovery must not reconnect using the launcher key") + XCTAssertNil(store.values[provider]) + XCTAssertEqual(calls, 0, "Disconnect must invalidate the queued reporting request") + XCTAssertEqual(coordinator.apiConnectionAction(for: provider), .connect) + XCTAssertEqual(coordinator.usages[.primary(provider)]?.unavailable, .dataNotFound("API connection")) + XCTAssertNil(coordinator.apiConnectionErrors[provider]) + XCTAssertFalse(coordinator.testingAPIProviders.contains(provider)) + } + } + + func testEnvironmentFallbackIsNotPersistedAndDemoCannotMutateStore() async throws { + let store = MemoryAPIKeyStore() + let keys = APIKeySession(environment: ["OPENAI_ADMIN_KEY": "fixture-env"], store: store) + XCTAssertTrue(keys.key(for: .openAI) == "fixture-env") + XCTAssertTrue(store.values.isEmpty) + let demo = AppCoordinator(preferences: Preferences(), isDemoMode: true, apiKeys: keys) + await demo.connectAPI(.openAI, key: "fixture-demo") + demo.disconnectAPI(.openAI) + await demo.retrySavedAPIKey(.openAI) + XCTAssertTrue(keys.key(for: .openAI) == "fixture-env") + XCTAssertTrue(store.values.isEmpty) + } + + func testOldResponseCannotOverwriteReplacementConnection() async throws { + let store = MemoryAPIKeyStore() + let keys = APIKeySession(environment: [:], store: store) + let started = expectation(description: "old request started") + let source = SuspendedConnectionSource(started: started) + let coordinator = AppCoordinator(preferences: Preferences(), usageSources: [source], apiKeys: keys) + let connecting = Task { await coordinator.connectAPI(.openAI, key: "fixture-old") } + await fulfillment(of: [started], timeout: 2) + coordinator.disconnectAPI(.openAI) + try keys.set("fixture-new", for: .openAI) + await source.finish() + await connecting.value + XCTAssertTrue(APIKeySession(environment: [:], store: store).key(for: .openAI) == "fixture-new") + XCTAssertNil(coordinator.usages[.primary(.openAI)]?.value) + } +} + +private final class MemoryAPIKeyStore: APIKeyStore { + var values: [Provider: String] = [:] + var readError: Error? + var writeError: Error? + func read(_ provider: Provider) throws -> String? { + if let readError { throw readError } + return values[provider] + } + func write(_ key: String?, for provider: Provider) throws { + if let writeError { throw writeError } + values[provider] = key + } +} + +private final class ConnectionProtocol: URLProtocol { + static var handler: ((URLRequest) -> Int)? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + let status = Self.handler!(request) + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + let body = status == 200 ? "{\"data\":[],\"has_more\":false}" : "private-error" + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +private actor SuspendedConnectionSource: UsageSource { + nonisolated let provider: Provider = .openAI + let started: XCTestExpectation + private var continuation: CheckedContinuation? + init(started: XCTestExpectation) { self.started = started } + func fetchUsage() async throws -> ProviderUsage { + await withCheckedContinuation { + continuation = $0 + started.fulfill() + } + } + func finish() { + continuation?.resume(returning: ProviderUsage(provider: .openAI, sessionCount: 0, messageCount: 5, capturedAt: Date())) + continuation = nil + } +} diff --git a/Tests/MeterUsageTests/AnthropicUsageSourceTests.swift b/Tests/MeterUsageTests/AnthropicUsageSourceTests.swift new file mode 100644 index 0000000..57eaaad --- /dev/null +++ b/Tests/MeterUsageTests/AnthropicUsageSourceTests.swift @@ -0,0 +1,185 @@ +import XCTest +@testable import MeterUsage + +final class AnthropicUsageSourceTests: XCTestCase { + func testAnthropicAPIMonitorIsAvailableSeparatelyFromClaudeCode() { + XCTAssertTrue(Composition.usageSources().contains { $0.provider.displayName == "Anthropic API" }) + XCTAssertTrue(Provider.allCases.contains { $0.displayName == "Claude" }) + } + + private let now = ISO8601DateFormatter().date(from: "2026-09-27T12:00:00Z")! + + private func source(_ handler: @escaping (URLRequest) throws -> (Int, String)) -> AnthropicUsageSource { + AnthropicProtocol.handler = handler + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [AnthropicProtocol.self] + let date = now + return AnthropicUsageSource(adminKey: { "synthetic-admin" }, session: URLSession(configuration: config), now: { date }) + } + + override func tearDown() { + AnthropicProtocol.handler = nil + super.tearDown() + } + + private func page(_ buckets: String, more: Bool = false, cursor: String = "null") -> String { + "{\"data\":[\(buckets)],\"has_more\":\(more),\"next_page\":\(cursor)}" + } + + private func usage(day: String, input: Int = 100) -> String { + """ + {"starting_at":"\(day)","results":[{"uncached_input_tokens":\(input),"output_tokens":20, + "cache_read_input_tokens":40,"cache_creation":{"ephemeral_1h_input_tokens":30,"ephemeral_5m_input_tokens":10}, + "server_tool_use":{"web_search_requests":9},"workspace_id":"private-workspace","api_key_id":"private-key"}]} + """ + } + + private func cost(day: String, amount: String = "123.45", currency: String = "USD") -> String { + """ + {"starting_at":"\(day)","results":[{"amount":"\(amount)","currency":"\(currency)","description":"private-description"}]} + """ + } + + func testPaginationUTCBoundsCacheCategoriesAndCentsConversion() async throws { + var calls = 0 + let reader = source { request in + calls += 1 + let url = try XCTUnwrap(request.url) + XCTAssertEqual(url.host, "api.anthropic.com") + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertNil(request.httpBody) + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + XCTAssertEqual(request.value(forHTTPHeaderField: "x-api-key"), "synthetic-admin") + XCTAssertEqual(request.value(forHTTPHeaderField: "anthropic-version"), "2023-06-01") + let query = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + XCTAssertTrue(query.contains(URLQueryItem(name: "starting_at", value: "2026-08-29T00:00:00Z"))) + XCTAssertTrue(query.contains(URLQueryItem(name: "ending_at", value: "2026-09-28T00:00:00Z"))) + XCTAssertTrue(query.contains(URLQueryItem(name: "bucket_width", value: "1d"))) + XCTAssertFalse(query.contains { $0.name.contains("key") || $0.name.contains("workspace") || $0.name.contains("group") }) + let nextPage = query.contains { $0.name == "page" && $0.value == "next" } + if url.path.hasSuffix("usage_report/messages") { + return nextPage + ? (200, self.page(self.usage(day: "2026-09-27T00:00:00.000Z"))) + : (200, self.page(self.usage(day: "2026-09-26T00:00:00Z"), more: true, cursor: "\"next\"")) + } + XCTAssertEqual(url.path, "/v1/organizations/cost_report") + return nextPage + ? (200, self.page(self.cost(day: "2026-09-27T07:00:00+07:00") + "," + self.cost(day: "2026-09-27T00:00:00Z", amount: "-23.45"))) + : (200, self.page(self.cost(day: "2026-09-26T00:00:00Z", amount: "250"), more: true, cursor: "\"next\"")) + } + let reading = try await reader.fetchUsage() + XCTAssertEqual(calls, 4) + XCTAssertEqual(reading.provider, .anthropic) + XCTAssertEqual(reading.tokens, TokenTotals(input: 200, output: 40, cacheRead: 80, cacheWrite: 80)) + XCTAssertEqual(reading.tokens?.total, 400) + XCTAssertEqual(reading.todayTokens?.total, 200) + XCTAssertEqual(try XCTUnwrap(reading.estimatedCostUSD), 3.5, accuracy: 0.000001) + XCTAssertEqual(try XCTUnwrap(reading.todayCostUSD), 1, accuracy: 0.000001) + // Tool calls are not model requests or local sessions. + XCTAssertEqual(reading.messageCount, 0) + XCTAssertEqual(reading.sessionCount, 0) + XCTAssertFalse(String(reflecting: reading).contains("private-")) + XCTAssertFalse(String(reflecting: reading).contains("synthetic-admin")) + } + + func testEmptySuccessfulPagesAreMeasuredZero() async throws { + let reading = try await source { _ in (200, self.page("")) }.fetchUsage() + XCTAssertEqual(reading.tokens, TokenTotals()) + XCTAssertEqual(reading.todayTokens, TokenTotals()) + XCTAssertEqual(reading.estimatedCostUSD, 0) + XCTAssertEqual(reading.todayCostUSD, 0) + } + + func testMissingCredentialsAndHTTPFailuresAreSanitized() async { + do { + _ = try await AnthropicUsageSource(adminKey: { nil }).fetchUsage() + XCTFail("Expected missing key") + } catch { XCTAssertEqual(error as? SourceUnavailable, .dataNotFound("Anthropic Admin API key")) } + for status in [401, 403, 429, 500] { + var calls = 0 + do { + _ = try await source { _ in calls += 1; return (status, "sensitive-error") }.fetchUsage() + XCTFail("Expected unavailable state") + } catch { + let expected: SourceUnavailable = status == 401 || status == 403 + ? .dataNotFound("Anthropic Admin API key with usage access") : .failed(.anthropic) + XCTAssertEqual(error as? SourceUnavailable, expected) + XCTAssertFalse(String(describing: error).contains("sensitive-error")) + } + XCTAssertEqual(calls, 1) + } + do { + _ = try await source { _ in throw URLError(.notConnectedToInternet) }.fetchUsage() + XCTFail("Expected offline state") + } catch { XCTAssertEqual(error as? SourceUnavailable, .offline) } + } + + func testPartialMalformedAndNonUSDReadingsNeverBecomeZeroSpend() async { + for badCost in ["{}", page(cost(day: "invalid")), page(cost(day: "2026-09-27T00:00:00Z", amount: "NaN")), + page(cost(day: "2026-09-27T00:00:00Z", currency: "EUR"))] { + do { + _ = try await source { request in + (200, request.url!.path.hasSuffix("cost_report") ? badCost : self.page("")) + }.fetchUsage() + XCTFail("Expected invalid cost failure") + } catch { XCTAssertEqual(error as? SourceUnavailable, .failed(.anthropic)) } + } + do { + _ = try await source { request in + request.url!.path.hasSuffix("cost_report") ? (403, "private-error") : (200, self.page("")) + }.fetchUsage() + XCTFail("Expected unavailable reading, not zero spend") + } catch { XCTAssertEqual(error as? SourceUnavailable, .dataNotFound("Anthropic Admin API key with usage access")) } + for mode in 0..<3 { + var calls = 0 + do { + _ = try await source { _ in + calls += 1 + return (200, self.page("", more: true, cursor: mode == 0 ? "null" : "\"\(mode == 1 ? 1 : calls)\"")) + }.fetchUsage() + XCTFail("Expected incomplete pagination failure") + } catch { XCTAssertEqual(error as? SourceUnavailable, .failed(.anthropic)) } + XCTAssertEqual(calls, [1, 2, 5][mode]) + } + } + + @MainActor + func testOptInPersistsAndOrganizationUsageStaysOutOfLocalTotals() async throws { + let name = "MeterUsageTests-" + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + XCTAssertFalse(Preferences(defaults: defaults).isEnabled(.anthropic)) + defaults.set(true, forKey: PrefKey.showAnthropic) + let preferences = Preferences(defaults: defaults) + XCTAssertTrue(Preferences(defaults: defaults).isEnabled(.anthropic)) + let coordinator = AppCoordinator(preferences: preferences, usageSources: [DemoAnthropicUsageSource()]) + XCTAssertTrue(coordinator.visibleUsageProviders.contains(.anthropic)) + XCTAssertFalse(coordinator.visibleQuotaSlots.contains(.primary(.anthropic))) + XCTAssertFalse(coordinator.sideNotchSlots.contains(.primary(.anthropic))) + XCTAssertFalse(coordinator.menuBarSlots.contains(.primary(.anthropic))) + let reading = try await DemoAnthropicUsageSource().fetchUsage() + let totals = StripTotals.calculate(activities: [], usages: [reading], now: now) + XCTAssertEqual(totals.todayTokens, 0) + XCTAssertEqual(totals.todayCost, 0) + XCTAssertTrue(BurnAttributionCalculator.attributionSessions( + activities: [:], usages: [.primary(.anthropic): .value(reading)], now: now + ).isEmpty) + } +} + +private final class AnthropicProtocol: URLProtocol { + static var handler: ((URLRequest) throws -> (Int, String))? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + do { + let (status, body) = try Self.handler!(request) + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } catch { client?.urlProtocol(self, didFailWithError: error) } + } + override func stopLoading() {} +} diff --git a/Tests/MeterUsageTests/MenuBarTests.swift b/Tests/MeterUsageTests/MenuBarTests.swift index cb9bff4..615f7da 100644 --- a/Tests/MeterUsageTests/MenuBarTests.swift +++ b/Tests/MeterUsageTests/MenuBarTests.swift @@ -18,7 +18,7 @@ final class MenuBarTests: XCTestCase { defer { defaults.removePersistentDomain(forName: suiteName) } let preferences = Preferences(defaults: defaults) - XCTAssertEqual(preferences.menuBarProviders, Set(Provider.allCases)) + XCTAssertEqual(preferences.menuBarProviders, Set(Provider.allCases).subtracting([.anthropic])) XCTAssertTrue(preferences.showsInMenuBar(.codex)) XCTAssertTrue(preferences.showsInMenuBar(.grok)) } @@ -35,7 +35,7 @@ final class MenuBarTests: XCTestCase { let first = Preferences(defaults: defaults) let second = Preferences(defaults: defaults) - XCTAssertEqual(first.menuBarProviders, Set([.codex, .antigravity, .openCodeGo, .openRouter, .claude, .cursor, .copilot, .gemini])) + XCTAssertEqual(first.menuBarProviders, Set([.codex, .openAI, .antigravity, .openCodeGo, .openRouter, .claude, .cursor, .copilot, .gemini])) XCTAssertEqual(second.menuBarProviders, first.menuBarProviders) XCTAssertFalse(first.showsInMenuBar(.grok)) } diff --git a/Tests/MeterUsageTests/OpenAIUsageSourceTests.swift b/Tests/MeterUsageTests/OpenAIUsageSourceTests.swift new file mode 100644 index 0000000..fbfabd5 --- /dev/null +++ b/Tests/MeterUsageTests/OpenAIUsageSourceTests.swift @@ -0,0 +1,236 @@ +import XCTest +@testable import MeterUsage + +final class OpenAIUsageSourceTests: XCTestCase { + private let now = ISO8601DateFormatter().date(from: "2026-09-27T12:00:00Z")! + + private func source(_ handler: @escaping (URLRequest) throws -> (Int, String)) -> OpenAIUsageSource { + OpenAIProtocol.handler = handler + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [OpenAIProtocol.self] + let date = now + return OpenAIUsageSource(adminKey: { "synthetic-admin" }, session: URLSession(configuration: config), now: { date }) + } + + override func tearDown() { + OpenAIProtocol.handler = nil + super.tearDown() + } + + private func page(_ buckets: String, more: Bool = false, cursor: String = "null") -> String { + "{\"data\":[\(buckets)],\"has_more\":\(more),\"next_page\":\(cursor)}" + } + + private func completion(day: Int, input: Int = 100, cached: Int = 40, output: Int = 20) -> String { + """ + {"start_time":\(day),"results":[{"input_tokens":\(input),"input_cached_tokens":\(cached), + "output_tokens":\(output),"num_model_requests":2,"project_id":"private-project","api_key_id":"private-key-id"}]} + """ + } + + private func cost(day: Int, currency: String = "usd", value: Double = 1.25) -> String { + """ + {"start_time":\(day),"results":[{"amount":{"value":\(value),"currency":"\(currency)"}, + "organization_id":"private-org","line_item":"private-label"}]} + """ + } + + func testPaginatedUTCTotalsSeparateCachedTokensAndProviderCosts() async throws { + let today = Int(OpenAIUsageSource.utcCalendar.startOfDay(for: now).timeIntervalSince1970) + var calls = 0 + let reader = source { request in + calls += 1 + let url = try XCTUnwrap(request.url) + XCTAssertEqual(url.host, "api.openai.com") + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer synthetic-admin") + XCTAssertNil(request.httpBody) + let query = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + XCTAssertTrue(query.contains(URLQueryItem(name: "start_time", value: String(today - 29 * 86400)))) + XCTAssertTrue(query.contains(URLQueryItem(name: "end_time", value: String(today + 12 * 3600)))) + XCTAssertTrue(query.contains(URLQueryItem(name: "bucket_width", value: "1d"))) + XCTAssertFalse(query.contains { $0.name == "group_by" }) + let nextPage = query.contains { $0.name == "page" && $0.value == "next" } + if url.path.hasSuffix("completions") { + return nextPage + ? (200, self.page(self.completion(day: today))) + : (200, self.page(self.completion(day: today - 86400), more: true, cursor: "\"next\"")) + } + XCTAssertEqual(url.path, "/v1/organization/costs") + return nextPage + ? (200, self.page(self.cost(day: today, value: -0.25))) + : (200, self.page(self.cost(day: today - 86400), more: true, cursor: "\"next\"")) + } + let usage = try await reader.fetchUsage() + XCTAssertEqual(calls, 4) + XCTAssertEqual(usage.provider, .openAI) + XCTAssertEqual(usage.sessionCount, 0) + XCTAssertEqual(usage.messageCount, 4) + XCTAssertEqual(usage.tokens, TokenTotals(input: 120, output: 40, cacheRead: 80)) + XCTAssertEqual(usage.tokens?.total, 240) + XCTAssertEqual(usage.todayTokens?.total, 120) + XCTAssertEqual(usage.weekTokens?.total, 240) + XCTAssertEqual(usage.todayCostUSD, -0.25) + XCTAssertEqual(usage.estimatedCostUSD, 1) + XCTAssertEqual(usage.usageWindows?.map(\.label), ["Today (UTC)", "last 30d"]) + XCTAssertNil(usage.telemetry?.lifetimeTokens) + XCTAssertFalse(String(reflecting: usage).contains("private-")) + XCTAssertFalse(String(reflecting: usage).contains("synthetic-admin")) + } + + func testEmptySuccessfulPagesAreMeasuredZero() async throws { + let usage = try await source { _ in (200, self.page("")) }.fetchUsage() + XCTAssertEqual(usage.tokens, TokenTotals()) + XCTAssertEqual(usage.todayTokens, TokenTotals()) + XCTAssertEqual(usage.estimatedCostUSD, 0) + XCTAssertEqual(usage.todayCostUSD, 0) + } + + func testMissingKeyDoesNotSendRequest() async { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [OpenAIProtocol.self] + OpenAIProtocol.handler = { _ in XCTFail("No request without opt-in credentials"); return (200, "") } + do { + _ = try await OpenAIUsageSource(adminKey: { " \n " }, session: URLSession(configuration: config)).fetchUsage() + XCTFail("Expected missing-key state") + } catch { + XCTAssertEqual(error as? SourceUnavailable, .dataNotFound("OpenAI Admin API key")) + } + } + + func testHTTPFailuresAndOfflineAreSanitizedWithoutRetry() async { + for status in [401, 403, 429, 500] { + var calls = 0 + let reader = source { _ in calls += 1; return (status, "sensitive-provider-error") } + do { + _ = try await reader.fetchUsage() + XCTFail("Expected unavailable state") + } catch { + let expected: SourceUnavailable = status == 401 || status == 403 + ? .dataNotFound("OpenAI Admin API key with usage access") : .failed(.openAI) + XCTAssertEqual(error as? SourceUnavailable, expected) + XCTAssertFalse(String(describing: error).contains("sensitive-provider-error")) + } + XCTAssertEqual(calls, 1) + } + do { + _ = try await source { _ in throw URLError(.notConnectedToInternet) }.fetchUsage() + XCTFail("Expected offline state") + } catch { XCTAssertEqual(error as? SourceUnavailable, .offline) } + } + + func testCostsFailureNeverBecomesZeroSpend() async { + let reader = source { request in + request.url!.path.hasSuffix("costs") ? (403, "private-error") : (200, self.page("")) + } + do { + _ = try await reader.fetchUsage() + XCTFail("Incomplete readings must not appear as zero spend") + } catch { XCTAssertEqual(error as? SourceUnavailable, .dataNotFound("OpenAI Admin API key with usage access")) } + } + + func testDefaultSessionKeepsCredentialsOffDiskAndRejectsRedirects() throws { + let session = OpenAIUsageSource.defaultSession() + defer { session.invalidateAndCancel() } + XCTAssertNil(session.configuration.urlCache) + XCTAssertNil(session.configuration.httpCookieStorage) + XCTAssertFalse(session.configuration.httpShouldSetCookies) + let url = URL(string: "https://api.openai.com/v1/organization/costs")! + let response = HTTPURLResponse(url: url, statusCode: 302, httpVersion: nil, headerFields: nil)! + let redirected = URLRequest(url: URL(string: "https://example.com/collect")!) + let delegate = try XCTUnwrap(session.delegate as? URLSessionTaskDelegate) + let rejected = expectation(description: "Redirect rejected") + delegate.urlSession?(session, task: session.dataTask(with: url), willPerformHTTPRedirection: response, + newRequest: redirected) { request in + XCTAssertNil(request) + rejected.fulfill() + } + wait(for: [rejected], timeout: 1) + } + + func testMalformedRepeatedAndUnboundedPaginationFails() async { + for mode in 0..<3 { + var calls = 0 + let reader = source { _ in + calls += 1 + let cursor = mode == 0 ? "null" : "\"\(mode == 1 ? 1 : calls)\"" + return (200, self.page("", more: true, cursor: cursor)) + } + do { + _ = try await reader.fetchUsage() + XCTFail("Expected incomplete-page failure") + } catch { XCTAssertEqual(error as? SourceUnavailable, .failed(.openAI)) } + XCTAssertEqual(calls, [1, 2, 5][mode]) + } + } + + func testMalformedResponsesAndNonUSDCostsFail() async { + let day = Int(OpenAIUsageSource.utcCalendar.startOfDay(for: now).timeIntervalSince1970) + for invalid in ["{}", page(completion(day: day, cached: 101)), page(completion(day: day, input: -1))] { + do { + _ = try await source { request in + (200, request.url!.path.hasSuffix("completions") ? invalid : self.page("")) + }.fetchUsage() + XCTFail("Expected malformed reading failure") + } catch { XCTAssertEqual(error as? SourceUnavailable, .failed(.openAI)) } + } + do { + _ = try await source { request in + (200, request.url!.path.hasSuffix("costs") ? self.page(self.cost(day: day, currency: "eur")) : self.page("")) + }.fetchUsage() + XCTFail("Must not label another currency as USD") + } catch { XCTAssertEqual(error as? SourceUnavailable, .failed(.openAI)) } + } + + @MainActor + func testOptInPersistsSeparatelyFromCodexWithoutQuotaOrTray() throws { + let name = "MeterUsageTests-" + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let initial = AppCoordinator(preferences: Preferences(defaults: defaults)) + XCTAssertFalse(initial.preferences.isEnabled(.openAI)) + XCTAssertFalse(initial.sideNotchSlots.contains(.primary(.openAI))) + defaults.set(true, forKey: PrefKey.showOpenAI) + defaults.set(false, forKey: PrefKey.showCodex) + let preferences = Preferences(defaults: defaults) + let coordinator = AppCoordinator(preferences: preferences, usageSources: [DemoOpenAIUsageSource()]) + XCTAssertTrue(coordinator.visibleUsageProviders.contains(.openAI)) + XCTAssertFalse(preferences.isEnabled(.codex)) + XCTAssertFalse(coordinator.visibleQuotaSlots.contains(.primary(.openAI))) + XCTAssertFalse(coordinator.menuBarSlots.contains(.primary(.openAI))) + XCTAssertTrue(coordinator.sideNotchSlots.contains(.primary(.openAI))) + defaults.set(false, forKey: PrefKey.menuBarOpenAI) + let relaunched = AppCoordinator(preferences: Preferences(defaults: defaults)) + XCTAssertTrue(relaunched.preferences.isEnabled(.openAI)) + XCTAssertFalse(relaunched.sideNotchSlots.contains(.primary(.openAI))) + XCTAssertTrue(Composition.usageSources().contains { $0.provider == .openAI }) + } + + func testOrganizationUsageDoesNotDoubleCountLocalCodingTotals() async throws { + let usage = try await DemoOpenAIUsageSource().fetchUsage() + let totals = StripTotals.calculate(activities: [], usages: [usage], now: now) + XCTAssertEqual(totals.todayTokens, 0) + XCTAssertEqual(totals.weekTokens, 0) + XCTAssertEqual(totals.todayCost, 0) + XCTAssertTrue(BurnAttributionCalculator.attributionSessions( + activities: [:], usages: [.primary(.openAI): .value(usage)], now: now + ).isEmpty) + } +} + +private final class OpenAIProtocol: URLProtocol { + static var handler: ((URLRequest) throws -> (Int, String))? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + do { + let (status, body) = try Self.handler!(request) + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } catch { client?.urlProtocol(self, didFailWithError: error) } + } + override func stopLoading() {} +} diff --git a/Tests/MeterUsageTests/SideNotchPanelTests.swift b/Tests/MeterUsageTests/SideNotchPanelTests.swift index 5fcef93..6e84975 100644 --- a/Tests/MeterUsageTests/SideNotchPanelTests.swift +++ b/Tests/MeterUsageTests/SideNotchPanelTests.swift @@ -609,7 +609,7 @@ final class SideNotchPanelTests: XCTestCase { ) XCTAssertEqual(entries.count, 1) XCTAssertEqual(entries[0].slot, ProviderSlot.primary(.openRouter)) - XCTAssertEqual(entries[0].usedPercent, 32.2, accuracy: 0.01) + XCTAssertEqual(entries[0].usedPercent ?? -1, 32.2, accuracy: 0.01) // Case 2: OpenRouter with an explicit key limit window let keyWindowQuota = ProviderQuota( @@ -1049,13 +1049,113 @@ final class SideNotchPanelTests: XCTestCase { } } + @MainActor + func testOpenAIEntryShowsSpendWithoutQuotaAndKeepsMissingDistinctFromZero() async throws { + let slot = ProviderSlot.primary(.openAI) + let demo = try await DemoOpenAIUsageSource().fetchUsage() + let zero = ProviderUsage(provider: .openAI, sessionCount: 0, messageCount: 0, + estimatedCostUSD: 0, capturedAt: Date()) + let adjustment = ProviderUsage(provider: .openAI, sessionCount: 0, messageCount: 0, + estimatedCostUSD: -1.25, capturedAt: Date()) + let states: [(Loaded, String)] = [ + (.idle, "N/A"), (.missing(.offline), "N/A"), + (.missing(.dataNotFound("OpenAI Admin API key")), "N/A"), + (.value(demo), Fmt.usd(12.50)), (.value(zero), Fmt.usd(0)), + (.value(adjustment), Fmt.usd(-1.25)) + ] + for (state, text) in states { + let entries = SideNotchPanelView.entries( + menuBarSlots: [slot], quotas: [:], statuses: [:], usages: [slot: state] + ) + XCTAssertEqual(entries.count, 1) + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.primaryText, text) + XCTAssertNil(entry.usedPercent) + XCTAssertNil(entry.fraction) + XCTAssertNil(entry.resetsAt) + XCTAssertNil(entry.etaText) + XCTAssertFalse(entry.isDeficit) + XCTAssertFalse(entry.isStale) + XCTAssertTrue(entry.accessibilityText.contains(state.value == nil ? "unavailable" : "last 30 days (UTC)")) + } + } + + @MainActor + func testQuotaStripReadingKeepsGlyphSizeUnderChangingHeightProposals() throws { + let entry = SideNotchPanelView.Entry( + slot: .codex, usedPercent: 83, fraction: 0.83, + ringTint: .orange, markTint: .white, resetsAt: nil, isStale: false + ) + var originalGlyphSize: NSSize? + for height: CGFloat in [14, 9, 6, 14] { + let host = NSHostingView(rootView: SideNotchPanelView.stripReading(for: entry) + .frame(width: 32, height: height) + .frame(width: 44, height: 28)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 44, height: 28), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.isOpaque = false + window.backgroundColor = .clear + window.contentView = host + defer { window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + var minX = bitmap.pixelsWide, maxX = -1 + var minY = bitmap.pixelsHigh, maxY = -1 + for y in 0.. 0.3, color.redComponent > 0.8 { + minX = min(minX, x); maxX = max(maxX, x) + minY = min(minY, y); maxY = max(maxY, y) + } + } + } + XCTAssertGreaterThanOrEqual(maxX, minX, "percentage glyphs must render") + let size = NSSize(width: maxX - minX + 1, height: maxY - minY + 1) + if let originalGlyphSize { + XCTAssertEqual(size, originalGlyphSize, "unchanged percentage must not zoom with layout height") + } else { originalGlyphSize = size } + } + } + + @MainActor + func testRenderedOpenAIAndQuotaDetailCards() async throws { + let coordinator = try await Self.coordinator( + quotas: [(.codex, [("Session", 40, 3600), ("Weekly", 60, 86400)])], + usageSources: [DemoOpenAIUsageSource()] + ) + let windowsBefore = Set(NSApplication.shared.windows.map(ObjectIdentifier.init)) + let controller = SideNotchPanelController(coordinator: coordinator) + let panel = try XCTUnwrap(NSApplication.shared.windows.first { !windowsBefore.contains(ObjectIdentifier($0)) }) + defer { panel.contentView = nil; panel.close() } + let view = try XCTUnwrap(panel.contentView as? NSHostingView).rootView + let captureDir = FileManager.default.temporaryDirectory.appendingPathComponent("meterusage-notch-fixtures") + try FileManager.default.createDirectory(at: captureDir, withIntermediateDirectories: true) + for provider in [Provider.openAI, .codex] { + let host = NSHostingView(rootView: view.detailCard(for: .primary(provider))) + host.appearance = NSAppearance(named: .darkAqua) + panel.contentView = host + panel.setContentSize(host.fittingSize) + host.layoutSubtreeIfNeeded() + XCTAssertEqual(host.bounds.width, SideNotchPanelLayout.cardWidth) + XCTAssertGreaterThan(host.bounds.height, 100) + let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + try png.write(to: captureDir.appendingPathComponent("\(provider.rawValue).png")) + } + } + // MARK: - Helpers /// Builds a coordinator with stub quota sources and lets one refresh sweep /// run to completion. Same pattern as `MenuBarTests`. @MainActor private static func coordinator( - quotas: [(Provider, [(String, Double, TimeInterval?)])] + quotas: [(Provider, [(String, Double, TimeInterval?)])], + usageSources: [UsageSource] = [] ) async throws -> AppCoordinator { let suiteName = "MeterUsageTests-" + UUID().uuidString let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -1063,6 +1163,7 @@ final class SideNotchPanelTests: XCTestCase { defaults.set(true, forKey: PrefKey.showClaude) defaults.set(true, forKey: PrefKey.showAntigravity) defaults.set(true, forKey: PrefKey.showGrok) + defaults.set(usageSources.contains { $0.provider == .openAI }, forKey: PrefKey.showOpenAI) let preferences = Preferences(defaults: defaults) let coordinator = AppCoordinator( @@ -1075,6 +1176,7 @@ final class SideNotchPanelTests: XCTestCase { } ) }, + usageSources: usageSources, // Never touch the real archive: a remembered reading on the // developer's own machine must not leak into fixture assertions. quotaArchiveURL: FileManager.default.temporaryDirectory diff --git a/Tests/MeterUsageTests/SupplementalUsageSourceTests.swift b/Tests/MeterUsageTests/SupplementalUsageSourceTests.swift index 66ff32b..7acc292 100644 --- a/Tests/MeterUsageTests/SupplementalUsageSourceTests.swift +++ b/Tests/MeterUsageTests/SupplementalUsageSourceTests.swift @@ -14,7 +14,7 @@ final class SupplementalUsageSourceTests: XCTestCase { func testProviderOrderIsCodexFirst() { XCTAssertEqual( Provider.allCases, - [.codex, .antigravity, .grok, .openCodeGo, .openRouter, .claude, .cursor, .copilot, .gemini] + [.codex, .openAI, .anthropic, .antigravity, .grok, .openCodeGo, .openRouter, .claude, .cursor, .copilot, .gemini] ) } diff --git a/docs/DEMO.md b/docs/DEMO.md index e5e3fa6..f03f53e 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -56,6 +56,8 @@ All demo data is invented and deterministic: - *Gemini Models*: Weekly limit (89% used, 11% left) and 5-hour limit (9% used, 91% left). - *Claude and GPT models*: Weekly limit (1% used, 99% left) and 5-hour limit (0% used, 100% left). - **OpenRouter**: Synthetic monthly dollar spending limit, account balance meter, and 30-day token telemetry. +- **OpenAI API**: Synthetic organization spend and completion usage, visible after enabling OpenAI API in Settings. No Admin key or API request is used in demo mode. +- **Anthropic API**: Synthetic organization spend and Messages API tokens, visible after enabling Anthropic API in Settings. No Admin key or API request is used in demo mode. - **Grok**: Weekly allowance window with countdown and session activity history. - **OpenCode Go**: 26 sessions, 492 messages, token volume totals, and estimated cost. - **Claude**: Optional companion-file quota windows and tokens-per-day heatmap. diff --git a/docs/KB.md b/docs/KB.md index b27a351..87c3f75 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -1,11 +1,11 @@ # Project knowledge -Last verified: 2026-09-29 +Last verified: 2026-10-04 ## Repository state - Default branch: `main`. -- Reviewed source revision: `1c4f169`. +- Reviewed source revision: `79a6cc9`, plus the API usage changes documented here. - This index is public-safe repository documentation. It does not prove current local provider state, runtime behavior, release availability, or external service state. ## Product and source facts @@ -30,8 +30,19 @@ Last verified: 2026-09-29 - Side notch chrome is derived per theme in `SideNotchPanelView` by an HSV mix pinned to the popover surface's Rec. 709 relative luminance (`blendTinted`), so every accent renders at the same perceived brightness as the window beside it. The notch bands and hues delegate to the shared `headroomColor`/`MU` scale (80/95 thresholds), and the panel window forces `darkAqua` so those tokens resolve their dark variants on the strip. - Second accounts (multi-account support, ADR 0005): `.codexAlt` / `.claudeAlt` provider slots are keyed by an alternate config directory resolved in `AccountSlots` (`Sources/MeterUsage/Services/DataSource.swift`) from `METERUSAGE_CODEX_ALT_HOME` / `METERUSAGE_CLAUDE_ALT_CONFIG` or the `meterusage.codexAltHome` / `meterusage.claudeAltConfig` defaults keys, which are editable in Settings → Providers. A slot exists only when its directory exists; slots never merge with the primary account, keep per-slot durable history and archive keys, and are named by position only (no account identity is read). The JSON report lists them under `codexAlt` / `claudeAlt`. +- OpenAI API monitoring is opt-in and separate from Codex. `OpenAIUsageSource` reads organization completion usage and USD costs for today and the last 30 UTC calendar days using an explicitly supplied Admin key. The popover and side-notch card show reported spend and completion tokens/requests. The notch strip shows 30-day spend, or N/A when unavailable; its Settings Notch control persists across launches. It does not supply quota rings, menu-bar clusters, the quota JSON CLI, or local coding/burn totals. See [ADR 0007](adr/0007-openai-api-usage.md), [ADR 0010](adr/0010-openai-side-notch.md), and the README setup instructions. +- Anthropic API monitoring is opt-in and separate from Claude Code. `AnthropicUsageSource` reads organization Messages API tokens and reported spend using an explicitly configured organization key, for the same UTC day windows. It converts decimal cents to USD, includes all reported cache-token categories, and omits request counts because the endpoint does not supply them. Reported costs exclude Priority Tier charges. Organization usage does not supply quota rings or contribute to local coding/burn totals. See [ADR 0008](adr/0008-anthropic-api-usage.md) and the README setup instructions. +- Both API providers have Connect and Test connection controls in Settings. Keys entered in Settings persist in MeterUsage-owned macOS Keychain items across restarts and updates. Disconnect deletes the saved key, clears the reading, and rejects in-flight results. Storage errors are visible in Settings; unreadable saved keys offer Restore saved connection instead of key entry. Failed saves preserve the entered value in memory for Retry saving key, while failed saves/deletes preserve the current connection. Ad-hoc updates may require Keychain access approval. Saved keys take precedence over explicit launcher environment keys, which remain session-only. Demo mode and normal tests never access production Keychain items. See [ADR 0011](adr/0011-persistent-api-connections.md), which supersedes the storage decision in ADR 0009. +- Saved-key recovery checks that a read error still needs recovery before + restoring a key. Successful Disconnect clears that state, so a queued restore + cannot reconnect with a launcher key. `APIConnectionTests` covers the denied + launch read, queued restore, and Disconnect sequence for both API providers. +- Anthropic's Admin API excludes individual accounts. Those users can view usage in Claude Console, but MeterUsage cannot sync their history through the reporting API. An organization Admin role alone does not establish an eligible account type. See the README's Anthropic setup section and its official documentation link. + ## Verification gaps + + - Repository files do not prove current provider authentication, quota freshness, network responses, local machine state, app installation, signed-bundle state, GitHub Release state, or runtime UI behavior. - Treat cost figures as estimates. `README.md` identifies provider dashboards as the billing source of record. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index e12ddac..d692494 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -6,7 +6,7 @@ meterusage reads AI coding-assistant usage from your own machine. That means it ### ⚡ TL;DR -* 🛡️ **Zero Credential Exposure**: Never touches Claude or Codex authentication tokens, passwords, or macOS Keychain items. OpenRouter uses an existing environment variable/local key strictly for aggregate balances in memory. +* 🛡️ **Zero Credential Exposure**: Never touches Claude or Codex authentication tokens, passwords, or their macOS Keychain items. OpenRouter uses an existing environment variable/local key for aggregate balances in memory. OpenAI and Anthropic API monitoring save explicitly entered keys in MeterUsage-owned macOS Keychain items. Explicit launcher environment keys stay in memory. * 🚫 **No Prompts, Code, or Message Inspection**: Only reads numeric token tallies and event timestamps from local CLI stores. Message bodies, prompts, tool inputs/outputs, and workspace paths are never decoded or transmitted. * 🧼 **Sanitized at the Boundary**: User paths (`/Users//`), terminal IDs, hostnames, and emails are structurally dropped before reaching memory or the UI. * 🔒 **Strict Network Isolation**: Zero analytics, telemetry, crash reporting, or remote tracking servers. Only connects directly to documented usage endpoints or public status feeds. @@ -16,10 +16,11 @@ meterusage reads AI coding-assistant usage from your own machine. That means it ## What meterusage never does -- **Never reads Codex or Claude credentials.** It does not open `~/.codex/auth.json`, `~/.claude/.credentials.json`, or any macOS Keychain item. OpenRouter is the explicit exception: when configured, it reads an existing `OPENROUTER_API_KEY` or supported local key file in memory only to call OpenRouter's aggregate usage and balance endpoints; it never displays, logs, or stores that key. -- **Never asks you to paste a key.** There is no login screen, no token field, and no account connection flow. +- **Never reads Codex or Claude credentials.** It does not open `~/.codex/auth.json`, `~/.claude/.credentials.json`, or another app's macOS Keychain items. OpenRouter, OpenAI API, and Anthropic API monitoring use separately configured keys. OpenAI and Anthropic accept masked key entry in Settings or load explicitly supplied `OPENAI_ADMIN_KEY` and `ANTHROPIC_ADMIN_KEY` values at launch. Keys entered in Settings are saved in MeterUsage-owned Keychain items. The app never displays keys as plain text or includes them in logs, diagnostics, preferences, or plaintext files. Network reads require an enabled provider or an explicit connection test. When configured, OpenRouter reads an existing `OPENROUTER_API_KEY` or supported local key file in memory only to call OpenRouter's aggregate usage and balance endpoints; it never displays, logs, or stores that key. +- **API key entry is explicit and persistent.** OpenAI and Anthropic connections use a masked field in Settings. The app clears unfinished entry when Settings closes and never fills the field with a saved key. Native Security framework calls store one generic-password item per provider under the stable service `com.meterusage.api-keys`, with accounts `openAI` and `anthropic`. These items use macOS's default application access control and do not sync through iCloud. No other app's items are queried. Disconnect deletes the saved item before clearing the active key and displayed reading. A denied save or delete reports an error and preserves the current connection. A denied read offers Restore saved connection instead of new-key entry, without falling back to a different environment credential. A failed save retains the entered value privately in memory for Retry saving key; successful save or Disconnect clears that pending value. Closing Settings does not discard it, but quitting before saving does. Existing requests may finish after disconnect or replacement, but their results are discarded. Never include keys in screenshots, support messages, or diagnostics. +- **Updates retain Keychain items.** Ad-hoc signed builds can require macOS access approval after rebuilding because their signing identity changes. MeterUsage does not weaken Keychain access controls to suppress that prompt. Saved keys take precedence over launcher variables. Environment keys are not persisted automatically, and Disconnect suppresses them until the next launch. Demo mode and normal unit tests never open the production key store. - **Never uses undocumented provider APIs.** It does not reuse another application's OAuth client id, and it does not call private endpoints. -- **Never sends prompts or code anywhere.** Provider requests are limited to the documented Codex/OpenRouter usage calls and public status feeds. There is no telemetry, analytics, crash reporting, or update ping; it has no server. +- **Never sends prompts or code anywhere.** Provider requests are limited to the documented Codex/OpenRouter/OpenAI/Anthropic usage calls and public status feeds. There is no telemetry, analytics, crash reporting, or update ping; it has no server. - **Never reads your prompts or code.** It parses only usage and metadata fields from local transcripts. Message content is skipped, not stored. ## Where the numbers actually come from @@ -29,6 +30,8 @@ meterusage reads AI coding-assistant usage from your own machine. That means it | Codex quota | Spawns `codex app-server --stdio` and makes a JSON-RPC `account/rateLimits/read` call with the CLI's experimental rate-limit detail capability enabled. That returns general/model-specific windows and earned reset-credit expiry details when the account provides them. This is a supported CLI surface; the subprocess authenticates itself using your existing `codex login`. meterusage never sees the token. | Yes, by the CLI subprocess | | Second Codex account | The same subprocess mechanism with `CODEX_HOME` pointed at the alternate account's config directory (`METERUSAGE_CODEX_ALT_HOME` or a stored default). The child authenticates itself from that home; meterusage still never opens any auth file, and only spawns the subprocess when that directory exists. | Yes, by the CLI subprocess | | OpenRouter quota | Calls the documented `/api/v1/key` and `/api/v1/credits` endpoints with an existing API key and retains only aggregate dollar usage, account balance, optional limit, and reset cadence. It does not send prompts or model requests. | Yes | +| OpenAI API usage and costs | GET requests to `/v1/organization/usage/completions` and `/v1/organization/costs` on `api.openai.com` with an explicitly configured organization Admin key. Only numeric usage, USD amounts, and bucket timestamps are decoded. Account, project, key, and user identifiers are ignored. An ephemeral session rejects redirects and retains no disk cache or cookies. | Yes | +| Anthropic API usage and costs | GET requests to `/v1/organizations/usage_report/messages` and `/v1/organizations/cost_report` on `api.anthropic.com` with an explicitly configured organization Admin key. Only token counts, USD amounts, and bucket timestamps are decoded. Workspace, key, model, and description fields are ignored. The same ephemeral session rejects redirects and retains no disk cache or cookies. | Yes | | OpenRouter activity | Calls `https://openrouter.ai/api/v1/activity` with an OpenRouter Management Key (`OPENROUTER_MANAGEMENT_KEY` or `~/.cli-proxy-api/openrouter-management-key`) to fetch aggregate daily token volume (input, output, reasoning) over the last 30 days. | Yes | | Grok quota | Calls the billing endpoint the Grok CLI itself uses (`cli-chat-proxy.grok.com/v1/billing`). The OIDC bearer token is re-read from `~/.grok/auth.json` on every refresh — never cached from launch — and is sent only in the request Authorization header. Only the allowance percent, period type, and reset time are retained; no prompts or model requests are sent. | Yes | | Claude activity | Streams your own transcript files under `~/.claude/projects/`, summing token-usage fields. | No | @@ -48,7 +51,7 @@ Claude quota bars are a pure bonus. A companion tool must already have written a Honest limits of that path: -- **No direct Anthropic quota fetch.** meterusage does not call the usage API and does not open `~/.claude/.credentials.json` or Keychain items for this purpose (or any other). +- **No direct Anthropic quota fetch.** meterusage does not call an undocumented subscription-usage endpoint and does not open `~/.claude/.credentials.json` or Claude Keychain items for this purpose. - **`limits[]` is parsed when present.** When the snapshot includes a non-empty `limits` array, those windows fully replace legacy 5-hour / 7-day / weekly keys so the same window is not drawn twice. A `weekly_scoped` entry with `scope.model.display_name == "Fable"` can render as a real Fable plan-allowance bar. - **Fable display depends on the writer.** The parser can handle `limits[]`, but the bar only appears if the local companion emits that shape. Current claudewatch JSON may still contain only legacy fields (`five_hour`, `seven_day`, `extra_usage`); until a writer includes `limits[]` (or an equivalent weekly breakdown), no Fable quota bar is shown. - **Credits stay orthogonal.** Extra-usage / credit balance is separate from plan windows and is not how Fable is labelled. @@ -59,9 +62,13 @@ Anthropic publishes no supported API for Claude subscription quota. The only san The consequence is honest rather than hidden: Codex shows real live quota, including Codex's 2,500-credits-to-$100 display conversion; OpenRouter shows provider-reported dollar usage and remaining account balance, local usage rows show only the fields each provider can prove, and Claude quota bars appear only if a usage file is already present — and only with the windows that file actually contains. Nothing is silently estimated and labelled as authoritative. -## Cost figures are estimates +## Cost figures -Costs are computed locally from token counts against a rate table in `Sources/MeterUsage/Services/Pricing.swift`. That table covers Claude list rates and OpenAI/Codex Standard list rates (the GPT-6 and GPT-5.6 families and `gpt-5.3-codex`); Codex sessions are priced using the model recorded in the local rollout, and cache writes are free because Codex does not charge for them. Published rates change, and the table can drift. Treat every cost in this app as an approximation for awareness — never as a billing figure. Your provider's dashboard is the only source of truth for what you owe. +OpenAI API spend is the USD amount reported by the organization Costs API, including adjustments. It is separate from local estimates and can lag the billing dashboard. Completion token totals do not represent every OpenAI product. + +Anthropic API spend is the organization Cost API amount converted from decimal cents to USD, including adjustments. The endpoint excludes Priority Tier charges, which the card discloses. Tokens cover the Messages API, including cache reads and cache creation. Reporting can lag. + +Local cost estimates use token counts and a rate table in `Sources/MeterUsage/Services/Pricing.swift`. That table covers Claude list rates and OpenAI/Codex Standard list rates (the GPT-6 and GPT-5.6 families and `gpt-5.3-codex`); Codex sessions are priced using the model recorded in the local rollout, and cache writes are free because Codex does not charge for them. Published rates change, and the table can drift. Treat locally estimated costs as approximations for awareness, never as billing figures. Your provider's dashboard is the only source of truth for what you owe. ## What leaves your machine @@ -73,6 +80,9 @@ Outbound requests or subprocess-backed provider checks, all of which you can ver 4. meterusage fetches OpenRouter's documented `/api/v1/key` and `/api/v1/credits` endpoints with the existing key, retaining only aggregate dollar usage and balance fields. When an OpenRouter Management Key is configured, it queries `/api/v1/activity` for 30-day token volumes. 5. meterusage fetches Grok's billing endpoint (`cli-chat-proxy.grok.com/v1/billing`) with the OIDC bearer token re-read from `~/.grok/auth.json`, retaining only the allowance percent, period type, and reset time. No prompts or model requests are sent. 6. At most once a day, meterusage fetches `https://api.github.com/repos/pekth/meterusage/releases/latest` to check for a newer release. The request is unauthenticated, carries no body, no identifier, and no usage data — the server sees only your IP and a User-Agent string, the same as any web visit. The response's version tag is compared to the running build; a failed or rate-limited check is silently ignored. This check can be switched off in Settings → General → "Check for updates". When you click Install, the release zip is downloaded from the same release's asset URL and its SHA-256 is verified against the digest GitHub publishes with the asset; a missing or mismatched digest aborts the install. +7. When OpenAI API monitoring is enabled, meterusage reads organization usage and costs directly from `https://api.openai.com/v1/organization/usage/completions` and `https://api.openai.com/v1/organization/costs`. Its Admin key stays in memory and is sent only in the Authorization header. Requests include date bounds, daily bucket width, page size, and an opaque pagination cursor. No model request is made. + +8. When Anthropic API monitoring is enabled, meterusage reads organization usage and costs directly from `https://api.anthropic.com/v1/organizations/usage_report/messages` and `https://api.anthropic.com/v1/organizations/cost_report`. Its Admin key stays in memory and is sent only in the `x-api-key` header. Requests include UTC date bounds, daily bucket width, page size, and an opaque pagination cursor. No model request is made. The local usage commands and file reads above add no outbound request. There is no analytics endpoint to disable because there is none. diff --git a/docs/SIDE-NOTCH.md b/docs/SIDE-NOTCH.md index f94434d..510afdb 100644 --- a/docs/SIDE-NOTCH.md +++ b/docs/SIDE-NOTCH.md @@ -9,8 +9,16 @@ record is [`docs/adr/0004`](adr/0004-side-notch-anchor-invariant.md). | State | Trigger | What shows | | --- | --- | --- | | Folded pill | Resting, no pointer | A capsule of up to five tinted dots, one per provider ring. | -| Strip | Unfolded | One ring per menu-bar provider, with the used percent and, when the reading warrants it, an ETA chip. | -| Detail card | Pointer on a ring, or the accessibility "Show details" action | The provider's rate-limit windows, reset times, pacing, telemetry, and reset credits. | +| Strip | Unfolded | Selected quota providers show used percent and, when warranted, an ETA chip. OpenAI API shows reported 30-day spend beneath its mark, without a progress arc. | +| Detail card | Pointer on a provider, or the accessibility "Show details" action | Quota providers show rate-limit windows, reset times, pacing, telemetry, and reset credits. OpenAI API shows reported spend, completion tokens and requests for today and the last 30 UTC calendar days. | + +Enable OpenAI API in Settings, then use its Notch control to select it for the +strip. An unavailable reading shows N/A and the same connection guidance as +the popover. A measured zero shows $0.00. Costs do not establish a quota, +balance, reset, or pace. The shared usage details retain their capture time +and reporting-delay notice. See [ADR 0010](adr/0010-openai-side-notch.md). +Quota percentages keep the same text size when cards change. Spend labels +may shrink to fit the narrow strip. ## Tints @@ -117,3 +125,14 @@ measured frame over a plausible cause. - `Sources/MeterUsage/App/SideNotchPanelController.swift` - `Sources/MeterUsage/Views/SideNotchPanelView.swift` - `Tests/MeterUsageTests/SideNotchPanelTests.swift` + +### OpenAI API card fixtures + +The native rendering test `testRenderedOpenAIAndQuotaDetailCards` captures +synthetic OpenAI and Codex detail cards at different heights. These captures +verify card content and layout. They do not prove physical hover, full-panel +motion, or live account access. The strip anchor and drag checks remain in +`SideNotchPanelTests`. + +![OpenAI API synthetic detail card](images/side-notch-openai-fixture.png) +![Codex synthetic detail card](images/side-notch-codex-fixture.png) diff --git a/docs/adr/0007-openai-api-usage.md b/docs/adr/0007-openai-api-usage.md new file mode 100644 index 0000000..845425b --- /dev/null +++ b/docs/adr/0007-openai-api-usage.md @@ -0,0 +1,28 @@ +# ADR 0007: OpenAI API usage is an opt-in organization monitor + +- Status: Accepted +- Date: 2026-09-27 + +OpenAI Platform API billing and Codex subscription limits describe separate +budgets. Add an `openAI` provider using `UsageSource`, with no `QuotaSource`. +Show its own popover card and Settings toggle. Do not infer a budget, credit +balance, percentage, or reset time from costs. + +Read only `OPENAI_ADMIN_KEY` from the process environment, at each refresh. +Use it for GET requests to `api.openai.com/v1/organization/usage/completions` +and `api.openai.com/v1/organization/costs`. Use an ephemeral session, reject +redirects, and retain only numeric usage, USD amounts, and bucket timestamps. +Do not read Codex credentials, store the Admin key, or decode account identity. + +Fetch today and the previous 29 UTC days. Follow pagination with a bounded +page count; incomplete pages or failed costs are unavailable, never zero spend. +Successful empty responses are measured zero. Costs cover the organization; +tokens and requests cover completions only. Preserve provider cost adjustments +and count cached input once. + +Organization usage can overlap local CLI records and include other users. +Exclude it from the local coding strip and burn attribution. This narrows +ADR 0003's all-provider rule to exclude organization billing aggregates. +The existing quota JSON report and quota rings remain quota-only. + +Source: [OpenAI Usage and Costs example](https://developers.openai.com/cookbook/examples/completions_usage_api). diff --git a/docs/adr/0008-anthropic-api-usage.md b/docs/adr/0008-anthropic-api-usage.md new file mode 100644 index 0000000..10edb7f --- /dev/null +++ b/docs/adr/0008-anthropic-api-usage.md @@ -0,0 +1,28 @@ +# ADR 0008: Anthropic API usage is an opt-in organization monitor + +- Status: Accepted +- Date: 2026-09-27 + +Use the existing `UsageSource` and API usage card for an `anthropic` provider, +separate from Claude Code activity and subscription quota. As in ADR 0007, +organization totals can overlap local activity. Exclude them from local coding +totals and burn attribution. Do not infer a budget, quota, or reset time. + +Read only `ANTHROPIC_ADMIN_KEY` from the process environment. Use it for GET +requests to `api.anthropic.com/v1/organizations/usage_report/messages` and +`api.anthropic.com/v1/organizations/cost_report`, with the documented API +version header. Reuse the ephemeral session that rejects redirects. Do not +read Claude Code credentials, save the key, or decode account identity. + +Fetch today and the previous 29 UTC days. Bound pagination and treat incomplete +responses as unavailable. Count uncached input, output, cache reads, and both +cache-creation durations. Convert decimal cents to USD and preserve cost +adjustments. The cost endpoint excludes Priority Tier charges; disclose that +limit in the card. Omit request counts because tool-use counts are not a total +number of model requests. Successful empty reports are measured zero. + +The Admin APIs require a Console organization Admin key. Individual accounts, +regular workspace keys, and Claude subscription logins cannot supply this +reading. Both the provider toggle and the key are opt-in. + +Source: [Anthropic Usage and Cost API guide](https://platform.claude.com/docs/en/manage-claude/usage-cost-api). diff --git a/docs/adr/0009-session-only-api-connections.md b/docs/adr/0009-session-only-api-connections.md new file mode 100644 index 0000000..d752d5b --- /dev/null +++ b/docs/adr/0009-session-only-api-connections.md @@ -0,0 +1,25 @@ +# ADR 0009: API connections keep keys only for the app session + +- Status: Superseded by [ADR 0011](0011-persistent-api-connections.md) for credential storage +- Date: 2026-09-27 + +The environment-only setup in ADRs 0007 and 0008 leaves normal app launches +without a connection flow. Add Connect, masked key entry, Test connection, +Replace key, and Disconnect below each enabled API provider in Settings. + +Keep entered keys in `APIKeySession` memory. Do not write them to preferences, +files, Keychain, logs, or diagnostics. Clear unfinished key entry when Settings +closes. Keep validated and retryable connection credentials until disconnect, +replacement, or quit. An app restart requires entry again unless the user's +launcher explicitly supplies the existing Admin-key environment variables. + +Test the same usage and cost readers used by polling. Show Connected only +after both reports succeed. Report sanitized failures and the last successful +reading time. Disconnect clears the active key and visible reading, suppresses +environment fallback for this session, and discards late responses associated +with a disconnected or replaced credential. + +Requests go directly from the user's Mac to the provider. No MeterUsage server, +browser cookies, or provider login tokens are involved. Anthropic documents +OAuth through its official CLI, which stores credentials locally; this change +adds only key entry. No persistent credential storage is introduced. diff --git a/docs/adr/0010-openai-side-notch.md b/docs/adr/0010-openai-side-notch.md new file mode 100644 index 0000000..9fc1f86 --- /dev/null +++ b/docs/adr/0010-openai-side-notch.md @@ -0,0 +1,17 @@ +# ADR 0010: Show OpenAI API spend in the side notch + +- Status: Accepted +- Date: 2026-09-27 + +Add OpenAI API to the side notch with the existing provider Notch control. +Keep it out of menu-bar quota clusters. Enabling the provider selects its +notch entry by default; users can hide it independently. + +The entry shows reported spend for the last 30 UTC calendar days. Its mark has +no progress arc, percentage, reset, or pace. Reuse the popover's usage details +and unavailable-state guidance in the hover card. Keep measured zero distinct +from missing data and retain the reading's own capture time. + +This extends the display locations in ADR 0007 while preserving its +organization billing boundary. Anthropic remains popover-only. The anchor, +geometry, and capture requirements in ADR 0004 remain unchanged. diff --git a/docs/adr/0011-persistent-api-connections.md b/docs/adr/0011-persistent-api-connections.md new file mode 100644 index 0000000..a3f888a --- /dev/null +++ b/docs/adr/0011-persistent-api-connections.md @@ -0,0 +1,40 @@ +# ADR 0011: Keep API connections in macOS Keychain + +- Status: Accepted +- Date: 2026-09-27 +- Supersedes: credential storage in [ADR 0009](0009-session-only-api-connections.md) + +Session-only keys make users reconnect after every restart or update. Save +keys entered for OpenAI and Anthropic in native macOS Keychain items. Keep the +existing masked entry, connection tests, and direct provider requests. + +Use generic-password items under `com.meterusage.api-keys`, with stable +`openAI` and `anthropic` accounts. Keep the default macOS application access +control and disable synchronization. Do not read another app's credentials or +copy keys into preferences, plaintext files, diagnostics, or logs. Never +restore a saved key into the entry field. + +Save or replace the Keychain item before changing the active credential. Delete +it before reporting Disconnect. Failed writes leave the previous connection +intact. Failed reads show an error and allow retry without new key entry. Saved +keys take precedence over launcher environment keys; use the latter only when +no item exists, and never persist them automatically. Disconnect suppresses +launcher fallback until the next launch. Provider failures retain the key so +a later refresh can retry. Revisions reject late results after replacement or +disconnect. + +Use an injected memory store in normal unit tests. Demo mode never creates a +Keychain store. Native storage checks use isolated synthetic items and delete +only those items afterward. + +Updates preserve items, but ad-hoc signed binaries have a changing designated +requirement. macOS may ask for access approval after an update. Do not weaken +access controls to avoid this prompt. The old process has no supported export +of its memory-only key, so the first upgrade requires one final entry in the +app. Restarting does not revoke the provider's key. + +Recovery clarification: distinguish a missing key from a saved-key read error. +An unreadable saved key offers Restore saved connection and never opens the +entry field. A failed save retains the submitted value privately in memory +and offers Retry saving key. Saving or explicit Disconnect clears that pending +value. Keep the app open until saving succeeds; pending memory is not durable. diff --git a/docs/adr/README.md b/docs/adr/README.md index 43a858c..5c47f75 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -12,3 +12,8 @@ This directory records decisions that affect repository work. Read an ADR before | [0004](0004-side-notch-anchor-invariant.md) | Keep the side notch anchored by the strip's top-right corner, use whole-point frames, anchor content to the window top, and require captured frame evidence for layout or motion changes. | Accepted | | [0005](0005-multi-account-slots.md) | Model a second Claude/Codex account as its own provider slot keyed by an alternate config directory; never merge two accounts' windows and never read or display account identity. | Accepted; slot mechanism amended by [0006](0006-managed-account-list.md) | | [0006](0006-managed-account-list.md) | Additional accounts are a managed, unbounded Settings list; `ProviderSlot` (provider + generated id, label display-only) keys every metered surface, with primary-slot persistence formats unchanged. | Accepted | +| [0007](0007-openai-api-usage.md) | Keep OpenAI API organization usage separate from Codex limits and local coding totals; use an opt-in Admin key for aggregate usage and costs. | Accepted | +| [0008](0008-anthropic-api-usage.md) | Apply the organization-monitor boundary to Anthropic API spend and Messages API tokens, separate from Claude Code and subscription quota. | Accepted | +| [0009](0009-session-only-api-connections.md) | Add masked API key entry and connection testing, retaining credentials only for the app session. | Storage superseded by 0011; connection controls retained | +| [0010](0010-openai-side-notch.md) | Show OpenAI API reported spend in the side notch without quota semantics; reuse the popover usage details. | Accepted; extends display locations in 0007 | +| [0011](0011-persistent-api-connections.md) | Save explicitly entered API keys in macOS Keychain so connections survive restarts and updates. | Accepted; supersedes storage in 0009 | diff --git a/docs/images/side-notch-codex-fixture.png b/docs/images/side-notch-codex-fixture.png new file mode 100644 index 0000000..430193a Binary files /dev/null and b/docs/images/side-notch-codex-fixture.png differ diff --git a/docs/images/side-notch-openai-fixture.png b/docs/images/side-notch-openai-fixture.png new file mode 100644 index 0000000..1ebd901 Binary files /dev/null and b/docs/images/side-notch-openai-fixture.png differ