From 63e616fdf28e718e6c1d537c2bcc522890e8be80 Mon Sep 17 00:00:00 2001 From: Leo Lin Date: Wed, 10 Jun 2026 17:30:32 +0800 Subject: [PATCH 1/7] fix(doubao): treat 200 + limit>0 + remaining=0 as unreliable headers, not 100% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Volcano Ark returns HTTP 200 with `x-ratelimit-limit-requests > 0` and `x-ratelimit-remaining-requests = 0` on some account tiers (notably unverified personal keys) without actually rate-limiting the request — a genuine throttle would return 429. The previous math computed `used = limit` and clamped to 100%, so the Doubao card always showed 100% used for affected users. Tighten the normal-math guard to `limitRequests > 0 && remainingRequests > 0` so the unreliable-headers state falls through to the existing "Active - check dashboard for details" fallback (which was already used when both headers are missing). Also emit a `log.warning` when the pattern is observed so users hitting this can attach evidence from `~/Library/Logs/CodexBar/CodexBar.log` to bug reports. Adds `Tests/CodexBarTests/DoubaoUsageFetcherTests.swift` covering the normal path, the boundary near-full path, the unreliable-headers path, the both-headers-missing path, the invalid-key path, and provider identity tagging. Fixes #1382. Reported by @foobra on PR #498. --- CHANGELOG.md | 1 + .../Providers/Doubao/DoubaoUsageFetcher.swift | 22 ++++- .../DoubaoUsageFetcherTests.swift | 86 +++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 Tests/CodexBarTests/DoubaoUsageFetcherTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b12e3fe1..197716a187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.32.6 — Unreleased ### Fixed +- Doubao: stop misreading unreliable Volcano Ark rate-limit headers as 100% used; the card now falls back to the existing "Active — check dashboard for details" hint when HTTP 200 is returned with `x-ratelimit-limit-requests > 0` and `x-ratelimit-remaining-requests = 0`, which is the unreliable-headers pattern Volcano returns on some account tiers rather than a real throttle (#1382). Thanks @foobra! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! - Claude: show a direct claude.ai re-login action when a configured web session expires or becomes invalid (#1377). Thanks @LeoLin990405! diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index 9bfad55db3..94b92d83fd 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -30,7 +30,14 @@ public struct DoubaoUsageSnapshot: Sendable { let usedPercent: Double let resetDescription: String - if self.limitRequests > 0 { + // Volcano Ark can return 200 with `x-ratelimit-limit-requests > 0` and + // `x-ratelimit-remaining-requests == 0` on some account tiers (notably + // unverified personal keys) without actually rate-limiting the + // request — a real throttle would return 429. Trust the header pair + // only when both values are present and the remaining bucket is non-empty; + // otherwise treat the headers as unreliable and surface a dashboard hint + // instead of misreporting 100% used. + if self.limitRequests > 0, self.remainingRequests > 0 { let used = max(0, self.limitRequests - self.remainingRequests) usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100)) resetDescription = "\(used)/\(self.limitRequests) requests" @@ -178,6 +185,19 @@ public struct DoubaoUsageFetcher: Sendable { limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid) """) + // Diagnose unreliable-headers scenario: 200 status with a non-empty limit + // and zero remaining is not a real throttle (Volcano would have returned + // 429). Surface it in logs so users hitting "always 100%" reports can + // confirm the misread and attach evidence. + if response.statusCode == 200, (limit ?? 0) > 0, (remaining ?? 0) == 0 { + Self.log.warning( + """ + Doubao Ark returned limit=\(limit ?? 0) remaining=0 with HTTP 200 \ + (not a real throttle). Treating headers as unreliable and falling \ + back to dashboard hint instead of reporting 100% used. + """) + } + return snapshot } diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift new file mode 100644 index 0000000000..dcef5552d2 --- /dev/null +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DoubaoUsageSnapshotTests { + @Test + func `normal usage with both headers present and non-empty reports correct percent`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 750, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "250/1000 requests") + } + + @Test + func `boundary normal usage at near-full reports correct percent`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 1, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 99.9) + #expect(usage.primary?.resetDescription == "999/1000 requests") + } + + @Test + func `unreliable headers limit positive remaining zero falls back to Active hint`() { + // Volcano Ark returns this combination on unverified / personal account + // tiers without actually rate-limiting the request. Previously the math + // computed 100% used; the fix treats the pair as unreliable. + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + } + + @Test + func `both headers missing but key valid falls back to Active hint`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + } + + @Test + func `invalid key with no headers reports No usage data`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "No usage data") + } + + @Test + func `provider identity is correctly tagged as doubao`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 500, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.providerID == .doubao) + #expect(usage.identity?.accountEmail == nil) + } +} From bb6adb7c3177f1113610b766caadd5cd9bd4ddb1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 12:22:30 +0100 Subject: [PATCH 2/7] fix: preserve Doubao throttle state --- CHANGELOG.md | 2 +- .../Providers/Doubao/DoubaoUsageFetcher.swift | 28 ++++++++----------- .../DoubaoUsageFetcherTests.swift | 17 +++++++++-- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 197716a187..e1d50d1527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.32.6 — Unreleased ### Fixed -- Doubao: stop misreading unreliable Volcano Ark rate-limit headers as 100% used; the card now falls back to the existing "Active — check dashboard for details" hint when HTTP 200 is returned with `x-ratelimit-limit-requests > 0` and `x-ratelimit-remaining-requests = 0`, which is the unreliable-headers pattern Volcano returns on some account tiers rather than a real throttle (#1382). Thanks @foobra! +- Doubao: stop misreading unreliable HTTP 200 request-limit headers as 100% used while preserving explicit HTTP 429 exhaustion (#1383). Thanks @LeoLin990405 and @foobra! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! - Claude: show a direct claude.ai re-login action when a configured web session expires or becomes invalid (#1377). Thanks @LeoLin990405! diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index 94b92d83fd..970d7f73f8 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -10,13 +10,15 @@ public struct DoubaoUsageSnapshot: Sendable { public let updatedAt: Date public let apiKeyValid: Bool public let totalTokens: Int? + public let isRateLimited: Bool public init( remainingRequests: Int, limitRequests: Int, resetTime: Date?, updatedAt: Date, apiKeyValid: Bool = false, - totalTokens: Int? = nil) + totalTokens: Int? = nil, + isRateLimited: Bool = false) { self.remainingRequests = remainingRequests self.limitRequests = limitRequests @@ -24,20 +26,16 @@ public struct DoubaoUsageSnapshot: Sendable { self.updatedAt = updatedAt self.apiKeyValid = apiKeyValid self.totalTokens = totalTokens + self.isRateLimited = isRateLimited } public func toUsageSnapshot() -> UsageSnapshot { let usedPercent: Double let resetDescription: String - // Volcano Ark can return 200 with `x-ratelimit-limit-requests > 0` and - // `x-ratelimit-remaining-requests == 0` on some account tiers (notably - // unverified personal keys) without actually rate-limiting the - // request — a real throttle would return 429. Trust the header pair - // only when both values are present and the remaining bucket is non-empty; - // otherwise treat the headers as unreliable and surface a dashboard hint - // instead of misreporting 100% used. - if self.limitRequests > 0, self.remainingRequests > 0 { + // Some successful Ark responses report a positive limit with zero remaining + // even though later requests still succeed. Trust zero only after an explicit 429. + if self.limitRequests > 0, self.remainingRequests > 0 || self.isRateLimited { let used = max(0, self.limitRequests - self.remainingRequests) usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100)) resetDescription = "\(used)/\(self.limitRequests) requests" @@ -177,7 +175,8 @@ public struct DoubaoUsageFetcher: Sendable { resetTime: resetTime, updatedAt: Date(), apiKeyValid: keyValid, - totalTokens: totalTokens) + totalTokens: totalTokens, + isRateLimited: response.statusCode == 429) Self.log.debug( """ @@ -185,16 +184,11 @@ public struct DoubaoUsageFetcher: Sendable { limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid) """) - // Diagnose unreliable-headers scenario: 200 status with a non-empty limit - // and zero remaining is not a real throttle (Volcano would have returned - // 429). Surface it in logs so users hitting "always 100%" reports can - // confirm the misread and attach evidence. if response.statusCode == 200, (limit ?? 0) > 0, (remaining ?? 0) == 0 { Self.log.warning( """ - Doubao Ark returned limit=\(limit ?? 0) remaining=0 with HTTP 200 \ - (not a real throttle). Treating headers as unreliable and falling \ - back to dashboard hint instead of reporting 100% used. + Doubao Ark returned limit=\(limit ?? 0) remaining=0 with HTTP 200; \ + treating request-limit headers as unreliable. """) } diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index dcef5552d2..a0cb8320f7 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -31,9 +31,6 @@ struct DoubaoUsageSnapshotTests { @Test func `unreliable headers limit positive remaining zero falls back to Active hint`() { - // Volcano Ark returns this combination on unverified / personal account - // tiers without actually rate-limiting the request. Previously the math - // computed 100% used; the fix treats the pair as unreliable. let snapshot = DoubaoUsageSnapshot( remainingRequests: 0, limitRequests: 1000, @@ -45,6 +42,20 @@ struct DoubaoUsageSnapshotTests { #expect(usage.primary?.resetDescription == "Active - check dashboard for details") } + @Test + func `explicit rate limit with zero remaining reports exhausted quota`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true, + isRateLimited: true) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + } + @Test func `both headers missing but key valid falls back to Active hint`() { let snapshot = DoubaoUsageSnapshot( From d9be5402e1f6925647f791f479d6ea1b8130cf73 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 12:51:03 +0100 Subject: [PATCH 3/7] fix: confirm ambiguous Doubao request limits --- CHANGELOG.md | 2 +- .../Providers/Doubao/DoubaoUsageFetcher.swift | 93 ++++++++++++++----- .../DoubaoUsageFetcherTests.swift | 90 +++++++++++++++++- 3 files changed, 160 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d50d1527..a25026834d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.32.6 — Unreleased ### Fixed -- Doubao: stop misreading unreliable HTTP 200 request-limit headers as 100% used while preserving explicit HTTP 429 exhaustion (#1383). Thanks @LeoLin990405 and @foobra! +- Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra! - Antigravity: exclude model quotas without a remaining fraction from family summaries so they no longer mask tracked usage in the automatic menu-bar metric (#1369). Thanks @Martin-Hausleitner! - Claude: add bundled Fable 5 pricing, account for native 1-hour cache-write usage, and refresh Sonnet 4.6 full-context rates (#1368). Thanks @MoollaMore! - Claude: show a direct claude.ai re-login action when a configured web session expires or becomes invalid (#1377). Thanks @LeoLin990405! diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index 970d7f73f8..3eb95cd6c6 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -10,7 +10,7 @@ public struct DoubaoUsageSnapshot: Sendable { public let updatedAt: Date public let apiKeyValid: Bool public let totalTokens: Int? - public let isRateLimited: Bool + public let requestLimitsReliable: Bool public init( remainingRequests: Int, limitRequests: Int, @@ -18,7 +18,7 @@ public struct DoubaoUsageSnapshot: Sendable { updatedAt: Date, apiKeyValid: Bool = false, totalTokens: Int? = nil, - isRateLimited: Bool = false) + requestLimitsReliable: Bool = true) { self.remainingRequests = remainingRequests self.limitRequests = limitRequests @@ -26,16 +26,14 @@ public struct DoubaoUsageSnapshot: Sendable { self.updatedAt = updatedAt self.apiKeyValid = apiKeyValid self.totalTokens = totalTokens - self.isRateLimited = isRateLimited + self.requestLimitsReliable = requestLimitsReliable } public func toUsageSnapshot() -> UsageSnapshot { let usedPercent: Double let resetDescription: String - // Some successful Ark responses report a positive limit with zero remaining - // even though later requests still succeed. Trust zero only after an explicit 429. - if self.limitRequests > 0, self.remainingRequests > 0 || self.isRateLimited { + if self.limitRequests > 0, self.requestLimitsReliable { let used = max(0, self.limitRequests - self.remainingRequests) usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100)) resetDescription = "\(used)/\(self.limitRequests) requests" @@ -101,7 +99,21 @@ public struct DoubaoUsageFetcher: Sendable { "doubao-lite-32k", ] - public static func fetchUsage(apiKey: String) async throws -> DoubaoUsageSnapshot { + private struct ProbeResult { + let snapshot: DoubaoUsageSnapshot + let statusCode: Int + + var hasAmbiguousZeroRemaining: Bool { + self.statusCode == 200 + && self.snapshot.limitRequests > 0 + && self.snapshot.remainingRequests == 0 + } + } + + public static func fetchUsage( + apiKey: String, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> DoubaoUsageSnapshot + { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw DoubaoUsageError.missingCredentials } @@ -109,7 +121,16 @@ public struct DoubaoUsageFetcher: Sendable { var lastError: Error? for model in self.probeModels { do { - return try await self.probe(apiKey: apiKey, model: model) + let result = try await self.probe(apiKey: apiKey, model: model, transport: transport) + guard result.hasAmbiguousZeroRemaining else { + return result.snapshot + } + + return await self.confirmAmbiguousZeroRemaining( + initial: result, + apiKey: apiKey, + model: model, + transport: transport) } catch let error as DoubaoUsageError { if case let .apiError(code, _) = error, code == 404 || code == 403 { Self.log.debug("Doubao probe model \(model) unavailable (\(code)), trying next") @@ -122,7 +143,46 @@ public struct DoubaoUsageFetcher: Sendable { throw lastError ?? DoubaoUsageError.apiError(0, "All probe models failed") } - private static func probe(apiKey: String, model: String) async throws -> DoubaoUsageSnapshot { + private static func confirmAmbiguousZeroRemaining( + initial: ProbeResult, + apiKey: String, + model: String, + transport: any ProviderHTTPTransport) async -> DoubaoUsageSnapshot + { + do { + let confirmation = try await self.probe(apiKey: apiKey, model: model, transport: transport) + guard confirmation.hasAmbiguousZeroRemaining else { + return confirmation.snapshot + } + + Self.log.warning( + """ + Doubao Ark returned limit=\(confirmation.snapshot.limitRequests) remaining=0 \ + with HTTP 200 twice; treating request-limit headers as unreliable. + """) + return DoubaoUsageSnapshot( + remainingRequests: confirmation.snapshot.remainingRequests, + limitRequests: confirmation.snapshot.limitRequests, + resetTime: confirmation.snapshot.resetTime, + updatedAt: confirmation.snapshot.updatedAt, + apiKeyValid: confirmation.snapshot.apiKeyValid, + totalTokens: confirmation.snapshot.totalTokens, + requestLimitsReliable: false) + } catch { + self.log.warning( + """ + Doubao zero-remaining confirmation failed; preserving the initial exhausted state: \ + \(error.localizedDescription) + """) + return initial.snapshot + } + } + + private static func probe( + apiKey: String, + model: String, + transport: any ProviderHTTPTransport) async throws -> ProbeResult + { var request = URLRequest(url: self.apiURL) request.httpMethod = "POST" request.timeoutInterval = 15 @@ -140,7 +200,7 @@ public struct DoubaoUsageFetcher: Sendable { request.httpBody = try JSONSerialization.data(withJSONObject: body) - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await transport.response(for: request) let data = response.data // Accept both 200 (success) and 429 (rate limited) – both carry rate limit headers. @@ -175,8 +235,7 @@ public struct DoubaoUsageFetcher: Sendable { resetTime: resetTime, updatedAt: Date(), apiKeyValid: keyValid, - totalTokens: totalTokens, - isRateLimited: response.statusCode == 429) + totalTokens: totalTokens) Self.log.debug( """ @@ -184,15 +243,7 @@ public struct DoubaoUsageFetcher: Sendable { limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid) """) - if response.statusCode == 200, (limit ?? 0) > 0, (remaining ?? 0) == 0 { - Self.log.warning( - """ - Doubao Ark returned limit=\(limit ?? 0) remaining=0 with HTTP 200; \ - treating request-limit headers as unreliable. - """) - } - - return snapshot + return ProbeResult(snapshot: snapshot, statusCode: response.statusCode) } private static func stringHeader(_ headers: [AnyHashable: Any], _ name: String) -> String? { diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index a0cb8320f7..b4c324c7aa 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -36,7 +36,8 @@ struct DoubaoUsageSnapshotTests { limitRequests: 1000, resetTime: nil, updatedAt: Date(), - apiKeyValid: true) + apiKeyValid: true, + requestLimitsReliable: false) let usage = snapshot.toUsageSnapshot() #expect(usage.primary?.usedPercent == 0) #expect(usage.primary?.resetDescription == "Active - check dashboard for details") @@ -49,8 +50,7 @@ struct DoubaoUsageSnapshotTests { limitRequests: 1000, resetTime: nil, updatedAt: Date(), - apiKeyValid: true, - isRateLimited: true) + apiKeyValid: true) let usage = snapshot.toUsageSnapshot() #expect(usage.primary?.usedPercent == 100) #expect(usage.primary?.resetDescription == "1000/1000 requests") @@ -95,3 +95,87 @@ struct DoubaoUsageSnapshotTests { #expect(usage.identity?.accountEmail == nil) } } + +struct DoubaoUsageFetcherTests { + @Test + func `repeated successful zero remaining responses use active fallback`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 200, limit: 1000, remaining: 0), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + #expect(await transport.requestCount() == 2) + } + + @Test + func `successful final request followed by rate limit reports exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 429, limit: 1000, remaining: 0), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } + + @Test + func `failed zero remaining confirmation preserves exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .failure(URLError(.timedOut)), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } +} + +private actor DoubaoScriptedTransport: ProviderHTTPTransport { + enum Result { + case response(statusCode: Int, limit: Int, remaining: Int) + case failure(URLError) + } + + private var results: [Result] + private var requests = 0 + + init(results: [Result]) { + self.results = results + } + + func requestCount() -> Int { + self.requests + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + self.requests += 1 + let result = self.results.removeFirst() + switch result { + case let .response(statusCode, limit, remaining): + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: [ + "x-ratelimit-limit-requests": String(limit), + "x-ratelimit-remaining-requests": String(remaining), + ])! + return (Data(#"{"usage":{"total_tokens":1}}"#.utf8), response) + case let .failure(error): + throw error + } + } +} From 1a12db35d52c8f50111e9a72d8f72a35e97f17d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 12:54:48 +0100 Subject: [PATCH 4/7] fix: preserve Doubao confirmation semantics --- .../Providers/Doubao/DoubaoUsageFetcher.swift | 10 +++- .../DoubaoUsageFetcherTests.swift | 60 +++++++++++++++++-- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index 3eb95cd6c6..376305208b 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -126,7 +126,7 @@ public struct DoubaoUsageFetcher: Sendable { return result.snapshot } - return await self.confirmAmbiguousZeroRemaining( + return try await self.confirmAmbiguousZeroRemaining( initial: result, apiKey: apiKey, model: model, @@ -147,10 +147,13 @@ public struct DoubaoUsageFetcher: Sendable { initial: ProbeResult, apiKey: String, model: String, - transport: any ProviderHTTPTransport) async -> DoubaoUsageSnapshot + transport: any ProviderHTTPTransport) async throws -> DoubaoUsageSnapshot { do { let confirmation = try await self.probe(apiKey: apiKey, model: model, transport: transport) + if confirmation.statusCode == 429, confirmation.snapshot.limitRequests == 0 { + return initial.snapshot + } guard confirmation.hasAmbiguousZeroRemaining else { return confirmation.snapshot } @@ -169,6 +172,9 @@ public struct DoubaoUsageFetcher: Sendable { totalTokens: confirmation.snapshot.totalTokens, requestLimitsReliable: false) } catch { + if error is CancellationError || (error as? URLError)?.code == .cancelled { + throw error + } self.log.warning( """ Doubao zero-remaining confirmation failed; preserving the initial exhausted state: \ diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index b4c324c7aa..8d734e5ac3 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -127,6 +127,21 @@ struct DoubaoUsageFetcherTests { #expect(await transport.requestCount() == 2) } + @Test + func `headerless rate limit confirmation preserves exhausted quota`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .response(statusCode: 429, limit: nil, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 2) + } + @Test func `failed zero remaining confirmation preserves exhausted quota`() async throws { let transport = DoubaoScriptedTransport(results: [ @@ -141,12 +156,41 @@ struct DoubaoUsageFetcherTests { #expect(usage.primary?.resetDescription == "1000/1000 requests") #expect(await transport.requestCount() == 2) } + + @Test + func `task cancellation during confirmation propagates`() async { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .cancellation, + ]) + + await #expect(throws: CancellationError.self) { + _ = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + } + #expect(await transport.requestCount() == 2) + } + + @Test + func `url cancellation during confirmation propagates`() async { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 200, limit: 1000, remaining: 0), + .failure(URLError(.cancelled)), + ]) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + } throws: { error in + (error as? URLError)?.code == .cancelled + } + #expect(await transport.requestCount() == 2) + } } private actor DoubaoScriptedTransport: ProviderHTTPTransport { enum Result { - case response(statusCode: Int, limit: Int, remaining: Int) + case response(statusCode: Int, limit: Int?, remaining: Int?) case failure(URLError) + case cancellation } private var results: [Result] @@ -165,17 +209,23 @@ private actor DoubaoScriptedTransport: ProviderHTTPTransport { let result = self.results.removeFirst() switch result { case let .response(statusCode, limit, remaining): + var headers: [String: String] = [:] + if let limit { + headers["x-ratelimit-limit-requests"] = String(limit) + } + if let remaining { + headers["x-ratelimit-remaining-requests"] = String(remaining) + } let response = HTTPURLResponse( url: request.url!, statusCode: statusCode, httpVersion: "HTTP/1.1", - headerFields: [ - "x-ratelimit-limit-requests": String(limit), - "x-ratelimit-remaining-requests": String(remaining), - ])! + headerFields: headers)! return (Data(#"{"usage":{"total_tokens":1}}"#.utf8), response) case let .failure(error): throw error + case .cancellation: + throw CancellationError() } } } From f46a590c0c20e5004aaa86db1c0bfc69d205a472 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 12:58:02 +0100 Subject: [PATCH 5/7] fix: require complete Doubao request limits --- .../Providers/Doubao/DoubaoUsageFetcher.swift | 7 +++---- .../DoubaoUsageFetcherTests.swift | 20 ++++++++++++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index 376305208b..baf49acf73 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -105,6 +105,7 @@ public struct DoubaoUsageFetcher: Sendable { var hasAmbiguousZeroRemaining: Bool { self.statusCode == 200 + && self.snapshot.requestLimitsReliable && self.snapshot.limitRequests > 0 && self.snapshot.remainingRequests == 0 } @@ -151,9 +152,6 @@ public struct DoubaoUsageFetcher: Sendable { { do { let confirmation = try await self.probe(apiKey: apiKey, model: model, transport: transport) - if confirmation.statusCode == 429, confirmation.snapshot.limitRequests == 0 { - return initial.snapshot - } guard confirmation.hasAmbiguousZeroRemaining else { return confirmation.snapshot } @@ -241,7 +239,8 @@ public struct DoubaoUsageFetcher: Sendable { resetTime: resetTime, updatedAt: Date(), apiKeyValid: keyValid, - totalTokens: totalTokens) + totalTokens: totalTokens, + requestLimitsReliable: limit != nil && remaining != nil) Self.log.debug( """ diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index 8d734e5ac3..6e1ab17a61 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -128,7 +128,7 @@ struct DoubaoUsageFetcherTests { } @Test - func `headerless rate limit confirmation preserves exhausted quota`() async throws { + func `headerless rate limit confirmation uses active fallback`() async throws { let transport = DoubaoScriptedTransport(results: [ .response(statusCode: 200, limit: 1000, remaining: 0), .response(statusCode: 429, limit: nil, remaining: nil), @@ -137,11 +137,25 @@ struct DoubaoUsageFetcherTests { let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) let usage = snapshot.toUsageSnapshot() - #expect(usage.primary?.usedPercent == 100) - #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") #expect(await transport.requestCount() == 2) } + @Test + func `partial rate limit headers use active fallback`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 429, limit: 1000, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + #expect(await transport.requestCount() == 1) + } + @Test func `failed zero remaining confirmation preserves exhausted quota`() async throws { let transport = DoubaoScriptedTransport(results: [ From 3b6486079bb85a4506f9ef92a1c0b92f3cd0f290 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 13:00:48 +0100 Subject: [PATCH 6/7] fix: classify Doubao request throttles --- .../Providers/Doubao/DoubaoUsageFetcher.swift | 7 ++++++- .../CodexBarTests/DoubaoUsageFetcherTests.swift | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index baf49acf73..40408b8274 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -232,6 +232,11 @@ public struct DoubaoUsageFetcher: Sendable { // 429 means the key is valid but rate-limited; treat it as valid so the UI // shows "Active" instead of "No usage data" when headers are absent. let keyValid = response.statusCode == 200 || response.statusCode == 429 + // A request-limit header on 429 identifies request-bucket exhaustion even + // when Ark omits remaining. A bare 429 may describe another throttle. + let requestLimitsReliable = response.statusCode == 429 + ? limit != nil + : limit != nil && remaining != nil let snapshot = DoubaoUsageSnapshot( remainingRequests: remaining ?? 0, @@ -240,7 +245,7 @@ public struct DoubaoUsageFetcher: Sendable { updatedAt: Date(), apiKeyValid: keyValid, totalTokens: totalTokens, - requestLimitsReliable: limit != nil && remaining != nil) + requestLimitsReliable: requestLimitsReliable) Self.log.debug( """ diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index 6e1ab17a61..1ef8176256 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -143,7 +143,7 @@ struct DoubaoUsageFetcherTests { } @Test - func `partial rate limit headers use active fallback`() async throws { + func `rate limit with request limit header reports exhausted quota`() async throws { let transport = DoubaoScriptedTransport(results: [ .response(statusCode: 429, limit: 1000, remaining: nil), ]) @@ -151,6 +151,20 @@ struct DoubaoUsageFetcherTests { let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") + #expect(await transport.requestCount() == 1) + } + + @Test + func `bare rate limit uses active fallback`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .response(statusCode: 429, limit: nil, remaining: nil), + ]) + + let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) #expect(usage.primary?.resetDescription == "Active - check dashboard for details") #expect(await transport.requestCount() == 1) From 945b8d45ddebe49089364f1da3a4b3da65a76632 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 10 Jun 2026 13:03:37 +0100 Subject: [PATCH 7/7] fix: preserve confirmed Doubao exhaustion --- .../Providers/Doubao/DoubaoUsageFetcher.swift | 8 ++++++++ Tests/CodexBarTests/DoubaoUsageFetcherTests.swift | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index 40408b8274..be675cd7ad 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -152,6 +152,14 @@ public struct DoubaoUsageFetcher: Sendable { { do { let confirmation = try await self.probe(apiKey: apiKey, model: model, transport: transport) + // This path starts only after a complete HTTP 200 request-limit pair + // reported zero. An immediate 429 confirms that exhausted state even + // when Ark omits the headers from the throttle response. + if confirmation.statusCode == 429 { + return confirmation.snapshot.requestLimitsReliable + ? confirmation.snapshot + : initial.snapshot + } guard confirmation.hasAmbiguousZeroRemaining else { return confirmation.snapshot } diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index 1ef8176256..c0a66bd433 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -128,7 +128,7 @@ struct DoubaoUsageFetcherTests { } @Test - func `headerless rate limit confirmation uses active fallback`() async throws { + func `headerless rate limit confirmation preserves exhausted quota`() async throws { let transport = DoubaoScriptedTransport(results: [ .response(statusCode: 200, limit: 1000, remaining: 0), .response(statusCode: 429, limit: nil, remaining: nil), @@ -137,8 +137,8 @@ struct DoubaoUsageFetcherTests { let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport) let usage = snapshot.toUsageSnapshot() - #expect(usage.primary?.usedPercent == 0) - #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "1000/1000 requests") #expect(await transport.requestCount() == 2) }