From 0045897835c55ece328222af7d3c2e90d3ad094d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 1 Jul 2026 07:42:37 +0100 Subject: [PATCH] fix: tolerate missing OpenCode weekly usage Co-authored-by: Moe Kanan --- CHANGELOG.md | 1 + .../OpenCodeGo/OpenCodeGoUsageFetcher.swift | 51 ++++++----- .../OpenCodeGo/OpenCodeGoUsageSnapshot.swift | 23 +++-- .../OpenCodeGoUsageParserTests.swift | 85 ++++++++++++++++++- docs/opencode.md | 4 +- 5 files changed, 131 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 113540cdfe..8133a8346b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Claude history: keep OAuth utilization separate across account switches while preserving continuity through token refreshes. - Linux CLI: keep Claude OAuth usage subprocess-free, skip version probes, and let Auto bypass unsupported web sources. Thanks @derekszen! - Usage display: make Usage widgets follow the used-versus-remaining preference already shared by menus and Overview rows (#1738). Thanks @OlegLustenko and @FrancoLan! +- OpenCode Go: keep rolling usage available when the dashboard omits the optional weekly window. Thanks @mohkg1017! ## 0.37.3 — 2026-06-28 diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index cdcb7d30dd..81f9a2312f 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -439,17 +439,19 @@ extension OpenCodeGoUsageFetcher { text: text), let rollingReset = self.extractInt( pattern: #"rollingUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#, - text: text), - let weeklyPercent = self.extractDouble( - pattern: #"weeklyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, - text: text), - let weeklyReset = self.extractInt( - pattern: #"weeklyUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#, text: text) else { throw OpenCodeGoUsageError.parseFailed("Missing usage fields.") } + let weeklyPercent = self.extractDouble( + pattern: #"weeklyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, + text: text) + let weeklyReset = self.extractInt( + pattern: #"weeklyUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#, + text: text) + let hasWeeklyUsage = weeklyPercent != nil && weeklyReset != nil + let monthlyPercent = self.extractDouble( pattern: #"monthlyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, text: text) @@ -458,12 +460,13 @@ extension OpenCodeGoUsageFetcher { text: text) return OpenCodeGoUsageSnapshot( + hasWeeklyUsage: hasWeeklyUsage, hasMonthlyUsage: monthlyPercent != nil || monthlyReset != nil, rollingUsagePercent: rollingPercent, - weeklyUsagePercent: weeklyPercent, + weeklyUsagePercent: weeklyPercent ?? 0, monthlyUsagePercent: monthlyPercent ?? 0, rollingResetInSec: rollingReset, - weeklyResetInSec: weeklyReset, + weeklyResetInSec: weeklyReset ?? 0, monthlyResetInSec: monthlyReset ?? 0, updatedAt: now) } @@ -513,7 +516,7 @@ extension OpenCodeGoUsageFetcher { let weekly = self.firstDict(from: dict, keys: weeklyKeys) let monthly = self.firstDict(from: dict, keys: monthlyKeys) - guard let rolling, let weekly else { return nil } + guard let rolling else { return nil } return self.buildSnapshot(rolling: rolling, weekly: weekly, monthly: monthly, now: now, renewsAt: renewsAt) } @@ -542,7 +545,7 @@ extension OpenCodeGoUsageFetcher { } } - if let rolling, let weekly { + if let rolling { let snapshot = self.buildSnapshot( rolling: rolling, weekly: weekly, @@ -590,9 +593,10 @@ extension OpenCodeGoUsageFetcher { candidate.pathLower.contains("month") } + let nonRollingIDs = Set((weeklyCandidates + monthlyCandidates).map(\.id)) let rolling = self.pickCandidate( preferred: rollingCandidates, - fallback: candidates, + fallback: candidates.filter { !nonRollingIDs.contains($0.id) }, pickShorter: true) let weekly = self.pickCandidate( from: weeklyCandidates.filter { candidate in @@ -605,17 +609,18 @@ extension OpenCodeGoUsageFetcher { }, pickShorter: false) - guard let rolling, let weekly else { return nil } + guard let rolling else { return nil } let renewsAt = self.dateValue(from: self.value(from: object as? [String: Any] ?? [:], keys: self.renewAtKeys)) ?? inheritedRenewsAt return OpenCodeGoUsageSnapshot( + hasWeeklyUsage: weekly != nil, hasMonthlyUsage: monthly != nil, rollingUsagePercent: rolling.percent, - weeklyUsagePercent: weekly.percent, + weeklyUsagePercent: weekly?.percent ?? 0, monthlyUsagePercent: monthly?.percent ?? 0, rollingResetInSec: rolling.resetInSec, - weeklyResetInSec: weekly.resetInSec, + weeklyResetInSec: weekly?.resetInSec ?? 0, monthlyResetInSec: monthly?.resetInSec ?? 0, renewsAt: renewsAt, updatedAt: now) @@ -704,26 +709,32 @@ extension OpenCodeGoUsageFetcher { private static func buildSnapshot( rolling: [String: Any], - weekly: [String: Any], + weekly: [String: Any]?, monthly: [String: Any]?, now: Date, renewsAt: Date? = nil) -> OpenCodeGoUsageSnapshot? { - guard let rollingWindow = self.parseWindow(rolling, now: now), - let weeklyWindow = self.parseWindow(weekly, now: now) - else { + guard let rollingWindow = self.parseWindow(rolling, now: now) else { return nil } + let weeklyWindow: (percent: Double, resetInSec: Int)? + if let weekly { + guard let parsed = self.parseWindow(weekly, now: now) else { return nil } + weeklyWindow = parsed + } else { + weeklyWindow = nil + } let monthlyWindow = monthly.flatMap { self.parseWindow($0, now: now) } return OpenCodeGoUsageSnapshot( + hasWeeklyUsage: weeklyWindow != nil, hasMonthlyUsage: monthlyWindow != nil, rollingUsagePercent: rollingWindow.percent, - weeklyUsagePercent: weeklyWindow.percent, + weeklyUsagePercent: weeklyWindow?.percent ?? 0, monthlyUsagePercent: monthlyWindow?.percent ?? 0, rollingResetInSec: rollingWindow.resetInSec, - weeklyResetInSec: weeklyWindow.resetInSec, + weeklyResetInSec: weeklyWindow?.resetInSec ?? 0, monthlyResetInSec: monthlyWindow?.resetInSec ?? 0, renewsAt: renewsAt, updatedAt: now) diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift index d1c1a2ce2d..0920cd8e06 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift @@ -2,6 +2,7 @@ import Foundation public struct OpenCodeGoUsageSnapshot: Sendable { public let isBalanceOnly: Bool + public let hasWeeklyUsage: Bool public let hasMonthlyUsage: Bool public let rollingUsagePercent: Double public let weeklyUsagePercent: Double @@ -15,6 +16,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { public init( isBalanceOnly: Bool = false, + hasWeeklyUsage: Bool = true, hasMonthlyUsage: Bool, rollingUsagePercent: Double, weeklyUsagePercent: Double, @@ -27,6 +29,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { updatedAt: Date) { self.isBalanceOnly = isBalanceOnly + self.hasWeeklyUsage = hasWeeklyUsage self.hasMonthlyUsage = hasMonthlyUsage self.rollingUsagePercent = rollingUsagePercent self.weeklyUsagePercent = weeklyUsagePercent @@ -42,6 +45,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { public static func zenBalanceOnly(balanceUSD: Double, updatedAt: Date) -> OpenCodeGoUsageSnapshot { OpenCodeGoUsageSnapshot( isBalanceOnly: true, + hasWeeklyUsage: false, hasMonthlyUsage: false, rollingUsagePercent: 0, weeklyUsagePercent: 0, @@ -64,18 +68,22 @@ public struct OpenCodeGoUsageSnapshot: Sendable { } let rollingReset = self.updatedAt.addingTimeInterval(TimeInterval(self.rollingResetInSec)) - let weeklyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.weeklyResetInSec)) - let primary = RateWindow( usedPercent: self.rollingUsagePercent, windowMinutes: 5 * 60, resetsAt: rollingReset, resetDescription: nil) - let secondary = RateWindow( - usedPercent: self.weeklyUsagePercent, - windowMinutes: 7 * 24 * 60, - resetsAt: weeklyReset, - resetDescription: nil) + let secondary: RateWindow? + if self.hasWeeklyUsage { + let weeklyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.weeklyResetInSec)) + secondary = RateWindow( + usedPercent: self.weeklyUsagePercent, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil) + } else { + secondary = nil + } let tertiary: RateWindow? if self.hasMonthlyUsage { let monthlyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.monthlyResetInSec)) @@ -122,6 +130,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { public func withZenBalanceUSD(_ balance: Double?) -> OpenCodeGoUsageSnapshot { OpenCodeGoUsageSnapshot( isBalanceOnly: self.isBalanceOnly, + hasWeeklyUsage: self.hasWeeklyUsage, hasMonthlyUsage: self.hasMonthlyUsage, rollingUsagePercent: self.rollingUsagePercent, weeklyUsagePercent: self.weeklyUsagePercent, diff --git a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift index 4676d80cd0..5f4447b5e9 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift @@ -124,6 +124,77 @@ struct OpenCodeGoUsageParserTests { #expect(snapshot.monthlyResetInSec == monthlyResetInSec) } + @Test + func `parses rolling only usage from seroval response`() throws { + let text = + "$R[16]($R[30],$R[41]={rollingUsage:$R[42]={status:\"ok\",resetInSec:5944,usagePercent:17}});" + let now = Date(timeIntervalSince1970: 0) + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.rollingResetInSec == 5944) + #expect(snapshot.hasWeeklyUsage == false) + #expect(usage.primary?.usedPercent == 17) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + } + + @Test + func `parses rolling only usage from JSON response`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "usage": [ + "rollingUsage": [ + "usagePercent": 25, + "resetInSec": 600, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.rollingResetInSec == 600) + #expect(snapshot.hasWeeklyUsage == false) + #expect(usage.primary?.usedPercent == 25) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + } + + @Test + func `recovers weekly usage from nested JSON window`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "usage": [ + "rollingUsage": [ + "usagePercent": 25, + "resetInSec": 600, + ], + "weeklyUsage": [ + "window": [ + "usagePercent": 75, + "resetInSec": 7200, + ], + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.hasWeeklyUsage == true) + #expect(snapshot.weeklyUsagePercent == 75) + #expect(snapshot.weeklyResetInSec == 7200) + #expect(usage.secondary?.usedPercent == 75) + } + @Test func `parses subscription from JSON with reset at and ratio percentages`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -261,7 +332,7 @@ struct OpenCodeGoUsageParserTests { } @Test - func `candidate fallback does not fabricate weekly from non weekly windows`() throws { + func `candidate fallback preserves missing weekly window`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let payload: [String: Any] = [ "windows": [ @@ -280,9 +351,15 @@ struct OpenCodeGoUsageParserTests { let data = try JSONSerialization.data(withJSONObject: payload) let text = String(data: data, encoding: .utf8) ?? "" - #expect(throws: OpenCodeGoUsageError.self) { - _ = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) - } + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.rollingUsagePercent == 15) + #expect(snapshot.hasWeeklyUsage == false) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 30) + #expect(usage.secondary == nil) + #expect(usage.tertiary?.usedPercent == 30) } @Test diff --git a/docs/opencode.md b/docs/opencode.md index a40f3cfe3f..3d1c504e70 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -16,12 +16,12 @@ read_when: ## Usage mapping - Primary window: rolling 5-hour usage (`rollingUsage.usagePercent`, `rollingUsage.resetInSec`). -- Secondary window: weekly usage (`weeklyUsage.usagePercent`, `weeklyUsage.resetInSec`). +- Secondary window: optional weekly usage (`weeklyUsage.usagePercent`, `weeklyUsage.resetInSec`). - Resets computed as `now + resetInSec`. ## Notes - Responses are `text/javascript` with serialized objects; parse via regex. -- Missing workspace ID or usage fields should raise parse errors. +- Missing workspace ID or rolling usage fields should raise parse errors; omitted weekly usage stays absent. - Cookie import defaults to Chrome-only to avoid extra browser prompts; pass a browser list to override. - Set `CODEXBAR_OPENCODE_WORKSPACE_ID` to skip workspace lookup and force a specific workspace. - Workspace override accepts a raw `wrk_…` ID or a full `https://opencode.ai/workspace/...` URL.