diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml index 35eacb2f..e494ba27 100644 --- a/.github/workflows/swift-ci.yml +++ b/.github/workflows/swift-ci.yml @@ -150,12 +150,21 @@ jobs: observed = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") windows = [ {"id": "claude-5h", "provider": "anthropic", "providerKey": "anthropic", "label": "5-hour window", "remainingPercent": 72.0, "isExhausted": False, "occurredAt": observed}, + {"id": "claude-weekly", "provider": "anthropic", "providerKey": "anthropic", "label": "Weekly", "remainingPercent": 61.0, "isExhausted": False, "occurredAt": observed}, {"id": "codex-weekly", "provider": "openai", "providerKey": "codex", "label": "Weekly", "remainingPercent": 44.0, "isExhausted": False, "occurredAt": observed}, {"id": "cursor-fast", "provider": "cursor", "providerKey": "cursor", "label": "Fast Requests", "remainingPercent": 28.0, "isExhausted": False, "occurredAt": observed}, ] with open(sys.argv[1], "w", encoding="utf-8") as snapshot: json.dump({"windows": windows}, snapshot) PY + # The main quota list with the seeded windows above (the Claude + # card carries two meter rows), so a change to the quota cards has + # a simulator screenshot to review. + xcrun simctl launch --terminate-running-process "$SIMULATOR_ID" "$bundle_id" + sleep 3 + xcrun simctl io "$SIMULATOR_ID" screenshot "$RUNNER_TEMP/codecaps-ios-quota.png" + test -s "$RUNNER_TEMP/codecaps-ios-quota.png" + xcrun simctl terminate "$SIMULATOR_ID" "$bundle_id" xcrun simctl launch --terminate-running-process "$SIMULATOR_ID" "$bundle_id" -openSettings -reorderPlatforms sleep 3 xcrun simctl io "$SIMULATOR_ID" screenshot "$screenshot" @@ -195,6 +204,7 @@ jobs: with: name: codecaps-ios-platform-reordering path: | + ${{ runner.temp }}/codecaps-ios-quota.png ${{ runner.temp }}/codecaps-ios-reorder.png ${{ runner.temp }}/codecaps-ios-empty.png ${{ runner.temp }}/codecaps-ios-settings.png diff --git a/Sources/CodeCaps/PlatformLogo.swift b/Sources/CodeCaps/PlatformLogo.swift index 8ac7b3d7..2cab4813 100644 --- a/Sources/CodeCaps/PlatformLogo.swift +++ b/Sources/CodeCaps/PlatformLogo.swift @@ -287,7 +287,8 @@ private static let resourceNames: [String: String] = [ "grok-bot": "grok-bot", "minimax": "minimax", "muse": "muse", - "muse-assist": "muse-assist", + // Muse Assist ships byte-identical artwork to Muse; one base serves both. + "muse-assist": "muse", "muse-code": "muse-code", "cursor": "cursor", "github-copilot": "copilot", diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-color.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-color.png deleted file mode 100644 index 4a30e111..00000000 Binary files a/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-color.png and /dev/null differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-dark.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-dark.png deleted file mode 100644 index ab69b7d6..00000000 Binary files a/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-dark.png and /dev/null differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-light.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-light.png deleted file mode 100644 index f2baa035..00000000 Binary files a/Sources/CodeCaps/Resources/ProviderMarks/muse-assist-light.png and /dev/null differ diff --git a/Sources/CodeCaps/Resources/ProviderMarks/muse-assist.png b/Sources/CodeCaps/Resources/ProviderMarks/muse-assist.png deleted file mode 100644 index 34560ac0..00000000 Binary files a/Sources/CodeCaps/Resources/ProviderMarks/muse-assist.png and /dev/null differ diff --git a/Sources/QuotaCore/GrokGrpcCredits.swift b/Sources/QuotaCore/GrokGrpcCredits.swift new file mode 100644 index 00000000..c44d0961 --- /dev/null +++ b/Sources/QuotaCore/GrokGrpcCredits.swift @@ -0,0 +1,264 @@ +import Foundation + +/// Best-effort parser for the grok.com billing gRPC-web fallback +/// (`grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig`), per the wire +/// contract documented by CodexBar (steipete/CodexBar, docs/grok.md, +/// descriptor checked 2026-09-21): +/// +/// - Request body is one gRPC-web frame carrying +/// `GetGrokCreditsConfigRequest { exclude_legacy_monthly_usage: false }`: +/// bytes `00 00 00 00 02 08 00`. An empty frame is rejected with gRPC +/// status 13, so the explicit false matters. +/// - The response config is field 1 of the reply (or the reply itself in +/// the raw form some successful requests return). Inside it, a top-level +/// fixed32 float is the credit usage percent, repeated field 7 entries +/// are per-product shares `{1: product id, 2: float percent}`, and the +/// current period is a submessage of two protobuf Timestamps. +/// - grok.com now expects a browser-held Web Key Exchange keypair, so a +/// bearer-only probe can fail with gRPC status 16 (`no-credentials`). +/// This parser is therefore strictly a fallback: it only ever fills a +/// percentage the CLI-proxy answer did not publish, and a malformed or +/// partial reply yields nil rather than a guessed number. +public enum GrokGrpcCredits { + public struct Reading: Equatable, Sendable { + /// Percent of the subscription credit pool already used. + public var usedPercent: Double + /// Current period bounds, when a complete active period was present. + public var periodStart: Date? + public var periodEnd: Date? + } + + /// The exact request frame bytes for the fallback call. + public static let requestFrame = Data([0x00, 0x00, 0x00, 0x00, 0x02, 0x08, 0x00]) + + /// Field 6 of the config is the current period, a `{start, end}` pair of + /// Timestamps. Period decoding is pinned to this number rather than to + /// "any length-delimited field that happens to hold two Timestamps", so + /// an unrelated submessage can never invent bounds for the window. + static let currentPeriodField = 6 + + /// Parse a gRPC-web (framed) or raw protobuf reply. `now` bounds the + /// implicit-zero rule: a complete active period with no percentage field + /// anywhere on the wire reads as zero usage, matching grok.com's own web + /// client, whose descriptor declares the percent an implicit-presence + /// float. + public static func parse(_ data: Data, now: Date) -> Reading? { + guard let message = unwrapFrames(data) else { return nil } + // Reply form: field 1 is the config. Raw form: the reply IS the + // config. Prefer the wrapped form when field 1 decodes as bytes. + let config: Data + if let fields = try? ProtobufScanner.fields(message), + let wrapped = fields.first(where: { $0.number == 1 && $0.wire == .bytes }) { + config = wrapped.bytes + } else { + config = message + } + guard let fields = try? ProtobufScanner.fields(config) else { return nil } + + var percent: Double? + var sawPercentField = false + var periodStart: Date? + var periodEnd: Date? + for field in fields { + switch field.wire { + case .fixed32: + // The only top-level floats in the config are usage + // percentages; product shares nest inside field 7 entries. + guard let value = field.floatValue else { continue } + // Presence is tracked apart from the accepted value. A + // published percentage that is out of range or non-finite is + // still a published percentage, and must not reach the + // implicit-zero rule below as if nothing had been published. + sawPercentField = true + if percent == nil, value.isFinite, (0...100).contains(value) { + percent = value + } + case .bytes: + // First valid match wins: a later field-6 submessage is not + // allowed to overwrite bounds an earlier one already supplied. + // A malformed or off-cadence field-6 submessage supplies + // nothing, so a valid one after it is still read. That skip + // is deliberate; do not tighten this into "first field 6 only". + guard field.number == Self.currentPeriodField, periodStart == nil, + let period = timestampPair(field.bytes), + Self.isRecognizedPeriod(period.start, period.end) else { continue } + periodStart = period.start + periodEnd = period.end + case .varint, .fixed64: + continue + } + } + + if let percent { + return Reading(usedPercent: percent, periodStart: periodStart, periodEnd: periodEnd) + } + // Implicit zero: complete active period, and no percentage field on + // the wire at all. A rejected percentage is not an absent one. + if !sawPercentField, let start = periodStart, let end = periodEnd, + start <= now, now <= end { + return Reading(usedPercent: 0, periodStart: start, periodEnd: end) + } + return nil + } + + /// The credits pool is billed weekly or monthly. A Timestamp pair of any + /// other length is not this field's subscription period, whatever its + /// shape, and is left uncaptured rather than used as a reset bound. + private static func isRecognizedPeriod(_ start: Date, _ end: Date) -> Bool { + let days = end.timeIntervalSince(start) / 86_400 + return (6.5...8).contains(days) || (27...32).contains(days) + } + + /// Whether the reply's gRPC-web trailer frame reports status 16 + /// (`UNAUTHENTICATED`). grok.com answers a probe it will not serve with + /// a framed 200 and a `grpc-status: 16` trailer, so an HTTP status check + /// alone cannot see it. Only the trailer carries the status, which is why + /// this lives beside the frame parser. Status 16 is the documented answer + /// to a bearer-only probe with no Web Key Exchange keypair (see above), so + /// the reader treats it as a fallback miss, not as a refused credential. + /// Both trailer spellings count: `grpc-status:16` and the header-style + /// `grpc-status: 16`. + public static func isUnauthenticated(_ data: Data) -> Bool { + let bytes = [UInt8](data) + var offset = 0 + var trailer = [UInt8]() + while offset + 5 <= bytes.count { + let flag = bytes[offset] + guard flag == 0x00 || flag == 0x80 else { break } + let length = Int(bytes[offset + 1]) << 24 | Int(bytes[offset + 2]) << 16 + | Int(bytes[offset + 3]) << 8 | Int(bytes[offset + 4]) + guard offset + 5 + length <= bytes.count else { break } + if flag == 0x80 { trailer.append(contentsOf: bytes[(offset + 5)..<(offset + 5 + length)]) } + offset += 5 + length + } + guard !trailer.isEmpty else { return false } + let text = String(decoding: trailer, as: UTF8.self).lowercased() + guard let marker = text.range(of: "grpc-status:") else { return false } + let value = text[marker.upperBound...].drop { $0 == " " || $0 == "\t" } + return value.prefix(while: \.isNumber) == "16" + } + + /// A `{start: Timestamp, end: Timestamp}` submessage, when the bytes + /// decode as exactly two Timestamp-shaped fields in order. + private static func timestampPair(_ data: Data) -> (start: Date, end: Date)? { + guard let fields = try? ProtobufScanner.fields(data), fields.count == 2, + fields.allSatisfy({ $0.wire == .bytes }) else { return nil } + guard let start = timestamp(fields[0].bytes), let end = timestamp(fields[1].bytes), + start < end else { return nil } + return (start, end) + } + + /// A protobuf Timestamp: field 1 varint seconds, optional field 2 nanos. + private static func timestamp(_ data: Data) -> Date? { + guard let fields = try? ProtobufScanner.fields(data), + let secondsField = fields.first(where: { $0.number == 1 && $0.wire == .varint }), + let seconds = secondsField.varintValue, + (1_500_000_000...2_000_000_000).contains(seconds) else { return nil } + return Date(timeIntervalSince1970: TimeInterval(seconds)) + } + + /// Strip gRPC-web framing: `[flag][4-byte big-endian length][payload]` + /// repeated, where flag 0x80 marks a trailer frame. Data that is not + /// framed is returned as-is (the raw protobuf form). + private static func unwrapFrames(_ data: Data) -> Data? { + guard data.count >= 5 else { return data.isEmpty ? nil : data } + var offset = 0 + var payload = Data() + var framed = false + let bytes = [UInt8](data) + while offset + 5 <= bytes.count { + let flag = bytes[offset] + guard flag == 0x00 || flag == 0x80 else { break } + let length = Int(bytes[offset + 1]) << 24 | Int(bytes[offset + 2]) << 16 + | Int(bytes[offset + 3]) << 8 | Int(bytes[offset + 4]) + guard offset + 5 + length <= bytes.count else { break } + framed = true + if flag == 0x00 { payload.append(contentsOf: bytes[(offset + 5)..<(offset + 5 + length)]) } + offset += 5 + length + } + return framed && offset == bytes.count ? payload : data + } +} + +/// Minimal protobuf wire scanner: enough to walk a message's top level +/// without a generated model. Malformed input throws; callers treat that +/// as "no reading", never as a partial one. Varints stop after ten bytes +/// and reject overflow, and length-delimited reads cannot exceed the +/// enclosing message — the same bounds CodexBar's reader documents. +enum ProtobufScanner { + enum Wire { + case varint, fixed64, bytes, fixed32 + } + + struct Field { + var number: Int + var wire: Wire + var varintValue: UInt64? + var floatValue: Double? + var bytes: Data + } + + enum ScanError: Error { case malformed } + + static func fields(_ data: Data) throws -> [Field] { + var reader = Reader(bytes: [UInt8](data)) + var fields: [Field] = [] + while !reader.atEnd { + let tag = try reader.varint() + let number = Int(tag >> 3) + guard number > 0 else { throw ScanError.malformed } + switch tag & 0x07 { + case 0: + let value = try reader.varint() + fields.append(Field(number: number, wire: .varint, varintValue: value, bytes: Data())) + case 1: + let raw = try reader.take(8) + fields.append(Field(number: number, wire: .fixed64, bytes: Data(raw))) + case 2: + let length = try reader.varint() + guard length <= UInt64(reader.remaining) else { throw ScanError.malformed } + let raw = try reader.take(Int(length)) + fields.append(Field(number: number, wire: .bytes, bytes: Data(raw))) + case 5: + let raw = try reader.take(4) + let bits = UInt32(raw[0]) | UInt32(raw[1]) << 8 | UInt32(raw[2]) << 16 | UInt32(raw[3]) << 24 + fields.append(Field(number: number, wire: .fixed32, + floatValue: Double(Float(bitPattern: bits)), bytes: Data(raw))) + default: + throw ScanError.malformed + } + } + return fields + } + + private struct Reader { + var bytes: [UInt8] + var offset = 0 + var atEnd: Bool { offset >= bytes.count } + var remaining: Int { bytes.count - offset } + + mutating func varint() throws -> UInt64 { + var result: UInt64 = 0 + var shift: UInt64 = 0 + for _ in 0..<10 { + guard offset < bytes.count else { throw ScanError.malformed } + let byte = bytes[offset] + offset += 1 + if shift == 63 { + // Tenth byte may only contribute bit 63. + guard byte <= 1 else { throw ScanError.malformed } + } + result |= UInt64(byte & 0x7F) << shift + if byte & 0x80 == 0 { return result } + shift += 7 + } + throw ScanError.malformed + } + + mutating func take(_ count: Int) throws -> [UInt8] { + guard count >= 0, offset + count <= bytes.count else { throw ScanError.malformed } + defer { offset += count } + return Array(bytes[offset..<(offset + count)]) + } + } +} diff --git a/Sources/QuotaCore/LocalQuotaReader.swift b/Sources/QuotaCore/LocalQuotaReader.swift index 3e97b3a0..7b97ca01 100644 --- a/Sources/QuotaCore/LocalQuotaReader.swift +++ b/Sources/QuotaCore/LocalQuotaReader.swift @@ -341,9 +341,142 @@ public struct LocalQuotaReader: Sendable { var request = URLRequest(url: URL(string: "https://cli-chat-proxy.grok.com/v1/billing?format=credits")!) request.httpMethod = "GET" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("xai-grok-cli", forHTTPHeaderField: "x-xai-token-auth") + request.setValue("application/json", forHTTPHeaderField: "Accept") let payload = try await requestJSON(request) - let windows = parseGrok(payload, observedAt: now()) - return ProviderRead(provider: provider, windows: windows, issue: windows.first?.remainingPercent == nil ? "Grok returned no readable quota percentage." : nil) + var windows = parseGrok(payload, observedAt: now()) + // Two best-effort enrichments, neither on the critical path, so they + // are issued together instead of one after the other: run serially + // they added up to eight seconds to every Grok refresh, and each was + // paid for even when it had nothing to contribute. + // + // - `/v1/settings` names the tier (`SuperGrok Heavy` vs `SuperGrok`), + // so it is only worth a request when a window has no plan name. + // - the grok.com gRPC fallback fills a percentage the proxy answer + // did not publish, so with no window at all there is nothing to + // fill and the request is skipped entirely. + let needsTier = windows.contains { $0.planName == nil } + let needsCredits = !windows.isEmpty && windows.first?.remainingPercent == nil + async let tierRequest = grokSettingsTier(token: token, onlyIf: needsTier) + async let creditsRequest = grokGrpcReading(token: token, onlyIf: needsCredits) + let (tier, credits) = await (tierRequest, creditsRequest) + if let tier { + windows = windows.map { window in + var window = window + if window.planName == nil { window.planName = tier } + return window + } + } + var fallbackRefused = false + switch credits { + case .refused: + // grok.com answered HTTP 401 to the credits fallback. That is + // reported; it is never folded into the "no readable percentage" + // miss below. + fallbackRefused = true + case .unavailable: + break + case .reading(let grpc): + // The proxy's period bounds stay authoritative; the fallback only + // supplies the percentage the proxy did not publish, so a window + // that already carries one is never a target. The credits pool + // is matched on its exact id, never on a suffix, so an unrelated + // `…:legacy:subscription` window cannot stand in for it. + let poolID = "local-mac:\(provider.key):subscription" + let target = windows.firstIndex(where: { $0.id == poolID && $0.remainingPercent == nil }) + ?? windows.firstIndex(where: { $0.remainingPercent == nil }) + if let index = target { + var window = windows[index] + window.remainingPercent = 100 - grpc.usedPercent + window.remainingUnknown = false + if window.resetAt == nil, let end = grpc.periodEnd { + window.resetAt = Self.isoString(end) + } + if window.periodStart == nil, let start = grpc.periodStart { + window.periodStart = Self.isoString(start) + } + // This window was normalized when it was still percentage-less, + // so `status`, `isExhausted`, `skip` and `skipReason` still + // describe the empty value. Re-derive them from the number + // just filled in, or a pool the fallback alone could read + // publishes as "unknown" and is never drawn as spent. + windows[index] = window.normalizedForExport() + } + } + // The CLI proxy accepted this same token moments ago (a refusal there + // throws `.reauth` before the fallback runs), so a grok.com refusal is + // about that endpoint, not proof the sign-in is bad: the message + // names the refusal and does not send the owner to sign in again. + let issue = fallbackRefused + ? "Grok returned no readable quota percentage; grok.com refused the credits fallback for GROK_TOKEN (\(Self.maskedTokenPreview(token)))." + : (windows.first?.remainingPercent == nil ? "Grok returned no readable quota percentage." : nil) + return ProviderRead(provider: provider, windows: windows, issue: issue) + } + + /// First 8 / last 4 of a credential, for a rejection message: enough to + /// tell two sign-ins apart on one Mac, never enough to replay. A value + /// too short to mask safely is reported as a fixed label, never echoed. + private static func maskedTokenPreview(_ token: String) -> String { + guard token.count > 16 else { return "(hidden)" } + return "\(token.prefix(8))…\(token.suffix(4))" + } + + /// `subscription_tier_display` from the CLI-proxy settings endpoint. Skipped + /// outright when every window already carries a plan name: there is + /// nothing for the tier to name, and the request costs up to two seconds. + private func grokSettingsTier(token: String, onlyIf: Bool = true) async -> String? { + guard onlyIf else { return nil } + var request = URLRequest(url: URL(string: "https://cli-chat-proxy.grok.com/v1/settings")!) + request.httpMethod = "GET" + request.timeoutInterval = 2 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("xai-grok-cli", forHTTPHeaderField: "x-xai-token-auth") + request.setValue("application/json", forHTTPHeaderField: "Accept") + guard let payload = try? await requestJSON(request) else { return nil } + let tier = firstString(payload, ["subscription_tier_display", "subscriptionTierDisplay"]) + return tier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? tier : nil + } + + /// What the grok.com gRPC-web credits fallback produced. `refused` is the + /// one outcome that is not a fallback miss: grok.com answered HTTP 401, + /// which is a different fact from absent credits data, so it has its own + /// case and is reported instead of being silently skipped. It names that + /// endpoint's answer, not a verdict on the sign-in (see `readGrok`). + private enum GrokGrpcOutcome { + case reading(GrokGrpcCredits.Reading) + case refused + case unavailable + } + + /// The grok.com gRPC-web credits fallback (see `GrokGrpcCredits`). + private func grokGrpcReading(token: String, onlyIf: Bool = true) async -> GrokGrpcOutcome { + // No window at all, or one that already carries a percentage, leaves + // this fallback with nothing to fill — so no request is made and the + // six-second budget is not spent on an answer nobody would use. + guard onlyIf else { return .unavailable } + var request = URLRequest(url: URL(string: "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")!) + request.httpMethod = "POST" + request.timeoutInterval = 6 + request.httpBody = GrokGrpcCredits.requestFrame + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/grpc-web+proto", forHTTPHeaderField: "Content-Type") + request.setValue("1", forHTTPHeaderField: "X-Grpc-Web") + guard let (data, response) = try? await fetchJSON(request) else { return .unavailable } + // Only an HTTP 401 is a refused credential. A 403, and gRPC status + // 16 (`UNAUTHENTICATED`), are what grok.com answers a bearer-only + // probe that carries no browser-held Web Key Exchange keypair, which + // this probe never sends (see `GrokGrpcCredits`): that is a capability + // miss on a token that still works, so it stays a plain miss and the + // fallback stays best-effort. + if response.statusCode == 401 { return .refused } + guard (200..<300).contains(response.statusCode) else { return .unavailable } + if GrokGrpcCredits.isUnauthenticated(data) { return .unavailable } + guard let reading = GrokGrpcCredits.parse(data, now: now()) else { return .unavailable } + return .reading(reading) + } + + private static func isoString(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) } private func readMiniMax() async throws -> ProviderRead { diff --git a/Sources/QuotaCore/QuotaModels.swift b/Sources/QuotaCore/QuotaModels.swift index 4de950b4..04610025 100644 --- a/Sources/QuotaCore/QuotaModels.swift +++ b/Sources/QuotaCore/QuotaModels.swift @@ -625,6 +625,35 @@ public extension QuotaResponse { return copy } + /// The Grok subscription credits pool is reported twice: the local CLI + /// reader files it under `grok`, while the fleet collector buckets the + /// same payload as `xai:subscription`. When both carry a subscription + /// window, keep the fresher reading and drop the staler duplicate, so + /// the two rows can never disagree about one pool. Any other xAI + /// window (API usage, a different cadence) is untouched. + static func dedupedGrokSubscription(_ windows: [QuotaWindow]) -> [QuotaWindow] { + // The one Grok credits pool, identified by exact component equality on the + // window id. Ids carry an optional source prefix + // (`local-mac:grok:subscription`), so the canonical form is the last + // two components: `:`, each compared in full. + // `grok:legacy:subscription` names a different window and is not the + // pool; `xai:api-monthly` is not the pool either. + func isPool(_ window: QuotaWindow) -> Bool { + let parts = window.id.split(separator: ":") + guard parts.count >= 2 else { return false } + return parts[parts.count - 2] == Substring(window.canonicalProviderKey) + && parts[parts.count - 1] == "subscription" + } + guard let grokIndex = windows.firstIndex(where: { isPool($0) && $0.canonicalProviderKey == "grok" }), + let xaiIndex = windows.firstIndex(where: { isPool($0) && $0.canonicalProviderKey == "xai" }) + else { return windows } + let grokDate = windows[grokIndex].occurredDate ?? .distantPast + let xaiDate = windows[xaiIndex].occurredDate ?? .distantPast + var copy = windows + copy.remove(at: grokDate >= xaiDate ? xaiIndex : grokIndex) + return copy + } + /// Returns the stable native dashboard order, followed by future provider /// keys supplied by the server. Missing expected providers are explicit. /// @@ -634,20 +663,31 @@ public extension QuotaResponse { /// offline fallback / pre-manifest backend path. That branch is kept as a /// safety net for first-launch installs before a sync has completed. func platformSections(now: Date = Date()) -> [QuotaPlatformSection] { - let normalizedWindows = windows.map { $0.normalized() } - var grouped = Dictionary(minimumCapacity: normalizedWindows.count) - for window in normalizedWindows { - grouped[window.canonicalProviderKey, default: []].append(window) - } - + // Collect every reading the response carries, then dedupe once over + // the whole collection. Dedupe before the provider groups are + // merged would drop the fleet `xai:subscription` only for a group to + // hand back the same duplicate moments later — the one disagreement + // this dedupe exists to remove, reached by the additive manifest path. + var collected = windows.map { $0.normalized() } // Older responses may not have top-level windows but do have the // additive provider groups. Fill only absent IDs to avoid duplicates. - var seen = Set(normalizedWindows.map { "\($0.canonicalProviderKey):\($0.id)" }) + var seen = Set(collected.map { "\($0.canonicalProviderKey):\($0.id)" }) for group in providerGroups { for window in group.windows.map({ $0.normalized() }) where seen.insert("\(window.canonicalProviderKey):\(window.id)").inserted { - grouped[window.canonicalProviderKey, default: []].append(window) + collected.append(window) } } + let normalizedWindows = Self.dedupedGrokSubscription(collected) + // A provider whose every reading the dedupe removed is not missing: + // its one pool is shown on the other row. Keep it out of the + // sections, or its expected-provider entry renders an empty card + // ("Quota unavailable") beside the row that carries the reading. + let emptiedByDedupe = Set(collected.map(\.canonicalProviderKey)) + .subtracting(normalizedWindows.map(\.canonicalProviderKey)) + var grouped = Dictionary(minimumCapacity: normalizedWindows.count) + for window in normalizedWindows { + grouped[window.canonicalProviderKey, default: []].append(window) + } // Build the manifest view: key, label, via, expected, iconHint, and // sort order, keyed by canonical key. Groups that canonicalize to @@ -716,7 +756,7 @@ public extension QuotaResponse { } var sections: [QuotaPlatformSection] = [] - for entry in manifest { + for entry in manifest where !emptiedByDedupe.contains(entry.key) { sections.append(makeSection(key: entry.key, label: entry.label, via: entry.via, expected: entry.expected, iconHint: entry.iconHint, grouped: grouped, now: now)) } diff --git a/Tests/CodeCapsTests/CompanionModelTests.swift b/Tests/CodeCapsTests/CompanionModelTests.swift index 01248728..32f9e985 100644 --- a/Tests/CodeCapsTests/CompanionModelTests.swift +++ b/Tests/CodeCapsTests/CompanionModelTests.swift @@ -95,6 +95,18 @@ final class CompanionModelTests: XCTestCase { ) } + func testDataSourcesFooterUsesSentenceGap() throws { + let source = try readCompanionContentViewSource() + XCTAssertTrue( + source.contains(#"Text("Readings from each source." + sentenceGap"#), + "The Data Sources footer must use sentenceGap after its first sentence" + ) + XCTAssertTrue( + source.contains(#"the difference shows here." + sentenceGap"#), + "The Data Sources footer must use sentenceGap after its second sentence" + ) + } + // MARK: - Reordering Logic func testPlatformOrderingLogic() { @@ -288,7 +300,7 @@ final class CompanionModelTests: XCTestCase { func testTheCompanionCardShowsTheAtCapLine() throws { let view = try readCompanionContentViewSource() XCTAssertTrue( - view.contains("item.atCapSummary(scope: model.sourceScope)"), + view.contains("item.atCapSummary(scope: scope)"), "the quota card must show the cap line" ) XCTAssertTrue( @@ -684,8 +696,8 @@ final class CompanionModelTests: XCTestCase { XCTAssertTrue(source.contains("sourceLooksLikeHostname")) let content = try readCompanionContentViewSource() XCTAssertTrue( - content.contains("model.sourceScope == .allSources, item.sourceCount > 1"), - "discrepancy UI must follow the multi-source chip gate" + content.contains("item.sourceCount > 1"), + "discrepancy UI must follow the multi-source gate" ) } @@ -741,15 +753,25 @@ final class CompanionModelTests: XCTestCase { ) } + /// PR #306 made the main list canonical: the card always reads + /// `.allSources`, so the header no longer carries a source-scope picker. + /// The device-aware label itself still ships, on the scope the model + /// exposes, and nothing on screen may fall back to the stored raw value + /// (which always says "This iPhone", even on an iPad). func testSourceScopePickerShowsTheDeviceAwareLabel() throws { - let view = try readCompanionContentViewSource() + let source = try readCompanionModelSource() XCTAssertTrue( - view.contains("Text(scope.displayTitle).tag(scope)"), - "The source-scope picker must show the device-aware label" + source.contains("public var displayTitle: String"), + "The scope must keep exposing the device-aware label the picker used to render" ) + let view = try readCompanionContentViewSource() XCTAssertFalse( view.contains("Text(scope.rawValue).tag(scope)"), - "The source-scope picker must not show the stored raw value, which always says This iPhone" + "no scope control may show the stored raw value, which always says This iPhone" + ) + XCTAssertFalse( + view.contains("Picker(\"Source scope\""), + "the main list is canonical-only: the source-scope picker belongs to source detail" ) } diff --git a/Tests/CodeCapsTests/PlatformLogoResourceTests.swift b/Tests/CodeCapsTests/PlatformLogoResourceTests.swift index e6458190..d3fd3c7d 100644 --- a/Tests/CodeCapsTests/PlatformLogoResourceTests.swift +++ b/Tests/CodeCapsTests/PlatformLogoResourceTests.swift @@ -57,8 +57,13 @@ final class PlatformLogoResourceTests: XCTestCase { func testTheResourceBundleCarriesArtwork() throws { let bundle = try XCTUnwrap(ResourceBundle.resolved, "the SwiftPM resource bundle could not be resolved at all") + // `muse-assist` is deliberately absent: PR #306 dropped its four + // artwork files and `PlatformLogo.resourceNames` now maps the key to + // the shared `muse` mark, so the bundle carries no file by that name. + // The key itself still has to resolve, which is what + // `testEveryProviderKeyResolvesABundledMark` above checks. let marks = ["claude", "openai", "gemini", "gemini-color", "gemini-mono", - "cursor", "xai", "grok", "grok-bot", "minimax", "muse", "muse-assist", "muse-code", "muse-code-dark"] + "cursor", "xai", "grok", "grok-bot", "minimax", "muse", "muse-code", "muse-code-dark"] // Every named mark must be present in some form. Deliberately not an // exact count: the owner replacing the fabricated MiniMax `{M}` with // the real PNG mark added a file, and an equality assertion is a test @@ -75,7 +80,7 @@ final class PlatformLogoResourceTests: XCTestCase { /// a different hat: `NSImage` returns an object, and the row draws nothing. func testEveryBundledMarkRasterises() throws { let bundle = try XCTUnwrap(ResourceBundle.resolved) - for name in ["claude", "openai", "gemini", "gemini-mono", "cursor", "xai", "grok", "grok-bot", "minimax", "muse", "muse-assist", "muse-code", "muse-code-dark"] { + for name in ["claude", "openai", "gemini", "gemini-mono", "cursor", "xai", "grok", "grok-bot", "minimax", "muse", "muse-code", "muse-code-dark"] { for ext in ["svg", "png"] { guard let url = bundle.url(forResource: name, withExtension: ext) else { continue } let image = try XCTUnwrap(NSImage(contentsOf: url), "\(name).\(ext) did not load") diff --git a/Tests/QuotaCoreTests/GrokGrpcCreditsTests.swift b/Tests/QuotaCoreTests/GrokGrpcCreditsTests.swift new file mode 100644 index 00000000..d515defc --- /dev/null +++ b/Tests/QuotaCoreTests/GrokGrpcCreditsTests.swift @@ -0,0 +1,188 @@ +import Foundation +import XCTest +@testable import QuotaCore + +/// Fixtures for the grok.com gRPC-web fallback parser, built byte-by-byte +/// from the documented wire contract (CodexBar docs/grok.md): config is +/// field 1 of the reply, a top-level fixed32 is the used percent, field 6 +/// is the current period as a `{start, end}` Timestamp pair on a weekly or +/// monthly cadence, and product shares nest inside repeated field 7 entries +/// so they must not be mistaken for the headline percent. +final class GrokGrpcCreditsTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_761_000_000) + /// Seven days. The credits pool is billed weekly or monthly, so a + /// recognized period is one of those two spans and nothing else. + private let week: UInt64 = 604_800 + + private func varint(_ value: UInt64) -> [UInt8] { + var value = value + var bytes: [UInt8] = [] + repeat { + var byte = UInt8(value & 0x7F) + value >>= 7 + if value > 0 { byte |= 0x80 } + bytes.append(byte) + } while value > 0 + return bytes + } + + private func field(_ number: Int, wire: Int, payload: [UInt8]) -> [UInt8] { + varint(UInt64(number << 3 | wire)) + payload + } + + private func lenField(_ number: Int, _ payload: [UInt8]) -> [UInt8] { + field(number, wire: 2, payload: varint(UInt64(payload.count)) + payload) + } + + private func fixed32Field(_ number: Int, _ value: Float) -> [UInt8] { + var bits = value.bitPattern + return field(number, wire: 5, payload: withUnsafeBytes(of: &bits) { Array($0) }) + } + + private func timestampField(_ number: Int, seconds: UInt64) -> [UInt8] { + lenField(number, field(1, wire: 0, payload: varint(seconds))) + } + + private func periodField(_ number: Int, start: UInt64, end: UInt64) -> [UInt8] { + lenField(number, timestampField(1, seconds: start) + timestampField(2, seconds: end)) + } + + private func framed(_ payload: [UInt8], trailer: Bool = false) -> Data { + var bytes: [UInt8] = [0x00, 0, 0, 0, UInt8(payload.count)] + bytes += payload + if trailer { + let trailer = Array("grpc-status: 0\r\n".utf8) + bytes += [0x80, 0, 0, 0, UInt8(trailer.count)] + trailer + } + return Data(bytes) + } + + func testWrappedFramedReplyYieldsPercentAndPeriod() { + let start: UInt64 = 1_760_400_000 + let end = start + week + let config = fixed32Field(4, 37.5) + periodField(6, start: start, end: end) + let reply = lenField(1, config) + let reading = GrokGrpcCredits.parse(framed(Array(reply), trailer: true), now: now) + XCTAssertEqual(reading?.usedPercent, 37.5) + XCTAssertEqual(reading?.periodStart, Date(timeIntervalSince1970: TimeInterval(start))) + XCTAssertEqual(reading?.periodEnd, Date(timeIntervalSince1970: TimeInterval(end))) + } + + func testRawUnframedConfigIsAccepted() { + let config = fixed32Field(4, 12.25) + let reading = GrokGrpcCredits.parse(Data(config), now: now) + XCTAssertEqual(reading?.usedPercent ?? -1, 12.25, accuracy: 0.001) + } + + func testProductSharesAreNotTheHeadlinePercent() { + // Field 7 entry: {1: product 2, 2: fixed32 55%} before the real 5%. + let share = field(1, wire: 0, payload: varint(2)) + fixed32Field(2, 55) + let config = lenField(7, Array(share)) + fixed32Field(4, 5) + let reading = GrokGrpcCredits.parse(Data(lenField(1, Array(config))), now: now) + XCTAssertEqual(reading?.usedPercent, 5) + } + + func testImplicitZeroForActivePeriodWithoutPercent() { + let start: UInt64 = 1_760_400_000 + let config = periodField(6, start: start, end: start + week) + let reading = GrokGrpcCredits.parse(Data(lenField(1, Array(config))), now: now) + XCTAssertEqual(reading?.usedPercent, 0) + } + + func testNoPercentAndNoActivePeriodIsNil() { + let start: UInt64 = 1_700_000_000 + XCTAssertNil(GrokGrpcCredits.parse(Data(lenField(1, Array(periodField(6, start: start, end: start + week)))), now: now)) + XCTAssertNil(GrokGrpcCredits.parse(Data([0xFF, 0xFF, 0xFF]), now: now)) + XCTAssertNil(GrokGrpcCredits.parse(Data(), now: now)) + } + + // MARK: - Only the documented field may set the period + + /// An unrelated length-delimited field that happens to hold two Timestamps + /// must not be read as the subscription period: a previous-period or + /// trial-window submessage would otherwise invent the window's bounds. + func testAnUnrelatedBytesFieldCannotInventPeriodBounds() { + let start: UInt64 = 1_760_400_000 + // Field 9 is not the current period, but it decodes as a valid pair. + let decoy = periodField(9, start: start, end: start + week) + let config = decoy + fixed32Field(4, 42) + let reading = GrokGrpcCredits.parse(Data(lenField(1, Array(config))), now: now) + XCTAssertEqual(reading?.usedPercent, 42) + XCTAssertNil(reading?.periodStart, "a non-period field must not supply bounds") + XCTAssertNil(reading?.periodEnd) + } + + /// A decoy must not be able to open the implicit-zero branch either. + func testAnUnrelatedBytesFieldCannotOpenTheImplicitZeroBranch() { + let start: UInt64 = 1_760_400_000 + let decoy = periodField(9, start: start, end: start + week) + XCTAssertNil(GrokGrpcCredits.parse(Data(lenField(1, Array(decoy))), now: now)) + } + + /// When the current-period field repeats, the first entry is the period; + /// a later one must not overwrite bounds already captured. + func testTheFirstPeriodFieldWins() { + let first: UInt64 = 1_760_400_000 + let second: UInt64 = 1_760_800_000 + let config = periodField(6, start: first, end: first + week) + + periodField(6, start: second, end: second + week) + let reading = GrokGrpcCredits.parse(Data(lenField(1, Array(config))), now: now) + XCTAssertEqual(reading?.usedPercent, 0) + XCTAssertEqual(reading?.periodStart, Date(timeIntervalSince1970: TimeInterval(first))) + } + + /// A Timestamp pair on no billing cadence is not this field's period. + func testAnUnrecognizedPeriodLengthIsNotCaptured() { + let start: UInt64 = 1_760_400_000 + let config = periodField(6, start: start, end: start + 1_008_000) // ~11.7 days + let reading = GrokGrpcCredits.parse(Data(lenField(1, Array(config))), now: now) + XCTAssertNil(reading, "a span that is neither weekly nor monthly is not the credits period") + } + + // MARK: - A rejected percentage is still a published percentage + + /// A percentage that arrived but could not be accepted must not fall + /// through to the implicit-zero rule, which would report the pool as + /// untouched when the wire said otherwise. + func testOutOfRangePercentIsNotReadAsZeroUsage() { + let start: UInt64 = 1_760_400_000 + let config = fixed32Field(4, 150) + periodField(6, start: start, end: start + week) + let reading = GrokGrpcCredits.parse(Data(lenField(1, Array(config))), now: now) + XCTAssertNil(reading, "150% is not a reading, and not a licence to infer 0% used") + } + + /// The same holds when the published value is not finite. + func testNonFinitePercentIsNotReadAsZeroUsage() { + let start: UInt64 = 1_760_400_000 + var nanBits = Float.nan.bitPattern + let config = field(4, wire: 5, payload: withUnsafeBytes(of: &nanBits) { Array($0) }) + + periodField(6, start: start, end: start + week) + let reading = GrokGrpcCredits.parse(Data(lenField(1, Array(config))), now: now) + XCTAssertNil(reading) + } + + // MARK: - The trailer status + + private func trailerOnly(_ text: String) -> Data { + let trailer = Array(text.utf8) + return Data([0x80, 0, 0, 0, UInt8(trailer.count)] + trailer) + } + + /// Both trailer spellings carry status 16: the unspaced form gRPC-web + /// servers emit and the header-style form with a space after the colon. + func testStatus16IsReadInBothTrailerSpellings() { + XCTAssertTrue(GrokGrpcCredits.isUnauthenticated(trailerOnly("grpc-status:16\r\n"))) + XCTAssertTrue(GrokGrpcCredits.isUnauthenticated(trailerOnly("grpc-status: 16\r\ngrpc-message: no-credentials\r\n"))) + } + + func testOtherStatusesAndMissingTrailersAreNotStatus16() { + XCTAssertFalse(GrokGrpcCredits.isUnauthenticated(trailerOnly("grpc-status: 0\r\n"))) + XCTAssertFalse(GrokGrpcCredits.isUnauthenticated(trailerOnly("grpc-status: 160\r\n"))) + XCTAssertFalse(GrokGrpcCredits.isUnauthenticated(framed(Array(lenField(1, fixed32Field(4, 5)))))) + XCTAssertFalse(GrokGrpcCredits.isUnauthenticated(Data())) + } + + func testRequestFrameMatchesDocumentedBytes() { + XCTAssertEqual(GrokGrpcCredits.requestFrame, Data([0x00, 0x00, 0x00, 0x00, 0x02, 0x08, 0x00])) + } +} diff --git a/Tests/QuotaCoreTests/GrokXaiDedupeTests.swift b/Tests/QuotaCoreTests/GrokXaiDedupeTests.swift new file mode 100644 index 00000000..e3ad64ca --- /dev/null +++ b/Tests/QuotaCoreTests/GrokXaiDedupeTests.swift @@ -0,0 +1,112 @@ +import XCTest +@testable import QuotaCore + +/// The same Grok credits pool arrives as `grok` (local CLI reader) and as +/// `xai:subscription` (fleet collector). Only the fresher may survive. +final class GrokXaiDedupeTests: XCTestCase { + private func window(providerKey: String, id: String, occurredAt: String, label: String = "Weekly window") -> QuotaWindow { + QuotaWindow(id: id, provider: providerKey, providerKey: providerKey, label: label, + remainingPercent: 50, occurredAt: occurredAt, source: "test") + } + + func testFresherLocalGrokDropsStaleFleetXai() { + let windows = [ + window(providerKey: "grok", id: "local-mac:grok:subscription", occurredAt: "2026-10-22T03:00:00Z"), + window(providerKey: "xai", id: "xai:subscription", occurredAt: "2026-10-21T22:02:00Z"), + ] + let result = QuotaResponse.dedupedGrokSubscription(windows) + XCTAssertEqual(result.map(\.id), ["local-mac:grok:subscription"]) + } + + func testFresherFleetXaiDropsStaleLocalGrok() { + let windows = [ + window(providerKey: "grok", id: "local-mac:grok:subscription", occurredAt: "2026-10-21T20:00:00Z"), + window(providerKey: "xai", id: "xai:subscription", occurredAt: "2026-10-22T01:00:00Z"), + ] + let result = QuotaResponse.dedupedGrokSubscription(windows) + XCTAssertEqual(result.map(\.id), ["xai:subscription"]) + } + + func testOtherXaiWindowsAreUntouched() { + let windows = [ + window(providerKey: "grok", id: "local-mac:grok:subscription", occurredAt: "2026-10-22T03:00:00Z"), + window(providerKey: "xai", id: "xai:api-monthly", occurredAt: "2026-10-21T22:02:00Z", label: "API credits"), + ] + XCTAssertEqual(QuotaResponse.dedupedGrokSubscription(windows).count, 2) + } + + func testSingleSidedPoolIsUntouched() { + let windows = [window(providerKey: "xai", id: "xai:subscription", occurredAt: "2026-10-21T22:02:00Z")] + XCTAssertEqual(QuotaResponse.dedupedGrokSubscription(windows).count, 1) + } + + /// The pool is identified by canonical identity, not by a suffix, so an + /// unrelated window whose id merely ends in `:subscription` cannot stand + /// in for it and delete the real reading. + func testAnUnrelatedSubscriptionSuffixIsNotThePool() { + let windows = [ + window(providerKey: "grok", id: "grok:legacy:subscription", occurredAt: "2026-10-22T03:00:00Z"), + window(providerKey: "xai", id: "xai:subscription", occurredAt: "2026-10-21T22:02:00Z"), + ] + let result = QuotaResponse.dedupedGrokSubscription(windows) + XCTAssertEqual(result.map(\.id).sorted(), ["grok:legacy:subscription", "xai:subscription"]) + } + + /// The fleet reading arriving in `providerGroups` — the additive manifest + /// path — must not re-add the duplicate the dedupe just dropped. + func testDedupeSurvivesTheProviderGroupsMerge() { + let local = window(providerKey: "grok", id: "local-mac:grok:subscription", occurredAt: "2026-10-22T03:00:00Z") + let fleet = window(providerKey: "xai", id: "xai:subscription", occurredAt: "2026-10-21T22:02:00Z") + let response = QuotaResponse( + generatedAt: "2026-10-22T03:00:00Z", + windows: [local], + providerGroups: [ + QuotaProviderGroup(provider: "xai", providerLabel: "xAI", expected: true, windows: [fleet]), + ]) + let subscriptionWindows = response.platformSections(now: Date()) + .flatMap(\.windows) + .map(\.window.id) + .filter { $0.contains("subscription") } + .sorted() + XCTAssertEqual(subscriptionWindows, ["local-mac:grok:subscription"], + "one credits pool must stay one row after the group merge") + } + + /// The fleet xAI group held only the duplicate pool, and the fresher local + /// reading won. xAI is not missing (its pool is on the Grok row), so it + /// must not render as an empty "Quota unavailable" card beside it. + func testAGroupTheDedupeEmptiesRendersNoEmptyCard() { + let local = window(providerKey: "grok", id: "local-mac:grok:subscription", occurredAt: "2026-10-22T03:00:00Z") + let fleet = window(providerKey: "xai", id: "xai:subscription", occurredAt: "2026-10-21T22:02:00Z") + let response = QuotaResponse( + generatedAt: "2026-10-22T03:00:00Z", + windows: [local], + providerGroups: [ + QuotaProviderGroup(provider: "grok", providerLabel: "Grok", expected: true, windows: [local]), + QuotaProviderGroup(provider: "xai", providerLabel: "xAI", expected: true, windows: [fleet]), + ]) + let sections = response.platformSections(now: Date()) + XCTAssertNil(sections.first { $0.providerKey == "xai" }, "the emptied xAI group must not render an empty card") + XCTAssertEqual(sections.first { $0.providerKey == "grok" }?.windows.map(\.window.id), + ["local-mac:grok:subscription"]) + } + + /// The same holds the other way round, and on the offline path, where the + /// bundled expected list (which names both Grok and xAI) is the manifest. + func testTheStalerSideLeavesNoEmptyCardOnTheOfflinePath() { + let local = window(providerKey: "grok", id: "local-mac:grok:subscription", occurredAt: "2026-10-21T20:00:00Z") + let fleet = window(providerKey: "xai", id: "xai:subscription", occurredAt: "2026-10-22T01:00:00Z") + let sections = QuotaResponse(generatedAt: "", windows: [local, fleet]).platformSections(now: Date()) + XCTAssertNil(sections.first { $0.providerKey == "grok" }, "the emptied Grok row must not render an empty card") + XCTAssertEqual(sections.first { $0.providerKey == "xai" }?.windows.map(\.window.id), ["xai:subscription"]) + } + + /// A provider that keeps another reading after the dedupe keeps its card. + func testAProviderWithAnotherReadingKeepsItsCard() { + let local = window(providerKey: "grok", id: "local-mac:grok:subscription", occurredAt: "2026-10-22T03:00:00Z") + let fleet = window(providerKey: "xai", id: "xai:subscription", occurredAt: "2026-10-21T22:02:00Z") + let api = window(providerKey: "xai", id: "xai:api-monthly", occurredAt: "2026-10-21T22:02:00Z", label: "API credits") + let sections = QuotaResponse(generatedAt: "", windows: [local, fleet, api]).platformSections(now: Date()) + XCTAssertEqual(sections.first { $0.providerKey == "xai" }?.windows.map(\.window.id), ["xai:api-monthly"]) + } +} diff --git a/Tests/QuotaCoreTests/LocalQuotaReaderTests.swift b/Tests/QuotaCoreTests/LocalQuotaReaderTests.swift index d1889f67..622e30f7 100644 --- a/Tests/QuotaCoreTests/LocalQuotaReaderTests.swift +++ b/Tests/QuotaCoreTests/LocalQuotaReaderTests.swift @@ -372,6 +372,86 @@ final class LocalQuotaReaderTests: XCTestCase { XCTAssertNil(grok.absoluteRemaining) } + // MARK: - The grok.com credits fallback + + private static let fallbackToken = "fixture-grok-credential-123456" + + /// A CLI-proxy answer with no percentage, so `readGrok` runs the grok.com + /// fallback; `grokCom` answers that gRPC-web request. + private func readGrokThroughFallback( + _ grokCom: @escaping @Sendable () -> (Data, HTTPURLResponse) + ) async throws -> LocalQuotaResult { + let home = try makeFixtureHome() + defer { try? FileManager.default.removeItem(at: home) } + try writeJSON(["token": Self.fallbackToken], to: home.appendingPathComponent(".grok/auth.json")) + let reader = LocalQuotaReader(homeDirectory: home, now: { self.observedAt }, fetchJSON: { request in + let host = request.url?.host ?? "" + let path = request.url?.path ?? "" + if host == "grok.com" { return grokCom() } + if host == "cli-chat-proxy.grok.com", path == "/v1/billing" { + return Self.httpResponse(#"{"period":"weekly"}"#) + } + return Self.httpResponse("{}", status: 500) + }, runAntigravity: { Data("{}".utf8) }) + return await reader.read() + } + + /// `{1: {4: fixed32 used%}}`: a reply whose config carries only the usage percent. + private static func creditsReply(usedPercent: Float) -> [UInt8] { + var bits = usedPercent.bitPattern.littleEndian + let percent: [UInt8] = [0x25] + withUnsafeBytes(of: &bits) { Array($0) } // field 4, fixed32 + return [0x0A, UInt8(percent.count)] + percent // field 1, length-delimited + } + + /// An optional gRPC-web data frame, then a trailer frame. + private static func grpcWebReply(payload: [UInt8], trailer: String) -> Data { + let trailerBytes = Array(trailer.utf8) + var bytes: [UInt8] = [] + if !payload.isEmpty { bytes += [0x00, 0, 0, 0, UInt8(payload.count)] + payload } + bytes += [0x80, 0, 0, 0, UInt8(trailerBytes.count)] + trailerBytes + return Data(bytes) + } + + private static func grpcResponse(_ data: Data, status: Int = 200) -> (Data, HTTPURLResponse) { + let url = URL(string: "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")! + let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + return (data, response) + } + + func testTheCreditsFallbackFillsThePercentageTheProxyLeftOut() async throws { + let reply = Self.grpcWebReply(payload: Self.creditsReply(usedPercent: 37.5), trailer: "grpc-status: 0\r\n") + let result = try await readGrokThroughFallback { Self.grpcResponse(reply) } + let grok = try XCTUnwrap(result.windows.first { $0.providerKey == "grok" }) + XCTAssertEqual(grok.remainingPercent, 62.5) + XCTAssertFalse(grok.remainingUnknown) + XCTAssertNil(result.issues["grok"]) + } + + /// gRPC status 16 is grok.com's documented answer to a bearer-only probe + /// with no Web Key Exchange keypair. The proxy just accepted the same + /// token, so this is the ordinary miss and never a "sign in again". + func testGrpcStatus16FromGrokComIsAFallbackMissNotARejectedToken() async throws { + let reply = Self.grpcWebReply(payload: [], trailer: "grpc-status: 16\r\ngrpc-message: no-credentials\r\n") + let result = try await readGrokThroughFallback { Self.grpcResponse(reply) } + XCTAssertEqual(result.issues["grok"], "Grok returned no readable quota percentage.") + } + + func testHTTP403FromGrokComIsAFallbackMissNotARejectedToken() async throws { + let result = try await readGrokThroughFallback { Self.grpcResponse(Data(), status: 403) } + XCTAssertEqual(result.issues["grok"], "Grok returned no readable quota percentage.") + } + + /// Only an HTTP 401 is reported as a refusal: masked to first 8 and last + /// 4, never the raw token, and without sending the owner to sign in. + func testHTTP401FromGrokComIsReportedWithAMaskedPreview() async throws { + let result = try await readGrokThroughFallback { Self.grpcResponse(Data(), status: 401) } + let issue = try XCTUnwrap(result.issues["grok"]) + XCTAssertTrue(issue.contains("grok.com refused the credits fallback for GROK_TOKEN (fixture-…3456)"), issue) + XCTAssertFalse(issue.contains(Self.fallbackToken)) + XCTAssertFalse(issue.lowercased().contains("sign in")) + } + func testClaudeSkipsNonCadenceRateLimitKeys() async throws { let root = try makeFixtureHome() defer { try? FileManager.default.removeItem(at: root) } diff --git a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift index 9a125a95..aa9abe8e 100644 --- a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift +++ b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift @@ -2002,9 +2002,7 @@ public final class CompanionQuotaModel: ObservableObject { let futureResets = primaryWindows.compactMap(\.resetAt).filter { $0 > now } let nearestReset = futureResets.min() ?? primaryWindows.compactMap(\.resetAt).min() - let sourceCount = Set(primaryWindows.compactMap(\.source) + duplicateWindows.compactMap(\.source)).count - let sourceSuffix = sourceCount > 1 ? " (\(sourceCount) sources)" : "" - let subtitle = Self.subtitlePhrase(for: primaryWindows, fallback: title) + sourceSuffix + let subtitle = Self.subtitlePhrase(for: primaryWindows, fallback: title) let displayTitle = WidgetPlatformDisplay.title( canonicalKey: providerKey, manifestProviderLabel: title, @@ -2113,8 +2111,6 @@ public final class CompanionQuotaModel: ObservableObject { let controllingPct: Double? let isExhausted: Bool - let sourceCount = Set(windows.compactMap(\.source)).count - let sourceSuffix = sourceCount > 1 ? " (\(sourceCount) sources)" : "" let subtitle: String // The headline reads the fresh windows, the way the Mac's dashboard row @@ -2130,13 +2126,13 @@ public final class CompanionQuotaModel: ObservableObject { if weeklyExhausted && weeklyWin != nil { controllingPct = weeklyPct ?? 0 isExhausted = true - subtitle = "\(QuotaWindowVocabulary.sevenDay) limit exhausted\(sourceSuffix)" + subtitle = "\(QuotaWindowVocabulary.sevenDay) limit exhausted" } else { controllingPct = freshPercents.min() ?? validPercents.min() ?? windows.compactMap(\.remainingPercent).min() isExhausted = QuotaCapRule.isAtCap(remainingPercent: freshPercents.min()) - subtitle = "\(QuotaWindowVocabulary.fiveHour) & \(QuotaWindowVocabulary.sevenDay)\(sourceSuffix)" + subtitle = "\(QuotaWindowVocabulary.fiveHour) & \(QuotaWindowVocabulary.sevenDay)" } // Collapse the pool's observations into one row per period. diff --git a/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift b/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift index 63c1c81c..7de5b0f5 100644 --- a/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift +++ b/ios/CodeCapsCompanion/App/Views/CompanionContentView.swift @@ -42,8 +42,6 @@ public struct CompanionContentView: View { NavigationStack { ScrollView { VStack(spacing: 16) { - headerCard - if let error = model.lastError { errorBanner(error) } @@ -190,19 +188,6 @@ public struct CompanionContentView: View { .accessibilityLabel("Fleet Status, \(model.items.count) platforms monitored") } - // MARK: - Source scope - - private var headerCard: some View { - Picker("Source scope", selection: $model.sourceScope) { - ForEach(CompanionSourceScope.allCases) { scope in - Text(scope.displayTitle).tag(scope) - } - } - .pickerStyle(.segmented) - .padding(14) - .background(cardBackground, in: RoundedRectangle(cornerRadius: 12)) - } - // MARK: - Error Banner private func errorBanner(_ message: String) -> some View { @@ -305,14 +290,17 @@ public struct CompanionContentView: View { } private func quotaCard(_ item: CompanionQuotaItem) -> some View { + // One canonical reading per allowance, the same resolution the Mac + // app leads with. Source detail lives in Settings, for debugging; + // the main list just shows quotas. + let scope: CompanionSourceScope = .allSources let isExpanded = expandedIds.contains(item.id) && item.isExpandable - let short = item.cardShortWindow(scope: model.sourceScope) - let long = item.cardLongWindow(scope: model.sourceScope) - let cardWindows = item.cardWindows(scope: model.sourceScope) + let short = item.cardShortWindow(scope: scope) + let long = item.cardLongWindow(scope: scope) + let cardWindows = item.cardWindows(scope: scope) let extraWindows = cardWindows.filter { $0.id != short?.window.id && $0.id != long?.window.id } - let headlineUpdatedLabel = [short?.updatedLabel, long?.updatedLabel].compactMap { $0 }.first return VStack(alignment: .leading, spacing: 12) { // Main clickable row @@ -323,22 +311,6 @@ public struct CompanionContentView: View { HStack(spacing: 6) { Text(item.title) .font(.system(size: 15, weight: .semibold)) - - if model.sourceScope == .allSources, item.sourceCount > 1 { - Text("\(item.sourceCount) sources") - .font(.system(size: 10, weight: .semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.secondary.opacity(0.14), in: Capsule()) - } - - if model.sourceScope == .allSources, item.sourceCount > 1, - item.hasSourceDiscrepancy(scope: model.sourceScope) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.orange) - .accessibilityLabel("Warning: sources disagree on this allowance") - } } if let sub = item.subtitle { @@ -366,15 +338,9 @@ public struct CompanionContentView: View { } VStack(alignment: .trailing, spacing: 2) { - Text(item.cardDisplayPercent(scope: model.sourceScope)) + Text(item.cardDisplayPercent(scope: scope)) .font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit()) - .foregroundStyle(item.cardStatusColor(scope: model.sourceScope)) - if let headlineUpdatedLabel { - Text(headlineUpdatedLabel) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - } + .foregroundStyle(item.cardStatusColor(scope: scope)) } if item.isExpandable { @@ -391,7 +357,7 @@ public struct CompanionContentView: View { // The cap line. The Mac's menu-bar window and PiP say which // allowance ran out and when it lifts; the phone said nothing, so // a capped Codex or Antigravity plan looked like any other. - if let atCapSummary = item.atCapSummary(scope: model.sourceScope) { + if let atCapSummary = item.atCapSummary(scope: scope) { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 11, weight: .semibold)) @@ -419,15 +385,13 @@ public struct CompanionContentView: View { if let short { CompanionMeterView( caption: short.window.caption, - window: short.window, - updatedLabel: short.updatedLabel + window: short.window ) } if let long, long.window.id != short?.window.id { CompanionMeterView( caption: long.window.caption, - window: long.window, - updatedLabel: long.updatedLabel + window: long.window ) } else if short == nil { if let first = cardWindows.first { @@ -436,28 +400,6 @@ public struct CompanionContentView: View { } } - if model.sourceScope == .allSources, item.sourceCount > 1, - item.hasSourceDiscrepancy(scope: model.sourceScope) { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.orange) - Text(item.sourceDiscrepancies(scope: model.sourceScope).first?.description ?? "Sources differ") - .font(.system(size: 11.5, weight: .semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - .minimumScaleFactor(0.85) - Spacer() - } - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Color.orange.opacity(0.14), in: RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.orange.opacity(0.28), lineWidth: 0.5) - ) - } - // Multiple time periods / windows when expanded (only extra allowances not already displayed on the card) if isExpanded && !extraWindows.isEmpty { Divider() @@ -664,8 +606,8 @@ public struct CompanionContentView: View { Text(item.title) .font(.system(size: 14, weight: .semibold)) Spacer() - if model.sourceScope == .allSources, item.sourceCount > 1, - item.hasSourceDiscrepancy(scope: model.sourceScope) { + if item.sourceCount > 1, + item.hasSourceDiscrepancy(scope: .allSources) { Label("Sources differ ≥ 10 pts", systemImage: "exclamationmark.triangle.fill") .font(.system(size: 11, weight: .medium)) .foregroundStyle(.orange) @@ -686,7 +628,7 @@ public struct CompanionContentView: View { .padding(.vertical, 1.5) .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 4)) } - if item.isStaleInSourceDetail(reading, scope: model.sourceScope) { + if item.isStaleInSourceDetail(reading, scope: .allSources) { Text("Stale") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.secondary) @@ -707,12 +649,12 @@ public struct CompanionContentView: View { .foregroundStyle(reading.statusColor) } .padding(.vertical, 2) - .opacity(item.isStaleInSourceDetail(reading, scope: model.sourceScope) ? 0.72 : 1) + .opacity(item.isStaleInSourceDetail(reading, scope: .allSources) ? 0.72 : 1) } - if model.sourceScope == .allSources, item.sourceCount > 1, - item.hasSourceDiscrepancy(scope: model.sourceScope) { - ForEach(item.sourceDiscrepancies(scope: model.sourceScope)) { disc in + if item.sourceCount > 1, + item.hasSourceDiscrepancy(scope: .allSources) { + ForEach(item.sourceDiscrepancies(scope: .allSources)) { disc in HStack(spacing: 5) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 10, weight: .semibold)) @@ -730,8 +672,9 @@ public struct CompanionContentView: View { } header: { Text("Data Sources & Mirrors") } footer: { - Text("CodeCaps monitors primary and mirror feeds for each platform." + sentenceGap - + "In All Sources, when a card lists two or more feeds reported within 20 minutes that disagree by 10 points or more, a warning appears on the card (hostnames are ignored). Otherwise the newest reading is shown and older feeds are marked Stale here.") + Text("Readings from each source." + sentenceGap + + "When two sources that reported within 20 minutes differ by 10 points or more, the difference shows here." + sentenceGap + + "The main list always shows the newest reading.") } .id("dataSourcesMirrorsSection") .accessibilityIdentifier("dataSourcesMirrorsSection") @@ -954,24 +897,24 @@ public struct CompanionContentView: View { public struct CompanionMeterView: View { public let caption: String public let window: CompanionWindowItem - public let updatedLabel: String? - public init(caption: String, window: CompanionWindowItem, updatedLabel: String? = nil) { + public init(caption: String, window: CompanionWindowItem) { self.caption = caption self.window = window - self.updatedLabel = updatedLabel } public var body: some View { - VStack(alignment: .trailing, spacing: 2) { + // Fixed columns so the percent sits in the same place on every card: + // caption, bar, percent, countdown. No per-window timestamps — the + // header already carries the last-updated time. HStack(spacing: 4) { Text(caption) .font(.system(size: 11, weight: .bold)) .foregroundStyle(.primary) - .lineLimit(2) + .lineLimit(1) .minimumScaleFactor(0.75) .multilineTextAlignment(.trailing) - .frame(width: 52, alignment: .trailing) + .frame(width: 32, alignment: .trailing) CompanionUsageBar( remainingPercent: window.isMasked ? nil : window.remainingPercent, @@ -985,25 +928,16 @@ public struct CompanionMeterView: View { .foregroundStyle(window.statusColor) .lineLimit(1) .minimumScaleFactor(0.8) - .frame(width: 36, alignment: .trailing) + .frame(width: 40, alignment: .trailing) Text(window.meterCountdown()) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.primary.opacity(0.85)) .lineLimit(1) .minimumScaleFactor(0.8) - .frame(width: 44, alignment: .center) + .frame(width: 46, alignment: .leading) } - .minimumScaleFactor(0.85) - if let updatedLabel { - Text(updatedLabel) - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - } - .frame(maxWidth: .infinity, alignment: .trailing) + .frame(maxWidth: .infinity) } } diff --git a/scripts/regenerate-provider-marks.py b/scripts/regenerate-provider-marks.py index 96f06673..5b79e6dd 100644 --- a/scripts/regenerate-provider-marks.py +++ b/scripts/regenerate-provider-marks.py @@ -221,13 +221,9 @@ def owner(*parts: str) -> str: Target("Sources/CodeCaps/Resources/ProviderMarks/gemini-light.png", repo_svg("gemini.svg"), "svg", "own"), # Muse -- owner raster with 840px of art. Target("Sources/CodeCaps/Resources/ProviderMarks/muse.png", MUSE, "png", "owner"), - Target("Sources/CodeCaps/Resources/ProviderMarks/muse-assist.png", MUSE, "png", "owner"), Target("Sources/CodeCaps/Resources/ProviderMarks/muse-color.png", MUSE, "png", "owner"), Target("Sources/CodeCaps/Resources/ProviderMarks/muse-dark.png", MUSE, "png", "owner"), Target("Sources/CodeCaps/Resources/ProviderMarks/muse-light.png", MUSE, "png", "owner"), - Target("Sources/CodeCaps/Resources/ProviderMarks/muse-assist-color.png", MUSE, "png", "owner"), - Target("Sources/CodeCaps/Resources/ProviderMarks/muse-assist-dark.png", MUSE, "png", "owner"), - Target("Sources/CodeCaps/Resources/ProviderMarks/muse-assist-light.png", MUSE, "png", "owner"), # Muse Code -- own vector. Target("Sources/CodeCaps/Resources/ProviderMarks/muse-code.png", repo_svg("muse-code.svg"), "svg", "own"), Target("Sources/CodeCaps/Resources/ProviderMarks/muse-code-color.png", repo_svg("muse-code.svg"), "svg", "own"),