diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b12e3fe1..a25026834d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.32.6 — Unreleased ### Fixed +- 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 9bfad55db3..be675cd7ad 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 requestLimitsReliable: Bool public init( remainingRequests: Int, limitRequests: Int, resetTime: Date?, updatedAt: Date, apiKeyValid: Bool = false, - totalTokens: Int? = nil) + totalTokens: Int? = nil, + requestLimitsReliable: Bool = true) { self.remainingRequests = remainingRequests self.limitRequests = limitRequests @@ -24,13 +26,14 @@ public struct DoubaoUsageSnapshot: Sendable { self.updatedAt = updatedAt self.apiKeyValid = apiKeyValid self.totalTokens = totalTokens + self.requestLimitsReliable = requestLimitsReliable } public func toUsageSnapshot() -> UsageSnapshot { let usedPercent: Double let resetDescription: String - if self.limitRequests > 0 { + 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" @@ -96,7 +99,22 @@ 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.requestLimitsReliable + && 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 } @@ -104,7 +122,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 try 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") @@ -117,7 +144,57 @@ 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 throws -> DoubaoUsageSnapshot + { + 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 + } + + 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 { + if error is CancellationError || (error as? URLError)?.code == .cancelled { + throw error + } + 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 @@ -135,7 +212,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. @@ -163,6 +240,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, @@ -170,7 +252,8 @@ public struct DoubaoUsageFetcher: Sendable { resetTime: resetTime, updatedAt: Date(), apiKeyValid: keyValid, - totalTokens: totalTokens) + totalTokens: totalTokens, + requestLimitsReliable: requestLimitsReliable) Self.log.debug( """ @@ -178,7 +261,7 @@ public struct DoubaoUsageFetcher: Sendable { limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid) """) - 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 new file mode 100644 index 0000000000..c0a66bd433 --- /dev/null +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -0,0 +1,259 @@ +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`() { + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 1000, + resetTime: nil, + updatedAt: Date(), + apiKeyValid: true, + requestLimitsReliable: false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #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) + 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( + 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) + } +} + +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 `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 `rate limit with request limit header reports exhausted quota`() 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 == 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) + } + + @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) + } + + @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 failure(URLError) + case cancellation + } + + 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): + 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: headers)! + return (Data(#"{"usage":{"total_tokens":1}}"#.utf8), response) + case let .failure(error): + throw error + case .cancellation: + throw CancellationError() + } + } +}