diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc27aecf8..4cf17b2c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Agent Sessions: list and focus live local or SSH-discovered Codex and Claude Code sessions from the menu and CLI. ### Fixed +- Gemini: prefer Google's paid-tier plan label over generic Free, Workspace, or Paid fallbacks while preserving acronym casing in the CLI. Thanks @Yuxin-Qiao! - Codex: avoid false session-reset celebrations from transient zero-usage samples until the reset boundary advances. Thanks @kiranmagic7! - Settings: keep visual-only preference changes on cached UI paths instead of refreshing provider quotas, while preserving refreshes for data-affecting settings. Thanks @Zihao-Qi! - Ollama: recognize current WorkOS AuthKit sessions during browser-cookie discovery and manual cookie validation. Thanks @joeVenner! diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 4923835be8..c27197df53 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -158,7 +158,7 @@ enum CLIRenderer { if provider == .codex { return CodexPlanFormatting.displayName(plan) ?? plan } - return plan.capitalized + return self.nonCodexPlanDisplay(provider: provider, plan: plan) } static func colorizeAccentBold(_ text: String) -> String { @@ -891,7 +891,7 @@ enum CLIRenderer { { plan } else { - plan.capitalized + self.nonCodexPlanDisplay(provider: provider, plan: plan) } lines.append(self.labelValueLine("Plan", value: displayPlan, useColor: context.useColor)) } @@ -903,6 +903,13 @@ enum CLIRenderer { } } + private static func nonCodexPlanDisplay(provider: UsageProvider, plan: String) -> String { + if provider == .gemini { + return UsageFormatter.cleanPlanName(plan) + } + return plan.capitalized + } + // swiftlint:disable:next function_parameter_count private static func appendRateWindowLines( provider: UsageProvider, @@ -1078,8 +1085,12 @@ enum CLIRenderer { { guard kind.supports(provider: provider) else { return nil } // Only pace a real session window here; Claude w/o 5-hour data falls a 7-day window into primary. - if case .session = kind, let minutes = window.windowMinutes, minutes > 300 { return nil } - if provider == .ollama, window.windowMinutes == nil { return nil } + if case .session = kind, let minutes = window.windowMinutes, minutes > 300 { + return nil + } + if provider == .ollama, window.windowMinutes == nil { + return nil + } guard window.remainingPercent > 0 else { return nil } // workDays applies only to the weekly (10 080-min) window; UsagePace.weekly ignores it for other durations. let workDays = kind == .weekly ? weeklyWorkDays : nil @@ -1179,7 +1190,9 @@ enum CLIRenderer { kind: PaceKind, now: Date) -> String? { - if pace.willLastToReset { return self.combinedLastsLabel(for: pace, provider: provider) } + if pace.willLastToReset { + return self.combinedLastsLabel(for: pace, provider: provider) + } guard let etaSeconds = pace.etaSeconds else { return nil } let etaText = Self.paceDurationText(seconds: etaSeconds, now: now) switch kind { @@ -1209,8 +1222,12 @@ enum CLIRenderer { private static func paceDurationText(seconds: TimeInterval, now: Date) -> String { let date = now.addingTimeInterval(seconds) let countdown = UsageFormatter.resetCountdownDescription(from: date, now: now) - if countdown == "now" { return "now" } - if countdown.hasPrefix("in ") { return String(countdown.dropFirst(3)) } + if countdown == "now" { + return "now" + } + if countdown.hasPrefix("in ") { + return String(countdown.dropFirst(3)) + } return countdown } diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 5863ff98ce..bb783d2a21 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -337,25 +337,10 @@ public struct GeminiStatusProbe: Sendable { let snapshot = try Self.parseAPIResponse(data, email: claims.email) - // Plan display strings with tier mapping: - // - standard-tier: Paid subscription (AI Pro, AI Ultra, Code Assist - // Standard/Enterprise, Developer Program Premium) - // - free-tier + hd claim: Workspace account (Gemini included free since Jan 2025) - // - free-tier: Personal free account (1000 req/day limit) - // - legacy-tier: Unknown legacy/grandfathered tier - // - nil (API failed): Leave blank (no display) - let plan: String? = switch (caStatus.tier, claims.hostedDomain) { - case (.standard, _): - "Paid" - case let (.free, .some(domain)): - { Self.log.info("Workspace account detected", metadata: ["domain": domain]); return "Workspace" }() - case (.free, .none): - { Self.log.info("Personal free account"); return "Free" }() - case (.legacy, _): - "Legacy" - case (.none, _): - { Self.log.info("Tier detection failed, leaving plan blank"); return nil }() - } + let plan = Self.resolveAccountPlan( + tier: caStatus.tier, + hostedDomain: claims.hostedDomain, + paidTierName: caStatus.paidTierName) return GeminiStatusSnapshot( modelQuotas: snapshot.modelQuotas, @@ -411,8 +396,9 @@ public struct GeminiStatusProbe: Sendable { private struct CodeAssistStatus { let tier: GeminiUserTierId? let projectId: String? + let paidTierName: String? - static let empty = CodeAssistStatus(tier: nil, projectId: nil) + static let empty = CodeAssistStatus(tier: nil, projectId: nil, paidTierName: nil) } private static func loadCodeAssistStatus( @@ -484,20 +470,26 @@ public struct GeminiStatusProbe: Sendable { } let tierId = (json["currentTier"] as? [String: Any])?["id"] as? String + let paidTierName = Self.parsePaidTierName(from: json) + guard let tierId else { Self.log.warning("loadCodeAssist: no currentTier.id in response", metadata: [ "json": "\(json)", ]) - return CodeAssistStatus(tier: nil, projectId: projectId) + return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName) } guard let tier = GeminiUserTierId(rawValue: tierId) else { Self.log.warning("loadCodeAssist: unknown tier ID", metadata: ["tierId": tierId]) - return CodeAssistStatus(tier: nil, projectId: projectId) + return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName) } - Self.log.info("loadCodeAssist: success", metadata: ["tier": tierId, "projectId": projectId ?? "nil"]) - return CodeAssistStatus(tier: tier, projectId: projectId) + Self.log.info("loadCodeAssist: success", metadata: [ + "tier": tierId, + "projectId": projectId ?? "nil", + "paidTierName": paidTierName ?? "nil", + ]) + return CodeAssistStatus(tier: tier, projectId: projectId, paidTierName: paidTierName) } private struct OAuthCredentials { @@ -1142,6 +1134,57 @@ public struct GeminiStatusProbe: Sendable { } } +extension GeminiStatusProbe { + /// Plan display strings with tier mapping: + /// - paidTier.name: Most-specific paid subscription label from Google, regardless of currentTier + /// - standard-tier: Paid subscription fallback (Code Assist Standard/Enterprise, Developer Program Premium) + /// - free-tier + hd claim: Workspace account (Gemini included free since Jan 2025) + /// - free-tier: Personal free account + /// - legacy-tier: Unknown legacy/grandfathered tier + /// - nil (API failed): Leave blank (no display) + fileprivate static func resolveAccountPlan( + tier: GeminiUserTierId?, + hostedDomain: String?, + paidTierName: String?) -> String? + { + // Match Gemini CLI's contract: a named paid tier is the most specific plan signal, + // even when currentTier is missing, unknown, or still reports free-tier. + if let paidTierName { + self.log.info("Paid tier detected", metadata: [ + "tier": tier?.rawValue ?? "unknown", + "plan": paidTierName, + ]) + return paidTierName + } + + switch (tier, hostedDomain) { + case (.standard, _): + return "Paid" + case let (.free, .some(domain)): + Self.log.info("Workspace account detected", metadata: ["domain": domain]) + return "Workspace" + case (.free, .none): + Self.log.info("Personal free account") + return "Free" + case (.legacy, _): + return "Legacy" + case (.none, _): + self.log.info("Tier detection failed, leaving plan blank") + return nil + } + } + + private static func parsePaidTierName(from json: [String: Any]) -> String? { + guard let paidTier = json["paidTier"] as? [String: Any], + let rawName = paidTier["name"] as? String + else { + return nil + } + let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + extension GeminiStatusProbe { private static let processTimeoutQueue = DispatchQueue( label: "com.steipete.codexbar.gemini-process-timeout", diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index 036415767f..a445474fb7 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -5,6 +5,36 @@ import Testing // swiftlint:disable:next type_body_length struct CLISnapshotTests { + @Test + func `renders Gemini paid plan without changing acronym casing`() { + let identity = ProviderIdentitySnapshot( + providerID: .gemini, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Gemini Code Assist in Google One AI Pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + + let output = CLIRenderer.renderText( + provider: .gemini, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Gemini", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(CLIRenderer.planBadgeText(provider: .gemini, snapshot: snapshot) == + "Gemini Code Assist in Google One AI Pro") + #expect(output.contains("Plan: Gemini Code Assist in Google One AI Pro")) + #expect(!output.contains("Google One Ai Pro")) + } + @Test func `renders Factory token rate billing with time window labels`() { let snap = UsageSnapshot( diff --git a/Tests/CodexBarTests/GeminiAPITestHelpers.swift b/Tests/CodexBarTests/GeminiAPITestHelpers.swift index 22fba5651f..19843ea182 100644 --- a/Tests/CodexBarTests/GeminiAPITestHelpers.swift +++ b/Tests/CodexBarTests/GeminiAPITestHelpers.swift @@ -76,19 +76,36 @@ enum GeminiAPITestHelpers { return "header.\(encoded).sig" } - static func loadCodeAssistResponse(tierId: String, projectId: String? = nil) -> Data { - var payload: [String: Any] = [ - "currentTier": [ + static func loadCodeAssistResponse( + tierId: String?, + projectId: String? = nil, + paidTierName: String? = nil) -> Data + { + var payload: [String: Any] = [:] + if let tierId { + payload["currentTier"] = [ "id": tierId, "name": tierId.replacingOccurrences(of: "-tier", with: ""), - ], - ] + ] + } if let projectId { payload["cloudaicompanionProject"] = projectId } + if let paidTierName { + payload["paidTier"] = [ + "name": paidTierName, + ] + } return self.jsonData(payload) } + static func loadCodeAssistConsumerPlusResponse(projectId: String? = "cloudaicompanion-123") -> Data { + self.loadCodeAssistResponse( + tierId: "free-tier", + projectId: projectId, + paidTierName: "Plus") + } + static func loadCodeAssistFreeTierResponse() -> Data { self.loadCodeAssistResponse(tierId: "free-tier") } @@ -97,6 +114,13 @@ enum GeminiAPITestHelpers { self.loadCodeAssistResponse(tierId: "standard-tier") } + static func loadCodeAssistGoogleOneProResponse(projectId: String? = "cloudaicompanion-123") -> Data { + self.loadCodeAssistResponse( + tierId: "standard-tier", + projectId: projectId, + paidTierName: "Gemini Code Assist in Google One AI Pro") + } + static func loadCodeAssistLegacyTierResponse() -> Data { self.loadCodeAssistResponse(tierId: "legacy-tier") } diff --git a/Tests/CodexBarTests/GeminiMenuCardTests.swift b/Tests/CodexBarTests/GeminiMenuCardTests.swift index bf7e89afff..fd21703c24 100644 --- a/Tests/CodexBarTests/GeminiMenuCardTests.swift +++ b/Tests/CodexBarTests/GeminiMenuCardTests.swift @@ -4,6 +4,44 @@ import Testing @testable import CodexBar struct GeminiMenuCardTests { + @Test + func `gemini plan preserves upstream acronym casing`() throws { + let identity = ProviderIdentitySnapshot( + providerID: .gemini, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Gemini Code Assist in Google One AI Pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.gemini]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .gemini, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date(timeIntervalSince1970: 0))) + + #expect(model.planText == "Gemini Code Assist in Google One AI Pro") + } + @Test func `gemini model uses flash lite title for tertiary metric`() throws { let now = Date() diff --git a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift index 9e38fb8534..0b89647f7b 100644 --- a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift @@ -252,6 +252,125 @@ struct GeminiStatusProbePlanTests { #expect(snapshot.accountPlan == "Workspace") } + @Test + func `detects consumer plus from free tier with paid tier name`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let idToken = GeminiAPITestHelpers.makeIDToken(email: "user@gmail.com") + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: idToken) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistConsumerPlusResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountPlan == "Plus") + } + + @Test + func `uses paid tier name for standard tier subscriptions`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistGoogleOneProResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountPlan == "Gemini Code Assist in Google One AI Pro") + } + + @Test + func `paid tier name overrides workspace fallback`() async throws { + let plan = try await Self.fetchPlan( + tierId: "free-tier", + hostedDomain: "example.com", + paidTierName: "Plus") + + #expect(plan == "Plus") + } + + @Test + func `paid tier name survives unknown current tier`() async throws { + let plan = try await Self.fetchPlan( + tierId: "future-tier", + hostedDomain: nil, + paidTierName: "Gemini Code Assist in Google One AI Pro") + + #expect(plan == "Gemini Code Assist in Google One AI Pro") + } + + @Test + func `paid tier name survives missing current tier`() async throws { + let plan = try await Self.fetchPlan( + tierId: nil, + hostedDomain: nil, + paidTierName: "Plus") + + #expect(plan == "Plus") + } + @Test func `detects free from free tier without hosted domain`() async throws { let env = try GeminiTestEnvironment() @@ -384,4 +503,55 @@ struct GeminiStatusProbePlanTests { let snapshot = try await probe.fetch() #expect(snapshot.accountPlan == nil) } + + private static func fetchPlan( + tierId: String?, + hostedDomain: String?, + paidTierName: String) async throws -> String? + { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let idToken = GeminiAPITestHelpers.makeIDToken( + email: "user@example.com", + hostedDomain: hostedDomain) + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: idToken) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistResponse( + tierId: tierId, + paidTierName: paidTierName)) + } + if url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + return try await probe.fetch().accountPlan + } } diff --git a/docs/gemini.md b/docs/gemini.md index 1b007a5f76..9d1d615101 100644 --- a/docs/gemini.md +++ b/docs/gemini.md @@ -64,8 +64,9 @@ Gemini uses the Gemini CLI OAuth credentials and private quota APIs. No browser ## Plan detection - Tier from `loadCodeAssist`: - - `standard-tier` → "Paid" - - `free-tier` + `hd` claim → "Workspace" + - `paidTier.name` → paid subscription label from Google, preferred whenever present + - `standard-tier` → "Paid" (fallback when `paidTier.name` is absent) + - `free-tier` + `hd` claim → "Workspace" (fallback when `paidTier.name` is absent) - `free-tier` → "Free" - `legacy-tier` → "Legacy" - Email from `id_token` JWT claims.