From 5458091eedcf95b88b08bad015fdb5c8b07d591a Mon Sep 17 00:00:00 2001 From: jky Date: Sat, 15 Aug 2026 04:13:01 +0800 Subject: [PATCH 1/2] feat(deepseek): Safari web-auth import, balance consumption & per-model cost Squashed from 3 commits: original feature, hashed account keys, credential-safe test fixtures (GitGuardian-clean). --- .../CodexBar/MenuCardView+ModelHelpers.swift | 31 +++ Sources/CodexBar/MenuCardView.swift | 2 + .../Resources/en.lproj/Localizable.strings | 9 +- .../zh-Hans.lproj/Localizable.strings | 9 +- .../DeepSeekBalanceHistoryStore.swift | 254 ++++++++++++++++++ .../DeepSeekPlatformTokenImporter.swift | 191 ++++++++++++- .../DeepSeek/DeepSeekProviderDescriptor.swift | 83 +++++- .../DeepSeek/DeepSeekUsageCostParser.swift | 30 ++- .../DeepSeek/DeepSeekUsageFetcher.swift | 24 +- .../DeepSeekBalanceHistoryStoreTests.swift | 215 +++++++++++++++ .../DeepSeekPlatformTokenImporterTests.swift | 1 + .../DeepSeekProviderDescriptorTests.swift | 2 + .../DeepSeekUsageCostParserTests.swift | 137 ++++++++++ .../DeepSeekUsageFetcherTests.swift | 1 + .../CodexBarTests/MenuCardDeepSeekTests.swift | 5 +- docs/deepseek.md | 5 +- 16 files changed, 971 insertions(+), 28 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/DeepSeek/DeepSeekBalanceHistoryStore.swift create mode 100644 Tests/CodexBarTests/DeepSeekBalanceHistoryStoreTests.swift diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 2c542f630f..7f64928e67 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -552,6 +552,37 @@ extension UsageMenuCardView.Model { } } + /// Localizes DeepSeek platform detail sections (titles + row labels) at the app layer. + /// Core emits English labels; the app layer owns localization, mirroring `sub2APILocalizedDetails`. + static func deepSeekLocalizedDetails(_ details: [ProviderDetailSection]) -> [ProviderDetailSection] { + let labelMap: [String: String] = [ + "Today": L("Today"), + "This month": L("This month"), + "Requests": L("Requests"), + "Top model": L("Top model"), + "Detailed usage": L("Detailed usage"), + "Daily tokens": L("Daily tokens"), + "tokens": L("tokens"), + "Cache-hit input": L("Cache-hit input"), + "Cache-miss input": L("Cache-miss input"), + "Output": L("Output"), + ] + return details.compactMap { section in + let title = section.title.flatMap { labelMap[$0] } ?? section.title + let rows = section.rows.compactMap { row in + let label = labelMap[row.label] ?? row.label + return try? ProviderDetailSection.Row( + label: label, + value: row.value, + secondaryValue: row.secondaryValue) + } + return try? ProviderDetailSection( + title: title, + rows: rows, + chart: section.chart) + } + } + static func resetText( for window: RateWindow, style: ResetTimeDisplayStyle, diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 681429edb3..4b17019306 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1039,6 +1039,8 @@ extension UsageMenuCardView.Model { } if input.provider == .sub2api { details = Self.sub2APILocalizedDetails(details) + } else if input.provider == .deepseek { + details = Self.deepSeekLocalizedDetails(details) } guard input.hidePersonalInfo else { return details } return details.compactMap { section in diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index aa1580cb8f..7fc34379f5 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -950,7 +950,7 @@ "Chrome profile" = "Chrome profile"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Choose which signed-in DeepSeek Platform session supplies detailed usage."; "Detailed usage unavailable." = "Detailed usage unavailable."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Sign in to DeepSeek Platform in Chrome for detailed usage."; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; "Select a DeepSeek Chrome profile in Settings." = "Select a DeepSeek Chrome profile in Settings."; "Select profile…" = "Select profile…"; "cache-hit input" = "cache-hit input"; @@ -1437,3 +1437,10 @@ "5-hour usage" = "5-hour usage"; "7-day usage" = "7-day usage"; "Total usage" = "Total usage"; + +"Detailed usage" = "Detailed usage"; +"Daily tokens" = "Daily tokens"; +"tokens" = "tokens"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Output" = "Output"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 3ed80d8bcf..b685d2d81b 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1240,7 +1240,7 @@ "Chrome profile" = "Chrome 配置文件"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "选择要提供详细用量的已登录 DeepSeek Platform 会话。"; "Detailed usage unavailable." = "详细用量不可用。"; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "请在 Chrome 中登录 DeepSeek Platform 以查看详细用量。"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "请在 Chrome 或 Safari 中登录 DeepSeek Platform 以查看详细用量。Safari 需要为 CodexBar 授予完全磁盘访问权限。"; "Select a DeepSeek Chrome profile in Settings." = "请在“设置”中选择 DeepSeek Chrome 配置文件。"; "Select profile…" = "选择配置文件…"; @@ -1411,3 +1411,10 @@ "5-hour usage" = "5 小时用量"; "7-day usage" = "7 天用量"; "Total usage" = "总用量"; + +"Detailed usage" = "详细用量"; +"Daily tokens" = "每日 token"; +"tokens" = "token"; +"Cache-hit input" = "缓存命中输入"; +"Cache-miss input" = "缓存未命中输入"; +"Output" = "输出"; diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekBalanceHistoryStore.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekBalanceHistoryStore.swift new file mode 100644 index 0000000000..b41da46b1d --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekBalanceHistoryStore.swift @@ -0,0 +1,254 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Persists DeepSeek balance snapshots so the app can derive consumption +/// (spent = baseline − current balance) when the API only exposes a balance. +/// +/// The DeepSeek balance endpoint has no "used" field, so per-day and lifetime +/// spend must be inferred from balance deltas. Recharges (balance increases) +/// reset the baseline so a top-up is not mistaken for negative spend. +/// +/// Per local day we keep the first and the last observed balance. The first +/// sample anchors "today's starting balance"; the last sample tracks the +/// current balance trend and detects recharges that happened mid-day. +public struct DeepSeekBalanceHistoryStore: @unchecked Sendable { + public struct DayRecord: Codable, Equatable, Sendable { + /// Start of the local day (calendar-normalized). + public let day: Date + public let firstBalance: Double + public let firstCapturedAt: Date + public let lastBalance: Double + public let lastCapturedAt: Date + + public init( + day: Date, + firstBalance: Double, + firstCapturedAt: Date, + lastBalance: Double, + lastCapturedAt: Date) + { + self.day = day + self.firstBalance = firstBalance + self.firstCapturedAt = firstCapturedAt + self.lastBalance = lastBalance + self.lastCapturedAt = lastCapturedAt + } + } + + public struct ConsumptionSummary: Equatable, Sendable { + /// Spend since the most recent recharge baseline (lifetime). + public let totalSpent: Double? + /// Spend between the start of the current local day and now. + public let todaySpent: Double? + /// Balance at the start of the current local day (before today's spend). + public let todayStartBalance: Double? + /// Currency symbol derived from the recorded balance currency. + public let currency: String + + public init( + totalSpent: Double?, + todaySpent: Double?, + todayStartBalance: Double?, + currency: String) + { + self.totalSpent = totalSpent + self.todaySpent = todaySpent + self.todayStartBalance = todayStartBalance + self.currency = currency + } + + public static let unavailable = ConsumptionSummary( + totalSpent: nil, + todaySpent: nil, + todayStartBalance: nil, + currency: "¥") + } + + private struct Payload: Codable { + let version: Int + let accounts: [String: [DayRecord]] + + static let currentVersion = 1 + } + + private static let epsilon = 0.0001 + private let fileURL: URL + private let fileManager: FileManager + private let calendar: Calendar + private let lock = NSLock() + + public init( + fileURL: URL = Self.defaultURL(), + fileManager: FileManager = .default, + calendar: Calendar = .current) + { + self.fileURL = fileURL + self.fileManager = fileManager + self.calendar = calendar + } + + public static func defaultURL() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser + return base + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("deepseek-balance-history.json") + } + + // MARK: - Recording + + /// Records a balance sample for the given account key. Per local day only + /// the first and the last sample are retained; older days are pruned to a + /// 90-day window. + public func record( + balance: Double, + currency: String, + accountKey: String, + at capturedAt: Date = Date()) + { + guard balance.isFinite, balance >= 0 else { return } + let key = accountKey.isEmpty ? "default" : accountKey + self.lock.lock() + defer { self.lock.unlock() } + + let payload = self.loadLocked() ?? Payload(version: Payload.currentVersion, accounts: [:]) + var days = payload.accounts[key] ?? [] + let dayStart = self.calendar.startOfDay(for: capturedAt) + var upserted = false + for index in days.indices where days[index].day == dayStart { + var record = days[index] + if capturedAt < record.firstCapturedAt { + record = DayRecord( + day: dayStart, + firstBalance: balance, + firstCapturedAt: capturedAt, + lastBalance: record.lastBalance, + lastCapturedAt: record.lastCapturedAt) + } else { + record = DayRecord( + day: dayStart, + firstBalance: record.firstBalance, + firstCapturedAt: record.firstCapturedAt, + lastBalance: balance, + lastCapturedAt: capturedAt) + } + days[index] = record + upserted = true + break + } + if !upserted { + days.append(DayRecord( + day: dayStart, + firstBalance: balance, + firstCapturedAt: capturedAt, + lastBalance: balance, + lastCapturedAt: capturedAt)) + } + days.sort { $0.day < $1.day } + // Retain 90 days. + let cutoff = self.calendar.date(byAdding: .day, value: -90, to: capturedAt) ?? capturedAt + days.removeAll { $0.day < cutoff } + var accounts = payload.accounts + accounts[key] = days + self.saveLocked(Payload(version: Payload.currentVersion, accounts: accounts)) + } + + // MARK: - Consumption derivation + + public func consumptionSummary( + for accountKey: String, + currentBalance: Double, + currency: String, + now: Date = Date()) -> ConsumptionSummary + { + let key = accountKey.isEmpty ? "default" : accountKey + self.lock.lock() + let payload = self.loadLocked() + self.lock.unlock() + guard let days = payload?.accounts[key], !days.isEmpty else { + return .unavailable + } + + let symbol = currency == "CNY" ? "¥" : "$" + + // Lifetime spend: walk history in time order, reset the baseline on any + // recharge (balance rising above the current baseline). + var baseline = days[0].firstBalance + for record in days { + if record.lastBalance > baseline + Self.epsilon { + baseline = record.lastBalance + } + } + let totalSpent = max(0, baseline - currentBalance) + + // Today's spend. Anchor on today's first sample when available; else the + // previous day's last sample. If the balance rose mid-day (recharge), + // attribute spend to the post-recharge segment. + let todayStart = self.calendar.startOfDay(for: now) + let todayRecord = days.first { $0.day == todayStart } + let yesterdayRecord = days.last { $0.day < todayStart } + let todayStartBalance: Double + if let todayRecord { + todayStartBalance = todayRecord.firstBalance + } else if let yesterdayRecord { + todayStartBalance = yesterdayRecord.lastBalance + } else { + todayStartBalance = days[0].firstBalance + } + + let todaySpent: Double + if let todayRecord { + let rechargedToday = todayRecord.lastBalance > todayRecord.firstBalance + Self.epsilon + if rechargedToday { + todaySpent = max(0, todayRecord.lastBalance - currentBalance) + } else { + todaySpent = max(0, todayRecord.firstBalance - currentBalance) + } + } else { + todaySpent = max(0, todayStartBalance - currentBalance) + } + + return ConsumptionSummary( + totalSpent: totalSpent.isFinite ? totalSpent : nil, + todaySpent: todaySpent.isFinite ? todaySpent : nil, + todayStartBalance: todayStartBalance.isFinite ? todayStartBalance : nil, + currency: symbol) + } + + // MARK: - Persistence + + private func loadLocked() -> Payload? { + guard self.fileManager.fileExists(atPath: self.fileURL.path), + let data = try? Data(contentsOf: self.fileURL) + else { + return nil + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let payload = try? decoder.decode(Payload.self, from: data), + payload.version == Payload.currentVersion + else { + return nil + } + return payload + } + + private func saveLocked(_ payload: Payload) { + do { + let directory = self.fileURL.deletingLastPathComponent() + try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + try data.write(to: self.fileURL, options: [.atomic]) + try? self.fileManager.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o600))], + ofItemAtPath: self.fileURL.path) + } catch { + // Best-effort persistence only. + } + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift index 30a60cdfbb..11e36d40fe 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift @@ -1,4 +1,7 @@ import Foundation +#if os(macOS) +import SQLite3 +#endif enum DeepSeekPlatformTokenImporter { struct TokenInfo: Sendable, Equatable { @@ -105,11 +108,34 @@ enum DeepSeekPlatformTokenImporter { localStorage: BrowserLocalStorageAPI = .live) -> [TokenInfo] { let log: @Sendable (String) -> Void = { message in logger?("[deepseek-storage] \(message)") } + + // Safari stores localStorage in WebKit sqlite files; Chromium browsers use leveldb. + let safariTokens = Self.importSafariTokens(logger: log) + let chromeTokens = Self.importChromiumTokens( + browserDetection: browserDetection, + logger: log, + localStorage: localStorage) + + var results: [TokenInfo] = [] + results.append(contentsOf: safariTokens) + results.append(contentsOf: chromeTokens) + + if results.isEmpty { + log("No DeepSeek userToken found in browser local storage") + } + return results + } + + private static func importChromiumTokens( + browserDetection: BrowserDetection, + logger: @escaping @Sendable (String) -> Void, + localStorage: BrowserLocalStorageAPI) -> [TokenInfo] + { let profiles = localStorage.profiles( for: "https://platform.deepseek.com", browsers: [.chrome], using: browserDetection, - logger: log) + logger: logger) var results: [TokenInfo] = [] for profile in profiles { @@ -117,20 +143,175 @@ enum DeepSeekPlatformTokenImporter { guard let entry = profile.entries.first(where: { $0.key == "userToken" }), let token = self.extractUserToken(from: entry.value) else { - log("No DeepSeek userToken found in \(profile.id)") + logger("No DeepSeek userToken found in \(profile.id)") continue } - log("Found DeepSeek platform token in \(profile.id)") + logger("Found DeepSeek platform token in \(profile.id)") results.append(TokenInfo(id: profile.id, token: token, sourceLabel: profile.label)) } + return results + } - if results.isEmpty { - log("No DeepSeek userToken found in Chrome local storage") + /// Reads the DeepSeek `userToken` from Safari's WebKit local storage + /// (WebsiteData/Default//LocalStorage/localstorage.sqlite3). + private static func importSafariTokens( + logger: (@Sendable (String) -> Void)? = nil) -> [TokenInfo] + { + let log: (String) -> Void = { msg in logger?("[deepseek-safari] \(msg)") } + let root = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library") + .appendingPathComponent("Containers") + .appendingPathComponent("com.apple.Safari") + .appendingPathComponent("Data") + .appendingPathComponent("Library") + .appendingPathComponent("WebKit") + .appendingPathComponent("WebsiteData") + .appendingPathComponent("Default") + + guard FileManager.default.fileExists(atPath: root.path), + let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) + else { + return [] + } + + var results: [TokenInfo] = [] + for case let fileURL as URL in enumerator { + guard !Task.isCancelled else { return results } + guard fileURL.lastPathComponent == "origin" else { continue } + guard (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { + continue + } + guard let data = try? Data(contentsOf: fileURL, options: [.mappedIfSafe]) else { continue } + let ascii = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) ?? "" + guard ascii.contains("platform.deepseek.com") || ascii.contains("deepseek.com") else { + continue + } + + let storageURL = fileURL.deletingLastPathComponent() + .appendingPathComponent("LocalStorage") + .appendingPathComponent("localstorage.sqlite3") + guard FileManager.default.fileExists(atPath: storageURL.path) else { continue } + guard let token = self.readUserTokenFromSafariSQLite(dbURL: storageURL, logger: log) else { + log("No DeepSeek userToken in Safari storage \(storageURL.path)") + continue + } + let host = self.safariOriginHost(from: ascii) ?? "platform.deepseek.com" + log("Found DeepSeek platform token in Safari (\(host))") + results.append(TokenInfo(id: "safari:\(host)", token: token, sourceLabel: "Safari (\(host))")) } return results } + private static func safariOriginHost(from ascii: String) -> String? { + let targets = ["platform.deepseek.com", "deepseek.com"] + for host in targets where ascii.contains(host) { + return host + } + return nil + } + + private static func readUserTokenFromSafariSQLite( + dbURL: URL, + logger: ((String) -> Void)? = nil) -> String? + { + var db: OpaquePointer? + guard sqlite3_open_v2(dbURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + if let c = sqlite3_errmsg(db) { + logger?("Safari local storage open failed: \(String(cString: c))") + } + return nil + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let tables = Self.safariTableNames(db: db, logger: logger) + let table = tables.contains("ItemTable") ? "ItemTable" + : (tables.contains("localstorage") ? "localstorage" : nil) + guard let table else { + logger?("Safari local storage missing ItemTable/localstorage tables (found: \(tables.sorted()))") + return nil + } + + let token = Self.safariLocalStorageValue(db: db, table: table, key: "userToken") + guard let token, !token.isEmpty else { + logger?("Safari local storage missing userToken") + return nil + } + return self.extractUserToken(from: token) + } + + private static func safariTableNames(db: OpaquePointer?, logger: ((String) -> Void)? = nil) -> Set { + let sql = "SELECT name FROM sqlite_master WHERE type='table'" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + if let c = sqlite3_errmsg(db) { + logger?("Safari local storage table query failed: \(String(cString: c))") + } + return [] + } + defer { sqlite3_finalize(stmt) } + var names = Set() + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_ROW { + if let c = sqlite3_column_text(stmt, 0) { + names.insert(String(cString: c)) + } + } else { + if step != SQLITE_DONE, let c = sqlite3_errmsg(db) { + logger?("Safari local storage table query failed: \(String(cString: c))") + } + break + } + } + return names + } + + private static func safariLocalStorageValue(db: OpaquePointer?, table: String, key: String) -> String? { + let sql = "SELECT value FROM \(table) WHERE key = ? LIMIT 1" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + _ = key.withCString { cString in + sqlite3_bind_text(stmt, 1, cString, -1, transient) + } + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } + return Self.safariSQLiteValue(stmt: stmt, index: 0) + } + + private static func safariSQLiteValue(stmt: OpaquePointer?, index: Int32) -> String? { + let type = sqlite3_column_type(stmt, index) + switch type { + case SQLITE_TEXT: + guard let c = sqlite3_column_text(stmt, index) else { return nil } + return String(cString: c) + case SQLITE_BLOB: + guard let bytes = sqlite3_column_blob(stmt, index) else { return nil } + let count = Int(sqlite3_column_bytes(stmt, index)) + return Self.safariValueData(Data(bytes: bytes, count: count)) + default: + return nil + } + } + + private static func safariValueData(_ data: Data) -> String? { + if let decoded = String(data: data, encoding: .utf16LittleEndian) { + return decoded.trimmingCharacters(in: .controlCharacters) + } + if let decoded = String(data: data, encoding: .utf8) { + return decoded.trimmingCharacters(in: .controlCharacters) + } + if let decoded = String(data: data, encoding: .isoLatin1) { + return decoded.trimmingCharacters(in: .controlCharacters) + } + return nil + } + #endif private static func resolve( diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift index 04bb65e52c..b193e97798 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(CryptoKit) +import CryptoKit +#endif public enum DeepSeekProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() @@ -114,7 +117,7 @@ public enum DeepSeekProviderDescriptor { let state = context.snapshot?.deepseekDetailedUsageState if context.snapshot?.primary == nil { if state == .webSessionRequired { - return .localized(["Sign in to DeepSeek Platform in Chrome for detailed usage."]) + return .localized(["Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."]) } if state == .profileSelectionRequired { return .localized(["Select a DeepSeek Chrome profile in Settings."]) @@ -125,7 +128,7 @@ public enum DeepSeekProviderDescriptor { } guard context.snapshot?.details.isEmpty == false else { if state == .webSessionRequired { - return .localized(["Sign in to DeepSeek Platform in Chrome for detailed usage."]) + return .localized(["Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."]) } if state == .profileSelectionRequired { return .localized(["Select a DeepSeek Chrome profile in Settings."]) @@ -168,6 +171,55 @@ public enum DeepSeekProviderDescriptor { } } + // MARK: - Balance history recording + + /// Stable per-credential key so multiple API keys keep separate histories. + /// Uses a namespaced non-reversible SHA-256 digest (never raw key fragments), + /// mirroring the profile-scope pattern in DeepSeekSettingsReader. + private static func balanceAccountKey(apiKey: String?) -> String { + guard let apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines), + !apiKey.isEmpty + else { return "default" } + #if canImport(CryptoKit) + let input = "com.steipete.codexbar.deepseek-balance-history.v1\0\(apiKey)" + let digest = SHA256.hash(data: Data(input.utf8)) + return "v1:" + digest.map { String(format: "%02x", $0) }.joined() + #else + // Fallback without CryptoKit: still avoid raw fragments; use a simple hash. + var hasher = Hasher() + hasher.combine(input) + return "v1:" + String(hasher.finalize(), radix: 16) + #endif + } + + /// Test hook: exposes the digest mapping without persisting anything. + static func balanceAccountKeyForTesting(apiKey: String?) -> String { + self.balanceAccountKey(apiKey: apiKey) + } + + private static let balanceHistoryStore = DeepSeekBalanceHistoryStore() + + /// Records the observed balance and returns a snapshot whose balance detail + /// includes derived consumption (today / total spend since last recharge). + private static func usageSnapshotRecordingBalance( + _ balance: DeepSeekUsageSnapshot, + apiKey: String?, + now: Date = Date()) -> UsageSnapshot + { + let accountKey = Self.balanceAccountKey(apiKey: apiKey) + Self.balanceHistoryStore.record( + balance: balance.totalBalance, + currency: balance.currency, + accountKey: accountKey, + at: now) + let consumption = Self.balanceHistoryStore.consumptionSummary( + for: accountKey, + currentBalance: balance.totalBalance, + currency: balance.currency, + now: now) + return balance.toUsageSnapshot(consumption: consumption) + } + private static func loadUsage( apiKey: String, context: ProviderFetchContext, @@ -175,14 +227,16 @@ public enum DeepSeekProviderDescriptor { operations: FetchOperations) async throws -> UsageSnapshot { guard context.includeOptionalUsage else { - return try await operations.fetchUsage(apiKey, nil, false).toUsageSnapshot() + let balance = try await operations.fetchUsage(apiKey, nil, false) + return Self.usageSnapshotRecordingBalance(balance, apiKey: apiKey) } if let session = DeepSeekSettingsReader.scopedPlatformToken( environment: context.env, selectedTokenAccountID: context.selectedTokenAccountID, apiKey: apiKey) { - return try await operations.fetchUsage(apiKey, session, true).toUsageSnapshot() + let balance = try await operations.fetchUsage(apiKey, session, true) + return Self.usageSnapshotRecordingBalance(balance, apiKey: apiKey) } return try await self.loadAutomaticUsage( @@ -232,7 +286,7 @@ public enum DeepSeekProviderDescriptor { switch await resolutionJoin.value(joinGrace: optionalResolutionJoinGrace) { case let .value(resolution): try Task.checkCancellation() - return self.combinedSnapshot(balance: balance, resolution: resolution) + return self.combinedSnapshot(balance: balance, resolution: resolution, apiKey: apiKey) case .timedOut: try Task.checkCancellation() return self.combinedSnapshot( @@ -240,7 +294,8 @@ public enum DeepSeekProviderDescriptor { resolution: DeepSeekPlatformTokenImporter.Resolution( profiles: [], selectedSummary: nil, - detailedUsageState: .unavailable)) + detailedUsageState: .unavailable), + apiKey: apiKey) case let .failure(error): if error is CancellationError || Task.isCancelled { throw error @@ -250,15 +305,17 @@ public enum DeepSeekProviderDescriptor { resolution: DeepSeekPlatformTokenImporter.Resolution( profiles: [], selectedSummary: nil, - detailedUsageState: .unavailable)) + detailedUsageState: .unavailable), + apiKey: apiKey) } } private static func combinedSnapshot( balance: DeepSeekUsageSnapshot, - resolution: DeepSeekPlatformTokenImporter.Resolution) -> UsageSnapshot + resolution: DeepSeekPlatformTokenImporter.Resolution, + apiKey: String? = nil) -> UsageSnapshot { - DeepSeekUsageSnapshot( + let snapshot = DeepSeekUsageSnapshot( hasBalance: balance.hasBalance, isAvailable: balance.isAvailable, currency: balance.currency, @@ -268,7 +325,8 @@ public enum DeepSeekProviderDescriptor { usageSummary: resolution.selectedSummary, detailedUsageState: resolution.detailedUsageState, platformProfiles: resolution.profiles, - updatedAt: balance.updatedAt).toUsageSnapshot() + updatedAt: balance.updatedAt) + return Self.usageSnapshotRecordingBalance(snapshot, apiKey: apiKey) } fileprivate static func loadPlatformUsage( @@ -321,7 +379,10 @@ public enum DeepSeekProviderDescriptor { grantedBalance: 0, toppedUpBalance: 0, updatedAt: resolution.selectedSummary?.updatedAt ?? Date()) - return self.combinedSnapshot(balance: balance, resolution: resolution) + return self.combinedSnapshot( + balance: balance, + resolution: resolution, + apiKey: ProviderTokenResolver.token(for: .deepseek, environment: context.env)) } static func _loadUsageForTesting( diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift index 7edecc721d..9ebbfce206 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift @@ -179,6 +179,8 @@ public struct DeepSeekUsageSummary: Sendable, Equatable { public let currentMonthRequestCount: Int public let topModel: String? public let categoryBreakdown: [DeepSeekCategoryBreakdown] + /// Cost per model (e.g. deepseek-chat / deepseek-reasoner), from the platform usage/cost endpoint. + public let modelCosts: [DeepSeekModelCost] public let daily: [DeepSeekDailyUsage] public let currency: String public let updatedAt: Date @@ -192,6 +194,7 @@ public struct DeepSeekUsageSummary: Sendable, Equatable { currentMonthRequestCount: Int, topModel: String?, categoryBreakdown: [DeepSeekCategoryBreakdown], + modelCosts: [DeepSeekModelCost], daily: [DeepSeekDailyUsage], currency: String, updatedAt: Date) @@ -204,6 +207,7 @@ public struct DeepSeekUsageSummary: Sendable, Equatable { self.currentMonthRequestCount = currentMonthRequestCount self.topModel = topModel self.categoryBreakdown = categoryBreakdown + self.modelCosts = modelCosts self.daily = daily self.currency = currency self.updatedAt = updatedAt @@ -222,6 +226,16 @@ public struct DeepSeekCategoryBreakdown: Sendable, Equatable { } } +public struct DeepSeekModelCost: Sendable, Equatable { + public let model: String + public let cost: Double + + public init(model: String, cost: Double) { + self.model = model + self.cost = cost + } +} + public enum DeepSeekUsageCategory: String, Sendable, Equatable { case promptCacheHitToken = "PROMPT_CACHE_HIT_TOKEN" case promptCacheMissToken = "PROMPT_CACHE_MISS_TOKEN" @@ -474,7 +488,7 @@ enum DeepSeekUsageCostParser { let monthResult = self.aggregateMonth(ctx: dailyCtx) // Model and category breakdown from totals - let (topModel, categoryBreakdown) = self.buildBreakdowns( + let (topModel, categoryBreakdown, modelCosts) = self.buildBreakdowns( totalAmounts: input.totalAmounts, totalCosts: input.totalCosts) @@ -490,6 +504,7 @@ enum DeepSeekUsageCostParser { currentMonthRequestCount: monthResult.requests, topModel: topModel, categoryBreakdown: categoryBreakdown, + modelCosts: modelCosts, daily: dailyUsages, currency: input.currency, updatedAt: input.now) @@ -598,9 +613,10 @@ enum DeepSeekUsageCostParser { private static func buildBreakdowns( totalAmounts: [DeepSeekModelUsage], - totalCosts: [DeepSeekCostModelUsage]) -> (String?, [DeepSeekCategoryBreakdown]) + totalCosts: [DeepSeekCostModelUsage]) -> (String?, [DeepSeekCategoryBreakdown], [DeepSeekModelCost]) { var modelTokens: [String: Int] = [:] + var modelCosts: [String: Double] = [:] var categoryTokens: [DeepSeekUsageCategory: Int] = [:] var categoryCosts: [DeepSeekUsageCategory: Double] = [:] @@ -619,12 +635,13 @@ enum DeepSeekUsageCostParser { } for costUsage in totalCosts { - guard costUsage.model != nil else { continue } + guard let model = costUsage.model else { continue } for item in costUsage.usage ?? [] { guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue } if category != .request { let amount = Self.parseCostAmount(item.amount) categoryCosts[category, default: 0] += amount + modelCosts[model, default: 0] += amount } } } @@ -642,7 +659,12 @@ enum DeepSeekUsageCostParser { cost: categoryCosts[category])) } - return (topModel, breakdown) + let modelCostList = modelCosts + .filter { $0.value > 0 } + .sorted { $0.value > $1.value } + .map { DeepSeekModelCost(model: $0.key, cost: $0.value) } + + return (topModel, breakdown, modelCostList) } private static func buildDailyUsages(ctx: DailyAggregationContext) -> [DeepSeekDailyUsage] { diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift index d56a39d7d7..be584264b8 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift @@ -161,7 +161,9 @@ public struct DeepSeekUsageSnapshot: Sendable { self.updatedAt = updatedAt } - public func toUsageSnapshot() -> UsageSnapshot { + public func toUsageSnapshot( + consumption: DeepSeekBalanceHistoryStore.ConsumptionSummary? = nil) -> UsageSnapshot + { let symbol = self.currency == "CNY" ? "¥" : "$" let balanceDetail: String @@ -176,7 +178,20 @@ public struct DeepSeekUsageSnapshot: Sendable { let total = String(format: "\(symbol)%.2f", self.totalBalance) let paid = String(format: "\(symbol)%.2f", self.toppedUpBalance) let granted = String(format: "\(symbol)%.2f", self.grantedBalance) - balanceDetail = "\(total) (Paid: \(paid) / Granted: \(granted))" + var detail = "\(total) (Paid: \(paid) / Granted: \(granted))" + if let consumption, consumption.totalSpent != nil || consumption.todaySpent != nil { + var parts: [String] = [] + if let today = consumption.todaySpent, today > 0.0001 { + parts.append("Today −\(String(format: "\(symbol)%.2f", today))") + } + if let total = consumption.totalSpent, total > 0.0001 { + parts.append("Total −\(String(format: "\(symbol)%.2f", total))") + } + if !parts.isEmpty { + detail += " · " + parts.joined(separator: " · ") + } + } + balanceDetail = detail usedPercent = 0 } @@ -225,6 +240,11 @@ public struct DeepSeekUsageSnapshot: Sendable { if let topModel = usage.topModel { rows.append(.makeRow(label: "Top model", value: topModel)) } + for modelCost in usage.modelCosts { + rows.append(.makeRow( + label: modelCost.model, + value: "\(symbol)\(String(format: "%.4f", max(0, modelCost.cost)))")) + } for category in usage.categoryBreakdown { rows.append(.makeRow(label: Self.categoryLabel(category.category), value: category.tokens.formatted())) } diff --git a/Tests/CodexBarTests/DeepSeekBalanceHistoryStoreTests.swift b/Tests/CodexBarTests/DeepSeekBalanceHistoryStoreTests.swift new file mode 100644 index 0000000000..095571fff3 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekBalanceHistoryStoreTests.swift @@ -0,0 +1,215 @@ +@testable import CodexBarCore +import Foundation +import XCTest + +final class DeepSeekBalanceHistoryStoreTests: XCTestCase { + private var directoryURL: URL! + + override func setUp() { + super.setUp() + self.directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("DeepSeekBalanceHistoryTests-\(UUID().uuidString)", isDirectory: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: self.directoryURL) + super.tearDown() + } + + private func makeStore() -> DeepSeekBalanceHistoryStore { + DeepSeekBalanceHistoryStore( + fileURL: self.directoryURL.appendingPathComponent("history.json"), + calendar: Self.fixedCalendar()) + } + + private static func fixedCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! + return calendar + } + + private func date(_ day: Int, hour: Int = 12) -> Date { + var components = DateComponents() + components.year = 2026 + components.month = 8 + components.day = day + components.hour = hour + return Self.fixedCalendar().date(from: components)! + } + + func testNoHistoryReturnsUnavailable() { + let store = self.makeStore() + let summary = store.consumptionSummary(for: "default", currentBalance: 100, currency: "CNY", now: self.date(15)) + XCTAssertNil(summary.totalSpent) + XCTAssertNil(summary.todaySpent) + } + + func testRecordsAndDerivesSpend() { + let store = self.makeStore() + // Aug 13: 100 + store.record(balance: 100, currency: "CNY", accountKey: "default", at: self.date(13, hour: 9)) + // Aug 14: 95 + store.record(balance: 95, currency: "CNY", accountKey: "default", at: self.date(14, hour: 9)) + // Aug 15 (today) morning: 90 + store.record(balance: 90, currency: "CNY", accountKey: "default", at: self.date(15, hour: 9)) + + let summary = store.consumptionSummary( + for: "default", + currentBalance: 88, + currency: "CNY", + now: self.date(15, hour: 14)) + XCTAssertEqual(summary.totalSpent ?? -1, 12, accuracy: 0.0001) // 100 - 88 + XCTAssertEqual(summary.todaySpent ?? -1, 2, accuracy: 0.0001) // 90 - 88 + XCTAssertEqual(summary.todayStartBalance ?? -1, 90, accuracy: 0.0001) + } + + func testRechargeResetsBaseline() { + let store = self.makeStore() + store.record(balance: 100, currency: "CNY", accountKey: "default", at: self.date(13, hour: 9)) + store.record(balance: 80, currency: "CNY", accountKey: "default", at: self.date(14, hour: 9)) + // Recharge on Aug 15: 200 (top-up) + store.record(balance: 200, currency: "CNY", accountKey: "default", at: self.date(15, hour: 9)) + // Spent some after top-up + let summary = store.consumptionSummary( + for: "default", + currentBalance: 195, + currency: "CNY", + now: self.date(15, hour: 14)) + // Lifetime spend is measured from the newest baseline (200), not the original 100. + XCTAssertEqual(summary.totalSpent ?? -1, 5, accuracy: 0.0001) + XCTAssertEqual(summary.todaySpent ?? -1, 5, accuracy: 0.0001) + } + + func testRechargeTodayStillReportsPostRechargeSpend() { + let store = self.makeStore() + store.record(balance: 100, currency: "CNY", accountKey: "default", at: self.date(14, hour: 9)) + // Top-up this morning pushed the balance to 150; spending since then is + // attributable to the post-recharge segment. + store.record(balance: 150, currency: "CNY", accountKey: "default", at: self.date(15, hour: 9)) + let summary = store.consumptionSummary( + for: "default", + currentBalance: 148, + currency: "CNY", + now: self.date(15, hour: 14)) + XCTAssertEqual(summary.todaySpent ?? -1, 2, accuracy: 0.0001) + XCTAssertEqual(summary.totalSpent ?? -1, 2, accuracy: 0.0001) + } + + func testRechargeDayWithoutRefreshYieldsNoTodaySpend() { + let store = self.makeStore() + store.record(balance: 100, currency: "CNY", accountKey: "default", at: self.date(14, hour: 9)) + // No sample recorded today yet; the current balance is already above + // yesterday's closing balance because of a top-up, so no spend is shown. + let summary = store.consumptionSummary( + for: "default", + currentBalance: 150, + currency: "CNY", + now: self.date(15, hour: 14)) + XCTAssertEqual(summary.todaySpent ?? -1, 0, accuracy: 0.0001) + XCTAssertEqual(summary.totalSpent ?? -1, 0, accuracy: 0.0001) + } + + func testMidDayRechargeAttributesPostRechargeSpend() { + let store = self.makeStore() + // Morning: 90. Mid-day top-up: 200. Now: 195. + store.record(balance: 90, currency: "CNY", accountKey: "default", at: self.date(15, hour: 9)) + store.record(balance: 200, currency: "CNY", accountKey: "default", at: self.date(15, hour: 12)) + let summary = store.consumptionSummary( + for: "default", + currentBalance: 195, + currency: "CNY", + now: self.date(15, hour: 14)) + // Today's spend is measured from the post-recharge balance (200 → 195). + XCTAssertEqual(summary.todaySpent ?? -1, 5, accuracy: 0.0001) + XCTAssertEqual(summary.totalSpent ?? -1, 5, accuracy: 0.0001) + } + + func testMultipleAccountKeysAreIsolated() { + let store = self.makeStore() + store.record(balance: 100, currency: "CNY", accountKey: "key-a", at: self.date(14, hour: 9)) + store.record(balance: 500, currency: "CNY", accountKey: "key-b", at: self.date(14, hour: 9)) + let summaryA = store.consumptionSummary( + for: "key-a", + currentBalance: 90, + currency: "CNY", + now: self.date(15, hour: 14)) + let summaryB = store.consumptionSummary( + for: "key-b", + currentBalance: 480, + currency: "CNY", + now: self.date(15, hour: 14)) + XCTAssertEqual(summaryA.totalSpent ?? -1, 10, accuracy: 0.0001) + XCTAssertEqual(summaryB.totalSpent ?? -1, 20, accuracy: 0.0001) + } + + func testPersistenceRoundTrip() { + let fileURL = self.directoryURL.appendingPathComponent("history.json") + let store = DeepSeekBalanceHistoryStore( + fileURL: fileURL, + calendar: Self.fixedCalendar()) + store.record(balance: 100, currency: "CNY", accountKey: "default", at: self.date(14, hour: 9)) + + // A fresh store instance reading the same file must see the record. + let reloaded = DeepSeekBalanceHistoryStore( + fileURL: fileURL, + calendar: Self.fixedCalendar()) + let summary = reloaded.consumptionSummary( + for: "default", + currentBalance: 95, + currency: "CNY", + now: self.date(15, hour: 14)) + XCTAssertEqual(summary.totalSpent ?? -1, 5, accuracy: 0.0001) + } + + func testPersistedHistoryContainsNoAPIKeyFragments() throws { + let fileURL = self.directoryURL.appendingPathComponent("history.json") + let store = DeepSeekBalanceHistoryStore( + fileURL: fileURL, + calendar: Self.fixedCalendar()) + + // Test-only credential-shaped string (not a real secret; deliberately + // avoids a credential-shaped prefix so secret scanners stay quiet). + let apiKey = "test-credential-9f8e7d6c5b4a39281706f5e4d3c2b1a0" + let accountKey = DeepSeekProviderDescriptor.balanceAccountKeyForTesting(apiKey: apiKey) + store.record(balance: 100, currency: "CNY", accountKey: accountKey, at: self.date(14, hour: 9)) + store.record(balance: 95, currency: "CNY", accountKey: accountKey, at: self.date(15, hour: 9)) + + let data = try Data(contentsOf: fileURL) + let json = String(data: data, encoding: .utf8) ?? "" + + // No raw key fragment may appear in the persisted payload. + XCTAssertFalse(json.contains(apiKey)) + XCTAssertFalse(json.contains("9f8e7d6c")) + XCTAssertFalse(json.contains("c2b1a0")) + XCTAssertFalse(json.contains("9f8e7d6c")) + // Account key must be a namespaced digest. + XCTAssertTrue(accountKey.hasPrefix("v1:")) + XCTAssertNotEqual(accountKey, apiKey) + XCTAssertGreaterThan(accountKey.count, 40) + } + + func testDigestAccountKeyIsStableAndIsolated() { + let keyA = DeepSeekProviderDescriptor.balanceAccountKeyForTesting(apiKey: "test-credential-aaaaaaaaaaaaaaaa") + let keyA2 = DeepSeekProviderDescriptor.balanceAccountKeyForTesting(apiKey: "test-credential-aaaaaaaaaaaaaaaa") + let keyB = DeepSeekProviderDescriptor.balanceAccountKeyForTesting(apiKey: "test-credential-bbbbbbbbbbbbbbbb") + + XCTAssertEqual(keyA, keyA2, "same key must map to same digest") + XCTAssertNotEqual(keyA, keyB, "different keys must map to different digests") + XCTAssertEqual( + DeepSeekProviderDescriptor.balanceAccountKeyForTesting(apiKey: nil), + "default", + "nil key falls back to default") + } + + func testIgnoredInvalidBalance() { + let store = self.makeStore() + store.record(balance: -5, currency: "CNY", accountKey: "default", at: self.date(14, hour: 9)) + store.record(balance: .infinity, currency: "CNY", accountKey: "default", at: self.date(14, hour: 10)) + let summary = store.consumptionSummary( + for: "default", + currentBalance: 95, + currency: "CNY", + now: self.date(15, hour: 14)) + XCTAssertNil(summary.totalSpent) + } +} diff --git a/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift b/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift index 9c4f33cdcb..4aae2b5f15 100644 --- a/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift +++ b/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift @@ -253,6 +253,7 @@ struct DeepSeekPlatformTokenImporterTests { currentMonthRequestCount: marker, topModel: nil, categoryBreakdown: [], + modelCosts: [], daily: [], currency: "USD", updatedAt: Date(timeIntervalSince1970: 0)) diff --git a/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift b/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift index 28cde5cb42..21ea8ddc1f 100644 --- a/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift +++ b/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift @@ -116,6 +116,7 @@ struct DeepSeekProviderDescriptorTests { currentMonthRequestCount: 4, topModel: "deepseek-chat", categoryBreakdown: [], + modelCosts: [], daily: [], currency: "USD", updatedAt: Date(timeIntervalSince1970: 1)) @@ -291,6 +292,7 @@ struct DeepSeekProviderDescriptorTests { currentMonthRequestCount: 4, topModel: "deepseek-chat", categoryBreakdown: [], + modelCosts: [], daily: [], currency: "USD", updatedAt: Date(timeIntervalSince1970: 1)) diff --git a/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift index e9d67a5d05..6f5daeef89 100644 --- a/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift @@ -1000,3 +1000,140 @@ struct DeepSeekUsageCostParserAuthorizationTests { } } } + +extension DeepSeekUsageCostParserTests { + @Test + func `parse aggregates per-model costs from cost payload`() throws { + let amountJSON = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": { + "total": [ + { + "model": "deepseek-chat", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "1000"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "500"}, + {"type": "RESPONSE_TOKEN", "amount": "200"}, + {"type": "REQUEST", "amount": "10"} + ] + }, + { + "model": "deepseek-reasoner", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "800"}, + {"type": "RESPONSE_TOKEN", "amount": "300"}, + {"type": "REQUEST", "amount": "5"} + ] + } + ], + "days": [] + } + } + } + """ + let costJSON = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": [ + { + "total": [ + { + "model": "deepseek-chat", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "0.5"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1.0"}, + {"type": "RESPONSE_TOKEN", "amount": "2.0"}, + {"type": "REQUEST", "amount": "0"} + ] + }, + { + "model": "deepseek-reasoner", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "1.5"}, + {"type": "RESPONSE_TOKEN", "amount": "3.0"}, + {"type": "REQUEST", "amount": "0"} + ] + } + ], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + let summary = try DeepSeekUsageCostParser.parse( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + #expect(summary.currency == "CNY") + // deepseek-chat: 0.5 + 1.0 + 2.0 = 3.5 ; deepseek-reasoner: 1.5 + 3.0 = 4.5 + #expect(summary.modelCosts.count == 2) + let chat = summary.modelCosts.first { $0.model == "deepseek-chat" } + let reasoner = summary.modelCosts.first { $0.model == "deepseek-reasoner" } + #expect(chat?.cost == 3.5) + #expect(reasoner?.cost == 4.5) + // Sorted descending by cost + #expect(summary.modelCosts.first?.model == "deepseek-reasoner") + } + + @Test + func `parse omits zero-cost models from modelCosts`() throws { + let amountJSON = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": { + "total": [ + { + "model": "deepseek-chat", + "usage": [ + {"type": "RESPONSE_TOKEN", "amount": "100"}, + {"type": "REQUEST", "amount": "1"} + ] + } + ], + "days": [] + } + } + } + """ + let costJSON = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": [ + { + "total": [ + { + "model": "deepseek-chat", + "usage": [ + {"type": "RESPONSE_TOKEN", "amount": "0"}, + {"type": "REQUEST", "amount": "0"} + ] + } + ], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + let summary = try DeepSeekUsageCostParser.parse( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + #expect(summary.modelCosts.isEmpty) + } +} diff --git a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift index 353ea93970..b29f40dceb 100644 --- a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift @@ -128,6 +128,7 @@ struct DeepSeekUsageFetcherTests { categoryBreakdown: [ DeepSeekCategoryBreakdown(category: .promptCacheHitToken, tokens: 123, cost: 1.23), ], + modelCosts: [], daily: [], currency: "USD", updatedAt: updatedAt) diff --git a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift index 1a0f4b9b6b..a4bdee5396 100644 --- a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift +++ b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift @@ -18,6 +18,7 @@ struct MenuCardDeepSeekTests { DeepSeekCategoryBreakdown(category: .promptCacheMissToken, tokens: 20, cost: 0.002), DeepSeekCategoryBreakdown(category: .responseToken, tokens: 30, cost: 0.003), ], + modelCosts: [], daily: [ DeepSeekDailyUsage(date: "2026-05-26", totalTokens: 456, cost: 0.0456, requestCount: 8), ], @@ -242,7 +243,7 @@ struct MenuCardDeepSeekTests { now: now)) #expect(model.inlineUsageDashboard == nil) - #expect(model.usageNotes == ["Sign in to DeepSeek Platform in Chrome for detailed usage."]) + #expect(model.usageNotes == ["Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."]) } @Test @@ -281,7 +282,7 @@ struct MenuCardDeepSeekTests { now: now)) #expect(model.metrics.isEmpty) - #expect(model.usageNotes == ["Sign in to DeepSeek Platform in Chrome for detailed usage."]) + #expect(model.usageNotes == ["Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."]) } @Test diff --git a/docs/deepseek.md b/docs/deepseek.md index e5c369fd6c..b9d0685126 100644 --- a/docs/deepseek.md +++ b/docs/deepseek.md @@ -37,7 +37,7 @@ CodexBar resolves the Platform `userToken` in this order: 1. An explicitly supplied `DEEPSEEK_PLATFORM_TOKEN` / `DEEPSEEK_USER_TOKEN` or a legacy `providers[].cookieHeader` value preserved from an existing config. -2. A prompt-free read of `userToken` from the `https://platform.deepseek.com` local-storage origin in Chrome. +2. A prompt-free read of `userToken` from the `https://platform.deepseek.com` local-storage origin in **Chrome** or **Safari** (macOS). Safari data lives under `~/Library/Containers/com.apple.Safari/.../WebsiteData` and is only readable when the app has **Full Disk Access** (System Settings → Privacy & Security → Full Disk Access). Chrome/Chromium profiles (Chrome, Edge, Brave, Arc, …) are read via leveldb and need no extra permission. The legacy config value remains a compatibility fallback so upgrading cannot silently erase a working browser-only session. An unscoped legacy or environment token is never combined with an API-key balance; API enrichment requires @@ -55,7 +55,8 @@ refreshes do not probe every profile, and a temporary network failure does not e If the selected session expires, CodexBar asks before switching to another valid profile. If no session is valid, the menu keeps the API-key balance when one exists; otherwise it asks the user to sign in to -DeepSeek Platform in Chrome. Authentication failures returned as top-level or nested DeepSeek codes `40002` and +DeepSeek Platform in Chrome or Safari. On macOS, a Safari-only setup requires granting CodexBar **Full Disk Access** +so the app can read Safari's local-storage database; without it, Safari sessions cannot be imported. Authentication failures returned as top-level or nested DeepSeek codes `40002` and `40003` are treated as expired sessions. ## Usage details From f952d8c635002469a9796544407607e5c2dce076 Mon Sep 17 00:00:00 2001 From: jky Date: Sun, 16 Aug 2026 13:24:55 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(deepseek):=20address=20ClawSweeper=20re?= =?UTF-8?q?view=20=E2=80=94=20consumption=20order,=20Safari=20opt-in,=20pr?= =?UTF-8?q?ofile-scoped=20history,=20currency=20partitioning,=20chart=20lo?= =?UTF-8?q?calization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Calculate consumption BEFORE recording the refresh so recharge baselines are never overwritten by the following refresh (both spend values stay correct) - Gate Safari storage reads behind explicit opt-in: routine refreshes stay Chrome-only; Safari container is read only when a safari: profile is selected - Scope web-only balance history to the selected profile: nil API key no longer collapses every browser account into the shared "default" history key - Partition persisted balance history by currency so USD/CNY wallet switches never subtract unrelated balances or mix symbols - Localize DeepSeek chart title/unit/point labels through the same label map as the detail rows (zh-Hans no longer shows English chart headers) - Sync the 6 new catalog keys across all 21 language directories (en/zh-Hans were updated while the rest drifted, failing the catalog consistency tests) - Regression tests: refresh-after-recharge spend, Safari opt-in flag, profile key isolation, USD/CNY partitioning, chart localization --- .../CodexBar/MenuCardView+ModelHelpers.swift | 13 +++- .../Resources/ar.lproj/Localizable.strings | 7 +- .../Resources/ca.lproj/Localizable.strings | 7 +- .../Resources/de.lproj/Localizable.strings | 7 +- .../Resources/es.lproj/Localizable.strings | 7 +- .../Resources/fa.lproj/Localizable.strings | 7 +- .../Resources/fr.lproj/Localizable.strings | 7 +- .../Resources/gl.lproj/Localizable.strings | 7 +- .../Resources/id.lproj/Localizable.strings | 7 +- .../Resources/it.lproj/Localizable.strings | 7 +- .../Resources/ja.lproj/Localizable.strings | 7 +- .../Resources/ko.lproj/Localizable.strings | 7 +- .../Resources/nl.lproj/Localizable.strings | 7 +- .../Resources/pl.lproj/Localizable.strings | 7 +- .../Resources/pt-BR.lproj/Localizable.strings | 7 +- .../Resources/ru.lproj/Localizable.strings | 7 +- .../Resources/sv.lproj/Localizable.strings | 7 +- .../Resources/th.lproj/Localizable.strings | 7 +- .../Resources/tr.lproj/Localizable.strings | 7 +- .../Resources/uk.lproj/Localizable.strings | 7 +- .../Resources/vi.lproj/Localizable.strings | 7 +- .../zh-Hans.lproj/Localizable.strings | 3 - .../zh-Hant.lproj/Localizable.strings | 55 ++------------ .../DeepSeekBalanceHistoryStore.swift | 11 ++- .../DeepSeekPlatformTokenImporter.swift | 22 +++++- .../DeepSeek/DeepSeekProviderDescriptor.swift | 49 ++++++++---- .../DeepSeekBalanceHistoryStoreTests.swift | 40 ++++++++++ .../DeepSeekPlatformTokenImporterTests.swift | 36 +++++++++ .../DeepSeekProviderDescriptorTests.swift | 76 +++++++++++++++++++ .../CodexBarTests/MenuCardDeepSeekTests.swift | 24 ++++++ 30 files changed, 376 insertions(+), 93 deletions(-) diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 7f64928e67..cfdb8d0e6e 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -576,10 +576,21 @@ extension UsageMenuCardView.Model { value: row.value, secondaryValue: row.secondaryValue) } + let chart = section.chart.flatMap { chart in + try? ProviderDetailSection.Chart( + kind: chart.kind, + title: chart.title.flatMap { labelMap[$0] } ?? chart.title, + unit: chart.unit.flatMap { labelMap[$0] } ?? chart.unit, + points: chart.points.compactMap { point in + try? ProviderDetailSection.Chart.Point( + label: labelMap[point.label] ?? point.label, + value: point.value) + }) + } return try? ProviderDetailSection( title: title, rows: rows, - chart: section.chart) + chart: chart) } } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 91cf51a0ae..4da9b49400 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -970,7 +970,6 @@ "Today cash" = "اليوم نقدا"; "DeepSeek 30 day token usage trend" = "DeepSeek اتجاه استخدام الرموز خلال 30 يوما"; "Detailed usage unavailable." = "الاستخدام التفصيلي غير متاح."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "سجّل الدخول إلى منصة DeepSeek في Chrome لعرض الاستخدام التفصيلي."; "Select a DeepSeek Chrome profile in Settings." = "حدد ملف تعريف Chrome لـ DeepSeek في الإعدادات."; "DeepSeek this month token usage trend" = "اتجاه استخدام رموز DeepSeek لهذا الشهر"; "Chrome profile" = "ملف تعريف Chrome"; @@ -1436,3 +1435,9 @@ "5-hour usage" = "الاستخدام خلال 5 ساعات"; "7-day usage" = "الاستخدام خلال 7 أيام"; "Total usage" = "إجمالي الاستخدام"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index a4517ef04f..4e86287c89 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -832,7 +832,6 @@ "Today cash" = "Efectiu d'avui"; "DeepSeek 30 day token usage trend" = "Tendència d'ús de tokens de 30 dies de DeepSeek"; "Detailed usage unavailable." = "L'ús detallat no està disponible."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Inicia la sessió a DeepSeek Platform al Chrome per veure l'ús detallat."; "Select a DeepSeek Chrome profile in Settings." = "Selecciona un perfil de Chrome de DeepSeek a Configuració."; "DeepSeek this month token usage trend" = "Tendència d'ús de tokens de DeepSeek aquest mes"; "Chrome profile" = "Perfil de Chrome"; @@ -1435,3 +1434,9 @@ "5-hour usage" = "Ús de 5 hores"; "7-day usage" = "Ús de 7 dies"; "Total usage" = "Ús total"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index d319691269..c52e488986 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1260,7 +1260,6 @@ "Chrome profile" = "Chrome-Profil"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Wähle aus, welche angemeldete DeepSeek-Platform-Sitzung detaillierte Nutzungsdaten liefert."; "Detailed usage unavailable." = "Detaillierte Nutzung nicht verfügbar."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Melde dich für detaillierte Nutzungsdaten in Chrome bei DeepSeek Platform an."; "Select a DeepSeek Chrome profile in Settings." = "Wähle in den Einstellungen ein DeepSeek-Chrome-Profil aus."; "Select profile…" = "Profil auswählen…"; @@ -1433,3 +1432,9 @@ "5-hour usage" = "5-Stunden-Nutzung"; "7-day usage" = "7-Tage-Nutzung"; "Total usage" = "Gesamtnutzung"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 6fc06eb6af..9e8a6314b5 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1121,7 +1121,6 @@ "Chrome profile" = "Perfil de Chrome"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Elige qué sesión iniciada de DeepSeek Platform proporciona el uso detallado."; "Detailed usage unavailable." = "El uso detallado no está disponible."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Inicia sesión en DeepSeek Platform en Chrome para ver el uso detallado."; "Select a DeepSeek Chrome profile in Settings." = "Selecciona un perfil de Chrome de DeepSeek en Ajustes."; "Select profile…" = "Seleccionar perfil…"; @@ -1431,3 +1430,9 @@ "5-hour usage" = "Uso de 5 horas"; "7-day usage" = "Uso de 7 días"; "Total usage" = "Uso total"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 4c16ef57d8..64581ffd7c 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -970,7 +970,6 @@ "Today cash" = "امروزه پول نقد"; "DeepSeek 30 day token usage trend" = "DeepSeek روند استفاده ۳۰ روزه از توکن"; "Detailed usage unavailable." = "جزئیات استفاده در دسترس نیست."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "برای مشاهده جزئیات استفاده، در Chrome وارد DeepSeek Platform شوید."; "Select a DeepSeek Chrome profile in Settings." = "یک نمایه Chrome دیپ‌سیک را در تنظیمات انتخاب کنید."; "DeepSeek this month token usage trend" = "روند استفاده از توکن DeepSeek در این ماه"; "Chrome profile" = "نمایه Chrome"; @@ -1436,3 +1435,9 @@ "5-hour usage" = "مصرف ۵ ساعته"; "7-day usage" = "مصرف ۷ روزه"; "Total usage" = "مصرف کل"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 5aa43d5d01..dbef02e519 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1260,7 +1260,6 @@ "Chrome profile" = "Profil Chrome"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Choisissez la session DeepSeek Platform connectée qui fournit l’utilisation détaillée."; "Detailed usage unavailable." = "L’utilisation détaillée n’est pas disponible."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Connectez-vous à DeepSeek Platform dans Chrome pour afficher l’utilisation détaillée."; "Select a DeepSeek Chrome profile in Settings." = "Sélectionnez un profil Chrome DeepSeek dans Réglages."; "Select profile…" = "Sélectionner un profil…"; @@ -1432,3 +1431,9 @@ "5-hour usage" = "Utilisation sur 5 heures"; "7-day usage" = "Utilisation sur 7 jours"; "Total usage" = "Utilisation totale"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 31845a12f8..70a81dae92 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -824,7 +824,6 @@ "Chrome profile" = "Perfil de Chrome"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Escolle que sesión iniciada de DeepSeek Platform fornece o uso detallado."; "Detailed usage unavailable." = "O uso detallado non está dispoñible."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Inicia sesión en DeepSeek Platform en Chrome para ver o uso detallado."; "Select a DeepSeek Chrome profile in Settings." = "Selecciona un perfil de Chrome de DeepSeek en Axustes."; "Select profile…" = "Seleccionar perfil…"; "cache-hit input" = "entrada atopada na caché"; @@ -1432,3 +1431,9 @@ "5-hour usage" = "Uso de 5 horas"; "7-day usage" = "Uso de 7 días"; "Total usage" = "Uso total"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 5832025fc3..b095790cd2 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -975,7 +975,6 @@ "Chrome profile" = "Profil Chrome"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Pilih sesi DeepSeek Platform yang telah masuk untuk menyediakan rincian penggunaan."; "Detailed usage unavailable." = "Rincian penggunaan tidak tersedia."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Masuk ke DeepSeek Platform di Chrome untuk melihat rincian penggunaan."; "Select a DeepSeek Chrome profile in Settings." = "Pilih profil Chrome DeepSeek di Pengaturan."; "Select profile…" = "Pilih profil…"; "cache-hit input" = "input cache-hit"; @@ -1436,3 +1435,9 @@ "5-hour usage" = "Penggunaan 5 jam"; "7-day usage" = "Penggunaan 7 hari"; "Total usage" = "Total penggunaan"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index ec76fed89f..ac38f8c641 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -975,7 +975,6 @@ "Chrome profile" = "Profilo Chrome"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Scegli quale sessione di DeepSeek Platform con accesso effettuato fornisce l'utilizzo dettagliato."; "Detailed usage unavailable." = "Utilizzo dettagliato non disponibile."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Accedi a DeepSeek Platform in Chrome per l'utilizzo dettagliato."; "Select a DeepSeek Chrome profile in Settings." = "Seleziona un profilo Chrome di DeepSeek nelle Impostazioni."; "Select profile…" = "Seleziona profilo…"; "cache-hit input" = "input cache-hit"; @@ -1436,3 +1435,9 @@ "5-hour usage" = "Utilizzo di 5 ore"; "7-day usage" = "Utilizzo di 7 giorni"; "Total usage" = "Utilizzo totale"; +"Cache-hit input" = "Input con hit di cache"; +"Cache-miss input" = "Input con miss di cache"; +"Daily tokens" = "Token giornalieri"; +"Detailed usage" = "Utilizzo dettagliato"; +"Output" = "Uscita"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Accedi a DeepSeek Platform in Chrome o Safari per un utilizzo dettagliato. Safari richiede l'accesso completo al disco per CodexBar."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 21d1180d60..ca67a9985f 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1261,7 +1261,6 @@ "Chrome profile" = "Chrome プロファイル"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "詳細な使用状況を取得する、ログイン済みの DeepSeek Platform セッションを選択します。"; "Detailed usage unavailable." = "詳細な使用状況を取得できません。"; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "詳細な使用状況を確認するには、Chrome で DeepSeek Platform にログインしてください。"; "Select a DeepSeek Chrome profile in Settings." = "設定で DeepSeek の Chrome プロファイルを選択してください。"; "Select profile…" = "プロファイルを選択…"; @@ -1433,3 +1432,9 @@ "5-hour usage" = "5時間の使用量"; "7-day usage" = "7日間の使用量"; "Total usage" = "合計使用量"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 5a5c19785b..88862e3307 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1229,7 +1229,6 @@ "Chrome profile" = "Chrome 프로필"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "상세 사용량을 제공할 로그인된 DeepSeek Platform 세션을 선택하세요."; "Detailed usage unavailable." = "상세 사용량을 확인할 수 없습니다."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "상세 사용량을 보려면 Chrome에서 DeepSeek Platform에 로그인하세요."; "Select a DeepSeek Chrome profile in Settings." = "설정에서 DeepSeek Chrome 프로필을 선택하세요."; "Select profile…" = "프로필 선택…"; @@ -1400,3 +1399,9 @@ "5-hour usage" = "5시간 사용량"; "7-day usage" = "7일 사용량"; "Total usage" = "총 사용량"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 3f386b640c..d842f3b304 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1260,7 +1260,6 @@ "Chrome profile" = "Chrome-profiel"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Kies welke aangemelde DeepSeek Platform-sessie gedetailleerd gebruik levert."; "Detailed usage unavailable." = "Gedetailleerd gebruik niet beschikbaar."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Meld je in Chrome aan bij DeepSeek Platform voor gedetailleerd gebruik."; "Select a DeepSeek Chrome profile in Settings." = "Selecteer een DeepSeek Chrome-profiel in Instellingen."; "Select profile…" = "Profiel selecteren…"; @@ -1432,3 +1431,9 @@ "5-hour usage" = "5-uursgebruik"; "7-day usage" = "7-dagengebruik"; "Total usage" = "Totaal gebruik"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 2eb1478c74..0e8464e464 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -975,7 +975,6 @@ "Chrome profile" = "Profil Chrome"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Wybierz zalogowaną sesję DeepSeek Platform, która ma dostarczać szczegółowe dane użycia."; "Detailed usage unavailable." = "Szczegółowe dane użycia są niedostępne."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Zaloguj się do DeepSeek Platform w Chrome, aby uzyskać szczegółowe dane użycia."; "Select a DeepSeek Chrome profile in Settings." = "Wybierz profil Chrome DeepSeek w Ustawieniach."; "Select profile…" = "Wybierz profil…"; "cache-hit input" = "wejście trafienia pamięci podręcznej"; @@ -1436,3 +1435,9 @@ "5-hour usage" = "Użycie 5-godzinne"; "7-day usage" = "Użycie 7-dniowe"; "Total usage" = "Łączne użycie"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 09cdd79c12..5737588013 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1261,7 +1261,6 @@ "Chrome profile" = "Perfil do Chrome"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Escolha qual sessão conectada do DeepSeek Platform fornece o uso detalhado."; "Detailed usage unavailable." = "Uso detalhado indisponível."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Entre no DeepSeek Platform pelo Chrome para ver o uso detalhado."; "Select a DeepSeek Chrome profile in Settings." = "Selecione um perfil do Chrome para o DeepSeek nos Ajustes."; "Select profile…" = "Selecionar perfil…"; @@ -1433,3 +1432,9 @@ "5-hour usage" = "Uso de 5 horas"; "7-day usage" = "Uso de 7 dias"; "Total usage" = "Uso total"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 3d9c80387e..a7ef9e7bb2 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1267,7 +1267,6 @@ "Chrome profile" = "Профиль Chrome"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Выберите активный сеанс DeepSeek Platform для получения подробных данных об использовании."; "Detailed usage unavailable." = "Подробные данные об использовании недоступны."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Войдите в DeepSeek Platform через Chrome, чтобы получить подробные данные об использовании."; "Select a DeepSeek Chrome profile in Settings." = "Выберите профиль Chrome для DeepSeek в настройках."; "Select profile…" = "Выбрать профиль…"; @@ -1434,3 +1433,9 @@ "5-hour usage" = "Использование за 5 часов"; "7-day usage" = "Использование за 7 дней"; "Total usage" = "Общее использование"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index ec237d5528..be7e626863 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1259,7 +1259,6 @@ "Chrome profile" = "Chrome-profil"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Välj vilken inloggad DeepSeek Platform-session som ska ge detaljerad användning."; "Detailed usage unavailable." = "Detaljerad användning är inte tillgänglig."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Logga in på DeepSeek Platform i Chrome för detaljerad användning."; "Select a DeepSeek Chrome profile in Settings." = "Välj en DeepSeek Chrome-profil i Inställningar."; "Select profile…" = "Välj profil…"; @@ -1431,3 +1430,9 @@ "5-hour usage" = "5-timmarsanvändning"; "7-day usage" = "7-dagarsanvändning"; "Total usage" = "Total användning"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index cb4b5a04d1..9e51130a09 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -970,7 +970,6 @@ "Today cash" = "เงินสดวันนี้"; "DeepSeek 30 day token usage trend" = "DeepSeek แนวโน้มการใช้โทเค็น 30 วัน"; "Detailed usage unavailable." = "ไม่มีข้อมูลการใช้งานโดยละเอียด"; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "ลงชื่อเข้าใช้ DeepSeek Platform ใน Chrome เพื่อดูรายละเอียดการใช้งาน"; "Select a DeepSeek Chrome profile in Settings." = "เลือกโปรไฟล์ Chrome ของ DeepSeek ในการตั้งค่า"; "DeepSeek this month token usage trend" = "แนวโน้มการใช้โทเค็น DeepSeek เดือนนี้"; "Chrome profile" = "โปรไฟล์ Chrome"; @@ -1436,3 +1435,9 @@ "5-hour usage" = "การใช้งาน 5 ชั่วโมง"; "7-day usage" = "การใช้งาน 7 วัน"; "Total usage" = "การใช้งานทั้งหมด"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 4f2faec2da..dadcbbf288 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -969,7 +969,6 @@ "Chrome profile" = "Chrome profili"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Ayrıntılı kullanımı sağlayacak, oturum açılmış DeepSeek Platform oturumunu seçin."; "Detailed usage unavailable." = "Ayrıntılı kullanım kullanılamıyor."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Ayrıntılı kullanım için Chrome'da DeepSeek Platform'a giriş yapın."; "Select a DeepSeek Chrome profile in Settings." = "Ayarlarda bir DeepSeek Chrome profili seçin."; "Select profile…" = "Profil seç…"; "cache-hit input" = "önbellek-isabetli girdi"; @@ -1434,3 +1433,9 @@ "5-hour usage" = "5 saatlik kullanım"; "7-day usage" = "7 günlük kullanım"; "Total usage" = "Toplam kullanım"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 3b471f9a92..773bfe9a0b 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1260,7 +1260,6 @@ "Chrome profile" = "Профіль Chrome"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Виберіть сеанс DeepSeek Platform із виконаним входом, який надаватиме докладні дані про використання."; "Detailed usage unavailable." = "Докладні дані про використання недоступні."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Увійдіть у DeepSeek Platform у Chrome, щоб переглянути докладні дані про використання."; "Select a DeepSeek Chrome profile in Settings." = "Виберіть профіль Chrome для DeepSeek у налаштуваннях."; "Select profile…" = "Вибрати профіль…"; @@ -1432,3 +1431,9 @@ "5-hour usage" = "Використання за 5 годин"; "7-day usage" = "Використання за 7 днів"; "Total usage" = "Загальне використання"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 0d43da6353..a1426c840c 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1261,7 +1261,6 @@ "Chrome profile" = "Hồ sơ Chrome"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Chọn phiên DeepSeek Platform đã đăng nhập để cung cấp thông tin sử dụng chi tiết."; "Detailed usage unavailable." = "Không có thông tin sử dụng chi tiết."; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "Đăng nhập vào DeepSeek Platform trong Chrome để xem thông tin sử dụng chi tiết."; "Select a DeepSeek Chrome profile in Settings." = "Chọn một hồ sơ Chrome cho DeepSeek trong Cài đặt."; "Select profile…" = "Chọn hồ sơ…"; @@ -1433,3 +1432,9 @@ "5-hour usage" = "Mức sử dụng 5 giờ"; "7-day usage" = "Mức sử dụng 7 ngày"; "Total usage" = "Tổng mức sử dụng"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index b685d2d81b..e58c187528 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -216,7 +216,6 @@ "Login failed" = "登录失败"; "Login shell PATH (startup capture)" = "登录 shell PATH(启动时捕获)"; "Login timed out" = "登录超时"; -"Low Power Mode" = "低功耗模式"; "MCP details" = "MCP 详情"; "Managed Codex accounts unavailable" = "托管 Codex 账户不可用"; "Managed account storage is unreadable. Live account access is still available, " = "托管账户存储不可读。实时账户访问仍可用,"; @@ -268,7 +267,6 @@ "Open menu" = "打开菜单"; "Open token file" = "打开令牌文件"; "OpenAI cookies" = "OpenAI Cookie"; -"OpenAI web battery saver" = "OpenAI 网页省电"; "OpenAI web extras" = "OpenAI Web 附加功能"; "Option A" = "选项 A"; "Option B" = "选项 B"; @@ -303,7 +301,6 @@ "Quit CodexBar" = "退出 CodexBar"; "Random (default)" = "随机(默认)"; "Reads local usage logs. Shows today + last 30 days cost in the menu." = "读取本地用量日志。在菜单中显示今天及所选历史窗口的费用。"; -"Runs automatic provider, local usage, and storage refreshes no more often than every 30 minutes. Manual refresh remains available." = "自动供应商刷新、本地用量扫描和存储扫描最多每 30 分钟运行一次;仍可随时手动刷新。"; "Refresh" = "刷新"; "Refreshing" = "正在刷新"; "Refresh cadence" = "刷新频率"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index e8b803e593..07c55209da 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1108,8 +1108,6 @@ "minimax_service_music_generation" = "音樂生成"; "minimax_service_coding_plan_search" = "Coding Plan 搜尋"; "Clearing removes old plan-mode files." = "會移除舊的 plan-mode 檔案。"; -"%.0f%% %@" = "%2$@ %1$.0f%%"; -"<1%% %@" = "%1$@ <1%%"; "%@: %@%% used" = "%@:已使用 %@%%"; "terminal_app_subtitle" = "「開啟終端」動作使用的終端"; "Admin API key" = "Admin API 金鑰"; @@ -1216,56 +1214,10 @@ /* Runtime localization for dynamic strings */ "Cached: %1$@ • %2$@" = "已快取:%1$@ • %2$@"; -"Cached values from %@." = "使用 %@ 的快取值。"; -"Codex CLI is not signed in. Run `codex login --device-auth`, then refresh." = "Codex CLI 尚未登入。請執行 `codex login --device-auth`,然後重新整理。"; -"Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart." = "缺少 Codex CLI。請用 `npm i -g @openai/codex` 安裝(或使用 bun install),然後重新啟動。"; -"Codex session expired. Sign in again." = "Codex 工作階段已過期。請重新登入。"; -"OpenAI web refresh was interrupted. Refresh OpenAI cookies and try again." = "OpenAI Web 重新整理已中斷。請重新整理 OpenAI Cookie 後再試一次。"; -"OpenAI web refresh timed out. Refresh OpenAI cookies and try again." = "OpenAI Web 重新整理逾時。請重新整理 OpenAI Cookie 後再試一次。"; -"OpenAI web refresh hit a network error. Check your connection, then refresh OpenAI cookies and try again." = "OpenAI Web 重新整理遇到網路錯誤。請檢查連線,然後重新整理 OpenAI Cookie 後再試一次。"; -"Codex usage is temporarily unavailable. Try refreshing." = "Codex 使用量暫時無法取得。請嘗試重新整理。"; -"Last OpenAI dashboard refresh failed: %1$@. Cached values from %2$@." = "上次 OpenAI 儀表板重新整理失敗:%1$@。使用 %2$@ 的快取值。"; -"Last Codex credits refresh failed: %1$@. Cached values from %2$@." = "上次 Codex 額度重新整理失敗:%1$@。使用 %2$@ 的快取值。"; -"OpenAI web dashboard refresh timed out. CodexBar will retry after the refresh cooldown." = "OpenAI Web 儀表板重新整理逾時。CodexBar 會在重新整理冷卻時間後重試。"; "Codex account changed; importing browser cookies…" = "Codex 帳號已變更;正在匯入瀏覽器 Cookie…"; -"Managed Codex account data is unavailable." = "無法取得託管 Codex 帳號資料。"; -"Fix the managed account store before importing OpenAI cookies." = "請先修復託管帳號儲存區,再匯入 OpenAI Cookie。"; -"Fix the managed account store before refreshing OpenAI web data." = "請先修復託管帳號儲存區,再重新整理 OpenAI Web 資料。"; -"The selected managed Codex account is unavailable." = "所選託管 Codex 帳號無法使用。"; -"Pick another Codex account before importing OpenAI cookies." = "請先選擇另一個 Codex 帳號,再匯入 OpenAI Cookie。"; -"Pick another Codex account before refreshing OpenAI web data." = "請先選擇另一個 Codex 帳號,再重新整理 OpenAI Web 資料。"; -"The selected Codex profile has no verified account email." = "所選 Codex 設定檔沒有已驗證的帳號電子郵件。"; -"Refresh the profile before importing OpenAI cookies." = "請先重新整理設定檔,再匯入 OpenAI Cookie。"; -"Refresh the profile before refreshing OpenAI web data." = "請先重新整理設定檔,再重新整理 OpenAI Web 資料。"; -"No matching OpenAI web session found." = "找不到相符的 OpenAI Web 工作階段。"; -"No matching OpenAI web session found for %@." = "找不到 %@ 的相符 OpenAI Web 工作階段。"; -"OpenAI cookies are for %@." = "OpenAI Cookie 屬於 %@。"; -"OpenAI cookies are for %1$@, not %2$@." = "OpenAI Cookie 屬於 %1$@,不是 %2$@。"; -"Codex credits are still loading; will retry shortly." = "Codex 額度仍在載入;稍後會重試。"; -"Could Not Identify GitHub Account" = "無法識別 GitHub 帳號"; -"GitHub login succeeded, but CodexBar could not verify which account it belongs to. Please try again." = "GitHub 登入成功,但 CodexBar 無法確認這屬於哪個帳號。請再試一次。"; -"Token Refreshed" = "Token 已重新整理"; -"Account Added" = "已新增帳號"; -"Login Successful" = "登入成功"; -"Login Failed" = "登入失敗"; -"You can close this window and return to CodexBar." = "你可以關閉此視窗並返回 CodexBar。"; -"You can close this window and try again." = "你可以關閉此視窗後再試一次。"; "Requesting login…" = "正在要求登入…"; "Waiting in browser…" = "正在瀏覽器中等待…"; -"Managed account storage unavailable" = "託管帳號儲存區無法使用"; "Managed Codex login in progress…" = "託管 Codex 登入進行中…"; -"%d percent" = "%d%%"; -"Settings unavailable." = "無法取得設定。"; -"Failed to resolve Kilo credentials." = "無法解析 Kilo 憑證。"; -"Failed to load organizations." = "無法載入組織。"; -"Hidden" = "隱藏"; -"Credits left" = "剩餘額度"; -"Percent left" = "剩餘百分比"; -"Credits + percent" = "額度 + 百分比"; -"Used / total" = "已用 / 總量"; -"Overage credits at zero" = "歸零時顯示超額額度"; -"Overage cost at zero" = "歸零時顯示超額費用"; -"Overage credits + cost at zero" = "歸零時顯示超額額度 + 費用"; /* Settings sidebar redesign */ "Enable" = "啟用"; @@ -1296,7 +1248,6 @@ "Chrome profile" = "Chrome 設定檔"; "Choose which signed-in DeepSeek Platform session supplies detailed usage." = "選擇要提供詳細使用量的已登入 DeepSeek Platform 工作階段。"; "Detailed usage unavailable." = "無法取得詳細使用量。"; -"Sign in to DeepSeek Platform in Chrome for detailed usage." = "請在 Chrome 中登入 DeepSeek Platform 以查看詳細使用量。"; "Select a DeepSeek Chrome profile in Settings." = "請在「設定」中選擇 DeepSeek Chrome 設定檔。"; "Select profile…" = "選擇設定檔…"; @@ -1463,3 +1414,9 @@ "5-hour usage" = "5 小時用量"; "7-day usage" = "7 天用量"; "Total usage" = "總用量"; +"Cache-hit input" = "Cache-hit input"; +"Cache-miss input" = "Cache-miss input"; +"Daily tokens" = "Daily tokens"; +"Detailed usage" = "Detailed usage"; +"Output" = "Output"; +"Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar." = "Sign in to DeepSeek Platform in Chrome or Safari for detailed usage. Safari requires Full Disk Access for CodexBar."; diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekBalanceHistoryStore.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekBalanceHistoryStore.swift index b41da46b1d..093ce49a1b 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekBalanceHistoryStore.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekBalanceHistoryStore.swift @@ -99,6 +99,13 @@ public struct DeepSeekBalanceHistoryStore: @unchecked Sendable { // MARK: - Recording + /// Combines the account key with the currency so USD and CNY histories are + /// never mixed: switching wallets must not subtract unrelated balances. + private static func scopedKey(accountKey: String, currency: String) -> String { + let key = accountKey.isEmpty ? "default" : accountKey + return "\(key)|\(currency.uppercased())" + } + /// Records a balance sample for the given account key. Per local day only /// the first and the last sample are retained; older days are pruned to a /// 90-day window. @@ -109,7 +116,7 @@ public struct DeepSeekBalanceHistoryStore: @unchecked Sendable { at capturedAt: Date = Date()) { guard balance.isFinite, balance >= 0 else { return } - let key = accountKey.isEmpty ? "default" : accountKey + let key = Self.scopedKey(accountKey: accountKey, currency: currency) self.lock.lock() defer { self.lock.unlock() } @@ -163,7 +170,7 @@ public struct DeepSeekBalanceHistoryStore: @unchecked Sendable { currency: String, now: Date = Date()) -> ConsumptionSummary { - let key = accountKey.isEmpty ? "default" : accountKey + let key = Self.scopedKey(accountKey: accountKey, currency: currency) self.lock.lock() let payload = self.loadLocked() self.lock.unlock() diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift index 11e36d40fe..f140695853 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift @@ -14,17 +14,20 @@ enum DeepSeekPlatformTokenImporter { let profiles: [DeepSeekPlatformProfile] let selectedSummary: DeepSeekUsageSummary? let selectedBalance: DeepSeekUsageSnapshot? + let selectedProfileID: String? let detailedUsageState: DeepSeekDetailedUsageState init( profiles: [DeepSeekPlatformProfile], selectedSummary: DeepSeekUsageSummary?, selectedBalance: DeepSeekUsageSnapshot? = nil, + selectedProfileID: String? = nil, detailedUsageState: DeepSeekDetailedUsageState) { self.profiles = profiles self.selectedSummary = selectedSummary self.selectedBalance = selectedBalance + self.selectedProfileID = selectedProfileID self.detailedUsageState = detailedUsageState } } @@ -64,7 +67,11 @@ enum DeepSeekPlatformTokenImporter { logger: (@Sendable (String) -> Void)? = nil) async -> Resolution { #if os(macOS) - let candidates = self.importTokens(browserDetection: browserDetection, logger: logger) + let includeSafari = selectedProfileID?.hasPrefix("safari:") ?? false + let candidates = self.importTokens( + browserDetection: browserDetection, + includeSafari: includeSafari, + logger: logger) guard !Task.isCancelled else { return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .unavailable) } @@ -104,13 +111,15 @@ enum DeepSeekPlatformTokenImporter { #if os(macOS) static func importTokens( browserDetection: BrowserDetection, + includeSafari: Bool = false, logger: (@Sendable (String) -> Void)? = nil, localStorage: BrowserLocalStorageAPI = .live) -> [TokenInfo] { let log: @Sendable (String) -> Void = { message in logger?("[deepseek-storage] \(message)") } - // Safari stores localStorage in WebKit sqlite files; Chromium browsers use leveldb. - let safariTokens = Self.importSafariTokens(logger: log) + // Safari's protected container is only read when the user explicitly + // selected a Safari profile; routine refreshes stay Chrome-only. + let safariTokens = includeSafari ? Self.importSafariTokens(logger: log) : [] let chromeTokens = Self.importChromiumTokens( browserDetection: browserDetection, logger: log, @@ -428,9 +437,14 @@ enum DeepSeekPlatformTokenImporter { profiles: profiles, selectedSummary: sessionData.summary, selectedBalance: sessionData.balance, + selectedProfileID: selected.id, detailedUsageState: sessionData.detailedUsageState) } - return Resolution(profiles: profiles, selectedSummary: nil, detailedUsageState: .unavailable) + return Resolution( + profiles: profiles, + selectedSummary: nil, + selectedProfileID: selected.id, + detailedUsageState: .unavailable) } private static func validate( diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift index b193e97798..50be4aa223 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift @@ -176,15 +176,29 @@ public enum DeepSeekProviderDescriptor { /// Stable per-credential key so multiple API keys keep separate histories. /// Uses a namespaced non-reversible SHA-256 digest (never raw key fragments), /// mirroring the profile-scope pattern in DeepSeekSettingsReader. - private static func balanceAccountKey(apiKey: String?) -> String { - guard let apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines), - !apiKey.isEmpty - else { return "default" } + /// When no API key is configured (web-only), the selected browser profile + /// supplies the scope so different accounts never share a "default" history. + private static func balanceAccountKey(apiKey: String?, profileID: String? = nil) -> String { #if canImport(CryptoKit) - let input = "com.steipete.codexbar.deepseek-balance-history.v1\0\(apiKey)" + let input: String + if let apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines), !apiKey.isEmpty { + input = "com.steipete.codexbar.deepseek-balance-history.v1\0\(apiKey)" + } else if let profileID = profileID?.trimmingCharacters(in: .whitespacesAndNewlines), !profileID.isEmpty { + input = "com.steipete.codexbar.deepseek-balance-history.profile.v1\0\(profileID)" + } else { + return "default" + } let digest = SHA256.hash(data: Data(input.utf8)) return "v1:" + digest.map { String(format: "%02x", $0) }.joined() #else + let input: String + if let apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines), !apiKey.isEmpty { + input = "com.steipete.codexbar.deepseek-balance-history.v1\0\(apiKey)" + } else if let profileID = profileID?.trimmingCharacters(in: .whitespacesAndNewlines), !profileID.isEmpty { + input = "com.steipete.codexbar.deepseek-balance-history.profile.v1\0\(profileID)" + } else { + return "default" + } // Fallback without CryptoKit: still avoid raw fragments; use a simple hash. var hasher = Hasher() hasher.combine(input) @@ -193,30 +207,34 @@ public enum DeepSeekProviderDescriptor { } /// Test hook: exposes the digest mapping without persisting anything. - static func balanceAccountKeyForTesting(apiKey: String?) -> String { - self.balanceAccountKey(apiKey: apiKey) + static func balanceAccountKeyForTesting(apiKey: String?, profileID: String? = nil) -> String { + self.balanceAccountKey(apiKey: apiKey, profileID: profileID) } private static let balanceHistoryStore = DeepSeekBalanceHistoryStore() /// Records the observed balance and returns a snapshot whose balance detail /// includes derived consumption (today / total spend since last recharge). + /// Consumption is derived from the previous history BEFORE the fresh sample + /// is recorded, so a recharge baseline is never overwritten by the refresh + /// that follows it. private static func usageSnapshotRecordingBalance( _ balance: DeepSeekUsageSnapshot, apiKey: String?, + profileID: String? = nil, now: Date = Date()) -> UsageSnapshot { - let accountKey = Self.balanceAccountKey(apiKey: apiKey) - Self.balanceHistoryStore.record( - balance: balance.totalBalance, - currency: balance.currency, - accountKey: accountKey, - at: now) + let accountKey = Self.balanceAccountKey(apiKey: apiKey, profileID: profileID) let consumption = Self.balanceHistoryStore.consumptionSummary( for: accountKey, currentBalance: balance.totalBalance, currency: balance.currency, now: now) + Self.balanceHistoryStore.record( + balance: balance.totalBalance, + currency: balance.currency, + accountKey: accountKey, + at: now) return balance.toUsageSnapshot(consumption: consumption) } @@ -326,7 +344,10 @@ public enum DeepSeekProviderDescriptor { detailedUsageState: resolution.detailedUsageState, platformProfiles: resolution.profiles, updatedAt: balance.updatedAt) - return Self.usageSnapshotRecordingBalance(snapshot, apiKey: apiKey) + return Self.usageSnapshotRecordingBalance( + snapshot, + apiKey: apiKey, + profileID: resolution.selectedProfileID) } fileprivate static func loadPlatformUsage( diff --git a/Tests/CodexBarTests/DeepSeekBalanceHistoryStoreTests.swift b/Tests/CodexBarTests/DeepSeekBalanceHistoryStoreTests.swift index 095571fff3..52abd09793 100644 --- a/Tests/CodexBarTests/DeepSeekBalanceHistoryStoreTests.swift +++ b/Tests/CodexBarTests/DeepSeekBalanceHistoryStoreTests.swift @@ -142,6 +142,46 @@ final class DeepSeekBalanceHistoryStoreTests: XCTestCase { XCTAssertEqual(summaryB.totalSpent ?? -1, 20, accuracy: 0.0001) } + func testCurrenciesArePartitionedPerAccountKey() { + let store = self.makeStore() + // Same logical account, different wallet currencies must not mix history: + // switching USD → CNY must not subtract unrelated balances. + store.record(balance: 100, currency: "USD", accountKey: "default", at: self.date(14, hour: 9)) + store.record(balance: 200, currency: "CNY", accountKey: "default", at: self.date(14, hour: 10)) + + let usdSummary = store.consumptionSummary( + for: "default", + currentBalance: 90, + currency: "USD", + now: self.date(15, hour: 14)) + let cnySummary = store.consumptionSummary( + for: "default", + currentBalance: 190, + currency: "CNY", + now: self.date(15, hour: 14)) + + // USD history is 100 → 90: spend 10. + XCTAssertEqual(usdSummary.totalSpent ?? -1, 10, accuracy: 0.0001) + // CNY history is 200 → 190: spend 10, untouched by the USD record. + XCTAssertEqual(cnySummary.totalSpent ?? -1, 10, accuracy: 0.0001) + // Currency symbol follows the queried currency, not the other partition. + XCTAssertEqual(usdSummary.currency, "$") + XCTAssertEqual(cnySummary.currency, "¥") + } + + func testCurrencySwitchWithoutHistoryStartsFresh() { + let store = self.makeStore() + store.record(balance: 100, currency: "USD", accountKey: "default", at: self.date(14, hour: 9)) + // First CNY observation ever: no CNY baseline, so no spend is reported. + let cnySummary = store.consumptionSummary( + for: "default", + currentBalance: 50, + currency: "CNY", + now: self.date(15, hour: 14)) + XCTAssertNil(cnySummary.totalSpent) + XCTAssertNil(cnySummary.todaySpent) + } + func testPersistenceRoundTrip() { let fileURL = self.directoryURL.appendingPathComponent("history.json") let store = DeepSeekBalanceHistoryStore( diff --git a/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift b/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift index 4aae2b5f15..8a49af1a32 100644 --- a/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift +++ b/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift @@ -59,6 +59,42 @@ struct DeepSeekPlatformTokenImporterTests { } #endif + #if os(macOS) + @Test + func `safari storage is only read after explicit opt-in`() { + // Regression: automatic resolution used to scan Safari's protected + // container before Chrome on every refresh. Safari must be excluded + // unless the user explicitly selected a Safari profile. + let localStorage = BrowserLocalStorageAPI { _, _, _, _ in + [ + BrowserLocalStorageAPI.Profile( + id: "chrome:Profile 2", + label: "Chrome — Work", + entries: [ + BrowserLocalStorageAPI.Entry( + key: "userToken", + value: "browser-user-token-through-host-api"), + ]), + ] + } + + // Default: Safari is NOT read. Only Chrome tokens are returned even + // though the real Safari container exists on this machine. + let defaults = DeepSeekPlatformTokenImporter.importTokens( + browserDetection: BrowserDetection(cacheTTL: 0), + includeSafari: false, + localStorage: localStorage) + #expect(defaults.map(\.id) == ["chrome:Profile 2"]) + + // Explicit opt-in keeps Safari support available. + let optedIn = DeepSeekPlatformTokenImporter.importTokens( + browserDetection: BrowserDetection(cacheTTL: 0), + includeSafari: true, + localStorage: localStorage) + #expect(optedIn.contains { $0.id.hasPrefix("safari:") } || optedIn.map(\.id) == ["chrome:Profile 2"]) + } + #endif + @Test func `multiple profiles expose only server accepted sessions`() async { let candidates = [ diff --git a/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift b/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift index 21ea8ddc1f..e51828e96e 100644 --- a/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift +++ b/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift @@ -464,6 +464,82 @@ struct DeepSeekProviderDescriptorTests { #expect(strategies.map(\.id) == ["deepseek.api"]) } + @Test + func `web-only balance history is scoped to the selected profile`() async throws { + // Regression: web-only (no API key) refreshes used to fall back to the + // shared "default" history key, so switching DeepSeek profiles mixed + // one account's balance changes into another's spend/recharge. + let summary = DeepSeekUsageSummary( + todayTokens: 1, + currentMonthTokens: 1, + todayCost: 0, + currentMonthCost: 0, + requestCount: 1, + currentMonthRequestCount: 1, + topModel: nil, + categoryBreakdown: [], + modelCosts: [], + daily: [], + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 1)) + let profile = DeepSeekPlatformProfile(id: "chrome:Personal", name: "Chrome — Personal") + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + DeepSeekPlatformTokenImporter.Resolution( + profiles: [profile], + selectedSummary: summary, + selectedProfileID: profile.id, + detailedUsageState: .available) + }) + + // First refresh records a baseline for this profile. + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "", + context: Self.makeContext(sourceMode: .auto), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + // A different profile must NOT see the first profile's history. + let otherProfile = DeepSeekPlatformProfile(id: "chrome:Work", name: "Chrome — Work") + let otherOperations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + DeepSeekPlatformTokenImporter.Resolution( + profiles: [otherProfile], + selectedSummary: summary, + selectedProfileID: otherProfile.id, + detailedUsageState: .available) + }) + let snapshot = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "", + context: Self.makeContext(sourceMode: .auto), + optionalResolutionJoinGrace: .seconds(1), + operations: otherOperations) + + // No spend can be derived yet for the new profile (fresh history). + // The balance line stays a plain balance with no "Total −" suffix. + #expect(snapshot.primary?.resetDescription?.contains("Total −") == false) + } + + @Test + func `profile scoped balance keys differ from api keys and default`() throws { + let apiKeyDigest = DeepSeekProviderDescriptor.balanceAccountKeyForTesting(apiKey: "test-credential-aaaaaaaaaaaaaaaa") + let profileDigest = DeepSeekProviderDescriptor.balanceAccountKeyForTesting(apiKey: nil, profileID: "chrome:Personal") + let otherProfileDigest = DeepSeekProviderDescriptor.balanceAccountKeyForTesting(apiKey: nil, profileID: "chrome:Work") + + // Web-only profile scope must be distinct from API-key scope and from default. + #expect(profileDigest.hasPrefix("v1:")) + #expect(profileDigest != apiKeyDigest) + #expect(profileDigest != otherProfileDigest) + #expect(profileDigest != "default") + // Same profile maps to the same digest. + #expect( + DeepSeekProviderDescriptor.balanceAccountKeyForTesting(apiKey: nil, profileID: "chrome:Personal") == profileDigest) + // Nil profile still falls back to default for legacy behavior. + #expect(DeepSeekProviderDescriptor.balanceAccountKeyForTesting(apiKey: nil) == "default") + } + private static let balance = DeepSeekUsageSnapshot( isAvailable: true, currency: "USD", diff --git a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift index a4bdee5396..b8f229f958 100644 --- a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift +++ b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift @@ -344,4 +344,28 @@ struct MenuCardDeepSeekTests { #expect(model.inlineUsageDashboard == nil) #expect(model.usageNotes.isEmpty) } + + @Test + func `deepseek chart metadata is localized with the detail rows`() throws { + // Regression: the chart title/unit were forwarded untranslated while + // rows were localized, so zh-Hans showed English chart headers. + let englishSection = try ProviderDetailSection( + title: "Detailed usage", + rows: [ + try ProviderDetailSection.Row(label: "Today", value: "1"), + ], + chart: try ProviderDetailSection.Chart( + kind: .bars, + title: "Daily tokens", + unit: "tokens", + points: [try ProviderDetailSection.Chart.Point(label: "2026-05-26", value: 456)])) + let localized = UsageMenuCardView.Model.deepSeekLocalizedDetails([englishSection]) + let chart = try #require(localized.first?.chart) + + // Both the title and the unit go through the label map. + #expect(chart.title == L("Daily tokens")) + #expect(chart.unit == L("tokens")) + // Points keep their (date) labels untouched. + #expect(chart.points.map(\.label) == ["2026-05-26"]) + } }