Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions Scripts/check-api-keychain.swift
Original file line number Diff line number Diff line change
@@ -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<OSStatus> = [
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.<unique-id> 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])")
}
}
18 changes: 13 additions & 5 deletions Sources/MeterUsage/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) })
]
}

Expand Down
53 changes: 53 additions & 0 deletions Sources/MeterUsage/Core/APIKeySession.swift
Original file line number Diff line number Diff line change
@@ -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."
}
}
Loading
Loading