diff --git a/CHANGELOG.md b/CHANGELOG.md index 053cc0ef21..09084037a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ - Claude: cache successful CLI version probes for 30 minutes while invalidating on executable changes, avoiding repeated PTY launches without retaining failed or stale wrapper results. Thanks @Yuxin-Qiao! - Linux CLI: bootstrap the configured IANA timezone before Foundation startup on non-FHS systems, preventing SIGILL on NixOS (#2127). Thanks @xikhar! - Ollama: release temporary dashboard network sessions after each fetch, preventing repeated refreshes from retaining delegates and URL-cache resources. Thanks @astuteprogrammer! +- DeepSeek: use a separate Platform web-session token for detailed usage, validate sessions across Chrome profiles, + offer a picker when multiple profiles are valid, fetch amount and cost concurrently, and preserve API-key balance + updates when detailed usage is unavailable. - Linux CLI: prevent usage rendering from crashing in Foundation bundle discovery when formatting rate windows. Thanks @thanthi-del! - Menus: keep overview provider-row clicks reliable during live menu rebuilds without stealing nested Copy or plan actions. Thanks @Yuxin-Qiao! - Startup: load persisted plan-utilization history away from the main thread so mature histories no longer delay app launch. Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 4319cd42c2..0cee5bfa6c 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -86,10 +86,28 @@ extension UsageMenuCardView.Model { ] } - if input.provider == .deepseek, - input.showOptionalCreditsAndExtraUsage, - let usage = input.snapshot?.deepseekUsage - { + if input.provider == .deepseek { + if input.isRefreshing { + return [] + } + if input.snapshot?.primary == nil { + if input.snapshot?.deepseekDetailedUsageState == .webSessionRequired { + return [L("Sign in to DeepSeek Platform in Chrome for detailed usage.")] + } + if input.snapshot?.deepseekDetailedUsageState == .profileSelectionRequired { + return [L("Select a DeepSeek Chrome profile in Settings.")] + } + } + guard input.showOptionalCreditsAndExtraUsage else { return nil } + guard let usage = input.snapshot?.deepseekUsage else { + if input.snapshot?.deepseekDetailedUsageState == .webSessionRequired { + return [L("Sign in to DeepSeek Platform in Chrome for detailed usage.")] + } + if input.snapshot?.deepseekDetailedUsageState == .profileSelectionRequired { + return [L("Select a DeepSeek Chrome profile in Settings.")] + } + return [L("Detailed usage unavailable.")] + } let symbol = usage.currency == "CNY" ? "¥" : "$" let todayCostStr = usage.todayCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—" return [ @@ -226,6 +244,7 @@ extension UsageMenuCardView.Model { return Self.minimaxInlineDashboard(billing) } if input.provider == .deepseek, + !input.isRefreshing, input.showOptionalCreditsAndExtraUsage, let usage = input.snapshot?.deepseekUsage, !usage.daily.isEmpty @@ -673,7 +692,7 @@ extension UsageMenuCardView.Model { let monthTokensStr = UsageFormatter.tokenCountString(usage.currentMonthTokens) return InlineUsageDashboardModel( - accessibilityLabel: L("DeepSeek 30 day token usage trend"), + accessibilityLabel: L("DeepSeek this month token usage trend"), valueStyle: .tokens, kpis: [ .init( diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index f2cf4da0c3..42f777d2a6 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -134,7 +134,8 @@ struct ProviderDetailView: View { provider: self.provider, model: self.model, openAIWebDiagnostic: self.openAIWebDiagnostic, - isEnabled: self.isEnabled) + isEnabled: self.isEnabled, + isRefreshing: self.store.refreshingProviders.contains(self.provider)) } header: { Text(L("Usage")) } @@ -350,6 +351,7 @@ struct ProviderMetricsInlineView: View { let model: UsageMenuCardView.Model let openAIWebDiagnostic: String? let isEnabled: Bool + let isRefreshing: Bool struct InfoRow: Identifiable, Equatable { enum ID: Hashable { @@ -430,10 +432,24 @@ struct ProviderMetricsInlineView: View { } private var placeholderText: String { - if !self.isEnabled { + Self.placeholderText( + isEnabled: self.isEnabled, + isRefreshing: self.isRefreshing, + modelPlaceholder: self.model.placeholder) + } + + static func placeholderText( + isEnabled: Bool, + isRefreshing: Bool, + modelPlaceholder: String?) -> String + { + if !isEnabled { return L("Disabled — no recent data") } - return self.model.placeholder.map(L) ?? L("No usage yet") + if isRefreshing { + return L("Refreshing") + } + return modelPlaceholder.map(L) ?? L("No usage yet") } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 9be5a35ab5..54a50bea7d 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -163,7 +163,7 @@ struct ProvidersPane: View { usageText = L("last_fetch_failed") } else if self.store.knownLimitsAvailability(for: provider)?.isUnavailable == true { usageText = L("Limits not available") - } else if let snapshot = self.store.snapshot(for: provider) { + } else if let snapshot = self.store.presentationSnapshot(for: provider) { let relative = snapshot.updatedAt.relativeDescription() usageText = relative } else { @@ -189,7 +189,7 @@ struct ProvidersPane: View { L("last_fetch_failed") } else if self.store.knownLimitsAvailability(for: provider)?.isUnavailable == true { L("Limits not available") - } else if let snapshot = self.store.snapshot(for: provider) { + } else if let snapshot = self.store.presentationSnapshot(for: provider) { snapshot.updatedAt.relativeDescription() } else { L("usage_not_fetched_yet") @@ -620,7 +620,7 @@ struct ProvidersPane: View { func menuCardModel(for provider: UsageProvider) -> UsageMenuCardView.Model { let metadata = self.store.metadata(for: provider) - let snapshot = self.store.snapshot(for: provider) + let snapshot = self.store.presentationSnapshot(for: provider) let now = Date() let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: provider, diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index e16cf8015f..6304ce0d87 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -56,7 +56,10 @@ struct ProviderRegistry { runtime: .app, sourceMode: sourceMode, includeCredits: false, - includeOptionalUsage: settings.showOptionalCreditsAndExtraUsage, + includeOptionalUsage: ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: provider, + settings: settings, + override: nil), webTimeout: 60, webDebugDumpHTML: false, verbose: verbose, diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift index 2d9b000e33..0d484e40ff 100644 --- a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift @@ -1,23 +1,71 @@ import CodexBarCore import Foundation +import SwiftUI struct DeepSeekProviderImplementation: ProviderImplementation { let id: UsageProvider = .deepseek @MainActor func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { - ProviderPresentation { _ in "api" } + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } } @MainActor func observeSettings(_: SettingsStore) {} @MainActor - func isAvailable(context: ProviderAvailabilityContext) -> Bool { - if DeepSeekSettingsReader.apiKey(environment: context.environment) != nil { - return true - } - return !context.settings.tokenAccounts(for: .deepseek).isEmpty + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let presentationSnapshot = context.store.presentationSnapshot(for: .deepseek) + ?? context.store.lastKnownResetSnapshots[.deepseek] + let profiles = presentationSnapshot?.deepseekPlatformProfiles ?? [] + guard profiles.count > 1 || presentationSnapshot?.deepseekDetailedUsageState == .profileSelectionRequired + else { return [] } + let apiKey = context.settings.selectedTokenAccount(for: .deepseek)?.token + ?? DeepSeekSettingsReader.apiKey(environment: context.store.environmentBase) + let selectedProfileID = context.settings.deepseekProfileID(apiKey: apiKey) + let hasValidSelection = profiles.contains { $0.id == selectedProfileID } + let profileBinding = Binding( + get: { + let profileID = context.settings.deepseekProfileID(apiKey: apiKey) + return profiles.contains { $0.id == profileID } ? profileID : "" + }, + set: { profileID in + guard !profileID.isEmpty else { return } + context.store.beginDeepSeekProfileTransition(preservingBalance: apiKey != nil) + context.settings.setDeepSeekProfileID(profileID, apiKey: apiKey) + }) + let options = (hasValidSelection + ? [] + : [ProviderSettingsPickerOption(id: "", title: "Select profile…")]) + + profiles.map { ProviderSettingsPickerOption(id: $0.id, title: $0.name) } + + return [ + ProviderSettingsPickerDescriptor( + id: "deepseek-chrome-profile", + title: "Chrome profile", + subtitle: "Choose which signed-in DeepSeek Platform session supplies detailed usage.", + dynamicSubtitle: { + context.store.refreshingProviders.contains(.deepseek) + ? "Refreshing" + : nil + }, + binding: profileBinding, + options: options, + isVisible: nil, + isEnabled: { !context.store.refreshingProviders.contains(.deepseek) }, + onChange: { _ in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await context.store.refreshProvider(.deepseek, allowDisabled: true) + } + }), + ] + } + + @MainActor + func isAvailable(context _: ProviderAvailabilityContext) -> Bool { + true } @MainActor diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift new file mode 100644 index 0000000000..bc24e63341 --- /dev/null +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift @@ -0,0 +1,28 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + func deepseekProfileID(apiKey: String?) -> String { + _ = self.configRevision + _ = self.providerDetailSettingsRevision + guard let config = self.config.providerConfig(for: .deepseek), + let profileID = config.sanitizedDeepSeekProfileID + else { return "" } + let accountID = self.selectedTokenAccount(for: .deepseek)?.id + let expectedScope = DeepSeekSettingsReader.profileScope(selectedTokenAccountID: accountID, apiKey: apiKey) + guard let expectedScope, config.sanitizedDeepSeekProfileScope == expectedScope else { return "" } + return profileID + } + + func setDeepSeekProfileID(_ newValue: String, apiKey: String?) { + let profileID = self.normalizedConfigValue(newValue) + let profileScope = DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: self.selectedTokenAccount(for: .deepseek)?.id, + apiKey: apiKey) + guard profileID == nil || profileScope != nil else { return } + self.updateProviderDetailConfig(provider: .deepseek) { entry in + entry.deepseekProfileID = profileID + entry.deepseekProfileScope = profileID == nil ? nil : profileScope + } + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift b/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift index 86c2618ceb..0cdea11ea3 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderTokenAccountSelection.swift @@ -16,4 +16,18 @@ enum ProviderTokenAccountSelection { if let override, override.provider == provider { return override.account } return settings.selectedTokenAccount(for: provider) } + + @MainActor + static func shouldIncludeOptionalUsage( + provider: UsageProvider, + settings: SettingsStore, + override: TokenAccountOverride?) -> Bool + { + guard settings.showOptionalCreditsAndExtraUsage else { return false } + guard provider == .deepseek, + let override, + override.provider == provider + else { return true } + return settings.selectedTokenAccount(for: provider)?.id == override.account.id + } } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 91cef1e6a5..59428be36f 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -898,6 +898,13 @@ "MiniMax 30 day token usage trend" = "MiniMax اتجاه استخدام الرموز خلال 30 يوما"; "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"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "اختر جلسة DeepSeek Platform المسجّل دخولها التي توفّر تفاصيل الاستخدام."; +"Select profile…" = "اختر ملفًا شخصيًا…"; "cache-hit input" = "إدخال الضربات المؤقتة"; "cache-miss input" = "إدخال ذاكرة تخزين مؤقت (CACHE-miss)"; "output" = "الإنتاج"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 77d03ca853..6b5eedb712 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -761,6 +761,13 @@ "MiniMax 30 day token usage trend" = "Tendència d'ús de tokens de 30 dies de MiniMax"; "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"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "Tria quina sessió iniciada de DeepSeek Platform proporciona l'ús detallat."; +"Select profile…" = "Selecciona un perfil…"; "cache-hit input" = "entrada amb encert de memòria cau"; "cache-miss input" = "entrada sense encert de memòria cau"; "output" = "sortida"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 3ac738963c..9daf1caaf4 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -899,6 +899,13 @@ "MiniMax 30 day token usage trend" = "MiniMax 30 day token usage trend"; "Today cash" = "Today cash"; "DeepSeek 30 day token usage trend" = "DeepSeek 30 day token usage trend"; +"DeepSeek this month token usage trend" = "DeepSeek this month token usage trend"; +"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."; +"Select a DeepSeek Chrome profile in Settings." = "Select a DeepSeek Chrome profile in Settings."; +"Select profile…" = "Select profile…"; "cache-hit input" = "cache-hit input"; "cache-miss input" = "cache-miss input"; "output" = "output"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 2491f6d09a..6015d32833 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -898,6 +898,13 @@ "MiniMax 30 day token usage trend" = "MiniMax روند استفاده ۳۰ روزه از توکن"; "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"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "انتخاب کنید کدام نشست واردشدهٔ DeepSeek Platform جزئیات استفاده را ارائه دهد."; +"Select profile…" = "انتخاب نمایه…"; "cache-hit input" = "ورودی کش و ضربه"; "cache-miss input" = "ورودی کش-خطا"; "output" = "خروجی"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 28999004ba..8c59bfbc10 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -755,6 +755,13 @@ "MiniMax 30 day token usage trend" = "Tendencia de uso de tokens de MiniMax en 30 días"; "Today cash" = "Gasto de hoxe"; "DeepSeek 30 day token usage trend" = "Tendencia de uso de tokens de DeepSeek en 30 días"; +"DeepSeek this month token usage trend" = "Tendencia de uso de tokens de DeepSeek este mes"; +"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é"; "cache-miss input" = "entrada non atopada na caché"; "output" = "saída"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 2cbf1a0105..ae8d99b552 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -900,6 +900,13 @@ "MiniMax 30 day token usage trend" = "Tren penggunaan token 30 hari MiniMax"; "Today cash" = "Kas hari ini"; "DeepSeek 30 day token usage trend" = "Tren penggunaan token 30 hari DeepSeek"; +"DeepSeek this month token usage trend" = "Tren penggunaan token DeepSeek bulan ini"; +"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"; "cache-miss input" = "input cache-miss"; "output" = "output"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 26e5b34cb4..fd596a6aa1 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -900,6 +900,13 @@ "MiniMax 30 day token usage trend" = "Trend utilizzo token 30 giorni MiniMax"; "Today cash" = "Spesa di oggi"; "DeepSeek 30 day token usage trend" = "Trend utilizzo token 30 giorni DeepSeek"; +"DeepSeek this month token usage trend" = "Trend utilizzo token DeepSeek di questo mese"; +"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"; "cache-miss input" = "input cache-miss"; "output" = "uscita"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index ba2fef33ba..b3bb23f420 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -900,6 +900,13 @@ "MiniMax 30 day token usage trend" = "Trend użycia tokenów MiniMax z 30 dni"; "Today cash" = "Dzisiejsza gotówka"; "DeepSeek 30 day token usage trend" = "Trend użycia tokenów DeepSeek z 30 dni"; +"DeepSeek this month token usage trend" = "Trend użycia tokenów DeepSeek w tym miesiącu"; +"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"; "cache-miss input" = "wejście chybienia pamięci podręcznej"; "output" = "wynik"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 690767b55c..ac56316e0c 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -898,6 +898,13 @@ "MiniMax 30 day token usage trend" = "MiniMax แนวโน้มการใช้โทเค็น 30 วัน"; "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"; +"Choose which signed-in DeepSeek Platform session supplies detailed usage." = "เลือกเซสชัน DeepSeek Platform ที่ลงชื่อเข้าใช้เพื่อแสดงรายละเอียดการใช้งาน"; +"Select profile…" = "เลือกโปรไฟล์…"; "cache-hit input" = "อินพุต cache-hit"; "cache-miss input" = "อินพุตแคชพลาด"; "output" = "เอาท์พุท"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index ad306f55dd..302512e2fd 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -894,6 +894,13 @@ "MiniMax 30 day token usage trend" = "MiniMax 30 günlük jeton kullanım trendi"; "Today cash" = "Bugünkü nakit"; "DeepSeek 30 day token usage trend" = "DeepSeek 30 günlük jeton kullanım trendi"; +"DeepSeek this month token usage trend" = "DeepSeek bu ayki jeton kullanım trendi"; +"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"; "cache-miss input" = "önbellek-kaçan girdi"; "output" = "çıktı"; diff --git a/Sources/CodexBar/SettingsStore+ConfigPersistence.swift b/Sources/CodexBar/SettingsStore+ConfigPersistence.swift index cf6ce6b327..14e8ed6017 100644 --- a/Sources/CodexBar/SettingsStore+ConfigPersistence.swift +++ b/Sources/CodexBar/SettingsStore+ConfigPersistence.swift @@ -58,6 +58,29 @@ extension SettingsStore { } } + /// Persists provider settings that only affect an already-visible provider detail. + /// This avoids rebuilding status items and open menus for a local selection change. + func updateProviderDetailConfig( + provider: UsageProvider, + mutate: (inout ProviderConfig) -> Void) + { + guard !self.configLoading else { return } + var config = self.config + if let index = config.providers.firstIndex(where: { $0.id == provider }) { + var entry = config.providers[index] + mutate(&entry) + config.providers[index] = entry + } else { + var entry = ProviderConfig(id: provider) + mutate(&entry) + config.providers.append(entry) + } + self.config = config.normalized() + self.updateProviderState(config: self.config) + self.schedulePersistConfig() + self.providerDetailSettingsRevision &+= 1 + } + func updateProviderTokenAccounts(_ accounts: [UsageProvider: ProviderTokenAccountData]) { let summary = accounts .sorted { $0.key.rawValue < $1.key.rawValue } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 2689d22576..6683d75c17 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -215,6 +215,7 @@ final class SettingsStore { @ObservationIgnored var selectedMenuProviderRawStorage: String? var defaultsState: SettingsDefaultsState var configRevision: Int = 0 + var providerDetailSettingsRevision: Int = 0 var backgroundWorkSettingsRevision: Int = 0 var providerOrder: [UsageProvider] = [] var providerEnablement: [UsageProvider: Bool] = [:] diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 04e49b4dec..3d82578043 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -34,7 +34,7 @@ extension StatusItemController { let snapshot: UsageSnapshot? = if surface == .overrideCard { snapshotOverride } else { - snapshotOverride ?? self.store.snapshot(for: target) + snapshotOverride ?? self.store.presentationSnapshot(for: target) } let projectedTokenSnapshot = self.store.tokenSnapshot(fromProviderSnapshot: snapshot, provider: target) let storedTokenSnapshot = UsageStore.tokenCostRequiresProviderSnapshot(target) diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 5759af4abe..8e8339b828 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -2,6 +2,12 @@ import CodexBarCore import Foundation extension UsageStore { + struct DeepSeekProfileTransition { + var snapshot: UsageSnapshot + let accountID: UUID? + let hasSyntheticBalance: Bool + } + func version(for provider: UsageProvider) -> String? { self.versions[provider] } @@ -14,6 +20,62 @@ extension UsageStore { self.snapshots[.claude] } + func presentationSnapshot(for provider: UsageProvider) -> UsageSnapshot? { + if provider == .deepseek, + let transition = self.deepseekProfileTransition, + transition.accountID == self.settings.selectedTokenAccount(for: .deepseek)?.id + { + return transition.snapshot + } + if let snapshot = self.snapshots[provider] { + return snapshot + } + guard provider == .deepseek, self.refreshingProviders.contains(provider) else { return nil } + return self.lastKnownResetSnapshots[provider] + } + + func beginDeepSeekProfileTransition(preservingBalance: Bool = true) { + guard self.deepseekProfileTransition == nil, + let snapshot = self.snapshots[.deepseek] ?? self.lastKnownResetSnapshots[.deepseek] + else { return } + var transitionSnapshot = snapshot.withoutDeepSeekDetailedUsage() + if !preservingBalance { + transitionSnapshot = transitionSnapshot.with( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: L("Refreshing")), + secondary: nil) + } + self.deepseekProfileTransition = DeepSeekProfileTransition( + snapshot: transitionSnapshot, + accountID: self.settings.selectedTokenAccount(for: .deepseek)?.id, + hasSyntheticBalance: !preservingBalance) + } + + func markDeepSeekProfileTransitionUnavailable() { + guard var transition = self.deepseekProfileTransition, + transition.hasSyntheticBalance + else { return } + transition.snapshot = transition.snapshot.with( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: L("Unavailable")), + secondary: nil) + self.deepseekProfileTransition = transition + } + + func clearDeepSeekProfileTransition() { + self.deepseekProfileTransition = nil + } + + var deepseekProfileTransitionSnapshot: UsageSnapshot? { + self.deepseekProfileTransition?.snapshot + } + var lastCodexError: String? { self.errors[.codex] } diff --git a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift index 5e7a929504..1f6b79e3c9 100644 --- a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift +++ b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift @@ -25,6 +25,9 @@ extension UsageStore { self.snapshots.removeValue(forKey: provider) self.lastKnownResetSnapshots.removeValue(forKey: provider) self.errors[provider] = nil + if provider == .deepseek { + self.clearDeepSeekProfileTransition() + } if provider == .gemini { self.clearGeminiConsumerTierDeprecationObservation() } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index af77c40960..383e274c69 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -496,8 +496,9 @@ extension UsageStore { } else { self.lastKnownResetSnapshots[provider] } + let profileStable = self.preservingDeepSeekProfileCatalog(in: accountScoped, provider: provider) let stabilized = Self.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( - current: accountScoped, + current: profileStable, previous: self.snapshots[provider]) let backfilled = stabilized.backfillingResetTimes(from: resetBackfillSource) let predictivePaceWarningAccountDiscriminatorOverride: String? = if provider == .claude { @@ -522,6 +523,9 @@ extension UsageStore { } self.lastKnownResetSnapshots[provider] = backfilled self.snapshots[provider] = backfilled + if provider == .deepseek { + self.clearDeepSeekProfileTransition() + } if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: backfilled, provider: provider) { self.tokenSnapshots[provider] = tokenSnapshot self.tokenErrors[provider] = nil @@ -612,8 +616,18 @@ extension UsageStore { return } // Credential-change cleanup already ran above; cancellation is now safe to suppress. - guard !Self.errorIsCancellation(error) else { return } + if Self.errorIsCancellation(error) { + if provider == .deepseek, + self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) + { + self.markDeepSeekProfileTransitionUnavailable() + } + return + } guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return } + if provider == .deepseek { + self.markDeepSeekProfileTransitionUnavailable() + } self.bindCodexFailurePublicationOwner( provider: provider, expectedGuard: context.codexExpectedGuard) @@ -625,6 +639,14 @@ extension UsageStore { generation: context.generation) } + private func preservingDeepSeekProfileCatalog( + in snapshot: UsageSnapshot, + provider: UsageProvider) -> UsageSnapshot + { + guard provider == .deepseek else { return snapshot } + return snapshot.preservingDeepSeekPlatformProfiles(from: self.presentationSnapshot(for: .deepseek)) + } + private func bindCodexFailurePublicationOwner( provider: UsageProvider, expectedGuard: CodexAccountScopedRefreshGuard?) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 91e4664487..6d2b306ea6 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -888,7 +888,10 @@ extension UsageStore { runtime: .app, sourceMode: sourceMode, includeCredits: includeCredits, - includeOptionalUsage: self.settings.showOptionalCreditsAndExtraUsage, + includeOptionalUsage: ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: provider, + settings: self.settings, + override: override), webTimeout: 60, webDebugDumpHTML: false, verbose: verbose, @@ -1458,7 +1461,11 @@ extension UsageStore { guard self.isCurrentProviderRefreshGeneration(provider, generation: generation) else { return nil as UsageSnapshot? } - let backfilled = labeled.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) + let profileStable = provider == .deepseek + ? labeled.preservingDeepSeekPlatformProfiles( + from: self.presentationSnapshot(for: .deepseek)) + : labeled + let backfilled = profileStable.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) let predictivePaceWarningAccountDiscriminatorOverride: String? = if provider == .claude { Self.predictivePaceWarningTokenAccountDiscriminator(account) } else { @@ -1472,6 +1479,9 @@ extension UsageStore { accountDiscriminatorOverride: predictivePaceWarningAccountDiscriminatorOverride) self.lastKnownResetSnapshots[provider] = backfilled self.snapshots[provider] = backfilled + if provider == .deepseek { + self.clearDeepSeekProfileTransition() + } self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) @@ -1486,6 +1496,9 @@ extension UsageStore { case let .failure(error): await MainActor.run { self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) + if provider == .deepseek { + self.markDeepSeekProfileTransitionUnavailable() + } guard let message = self.tokenAccountErrorMessage(error) else { self.errors[provider] = nil return diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 6241a098ee..9f6fc9d9c9 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -327,6 +327,7 @@ final class UsageStore { @ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset? @ObservationIgnored var codexHistoricalDatasetAccountKey: String? @ObservationIgnored var lastKnownResetSnapshots: [UsageProvider: UsageSnapshot] = [:] + @ObservationIgnored var deepseekProfileTransition: DeepSeekProfileTransition? @ObservationIgnored var sessionQuotaTransitionStates: [UsageProvider: SessionQuotaTransitionState] = [:] @ObservationIgnored var codexSessionQuotaBaselineRequirement: CodexSessionQuotaBaselineRequirement? var codexSessionQuotaBaselineRequired: Bool { diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index 515f488fef..7343b68262 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -51,9 +51,13 @@ public struct CodexBarConfig: Codable, Sendable { var normalized: [ProviderConfig] = [] normalized.reserveCapacity(max(self.providers.count, UsageProvider.allCases.count)) - for provider in self.providers { + for var provider in self.providers { guard !seen.contains(provider.id) else { continue } seen.insert(provider.id) + if provider.id == .deepseek { + provider.deepseekProfileID = provider.sanitizedDeepSeekProfileID + provider.deepseekProfileScope = provider.sanitizedDeepSeekProfileScope + } normalized.append(provider) } @@ -128,6 +132,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var kiloEnabledOrganizationIDs: [String]? public var awsProfile: String? public var awsAuthMode: String? + public var deepseekProfileID: String? + public var deepseekProfileScope: String? public init( id: UsageProvider, @@ -150,7 +156,9 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { kiloKnownOrganizations: [KiloOrganization]? = nil, kiloEnabledOrganizationIDs: [String]? = nil, awsProfile: String? = nil, - awsAuthMode: String? = nil) + awsAuthMode: String? = nil, + deepseekProfileID: String? = nil, + deepseekProfileScope: String? = nil) { self.id = id self.enabled = enabled @@ -173,6 +181,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.kiloEnabledOrganizationIDs = kiloEnabledOrganizationIDs self.awsProfile = awsProfile self.awsAuthMode = awsAuthMode + self.deepseekProfileID = deepseekProfileID + self.deepseekProfileScope = deepseekProfileScope } public var sanitizedAPIKey: String? { @@ -211,6 +221,14 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { Self.clean(self.awsAuthMode) } + public var sanitizedDeepSeekProfileID: String? { + Self.clean(self.deepseekProfileID).map(DeepSeekSettingsReader.canonicalProfileID) + } + + public var sanitizedDeepSeekProfileScope: String? { + Self.clean(self.deepseekProfileScope) + } + private static func clean(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 959cae77de..f6b3eca123 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -95,6 +95,8 @@ public enum ProviderConfigEnvironment { return env } return switch provider { + case .deepseek: + self.applyDeepSeekOverrides(base: base, config: config) case .deepgram: self.applyDeepgramOverrides(base: base, config: config) case .azureopenai: @@ -110,6 +112,23 @@ public enum ProviderConfigEnvironment { } } + private static func applyDeepSeekOverrides( + base: [String: String], + config: ProviderConfig?) -> [String: String] + { + var env = base + if let platformToken = config?.sanitizedCookieHeader { + env[DeepSeekSettingsReader.platformTokenEnvironmentKey] = platformToken + } + if let profileID = config?.sanitizedDeepSeekProfileID { + env[DeepSeekSettingsReader.profileIDEnvironmentKey] = profileID + } + if let profileScope = config?.sanitizedDeepSeekProfileScope { + env[DeepSeekSettingsReader.profileScopeEnvironmentKey] = profileScope + } + return env + } + private static func applyMultiFieldCredentialOverrides( base: [String: String], provider: UsageProvider, diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift new file mode 100644 index 0000000000..840059c5fd --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift @@ -0,0 +1,475 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif + +enum DeepSeekPlatformTokenImporter { + struct TokenInfo: Sendable, Equatable { + let id: String + let token: String + let sourceLabel: String + } + + struct Resolution: Sendable { + let profiles: [DeepSeekPlatformProfile] + let selectedSummary: DeepSeekUsageSummary? + let selectedBalance: DeepSeekUsageSnapshot? + let detailedUsageState: DeepSeekDetailedUsageState + + init( + profiles: [DeepSeekPlatformProfile], + selectedSummary: DeepSeekUsageSummary?, + selectedBalance: DeepSeekUsageSnapshot? = nil, + detailedUsageState: DeepSeekDetailedUsageState) + { + self.profiles = profiles + self.selectedSummary = selectedSummary + self.selectedBalance = selectedBalance + self.detailedUsageState = detailedUsageState + } + } + + private struct PlatformSessionData: Sendable { + let summary: DeepSeekUsageSummary? + let balance: DeepSeekUsageSnapshot? + let detailedUsageState: DeepSeekDetailedUsageState + } + + private enum ValidationOutcome: Sendable { + case valid(PlatformSessionData) + case invalid + case unavailable + } + + private struct ValidationResult: Sendable { + let candidate: TokenInfo + let outcome: ValidationOutcome + } + + private static let validationCache = DeepSeekPlatformValidationCache() + + static func resolveAutomaticSession( + selectedProfileID: String?, + requiresExplicitSelection: Bool = false, + includePlatformBalance: Bool = false, + includeOptionalUsage: Bool = true, + browserDetection: BrowserDetection, + logger: (@Sendable (String) -> Void)? = nil) async -> Resolution + { + #if os(macOS) + let candidates = self.importTokens(browserDetection: browserDetection, logger: logger) + guard !Task.isCancelled else { + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .unavailable) + } + return await self.resolve( + candidates: candidates, + selection: DeepSeekSettingsReader.ProfileSelection( + profileID: selectedProfileID, + requiresExplicitSelection: requiresExplicitSelection), + logger: logger, + cache: self.validationCache, + validate: { token in + if includePlatformBalance { + let snapshot = try await DeepSeekUsageFetcher.fetchPlatformUsage( + platformToken: token, + includeOptionalUsage: includeOptionalUsage) + return PlatformSessionData( + summary: snapshot.usageSummary, + balance: snapshot, + detailedUsageState: snapshot.detailedUsageState) + } + return try await PlatformSessionData( + summary: DeepSeekUsageFetcher.fetchUsageSummary(platformToken: token), + balance: nil, + detailedUsageState: .available) + }) + #else + _ = selectedProfileID + _ = requiresExplicitSelection + _ = includePlatformBalance + _ = includeOptionalUsage + _ = browserDetection + _ = logger + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .webSessionRequired) + #endif + } + + #if os(macOS) + static func importTokens( + browserDetection: BrowserDetection, + logger: (@Sendable (String) -> Void)? = nil) -> [TokenInfo] + { + let log: (String) -> Void = { message in logger?("[deepseek-storage] \(message)") } + let installedBrowsers = [Browser.chrome].browsersWithProfileData(using: browserDetection) + let roots = ChromiumProfileLocator.roots( + for: installedBrowsers, + homeDirectories: BrowserCookieClient.defaultHomeDirectories()) + + var results: [TokenInfo] = [] + for root in roots { + guard !Task.isCancelled else { return results } + for candidate in self.localStorageCandidates( + root: root.url, + browserID: root.browser.rawValue, + labelPrefix: root.labelPrefix) + { + guard !Task.isCancelled else { return results } + log("Checking \(candidate.id)") + let entries = SweetCookieKit.ChromiumLocalStorageReader.readEntries( + for: "https://platform.deepseek.com", + in: candidate.url, + logger: log) + guard let entry = entries.first(where: { $0.key == "userToken" }), + let token = self.extractUserToken(from: entry.value) + else { + log("No DeepSeek userToken found in \(candidate.id)") + continue + } + + log("Found DeepSeek platform token in \(candidate.id)") + results.append(TokenInfo(id: candidate.id, token: token, sourceLabel: candidate.label)) + } + } + + if results.isEmpty { + log("No DeepSeek userToken found in Chrome local storage") + } + return results + } + + private struct LocalStorageCandidate { + let id: String + let label: String + let url: URL + } + + private static func localStorageCandidates( + root: URL, + browserID: String, + labelPrefix: String) -> [LocalStorageCandidate] + { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return [] } + + let profileNames = self.chromeProfileNames(root: root) + return entries.compactMap { directory in + guard let isDirectory = try? directory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory, + isDirectory + else { return nil } + let name = directory.lastPathComponent + guard name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-") else { + return nil + } + let levelDB = directory.appendingPathComponent("Local Storage").appendingPathComponent("leveldb") + guard FileManager.default.fileExists(atPath: levelDB.path) else { return nil } + + let displayName = profileNames[name]?.trimmingCharacters(in: .whitespacesAndNewlines) + let label = if let displayName, !displayName.isEmpty, displayName != name { + "\(labelPrefix) — \(displayName)" + } else { + "\(labelPrefix) \(name)" + } + return LocalStorageCandidate( + id: "\(browserID):\(name)", + label: label, + url: levelDB) + } + .sorted { $0.label.localizedStandardCompare($1.label) == .orderedAscending } + } + + private static func chromeProfileNames(root: URL) -> [String: String] { + let localStateURL = root.appendingPathComponent("Local State") + guard let data = try? Data(contentsOf: localStateURL), + let rootObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let profile = rootObject["profile"] as? [String: Any], + let infoCache = profile["info_cache"] as? [String: Any] + else { return [:] } + + return infoCache.reduce(into: [:]) { result, entry in + guard let info = entry.value as? [String: Any] else { return } + let name = (info["gaia_given_name"] as? String) ?? (info["name"] as? String) + guard let name else { return } + result[entry.key] = name + } + } + + #endif + + private static func resolve( + candidates: [TokenInfo], + selection: DeepSeekSettingsReader.ProfileSelection, + logger: (@Sendable (String) -> Void)?, + cache: DeepSeekPlatformValidationCache, + validate: @escaping @Sendable (String) async throws -> PlatformSessionData) async -> Resolution + { + guard !Task.isCancelled else { + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .unavailable) + } + guard !candidates.isEmpty else { + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .webSessionRequired) + } + + let now = Date() + var lookups: [String: DeepSeekPlatformValidationCache.Lookup] = [:] + var candidatesToValidate: [TokenInfo] = [] + for candidate in candidates { + guard !Task.isCancelled else { + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .unavailable) + } + let lookup = await cache.lookup(candidate: candidate, now: now) + lookups[candidate.id] = lookup + if lookup.freshStatus == nil || candidate.id == selection.profileID { + candidatesToValidate.append(candidate) + } + } + + var outcomes = await self.validate( + candidates: candidatesToValidate, + logger: logger, + validate: validate) + guard !Task.isCancelled else { + return Resolution(profiles: [], selectedSummary: nil, detailedUsageState: .unavailable) + } + await self.record(outcomes: outcomes, cache: cache, now: now) + + var statusByID = self.resolvedStatuses(candidates: candidates, lookups: lookups, outcomes: outcomes) + var validCandidates = candidates.filter { statusByID[$0.id] == true } + var sessionDataByID = self.sessionDataByID(outcomes: outcomes) + + if validCandidates.count == 1, sessionDataByID[validCandidates[0].id] == nil { + let candidate = validCandidates[0] + let refresh = await self.validate(candidates: [candidate], logger: logger, validate: validate) + await self.record(outcomes: refresh, cache: cache, now: now) + outcomes.append(contentsOf: refresh) + statusByID = self.resolvedStatuses(candidates: candidates, lookups: lookups, outcomes: outcomes) + validCandidates = candidates.filter { statusByID[$0.id] == true } + sessionDataByID = self.sessionDataByID(outcomes: outcomes) + } + + let profiles = validCandidates.map { DeepSeekPlatformProfile(id: $0.id, name: $0.sourceLabel) } + guard !validCandidates.isEmpty else { + let validationWasUnavailable = outcomes.contains { result in + if case .unavailable = result.outcome { return true } + return false + } + return Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: validationWasUnavailable ? .unavailable : .webSessionRequired) + } + + let selected: TokenInfo? = if selection.requiresExplicitSelection { + nil + } else if let selectedProfileID = selection.profileID { + validCandidates.first(where: { $0.id == selectedProfileID }) + } else { + validCandidates.count == 1 ? validCandidates[0] : nil + } + guard let selected else { + return Resolution( + profiles: profiles, + selectedSummary: nil, + detailedUsageState: .profileSelectionRequired) + } + + if let sessionData = sessionDataByID[selected.id] { + return Resolution( + profiles: profiles, + selectedSummary: sessionData.summary, + selectedBalance: sessionData.balance, + detailedUsageState: sessionData.detailedUsageState) + } + return Resolution(profiles: profiles, selectedSummary: nil, detailedUsageState: .unavailable) + } + + private static func validate( + candidates: [TokenInfo], + logger: (@Sendable (String) -> Void)?, + validate: @escaping @Sendable (String) async throws -> PlatformSessionData) async -> [ValidationResult] + { + let results = await withTaskGroup(of: ValidationResult.self, returning: [ValidationResult].self) { group in + for candidate in candidates { + group.addTask { + guard !Task.isCancelled else { + return ValidationResult(candidate: candidate, outcome: .unavailable) + } + do { + let summary = try await validate(candidate.token) + return ValidationResult(candidate: candidate, outcome: .valid(summary)) + } catch DeepSeekUsageError.invalidPlatformToken { + return ValidationResult(candidate: candidate, outcome: .invalid) + } catch { + return ValidationResult(candidate: candidate, outcome: .unavailable) + } + } + } + + var results: [ValidationResult] = [] + for await result in group { + results.append(result) + } + return results + } + for result in results { + switch result.outcome { + case .valid: + logger?("[deepseek-storage] Validated \(result.candidate.id)") + case .invalid: + logger?("[deepseek-storage] Rejected expired session for \(result.candidate.id)") + case .unavailable: + logger?("[deepseek-storage] Could not validate \(result.candidate.id)") + } + } + return results + } + + private static func resolvedStatuses( + candidates: [TokenInfo], + lookups: [String: DeepSeekPlatformValidationCache.Lookup], + outcomes: [ValidationResult]) -> [String: Bool] + { + var statusByID = Dictionary(uniqueKeysWithValues: candidates.compactMap { candidate in + lookups[candidate.id]?.freshStatus.map { (candidate.id, $0) } + }) + for result in outcomes { + switch result.outcome { + case .valid: + statusByID[result.candidate.id] = true + case .invalid: + statusByID[result.candidate.id] = false + case .unavailable: + if let lastKnownStatus = lookups[result.candidate.id]?.lastKnownStatus { + statusByID[result.candidate.id] = lastKnownStatus + } + } + } + return statusByID + } + + private static func sessionDataByID(outcomes: [ValidationResult]) -> [String: PlatformSessionData] { + var sessionData: [String: PlatformSessionData] = [:] + for result in outcomes { + guard case let .valid(value) = result.outcome else { continue } + sessionData[result.candidate.id] = value + } + return sessionData + } + + private static func record( + outcomes: [ValidationResult], + cache: DeepSeekPlatformValidationCache, + now: Date) async + { + for result in outcomes { + switch result.outcome { + case .valid: + await cache.record(candidate: result.candidate, status: true, now: now) + case .invalid: + await cache.record(candidate: result.candidate, status: false, now: now) + case .unavailable: + break + } + } + } + + private static func extractUserToken(from rawValue: String) -> String? { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) + { + return self.token(fromJSONObject: object) + } + + let unquoted: String = if (trimmed.hasPrefix("\"") && trimmed.hasSuffix("\"")) || + (trimmed.hasPrefix("'") && trimmed.hasSuffix("'")) + { + String(trimmed.dropFirst().dropLast()) + } else { + trimmed + } + return self.isPlausibleToken(unquoted) ? unquoted : nil + } + + private static func token(fromJSONObject value: Any) -> String? { + if let string = value as? String { + return self.isPlausibleToken(string) ? string : nil + } + guard let dictionary = value as? [String: Any] else { return nil } + for key in ["value", "token", "access_token", "accessToken", "userToken"] { + guard let token = dictionary[key] as? String, self.isPlausibleToken(token) else { continue } + return token + } + return nil + } + + private static func isPlausibleToken(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.count >= 20 && !trimmed.contains(where: \.isWhitespace) + } + + static func _extractUserTokenForTesting(_ rawValue: String) -> String? { + self.extractUserToken(from: rawValue) + } + + static func _resolveForTesting( + candidates: [TokenInfo], + selectedProfileID: String?, + requiresExplicitSelection: Bool = false, + detailedUsageState: DeepSeekDetailedUsageState = .available, + cache: DeepSeekPlatformValidationCache? = nil, + validate: @escaping @Sendable (String) async throws -> DeepSeekUsageSummary) async -> Resolution + { + await self.resolve( + candidates: candidates, + selection: DeepSeekSettingsReader.ProfileSelection( + profileID: selectedProfileID, + requiresExplicitSelection: requiresExplicitSelection), + logger: nil, + cache: cache ?? DeepSeekPlatformValidationCache(validityTTL: 0), + validate: { token in + try await PlatformSessionData( + summary: validate(token), + balance: nil, + detailedUsageState: detailedUsageState) + }) + } +} + +actor DeepSeekPlatformValidationCache { + struct Lookup: Sendable { + let freshStatus: Bool? + let lastKnownStatus: Bool? + } + + private struct Entry: Sendable { + let token: String + let status: Bool + let checkedAt: Date + } + + private let validityTTL: TimeInterval + private var entries: [String: Entry] = [:] + + init(validityTTL: TimeInterval = 30 * 60) { + self.validityTTL = validityTTL + } + + func lookup(candidate: DeepSeekPlatformTokenImporter.TokenInfo, now: Date) -> Lookup { + guard let entry = self.entries[candidate.id], entry.token == candidate.token else { + return Lookup(freshStatus: nil, lastKnownStatus: nil) + } + let isFresh = now.timeIntervalSince(entry.checkedAt) < self.validityTTL + return Lookup( + freshStatus: isFresh ? entry.status : nil, + lastKnownStatus: entry.status) + } + + func record(candidate: DeepSeekPlatformTokenImporter.TokenInfo, status: Bool, now: Date) { + self.entries[candidate.id] = Entry(token: candidate.token, status: status, checkedAt: now) + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift index f2847c787d..0577abb47c 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift @@ -3,6 +3,43 @@ import Foundation public enum DeepSeekProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + private static let optionalResolutionJoinGrace: Duration = .seconds(5) + private static let platformResolutionJoinGrace: Duration = .seconds(20) + + struct FetchOperations: Sendable { + let fetchUsage: @Sendable (String, String?, Bool) async throws -> DeepSeekUsageSnapshot + let resolveAutomaticSession: + @Sendable (String?, Bool, Bool, Bool, BrowserDetection, Bool) async + -> DeepSeekPlatformTokenImporter.Resolution + + static var live: FetchOperations { + FetchOperations( + fetchUsage: { apiKey, platformToken, includeOptionalUsage in + try await DeepSeekUsageFetcher.fetchUsage( + apiKey: apiKey, + platformToken: platformToken, + includeOptionalUsage: includeOptionalUsage) + }, + resolveAutomaticSession: { profileID, explicit, includeBalance, includeOptional, detection, verbose in + if verbose { + return await DeepSeekPlatformTokenImporter.resolveAutomaticSession( + selectedProfileID: profileID, + requiresExplicitSelection: explicit, + includePlatformBalance: includeBalance, + includeOptionalUsage: includeOptional, + browserDetection: detection, + logger: { print($0) }) + } + return await DeepSeekPlatformTokenImporter.resolveAutomaticSession( + selectedProfileID: profileID, + requiresExplicitSelection: explicit, + includePlatformBalance: includeBalance, + includeOptionalUsage: includeOptional, + browserDetection: detection) + }) + } + } + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .deepseek, @@ -31,18 +68,240 @@ public enum DeepSeekProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "DeepSeek per-day cost history is not available via API." }), - fetchPlan: .apiToken( - strategyID: "deepseek.api", - resolveToken: { ProviderTokenResolver.deepseekToken(environment: $0) }, - missingCredentialsError: { DeepSeekUsageError.missingCredentials }, - loadUsage: { apiKey, context in - try await DeepSeekUsageFetcher.fetchUsage( - apiKey: apiKey, - includeOptionalUsage: context.includeOptionalUsage).toUsageSnapshot() - }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "deepseek", aliases: ["deep-seek", "ds"], versionDetector: nil)) } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + switch context.sourceMode { + case .api: + [DeepSeekAPIFetchStrategy()] + case .web: + [DeepSeekPlatformFetchStrategy()] + case .auto: + if ProviderTokenResolver.deepseekToken(environment: context.env) != nil { + [DeepSeekAPIFetchStrategy()] + } else { + [DeepSeekPlatformFetchStrategy()] + } + case .cli, .oauth: + [] + } + } + + private static func loadUsage( + apiKey: String, + context: ProviderFetchContext, + optionalResolutionJoinGrace: Duration, + operations: FetchOperations) async throws -> UsageSnapshot + { + guard context.includeOptionalUsage else { + return try await operations.fetchUsage(apiKey, nil, false).toUsageSnapshot() + } + if let platformToken = DeepSeekSettingsReader.platformToken(environment: context.env) { + return try await operations.fetchUsage(apiKey, platformToken, true).toUsageSnapshot() + } + + return try await self.loadAutomaticUsage( + apiKey: apiKey, + context: context, + optionalResolutionJoinGrace: optionalResolutionJoinGrace, + operations: operations) + } + + fileprivate static func loadAPIUsage(apiKey: String, context: ProviderFetchContext) async throws -> UsageSnapshot { + try await self.loadUsage( + apiKey: apiKey, + context: context, + optionalResolutionJoinGrace: self.optionalResolutionJoinGrace, + operations: .live) + } + + private static func loadAutomaticUsage( + apiKey: String, + context: ProviderFetchContext, + optionalResolutionJoinGrace: Duration, + operations: FetchOperations) async throws -> UsageSnapshot + { + let profileSelection = DeepSeekSettingsReader.profileSelection( + environment: context.env, + selectedTokenAccountID: context.selectedTokenAccountID, + apiKey: apiKey) + let resolutionTask = Task { + await operations.resolveAutomaticSession( + profileSelection.profileID, + profileSelection.requiresExplicitSelection, + false, + context.includeOptionalUsage, + context.browserDetection, + context.verbose) + } + let resolutionJoin = BoundedTaskJoin(sourceTask: resolutionTask) + + let balance: DeepSeekUsageSnapshot + do { + balance = try await operations.fetchUsage(apiKey, nil, false) + } catch { + resolutionTask.cancel() + throw error + } + + switch await resolutionJoin.value(joinGrace: optionalResolutionJoinGrace) { + case let .value(resolution): + try Task.checkCancellation() + return self.combinedSnapshot(balance: balance, resolution: resolution) + case .timedOut: + try Task.checkCancellation() + return self.combinedSnapshot( + balance: balance, + resolution: DeepSeekPlatformTokenImporter.Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: .unavailable)) + case let .failure(error): + if error is CancellationError || Task.isCancelled { + throw error + } + return self.combinedSnapshot( + balance: balance, + resolution: DeepSeekPlatformTokenImporter.Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: .unavailable)) + } + } + + private static func combinedSnapshot( + balance: DeepSeekUsageSnapshot, + resolution: DeepSeekPlatformTokenImporter.Resolution) -> UsageSnapshot + { + DeepSeekUsageSnapshot( + hasBalance: balance.hasBalance, + isAvailable: balance.isAvailable, + currency: balance.currency, + totalBalance: balance.totalBalance, + grantedBalance: balance.grantedBalance, + toppedUpBalance: balance.toppedUpBalance, + usageSummary: resolution.selectedSummary, + detailedUsageState: resolution.detailedUsageState, + platformProfiles: resolution.profiles, + updatedAt: balance.updatedAt).toUsageSnapshot() + } + + fileprivate static func loadPlatformUsage( + context: ProviderFetchContext, + resolutionJoinGrace: Duration = DeepSeekProviderDescriptor.platformResolutionJoinGrace, + operations: FetchOperations = .live) async throws -> UsageSnapshot + { + if let platformToken = DeepSeekSettingsReader.platformToken(environment: context.env) { + return try await DeepSeekUsageFetcher.fetchPlatformUsage( + platformToken: platformToken, + includeOptionalUsage: context.includeOptionalUsage).toUsageSnapshot() + } + + let profileSelection = DeepSeekSettingsReader.profileSelection( + environment: context.env, + selectedTokenAccountID: nil, + apiKey: nil) + let resolutionTask = Task { + await operations.resolveAutomaticSession( + profileSelection.profileID, + profileSelection.requiresExplicitSelection, + true, + context.includeOptionalUsage, + context.browserDetection, + context.verbose) + } + let resolutionJoin = BoundedTaskJoin(sourceTask: resolutionTask) + let resolution: DeepSeekPlatformTokenImporter.Resolution + switch await resolutionJoin.value(joinGrace: resolutionJoinGrace) { + case let .value(value): + resolution = value + case .timedOut: + throw DeepSeekUsageError.networkError("Chrome session resolution timed out") + case let .failure(error): + throw error + } + try Task.checkCancellation() + if resolution.selectedBalance == nil, resolution.detailedUsageState == .unavailable { + throw DeepSeekUsageError.networkError("Chrome session resolution unavailable") + } + let balance = resolution.selectedBalance ?? DeepSeekUsageSnapshot( + hasBalance: false, + isAvailable: false, + currency: resolution.selectedSummary?.currency ?? "USD", + totalBalance: 0, + grantedBalance: 0, + toppedUpBalance: 0, + updatedAt: resolution.selectedSummary?.updatedAt ?? Date()) + return self.combinedSnapshot(balance: balance, resolution: resolution) + } + + static func _loadUsageForTesting( + apiKey: String, + context: ProviderFetchContext, + optionalResolutionJoinGrace: Duration, + operations: FetchOperations) async throws -> UsageSnapshot + { + try await self.loadUsage( + apiKey: apiKey, + context: context, + optionalResolutionJoinGrace: optionalResolutionJoinGrace, + operations: operations) + } + + static func _loadPlatformUsageForTesting( + context: ProviderFetchContext, + resolutionJoinGrace: Duration = DeepSeekProviderDescriptor.platformResolutionJoinGrace, + operations: FetchOperations) async throws -> UsageSnapshot + { + try await self.loadPlatformUsage( + context: context, + resolutionJoinGrace: resolutionJoinGrace, + operations: operations) + } +} + +private struct DeepSeekAPIFetchStrategy: ProviderFetchStrategy { + let id = "deepseek.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + context.sourceMode == .api || ProviderTokenResolver.deepseekToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = ProviderTokenResolver.deepseekToken(environment: context.env) else { + throw DeepSeekUsageError.missingCredentials + } + let usage = try await DeepSeekProviderDescriptor.loadAPIUsage(apiKey: apiKey, context: context) + return self.makeResult(usage: usage, sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct DeepSeekPlatformFetchStrategy: ProviderFetchStrategy { + let id = "deepseek.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let usage = try await DeepSeekProviderDescriptor.loadPlatformUsage(context: context) + return self.makeResult(usage: usage, sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } } diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift index 3c787cabc3..caa2adc21b 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift @@ -1,13 +1,88 @@ import Foundation +#if canImport(CryptoKit) +import CryptoKit +#endif public struct DeepSeekSettingsReader: Sendable { + struct ProfileSelection: Sendable, Equatable { + let profileID: String? + let requiresExplicitSelection: Bool + } + public static let apiKeyEnvironmentKey = "DEEPSEEK_API_KEY" public static let apiKeyEnvironmentKeys = [Self.apiKeyEnvironmentKey, "DEEPSEEK_KEY"] + public static let platformTokenEnvironmentKey = "DEEPSEEK_PLATFORM_TOKEN" + public static let platformTokenEnvironmentKeys = [Self.platformTokenEnvironmentKey, "DEEPSEEK_USER_TOKEN"] + public static let profileIDEnvironmentKey = "CODEXBAR_DEEPSEEK_PROFILE_ID" + public static let profileScopeEnvironmentKey = "CODEXBAR_DEEPSEEK_PROFILE_SCOPE" + private static let browserProfileScope = "browser:v1" public static func apiKey( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { - for key in self.apiKeyEnvironmentKeys { + self.value(for: self.apiKeyEnvironmentKeys, environment: environment) + } + + public static func platformToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.value(for: self.platformTokenEnvironmentKeys, environment: environment) + } + + public static func profileID( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.value(for: [self.profileIDEnvironmentKey], environment: environment) + .map(self.canonicalProfileID) + } + + public static func profileScope( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.value(for: [self.profileScopeEnvironmentKey], environment: environment) + } + + public static func profileScope(selectedTokenAccountID: UUID?, apiKey: String?) -> String? { + guard let apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines), !apiKey.isEmpty else { + return self.browserProfileScope + } + #if canImport(CryptoKit) + let accountScope = selectedTokenAccountID?.uuidString.lowercased() ?? "environment" + let input = "com.steipete.codexbar.deepseek-profile-scope.v1\0\(accountScope)\0\(apiKey)" + let digest = SHA256.hash(data: Data(input.utf8)) + return "v1:" + digest.map { String(format: "%02x", $0) }.joined() + #else + return nil + #endif + } + + static func profileSelection( + environment: [String: String], + selectedTokenAccountID: UUID?, + apiKey: String?) -> ProfileSelection + { + let profileID = self.profileID(environment: environment) + let expectedScope = self.profileScope(selectedTokenAccountID: selectedTokenAccountID, apiKey: apiKey) + let storedScope = self.profileScope(environment: environment) + + if let expectedScope, storedScope == expectedScope { + return ProfileSelection(profileID: profileID, requiresExplicitSelection: false) + } + if apiKey == nil { + return ProfileSelection(profileID: nil, requiresExplicitSelection: false) + } + return ProfileSelection(profileID: nil, requiresExplicitSelection: true) + } + + public static func canonicalProfileID(_ rawValue: String) -> String { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.hasPrefix("/") else { return value } + let profileName = URL(fileURLWithPath: value).lastPathComponent + return profileName.isEmpty ? value : "chrome:\(profileName)" + } + + private static func value(for keys: [String], environment: [String: String]) -> String? { + for key in keys { guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift index 77e6e4f25b..7edecc721d 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift @@ -15,14 +15,11 @@ struct DeepSeekAmountPayload: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.code = try container.decodeIfPresent(Int.self, forKey: .code) self.msg = try container.decodeIfPresent(String.self, forKey: .msg) - if container.contains(.data) { - if let dataValue = try? container.decodeIfPresent(DeepSeekAmountData.self, forKey: .data) { - self.data = dataValue - } else { - self.data = nil - } + if let code, code != 0 { + // Error envelopes are not schema-stable. Preserve their code even if `data` has an unexpected shape. + self.data = try? container.decodeIfPresent(DeepSeekAmountData.self, forKey: .data) } else { - self.data = nil + self.data = try container.decodeIfPresent(DeepSeekAmountData.self, forKey: .data) } } } @@ -42,14 +39,10 @@ struct DeepSeekAmountData: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.bizCode = try container.decodeIfPresent(Int.self, forKey: .bizCode) self.bizMsg = try container.decodeIfPresent(String.self, forKey: .bizMsg) - if container.contains(.bizData) { - if let dataValue = try? container.decodeIfPresent(DeepSeekAmountBizData.self, forKey: .bizData) { - self.bizData = dataValue - } else { - self.bizData = nil - } + if let bizCode, bizCode != 0 { + self.bizData = try? container.decodeIfPresent(DeepSeekAmountBizData.self, forKey: .bizData) } else { - self.bizData = nil + self.bizData = try container.decodeIfPresent(DeepSeekAmountBizData.self, forKey: .bizData) } } } @@ -78,14 +71,10 @@ struct DeepSeekCostPayload: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.code = try container.decodeIfPresent(Int.self, forKey: .code) self.msg = try container.decodeIfPresent(String.self, forKey: .msg) - if container.contains(.data) { - if let dataValue = try? container.decodeIfPresent(DeepSeekCostData.self, forKey: .data) { - self.data = dataValue - } else { - self.data = nil - } + if let code, code != 0 { + self.data = try? container.decodeIfPresent(DeepSeekCostData.self, forKey: .data) } else { - self.data = nil + self.data = try container.decodeIfPresent(DeepSeekCostData.self, forKey: .data) } } } @@ -105,7 +94,11 @@ struct DeepSeekCostData: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.bizCode = try container.decodeIfPresent(Int.self, forKey: .bizCode) self.bizMsg = try container.decodeIfPresent(String.self, forKey: .bizMsg) - self.bizData = try container.decodeIfPresent([DeepSeekCostBizDataItem].self, forKey: .bizData) + if let bizCode, bizCode != 0 { + self.bizData = try? container.decodeIfPresent([DeepSeekCostBizDataItem].self, forKey: .bizData) + } else { + self.bizData = try container.decodeIfPresent([DeepSeekCostBizDataItem].self, forKey: .bizData) + } } } @@ -287,25 +280,37 @@ enum DeepSeekUsageCostParser { do { amountPayload = try self.decodeAmountPayload(data: amountData) } catch { - throw DeepSeekUsageError.parseFailed("amount: \(error.localizedDescription)") + throw DeepSeekUsageError.parseFailed("amount: \(self.decodingFailureDescription(error))") } do { costPayload = try self.decodeCostPayload(data: costData) } catch { - throw DeepSeekUsageError.parseFailed("cost: \(error.localizedDescription)") + throw DeepSeekUsageError.parseFailed("cost: \(self.decodingFailureDescription(error))") } // Validate responses if let code = amountPayload.code, code != 0 { + if self.isAuthenticationError(code) { + throw DeepSeekUsageError.invalidPlatformToken + } throw DeepSeekUsageError.apiError("amount code \(code)") } if let bizCode = amountPayload.data?.bizCode, bizCode != 0 { + if self.isAuthenticationError(bizCode) { + throw DeepSeekUsageError.invalidPlatformToken + } throw DeepSeekUsageError.apiError("amount biz_code \(bizCode)") } if let code = costPayload.code, code != 0 { + if self.isAuthenticationError(code) { + throw DeepSeekUsageError.invalidPlatformToken + } throw DeepSeekUsageError.apiError("cost code \(code)") } if let bizCode = costPayload.data?.bizCode, bizCode != 0 { + if self.isAuthenticationError(bizCode) { + throw DeepSeekUsageError.invalidPlatformToken + } throw DeepSeekUsageError.apiError("cost biz_code \(bizCode)") } @@ -333,6 +338,17 @@ enum DeepSeekUsageCostParser { calendar: calendar)) } + private static func isAuthenticationError(_ code: Int) -> Bool { + code == 40002 || code == 40003 + } + + private static func decodingFailureDescription(_ error: any Error) -> String { + if error is DecodingError { + return String(describing: error) + } + return error.localizedDescription + } + // MARK: - Aggregation private struct AggregationContext { diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift index d2f956ce49..d5edda5193 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift @@ -29,32 +29,135 @@ public struct DeepSeekBalanceInfo: Decodable, Sendable { } } +private struct DeepSeekPlatformUserSummaryResponse: Decodable { + let code: Int? + let data: DeepSeekPlatformUserSummaryData? + + private enum CodingKeys: String, CodingKey { + case code, data + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.code = try container.decodeIfPresent(Int.self, forKey: .code) + if let code, code != 0 { + // Error envelopes are not schema-stable. Preserve their code before inspecting `data`. + self.data = try? container.decodeIfPresent(DeepSeekPlatformUserSummaryData.self, forKey: .data) + } else { + self.data = try container.decodeIfPresent(DeepSeekPlatformUserSummaryData.self, forKey: .data) + } + } +} + +private struct DeepSeekPlatformUserSummaryData: Decodable { + let bizCode: Int? + let bizData: DeepSeekPlatformUserSummary? + + enum CodingKeys: String, CodingKey { + case bizCode = "biz_code" + case bizData = "biz_data" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.bizCode = try container.decodeIfPresent(Int.self, forKey: .bizCode) + if let bizCode, bizCode != 0 { + self.bizData = try? container.decodeIfPresent(DeepSeekPlatformUserSummary.self, forKey: .bizData) + } else { + self.bizData = try container.decodeIfPresent(DeepSeekPlatformUserSummary.self, forKey: .bizData) + } + } +} + +private struct DeepSeekPlatformUserSummary: Decodable { + let normalWallets: [DeepSeekPlatformWallet] + let bonusWallets: [DeepSeekPlatformWallet] + + enum CodingKeys: String, CodingKey { + case normalWallets = "normal_wallets" + case bonusWallets = "bonus_wallets" + } +} + +private struct DeepSeekPlatformWallet: Decodable { + let balance: Double + let currency: String + + enum CodingKeys: String, CodingKey { + case balance, currency + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.currency = try container.decode(String.self, forKey: .currency) + if let number = try? container.decode(Double.self, forKey: .balance) { + self.balance = number + return + } + let value = try container.decode(String.self, forKey: .balance) + guard let number = Double(value) else { + throw DecodingError.dataCorruptedError( + forKey: .balance, + in: container, + debugDescription: "Expected a numeric wallet balance") + } + self.balance = number + } +} + // MARK: - Domain snapshot +public enum DeepSeekDetailedUsageState: Sendable, Equatable { + case notRequested + case available + case webSessionRequired + case profileSelectionRequired + case unavailable +} + +public struct DeepSeekPlatformProfile: Sendable, Equatable { + public let id: String + public let name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } +} + public struct DeepSeekUsageSnapshot: Sendable { + public let hasBalance: Bool public let isAvailable: Bool public let currency: String public let totalBalance: Double public let grantedBalance: Double public let toppedUpBalance: Double public let usageSummary: DeepSeekUsageSummary? + public let detailedUsageState: DeepSeekDetailedUsageState + public let platformProfiles: [DeepSeekPlatformProfile] public let updatedAt: Date public init( + hasBalance: Bool = true, isAvailable: Bool, currency: String, totalBalance: Double, grantedBalance: Double, toppedUpBalance: Double, usageSummary: DeepSeekUsageSummary? = nil, + detailedUsageState: DeepSeekDetailedUsageState? = nil, + platformProfiles: [DeepSeekPlatformProfile] = [], updatedAt: Date) { + self.hasBalance = hasBalance self.isAvailable = isAvailable self.currency = currency self.totalBalance = totalBalance self.grantedBalance = grantedBalance self.toppedUpBalance = toppedUpBalance self.usageSummary = usageSummary + self.detailedUsageState = detailedUsageState ?? (usageSummary == nil ? .notRequested : .available) + self.platformProfiles = platformProfiles self.updatedAt = updatedAt } @@ -82,11 +185,15 @@ public struct DeepSeekUsageSnapshot: Sendable { accountEmail: nil, accountOrganization: nil, loginMethod: nil) - let balanceWindow = RateWindow( - usedPercent: usedPercent, - windowMinutes: nil, - resetsAt: nil, - resetDescription: balanceDetail) + let balanceWindow: RateWindow? = if self.hasBalance { + RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: balanceDetail) + } else { + nil + } return UsageSnapshot( primary: balanceWindow, @@ -94,6 +201,8 @@ public struct DeepSeekUsageSnapshot: Sendable { tertiary: nil, providerCost: nil, deepseekUsage: self.usageSummary, + deepseekDetailedUsageState: self.detailedUsageState, + deepseekPlatformProfiles: self.platformProfiles, updatedAt: self.updatedAt, identity: identity) } @@ -101,8 +210,9 @@ public struct DeepSeekUsageSnapshot: Sendable { // MARK: - Errors -public enum DeepSeekUsageError: LocalizedError, Sendable { +public enum DeepSeekUsageError: LocalizedError, Sendable, Equatable { case missingCredentials + case invalidPlatformToken case networkError(String) case apiError(String) case parseFailed(String) @@ -111,6 +221,8 @@ public enum DeepSeekUsageError: LocalizedError, Sendable { switch self { case .missingCredentials: "Missing DeepSeek API key." + case .invalidPlatformToken: + "DeepSeek Platform session is missing or expired." case let .networkError(message): "DeepSeek network error: \(message)" case let .apiError(message): @@ -124,12 +236,19 @@ public enum DeepSeekUsageError: LocalizedError, Sendable { // MARK: - Fetcher public struct DeepSeekUsageFetcher: Sendable { + private enum UsageSummaryPayload: Sendable { + case amount(Data) + case cost(Data) + } + private static let log = CodexBarLog.logger(LogCategories.deepSeekUsage) private static let balanceURL = URL(string: "https://api.deepseek.com/user/balance")! private static let usageAmountURL = URL(string: "https://platform.deepseek.com/api/v0/usage/amount")! private static let usageCostURL = URL(string: "https://platform.deepseek.com/api/v0/usage/cost")! + private static let platformUserSummaryURL = URL( + string: "https://platform.deepseek.com/api/v0/users/get_user_summary")! private static let timeoutSeconds: TimeInterval = 15 - private static let optionalSummaryJoinGrace: Duration = .seconds(2) + private static let optionalSummaryJoinGrace: Duration = .seconds(5) private static var apiCalendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.locale = Locale(identifier: "en_US_POSIX") @@ -139,22 +258,26 @@ public struct DeepSeekUsageFetcher: Sendable { public static func fetchUsage( apiKey: String, + platformToken: String? = nil, includeOptionalUsage: Bool = true) async throws -> DeepSeekUsageSnapshot { try await self.fetchUsage( apiKey: apiKey, + platformToken: platformToken, includeOptionalUsage: includeOptionalUsage, optionalSummaryJoinGrace: self.optionalSummaryJoinGrace, - fetchBalanceData: { key in - try await self.fetchBalanceData(apiKey: key) - }, - fetchSummary: { key in - try await self.fetchUsageSummary(apiKey: key) - }) + operations: FetchOperations( + fetchBalanceData: { key in + try await self.fetchBalanceData(apiKey: key) + }, + fetchSummary: { token in + try await self.fetchUsageSummary(platformToken: token) + })) } static func _fetchUsageForTesting( apiKey: String, + platformToken: String? = nil, includeOptionalUsage: Bool, optionalSummaryJoinGrace: Duration = .zero, fetchBalanceData: @escaping @Sendable (String) async throws -> Data, @@ -163,27 +286,36 @@ public struct DeepSeekUsageFetcher: Sendable { { try await self.fetchUsage( apiKey: apiKey, + platformToken: platformToken, includeOptionalUsage: includeOptionalUsage, optionalSummaryJoinGrace: optionalSummaryJoinGrace, - fetchBalanceData: fetchBalanceData, - fetchSummary: fetchSummary) + operations: FetchOperations( + fetchBalanceData: fetchBalanceData, + fetchSummary: fetchSummary)) + } + + private struct FetchOperations: Sendable { + let fetchBalanceData: @Sendable (String) async throws -> Data + let fetchSummary: @Sendable (String) async throws -> DeepSeekUsageSummary } private static func fetchUsage( apiKey: String, + platformToken: String?, includeOptionalUsage: Bool, optionalSummaryJoinGrace: Duration, - fetchBalanceData: @escaping @Sendable (String) async throws -> Data, - fetchSummary: @escaping @Sendable (String) async throws -> DeepSeekUsageSummary) + operations: FetchOperations) async throws -> DeepSeekUsageSnapshot { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw DeepSeekUsageError.missingCredentials } - let summaryTask: Task? = if includeOptionalUsage { + let normalizedPlatformToken = platformToken?.trimmingCharacters(in: .whitespacesAndNewlines) + let usablePlatformToken = normalizedPlatformToken?.isEmpty == false ? normalizedPlatformToken : nil + let summaryTask: Task? = if includeOptionalUsage, let usablePlatformToken { Task { - try await fetchSummary(apiKey) + try await operations.fetchSummary(usablePlatformToken) } } else { nil @@ -192,7 +324,7 @@ public struct DeepSeekUsageFetcher: Sendable { let balanceData: Data do { balanceData = try await withTaskCancellationHandler { - try await fetchBalanceData(apiKey) + try await operations.fetchBalanceData(apiKey) } onCancel: { summaryTask?.cancel() } @@ -208,20 +340,38 @@ public struct DeepSeekUsageFetcher: Sendable { throw error } + let initialDetailedUsageState: DeepSeekDetailedUsageState = if !includeOptionalUsage { + .notRequested + } else if usablePlatformToken == nil { + .webSessionRequired + } else { + .unavailable + } + snapshot = DeepSeekUsageSnapshot( + isAvailable: snapshot.isAvailable, + currency: snapshot.currency, + totalBalance: snapshot.totalBalance, + grantedBalance: snapshot.grantedBalance, + toppedUpBalance: snapshot.toppedUpBalance, + usageSummary: nil, + detailedUsageState: initialDetailedUsageState, + platformProfiles: snapshot.platformProfiles, + updatedAt: snapshot.updatedAt) + if let summaryTask { - let summary = try await self.completedOptionalUsageSummary( + let completion = try await self.completedOptionalUsageSummary( from: summaryTask, joinGrace: optionalSummaryJoinGrace) - if let summary { - snapshot = DeepSeekUsageSnapshot( - isAvailable: snapshot.isAvailable, - currency: snapshot.currency, - totalBalance: snapshot.totalBalance, - grantedBalance: snapshot.grantedBalance, - toppedUpBalance: snapshot.toppedUpBalance, - usageSummary: summary, - updatedAt: snapshot.updatedAt) - } + snapshot = DeepSeekUsageSnapshot( + isAvailable: snapshot.isAvailable, + currency: snapshot.currency, + totalBalance: snapshot.totalBalance, + grantedBalance: snapshot.grantedBalance, + toppedUpBalance: snapshot.toppedUpBalance, + usageSummary: completion.summary, + detailedUsageState: completion.state, + platformProfiles: snapshot.platformProfiles, + updatedAt: snapshot.updatedAt) } return snapshot @@ -246,22 +396,46 @@ public struct DeepSeekUsageFetcher: Sendable { private static func completedOptionalUsageSummary( from task: Task, - joinGrace: Duration) async throws -> DeepSeekUsageSummary? + joinGrace: Duration) async throws + -> (summary: DeepSeekUsageSummary?, state: DeepSeekDetailedUsageState) { let race = BoundedTaskJoin(sourceTask: task) switch await race.value(joinGrace: joinGrace) { case let .value(summary): try Task.checkCancellation() - return summary + return (summary, .available) case .timedOut: try Task.checkCancellation() - return nil + Self.log.warning("DeepSeek detailed usage unavailable", metadata: ["reason": "timeout"]) + return (nil, .unavailable) case let .failure(error): task.cancel() if Task.isCancelled { throw error } - return nil + Self.log.warning( + "DeepSeek detailed usage unavailable", + metadata: ["reason": self.optionalSummaryFailureReason(error)]) + return (nil, error is DeepSeekUsageError ? self.optionalSummaryFailureState(error) : .unavailable) + } + } + + private static func optionalSummaryFailureState(_ error: any Error) -> DeepSeekDetailedUsageState { + guard let error = error as? DeepSeekUsageError else { return .unavailable } + return error == .invalidPlatformToken ? .webSessionRequired : .unavailable + } + + private static func optionalSummaryFailureReason(_ error: any Error) -> String { + guard let error = error as? DeepSeekUsageError else { + return error is CancellationError ? "cancelled" : "unknown" + } + + switch error { + case .missingCredentials: return "missing_credentials" + case .invalidPlatformToken: return "platform_auth" + case .networkError: return "network" + case .apiError: return "api" + case .parseFailed: return "parse" } } @@ -270,23 +444,154 @@ public struct DeepSeekUsageFetcher: Sendable { } public static func fetchUsageSummary( - apiKey: String, + platformToken: String, now: Date = Date(), calendar: Calendar? = nil) async throws -> DeepSeekUsageSummary { let calendar = calendar ?? self.apiCalendar let period = try self.usagePeriod(now: now, calendar: calendar) - - let amountData = try await self.fetchAmount(apiKey: apiKey, month: period.month, year: period.year) - let costData = try await self.fetchCost(apiKey: apiKey, month: period.month, year: period.year) + let payloads = try await self.fetchUsagePayloads( + fetchAmount: { + try await self.fetchAmount(platformToken: platformToken, month: period.month, year: period.year) + }, + fetchCost: { + try await self.fetchCost(platformToken: platformToken, month: period.month, year: period.year) + }) return try DeepSeekUsageCostParser.parse( - amountData: amountData, - costData: costData, + amountData: payloads.amount, + costData: payloads.cost, now: now, calendar: calendar) } + public static func fetchPlatformUsage( + platformToken: String, + includeOptionalUsage: Bool = true) async throws -> DeepSeekUsageSnapshot + { + try await self.fetchPlatformUsage( + platformToken: platformToken, + includeOptionalUsage: includeOptionalUsage, + optionalSummaryJoinGrace: self.optionalSummaryJoinGrace, + operations: PlatformFetchOperations( + fetchBalance: { token in + try await self.fetchPlatformBalance(platformToken: token) + }, + fetchSummary: { token in + try await self.fetchUsageSummary(platformToken: token) + })) + } + + static func _fetchPlatformUsageForTesting( + includeOptionalUsage: Bool, + optionalSummaryJoinGrace: Duration = .zero, + fetchBalance: @escaping @Sendable () async throws -> DeepSeekUsageSnapshot, + fetchSummary: @escaping @Sendable () async throws -> DeepSeekUsageSummary) async throws + -> DeepSeekUsageSnapshot + { + try await self.fetchPlatformUsage( + platformToken: "test-platform-token", + includeOptionalUsage: includeOptionalUsage, + optionalSummaryJoinGrace: optionalSummaryJoinGrace, + operations: PlatformFetchOperations( + fetchBalance: { _ in try await fetchBalance() }, + fetchSummary: { _ in try await fetchSummary() })) + } + + private struct PlatformFetchOperations: Sendable { + let fetchBalance: @Sendable (String) async throws -> DeepSeekUsageSnapshot + let fetchSummary: @Sendable (String) async throws -> DeepSeekUsageSummary + } + + private static func fetchPlatformUsage( + platformToken: String, + includeOptionalUsage: Bool, + optionalSummaryJoinGrace: Duration, + operations: PlatformFetchOperations) async throws -> DeepSeekUsageSnapshot + { + let token = platformToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { throw DeepSeekUsageError.invalidPlatformToken } + + let summaryTask: Task? = if includeOptionalUsage { + Task { + try await operations.fetchSummary(token) + } + } else { + nil + } + + let balance: DeepSeekUsageSnapshot + do { + balance = try await withTaskCancellationHandler { + try await operations.fetchBalance(token) + } onCancel: { + summaryTask?.cancel() + } + } catch { + summaryTask?.cancel() + throw error + } + + var snapshot = DeepSeekUsageSnapshot( + isAvailable: balance.isAvailable, + currency: balance.currency, + totalBalance: balance.totalBalance, + grantedBalance: balance.grantedBalance, + toppedUpBalance: balance.toppedUpBalance, + detailedUsageState: includeOptionalUsage ? .unavailable : .notRequested, + updatedAt: balance.updatedAt) + if let summaryTask { + let completion = try await self.completedOptionalUsageSummary( + from: summaryTask, + joinGrace: optionalSummaryJoinGrace) + snapshot = DeepSeekUsageSnapshot( + isAvailable: balance.isAvailable, + currency: balance.currency, + totalBalance: balance.totalBalance, + grantedBalance: balance.grantedBalance, + toppedUpBalance: balance.toppedUpBalance, + usageSummary: completion.summary, + detailedUsageState: completion.state, + updatedAt: balance.updatedAt) + } + return snapshot + } + + static func _fetchUsagePayloadsForTesting( + fetchAmount: @escaping @Sendable () async throws -> Data, + fetchCost: @escaping @Sendable () async throws -> Data) async throws -> (amount: Data, cost: Data) + { + try await self.fetchUsagePayloads(fetchAmount: fetchAmount, fetchCost: fetchCost) + } + + private static func fetchUsagePayloads( + fetchAmount: @escaping @Sendable () async throws -> Data, + fetchCost: @escaping @Sendable () async throws -> Data) async throws -> (amount: Data, cost: Data) + { + try await withThrowingTaskGroup(of: UsageSummaryPayload.self) { group in + group.addTask { + try await .amount(fetchAmount()) + } + group.addTask { + try await .cost(fetchCost()) + } + + var amountData: Data? + var costData: Data? + for try await payload in group { + switch payload { + case let .amount(data): amountData = data + case let .cost(data): costData = data + } + } + + guard let amountData, let costData else { + throw DeepSeekUsageError.parseFailed("Missing usage response") + } + return (amount: amountData, cost: costData) + } + } + static func _apiUsagePeriodForTesting(now: Date, calendar: Calendar? = nil) throws -> (month: Int, year: Int) { try self.usagePeriod(now: now, calendar: calendar ?? self.apiCalendar) } @@ -299,7 +604,7 @@ public struct DeepSeekUsageFetcher: Sendable { return (month: month, year: year) } - private static func fetchAmount(apiKey: String, month: Int, year: Int) async throws -> Data { + private static func fetchAmount(platformToken: String, month: Int, year: Int) async throws -> Data { guard var components = URLComponents(url: self.usageAmountURL, resolvingAgainstBaseURL: false) else { throw DeepSeekUsageError.networkError("Invalid URL") } @@ -313,7 +618,7 @@ public struct DeepSeekUsageFetcher: Sendable { var request = URLRequest(url: url) request.httpMethod = "GET" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("Bearer \(platformToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = Self.timeoutSeconds @@ -321,7 +626,7 @@ public struct DeepSeekUsageFetcher: Sendable { let data = response.data guard response.statusCode == 200 else { if response.statusCode == 401 || response.statusCode == 403 { - throw DeepSeekUsageError.missingCredentials + throw DeepSeekUsageError.invalidPlatformToken } throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") } @@ -329,7 +634,24 @@ public struct DeepSeekUsageFetcher: Sendable { return data } - private static func fetchCost(apiKey: String, month: Int, year: Int) async throws -> Data { + private static func fetchPlatformBalance(platformToken: String) async throws -> DeepSeekUsageSnapshot { + var request = URLRequest(url: self.platformUserSummaryURL) + request.httpMethod = "GET" + request.setValue("Bearer \(platformToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw DeepSeekUsageError.invalidPlatformToken + } + throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") + } + return try self.parsePlatformBalance(data: response.data) + } + + private static func fetchCost(platformToken: String, month: Int, year: Int) async throws -> Data { guard var components = URLComponents(url: self.usageCostURL, resolvingAgainstBaseURL: false) else { throw DeepSeekUsageError.networkError("Invalid URL") } @@ -343,7 +665,7 @@ public struct DeepSeekUsageFetcher: Sendable { var request = URLRequest(url: url) request.httpMethod = "GET" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("Bearer \(platformToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = Self.timeoutSeconds @@ -351,7 +673,7 @@ public struct DeepSeekUsageFetcher: Sendable { let data = response.data guard response.statusCode == 200 else { if response.statusCode == 401 || response.statusCode == 403 { - throw DeepSeekUsageError.missingCredentials + throw DeepSeekUsageError.invalidPlatformToken } throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") } @@ -372,6 +694,69 @@ public struct DeepSeekUsageFetcher: Sendable { calendar: calendar) } + static func _parsePlatformBalanceForTesting(_ data: Data) throws -> DeepSeekUsageSnapshot { + try self.parsePlatformBalance(data: data) + } + + private static func parsePlatformBalance(data: Data) throws -> DeepSeekUsageSnapshot { + let payload: DeepSeekPlatformUserSummaryResponse + do { + payload = try JSONDecoder().decode(DeepSeekPlatformUserSummaryResponse.self, from: data) + } catch { + throw DeepSeekUsageError.parseFailed(error.localizedDescription) + } + if let code = payload.code, code != 0 { + if self.isPlatformAuthenticationError(code) { + throw DeepSeekUsageError.invalidPlatformToken + } + throw DeepSeekUsageError.apiError("user summary code \(code)") + } + if let bizCode = payload.data?.bizCode, bizCode != 0 { + if self.isPlatformAuthenticationError(bizCode) { + throw DeepSeekUsageError.invalidPlatformToken + } + throw DeepSeekUsageError.apiError("user summary biz_code \(bizCode)") + } + guard let summary = payload.data?.bizData else { + throw DeepSeekUsageError.parseFailed("Missing user summary biz_data") + } + + let toppedUp = Dictionary(grouping: summary.normalWallets, by: \.currency) + .mapValues { $0.reduce(0) { $0 + $1.balance } } + let granted = Dictionary(grouping: summary.bonusWallets, by: \.currency) + .mapValues { $0.reduce(0) { $0 + $1.balance } } + let currencies = Set(toppedUp.keys).union(granted.keys).sorted() + guard !currencies.isEmpty else { + return DeepSeekUsageSnapshot( + isAvailable: false, + currency: "USD", + totalBalance: 0, + grantedBalance: 0, + toppedUpBalance: 0, + updatedAt: Date()) + } + + let selectedCurrency = currencies.first { currency in + currency == "USD" && (toppedUp[currency, default: 0] + granted[currency, default: 0]) > 0 + } ?? currencies.first { currency in + toppedUp[currency, default: 0] + granted[currency, default: 0] > 0 + } ?? currencies.first(where: { $0 == "USD" }) ?? currencies[0] + let paidBalance = toppedUp[selectedCurrency, default: 0] + let grantedBalance = granted[selectedCurrency, default: 0] + let totalBalance = paidBalance + grantedBalance + return DeepSeekUsageSnapshot( + isAvailable: totalBalance > 0, + currency: selectedCurrency, + totalBalance: totalBalance, + grantedBalance: grantedBalance, + toppedUpBalance: paidBalance, + updatedAt: Date()) + } + + private static func isPlatformAuthenticationError(_ code: Int) -> Bool { + code == 40002 || code == 40003 + } + private static func parseSnapshot(data: Data) throws -> DeepSeekUsageSnapshot { let decoded: DeepSeekBalanceResponse do { diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 843934fbd6..2895cdaf08 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -186,6 +186,8 @@ public struct UsageSnapshot: Codable, Sendable { public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? public let deepseekUsage: DeepSeekUsageSummary? + public let deepseekDetailedUsageState: DeepSeekDetailedUsageState + public let deepseekPlatformProfiles: [DeepSeekPlatformProfile] public let mimoUsage: MiMoUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? public let sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? @@ -254,6 +256,8 @@ public struct UsageSnapshot: Codable, Sendable { zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, deepseekUsage: DeepSeekUsageSummary? = nil, + deepseekDetailedUsageState: DeepSeekDetailedUsageState = .notRequested, + deepseekPlatformProfiles: [DeepSeekPlatformProfile] = [], mimoUsage: MiMoUsageSnapshot? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, sakanaPayAsYouGo: SakanaPayAsYouGoSnapshot? = nil, @@ -287,6 +291,8 @@ public struct UsageSnapshot: Codable, Sendable { self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage self.deepseekUsage = deepseekUsage + self.deepseekDetailedUsageState = deepseekDetailedUsageState + self.deepseekPlatformProfiles = deepseekPlatformProfiles self.mimoUsage = mimoUsage self.openRouterUsage = openRouterUsage self.sakanaPayAsYouGo = sakanaPayAsYouGo @@ -325,6 +331,24 @@ public struct UsageSnapshot: Codable, Sendable { secondary: .value(secondary)) } + public func withoutDeepSeekDetailedUsage( + state: DeepSeekDetailedUsageState = .unavailable) -> UsageSnapshot + { + self.replacing( + deepseekUsage: .value(nil), + deepseekDetailedUsageState: .value(state)) + } + + public func preservingDeepSeekPlatformProfiles(from previous: UsageSnapshot?) -> UsageSnapshot { + guard self.deepseekDetailedUsageState == .unavailable, + self.deepseekPlatformProfiles.isEmpty, + let previous, + !previous.deepseekPlatformProfiles.isEmpty + else { return self } + return self.replacing( + deepseekPlatformProfiles: .value(previous.deepseekPlatformProfiles)) + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) @@ -337,6 +361,8 @@ public struct UsageSnapshot: Codable, Sendable { self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time self.deepseekUsage = nil // Not persisted, fetched fresh each time + self.deepseekDetailedUsageState = .notRequested // Live-only fetch state + self.deepseekPlatformProfiles = [] // Live-only browser profile catalog self.mimoUsage = try container.decodeIfPresent(MiMoUsageSnapshot.self, forKey: .mimoUsage) self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) self.sakanaPayAsYouGo = try container.decodeIfPresent( @@ -577,6 +603,9 @@ public struct UsageSnapshot: Codable, Sendable { secondary: Replacement = .unchanged, tertiary: Replacement = .unchanged, extraRateWindows: Replacement<[NamedRateWindow]?> = .unchanged, + deepseekUsage: Replacement = .unchanged, + deepseekDetailedUsageState: Replacement = .unchanged, + deepseekPlatformProfiles: Replacement<[DeepSeekPlatformProfile]> = .unchanged, codexResetCredits: Replacement = .unchanged, identity: Replacement = .unchanged, dataConfidence: Replacement = .unchanged) -> UsageSnapshot @@ -591,7 +620,9 @@ public struct UsageSnapshot: Codable, Sendable { providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, - deepseekUsage: self.deepseekUsage, + deepseekUsage: deepseekUsage.resolving(self.deepseekUsage), + deepseekDetailedUsageState: deepseekDetailedUsageState.resolving(self.deepseekDetailedUsageState), + deepseekPlatformProfiles: deepseekPlatformProfiles.resolving(self.deepseekPlatformProfiles), mimoUsage: self.mimoUsage, openRouterUsage: self.openRouterUsage, sakanaPayAsYouGo: self.sakanaPayAsYouGo, diff --git a/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift b/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift new file mode 100644 index 0000000000..4e8cc6c4df --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekPlatformTokenImporterTests.swift @@ -0,0 +1,194 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekPlatformTokenImporterTests { + @Test + func `extracts plain user token`() { + let token = "browser-user-token-1234567890" + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting(token) == token) + } + + @Test + func `extracts JSON encoded user token`() { + let token = "browser-user-token-abcdefghij" + let value = "{\"userToken\":\"\(token)\"}" + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting(value) == token) + } + + @Test + func `extracts DeepSeek value wrapped user token`() { + let token = "browser-user-token-value-wrapped" + let value = "{\"value\":\"\(token)\",\"expiresAt\":1234567890}" + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting(value) == token) + } + + @Test + func `does not treat an unrecognized JSON object as a token`() { + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting("{\"expiresAt\":1234567890}") == nil) + } + + @Test + func `rejects short or whitespace values`() { + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting("short") == nil) + #expect(DeepSeekPlatformTokenImporter._extractUserTokenForTesting("token with embedded spaces 12345") == nil) + } + + @Test + func `multiple profiles expose only server accepted sessions`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "valid-1"), + Self.candidate(id: "profile-2", token: "expired"), + Self.candidate(id: "profile-3", token: "valid-3"), + ] + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: nil, + validate: { token in + guard token != "expired" else { throw DeepSeekUsageError.invalidPlatformToken } + return Self.summary(marker: token == "valid-1" ? 1 : 3) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-1", "profile-3"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .profileSelectionRequired) + } + + @Test + func `single accepted profile is selected automatically`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "expired-1"), + Self.candidate(id: "profile-2", token: "valid-2"), + Self.candidate(id: "profile-3", token: "expired-3"), + ] + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: nil, + validate: { token in + guard token == "valid-2" else { throw DeepSeekUsageError.invalidPlatformToken } + return Self.summary(marker: 2) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-2"]) + #expect(resolution.selectedSummary?.todayTokens == 2) + #expect(resolution.detailedUsageState == .available) + } + + @Test + func `selected profile preserves its detailed usage state`() async { + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [Self.candidate(id: "profile-1", token: "valid-1")], + selectedProfileID: nil, + detailedUsageState: .notRequested, + validate: { _ in Self.summary(marker: 1) }) + + #expect(resolution.selectedSummary?.todayTokens == 1) + #expect(resolution.detailedUsageState == .notRequested) + } + + @Test + func `explicit selection requirement does not auto select a single accepted profile`() async { + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [Self.candidate(id: "profile-1", token: "valid-1")], + selectedProfileID: nil, + requiresExplicitSelection: true, + validate: { _ in Self.summary(marker: 1) }) + + #expect(resolution.profiles.map(\.id) == ["profile-1"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .profileSelectionRequired) + } + + @Test + func `stored selection chooses one of multiple accepted profiles`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "valid-1"), + Self.candidate(id: "profile-2", token: "valid-2"), + ] + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: "profile-2", + validate: { token in + Self.summary(marker: token == "valid-1" ? 1 : 2) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-1", "profile-2"]) + #expect(resolution.selectedSummary?.todayTokens == 2) + #expect(resolution.detailedUsageState == .available) + } + + @Test + func `expired stored selection does not silently switch to another profile`() async { + let candidates = [ + Self.candidate(id: "profile-1", token: "expired"), + Self.candidate(id: "profile-2", token: "valid-2"), + ] + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: candidates, + selectedProfileID: "profile-1", + validate: { token in + guard token == "valid-2" else { throw DeepSeekUsageError.invalidPlatformToken } + return Self.summary(marker: 2) + }) + + #expect(resolution.profiles.map(\.id) == ["profile-2"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .profileSelectionRequired) + } + + @Test + func `temporary validation failure is unavailable rather than signed out`() async { + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [Self.candidate(id: "profile-1", token: "maybe-valid")], + selectedProfileID: nil, + validate: { _ in throw DeepSeekUsageError.networkError("offline") }) + + #expect(resolution.profiles.isEmpty) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .unavailable) + } + + @Test + func `temporary validation failure keeps a previously accepted profile`() async { + let candidate = Self.candidate(id: "profile-1", token: "valid-1") + let cache = DeepSeekPlatformValidationCache(validityTTL: 0) + _ = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [candidate], + selectedProfileID: nil, + cache: cache, + validate: { _ in Self.summary(marker: 1) }) + + let resolution = await DeepSeekPlatformTokenImporter._resolveForTesting( + candidates: [candidate], + selectedProfileID: nil, + cache: cache, + validate: { _ in throw DeepSeekUsageError.networkError("offline") }) + + #expect(resolution.profiles.map(\.id) == ["profile-1"]) + #expect(resolution.selectedSummary == nil) + #expect(resolution.detailedUsageState == .unavailable) + } + + private static func candidate(id: String, token: String) -> DeepSeekPlatformTokenImporter.TokenInfo { + DeepSeekPlatformTokenImporter.TokenInfo(id: id, token: token, sourceLabel: "Chrome \(id)") + } + + private static func summary(marker: Int) -> DeepSeekUsageSummary { + DeepSeekUsageSummary( + todayTokens: marker, + currentMonthTokens: marker, + todayCost: nil, + currentMonthCost: nil, + requestCount: marker, + currentMonthRequestCount: marker, + topModel: nil, + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 0)) + } +} diff --git a/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift b/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift new file mode 100644 index 0000000000..743fa3c033 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekProviderDescriptorTests.swift @@ -0,0 +1,420 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekProviderDescriptorTests { + private actor CancellationProbe { + private(set) var wasCancelled = false + + func markCancelled() { + self.wasCancelled = true + } + } + + private actor ResolutionInputProbe { + private(set) var profileID: String? + private(set) var requiresExplicitSelection = false + private(set) var includesPlatformBalance = false + private(set) var includesOptionalUsage = true + + func record( + profileID: String?, + requiresExplicitSelection: Bool, + includesPlatformBalance: Bool = false, + includesOptionalUsage: Bool = true) + { + self.profileID = profileID + self.requiresExplicitSelection = requiresExplicitSelection + self.includesPlatformBalance = includesPlatformBalance + self.includesOptionalUsage = includesOptionalUsage + } + } + + @Test + func `balance failure cancels automatic session resolution promptly`() async { + let probe = CancellationProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.apiError("invalid key") + }, + resolveAutomaticSession: { _, _, _, _, _, _ in + do { + try await Task.sleep(for: .seconds(10)) + } catch { + await probe.markCancelled() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + await #expect { + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "invalid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .seconds(5), + operations: operations) + } throws: { error in + error as? DeepSeekUsageError == .apiError("invalid key") + } + + #expect(startedAt.duration(to: .now) < .seconds(1)) + let cancellationDeadline = ContinuousClock.now.advanced(by: .milliseconds(200)) + while await !(probe.wasCancelled), ContinuousClock.now < cancellationDeadline { + await Task.yield() + } + #expect(await probe.wasCancelled) + } + + @Test + func `automatic session resolution cannot hold balance past its grace`() async throws { + let probe = CancellationProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + do { + try await Task.sleep(for: .seconds(10)) + } catch { + await probe.markCancelled() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + let snapshot = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .milliseconds(20), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(snapshot.deepseekUsage == nil) + #expect(snapshot.deepseekDetailedUsageState == .unavailable) + #expect(startedAt.duration(to: .now) < .seconds(1)) + let cancellationDeadline = ContinuousClock.now.advanced(by: .milliseconds(200)) + while await !(probe.wasCancelled), ContinuousClock.now < cancellationDeadline { + await Task.yield() + } + #expect(await probe.wasCancelled) + } + + @Test + func `automatic session result enriches the required balance`() async throws { + let summary = DeepSeekUsageSummary( + todayTokens: 123, + currentMonthTokens: 456, + todayCost: 0.1, + currentMonthCost: 0.2, + requestCount: 3, + currentMonthRequestCount: 4, + topModel: "deepseek-chat", + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 1)) + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + DeepSeekPlatformTokenImporter.Resolution( + profiles: [DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal")], + selectedSummary: summary, + detailedUsageState: .available) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(snapshot.deepseekUsage?.todayTokens == 123) + #expect(snapshot.deepseekDetailedUsageState == .available) + #expect(snapshot.deepseekPlatformProfiles.map(\.id) == ["chrome:Default"]) + } + + @Test + func `automatic resolution timeout is hard when the resolver ignores cancellation`() async throws { + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + let deadline = ContinuousClock.now.advanced(by: .milliseconds(500)) + while ContinuousClock.now < deadline { + await Task.yield() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + let snapshot = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(), + optionalResolutionJoinGrace: .milliseconds(20), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(startedAt.duration(to: .now) < .milliseconds(200)) + } + + @Test + func `profile selection from another api account requires explicit replacement`() async throws { + let probe = ResolutionInputProbe() + let selectedAccountID = UUID() + let otherAccountID = UUID() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { profileID, requiresExplicitSelection, _, _, _, _ in + await probe.record( + profileID: profileID, + requiresExplicitSelection: requiresExplicitSelection) + return Self.unavailableResolution + }) + let otherAccountScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: otherAccountID, + apiKey: "valid")) + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: "chrome:Default", + DeepSeekSettingsReader.profileScopeEnvironmentKey: otherAccountScope, + ] + + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "valid", + context: Self.makeContext(environment: environment, selectedTokenAccountID: selectedAccountID), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection) + } + + @Test + func `replacing an api key in the same account requires explicit profile replacement`() async throws { + let probe = ResolutionInputProbe() + let selectedAccountID = UUID() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { profileID, requiresExplicitSelection, _, _, _, _ in + await probe.record(profileID: profileID, requiresExplicitSelection: requiresExplicitSelection) + return Self.unavailableResolution + }) + let oldScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: selectedAccountID, + apiKey: "old-key")) + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: "chrome:Default", + DeepSeekSettingsReader.profileScopeEnvironmentKey: oldScope, + ] + + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "new-key", + context: Self.makeContext(environment: environment, selectedTokenAccountID: selectedAccountID), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection) + } + + @Test + func `changing the environment api key requires explicit profile replacement`() async throws { + let probe = ResolutionInputProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { profileID, requiresExplicitSelection, _, _, _, _ in + await probe.record(profileID: profileID, requiresExplicitSelection: requiresExplicitSelection) + return Self.unavailableResolution + }) + let oldScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: nil, + apiKey: "old-key")) + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: "chrome:Default", + DeepSeekSettingsReader.profileScopeEnvironmentKey: oldScope, + ] + + _ = try await DeepSeekProviderDescriptor._loadUsageForTesting( + apiKey: "new-key", + context: Self.makeContext(environment: environment), + optionalResolutionJoinGrace: .seconds(1), + operations: operations) + + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection) + } + + @Test + func `browser only mode returns Platform balance and usage without an API key`() async throws { + let probe = ResolutionInputProbe() + let summary = DeepSeekUsageSummary( + todayTokens: 123, + currentMonthTokens: 456, + todayCost: 0.1, + currentMonthCost: 0.2, + requestCount: 3, + currentMonthRequestCount: 4, + topModel: "deepseek-chat", + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 1)) + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.missingCredentials + }, + resolveAutomaticSession: { profileID, explicit, includeBalance, includeOptional, _, _ in + await probe.record( + profileID: profileID, + requiresExplicitSelection: explicit, + includesPlatformBalance: includeBalance, + includesOptionalUsage: includeOptional) + return DeepSeekPlatformTokenImporter.Resolution( + profiles: [DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Yuqing")], + selectedSummary: summary, + selectedBalance: Self.balance, + detailedUsageState: .available) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto), + operations: operations) + + #expect(snapshot.primary?.resetDescription?.contains("$8.06") == true) + #expect(snapshot.deepseekUsage == summary) + #expect(snapshot.deepseekDetailedUsageState == .available) + #expect(snapshot.deepseekPlatformProfiles.map(\.id) == ["chrome:Default"]) + #expect(await probe.profileID == nil) + #expect(await probe.requiresExplicitSelection == false) + #expect(await probe.includesPlatformBalance) + #expect(await probe.includesOptionalUsage) + } + + @Test + func `browser only mode skips optional usage when extras are disabled`() async throws { + let probe = ResolutionInputProbe() + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.missingCredentials + }, + resolveAutomaticSession: { profileID, explicit, includeBalance, includeOptional, _, _ in + await probe.record( + profileID: profileID, + requiresExplicitSelection: explicit, + includesPlatformBalance: includeBalance, + includesOptionalUsage: includeOptional) + return DeepSeekPlatformTokenImporter.Resolution( + profiles: [DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Yuqing")], + selectedSummary: nil, + selectedBalance: Self.balance, + detailedUsageState: .notRequested) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto, includeOptionalUsage: false), + operations: operations) + + #expect(snapshot.primary != nil) + #expect(snapshot.deepseekUsage == nil) + #expect(snapshot.deepseekDetailedUsageState == .notRequested) + #expect(await probe.includesPlatformBalance) + #expect(await probe.includesOptionalUsage == false) + } + + @Test + func `browser only resolution timeout is hard when Chrome ignores cancellation`() async throws { + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in Self.balance }, + resolveAutomaticSession: { _, _, _, _, _, _ in + let deadline = ContinuousClock.now.advanced(by: .milliseconds(500)) + while ContinuousClock.now < deadline { + await Task.yield() + } + return Self.unavailableResolution + }) + let startedAt = ContinuousClock.now + + await #expect { + _ = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto), + resolutionJoinGrace: .milliseconds(20), + operations: operations) + } throws: { error in + guard case let DeepSeekUsageError.networkError(message) = error else { return false } + return message.contains("timed out") + } + #expect(startedAt.duration(to: .now) < .milliseconds(200)) + } + + @Test + func `browser only mode asks for Chrome sign in instead of an API key`() async throws { + let operations = DeepSeekProviderDescriptor.FetchOperations( + fetchUsage: { _, _, _ in + throw DeepSeekUsageError.missingCredentials + }, + resolveAutomaticSession: { _, _, _, _, _, _ in + DeepSeekPlatformTokenImporter.Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: .webSessionRequired) + }) + + let snapshot = try await DeepSeekProviderDescriptor._loadPlatformUsageForTesting( + context: Self.makeContext(sourceMode: .auto), + operations: operations) + + #expect(snapshot.primary == nil) + #expect(snapshot.deepseekDetailedUsageState == .webSessionRequired) + } + + @Test + func `automatic source uses Chrome session when API key is absent`() async { + let strategies = await DeepSeekProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies( + Self.makeContext(sourceMode: .auto)) + + #expect(strategies.map(\.id) == ["deepseek.web"]) + } + + @Test + func `automatic source keeps API path when API key is present`() async { + let strategies = await DeepSeekProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies( + Self.makeContext( + environment: [DeepSeekSettingsReader.apiKeyEnvironmentKey: "test-api-key"], + sourceMode: .auto)) + + #expect(strategies.map(\.id) == ["deepseek.api"]) + } + + private static let balance = DeepSeekUsageSnapshot( + isAvailable: true, + currency: "USD", + totalBalance: 8.06, + grantedBalance: 0, + toppedUpBalance: 8.06, + updatedAt: Date(timeIntervalSince1970: 1)) + + private static let unavailableResolution = DeepSeekPlatformTokenImporter.Resolution( + profiles: [], + selectedSummary: nil, + detailedUsageState: .unavailable) + + private static func makeContext( + environment: [String: String] = [:], + selectedTokenAccountID: UUID? = nil, + sourceMode: ProviderSourceMode = .api, + includeOptionalUsage: Bool = true) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + includeOptionalUsage: includeOptionalUsage, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: nil, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: selectedTokenAccountID) + } +} diff --git a/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift index 0aba8b4a83..bb9e322ff8 100644 --- a/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift +++ b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift @@ -1,4 +1,5 @@ import CodexBarCore +import Foundation import Testing struct DeepSeekSettingsReaderTests { @@ -54,6 +55,65 @@ struct DeepSeekSettingsReaderTests { let env = ["DEEPSEEK_API_KEY": " "] #expect(DeepSeekSettingsReader.apiKey(environment: env) == nil) } + + @Test + func `reads separate platform session token`() { + let env = ["DEEPSEEK_PLATFORM_TOKEN": " browser-session-token "] + #expect(DeepSeekSettingsReader.platformToken(environment: env) == "browser-session-token") + } + + @Test + func `falls back to DeepSeek user token environment key`() { + let env = ["DEEPSEEK_USER_TOKEN": "browser-user-token"] + #expect(DeepSeekSettingsReader.platformToken(environment: env) == "browser-user-token") + } + + @Test + func `reads selected Chrome profile id`() { + let env = [DeepSeekSettingsReader.profileIDEnvironmentKey: " /profiles/Profile 2 "] + #expect(DeepSeekSettingsReader.profileID(environment: env) == "chrome:Profile 2") + } + + @Test + func `migrates an absolute Chrome profile path to a stable identifier`() { + let environment = [ + DeepSeekSettingsReader.profileIDEnvironmentKey: + "/Users/example/Library/Application Support/Google/Chrome/Profile 2", + ] + + #expect(DeepSeekSettingsReader.profileID(environment: environment) == "chrome:Profile 2") + } + + @Test + func `profile scope fingerprints the api credential without storing it`() throws { + let accountID = UUID() + let first = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: accountID, + apiKey: "secret-api-key")) + let repeated = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: accountID, + apiKey: "secret-api-key")) + let replacedKey = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: accountID, + apiKey: "replacement-api-key")) + let otherAccount = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: UUID(), + apiKey: "secret-api-key")) + + #expect(first == repeated) + #expect(first != replacedKey) + #expect(first != otherAccount) + #expect(!first.contains("secret-api-key")) + } + + @Test + func `browser only profile scope persists without an API key`() throws { + let scope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: nil, + apiKey: nil)) + + #expect(!scope.isEmpty) + } } struct DeepSeekProviderTokenResolverTests { diff --git a/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift index c7cc32b925..e9d67a5d05 100644 --- a/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift @@ -853,3 +853,150 @@ struct DeepSeekUsageCostParserTests { #expect(summary.todayTokens == 450) // 150 + 300 } } + +struct DeepSeekUsageCostParserAuthorizationTests { + private static let emptyCostJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [] + } + } + """ + + @Test + func `invalid platform token code requests a new web session`() { + let amountJSON = """ + { + "code": 40003, + "msg": "Authorization Failed (invalid token)", + "data": null + } + """ + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [] + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `nested invalid platform token code requests a new web session`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 40002, + "biz_msg": "Authorization Failed", + "biz_data": null + } + } + """ + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [] + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `top level authentication error survives an unexpected data shape`() { + let amountJSON = """ + { + "code": 40003, + "msg": "Authorization Failed", + "data": "unexpected" + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(Self.emptyCostJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `nested authentication error survives an unexpected biz data shape`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 40002, + "biz_msg": "Authorization Failed", + "biz_data": "unexpected" + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(Self.emptyCostJSON.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `successful malformed payload reports its decoding path`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": "unexpected", + "days": [] + } + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(Self.emptyCostJSON.utf8)) + } throws: { error in + guard case let DeepSeekUsageError.parseFailed(message) = error else { return false } + return message.contains("total") && message.contains("typeMismatch") + } + } +} diff --git a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift index 5ebb3abbf4..f348d76939 100644 --- a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift @@ -46,6 +46,34 @@ struct DeepSeekUsageFetcherTests { } } + private actor ConcurrentFetchGate { + private var arrivalCount = 0 + private var waiters: [CheckedContinuation] = [] + + func arriveAndWait() async { + self.arrivalCount += 1 + if self.arrivalCount == 2 { + for waiter in self.waiters { + waiter.resume() + } + self.waiters.removeAll() + return + } + + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } + } + + private actor SummaryCallCounter { + private(set) var value = 0 + + func increment() { + self.value += 1 + } + } + private static func withTimeout( _ timeout: Duration, operation: @escaping @Sendable () async throws -> T) async throws -> T @@ -128,6 +156,90 @@ struct DeepSeekUsageFetcherTests { #expect(snapshot.toppedUpBalance == 40.0) } + @Test + func `parses paid and granted balances from Platform session summary`() throws { + let json = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": { + "normal_wallets": [ + {"balance": "7.97", "currency": "USD"} + ], + "bonus_wallets": [ + {"balance": 0.50, "currency": "USD"} + ] + } + } + } + """ + + let snapshot = try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + + #expect(snapshot.hasBalance) + #expect(snapshot.isAvailable) + #expect(snapshot.currency == "USD") + #expect(abs(snapshot.totalBalance - 8.47) < 0.000_001) + #expect(snapshot.toppedUpBalance == 7.97) + #expect(snapshot.grantedBalance == 0.50) + } + + @Test + func `Platform session summary rejects malformed balance`() { + let json = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": { + "normal_wallets": [{"balance": "not-a-number", "currency": "USD"}], + "bonus_wallets": [] + } + } + } + """ + + #expect(throws: DeepSeekUsageError.self) { + try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + } + } + + @Test + func `Platform session summary maps top level auth envelopes before decoding data`() { + let json = """ + { + "code": 40003, + "data": "unexpected" + } + """ + + #expect { + try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `Platform session summary maps nested auth envelopes before decoding wallets`() { + let json = """ + { + "code": 0, + "data": { + "biz_code": 40002, + "biz_data": "unexpected" + } + } + """ + + #expect { + try DeepSeekUsageFetcher._parsePlatformBalanceForTesting(Data(json.utf8)) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + @Test func `parses CNY balance response`() throws { let json = """ @@ -360,12 +472,32 @@ struct DeepSeekUsageFetcherTests { #expect(usage.deepseekUsage == nil) } + @Test + func `usage amount and cost fetch concurrently`() async throws { + let gate = ConcurrentFetchGate() + let payloads = try await Self.withTimeout(.seconds(1)) { + try await DeepSeekUsageFetcher._fetchUsagePayloadsForTesting( + fetchAmount: { + await gate.arriveAndWait() + return Data("amount".utf8) + }, + fetchCost: { + await gate.arriveAndWait() + return Data("cost".utf8) + }) + } + + #expect(String(bytes: payloads.amount, encoding: .utf8) == "amount") + #expect(String(bytes: payloads.cost, encoding: .utf8) == "cost") + } + @Test func `balance returns promptly when optional usage summary is slow`() async throws { let probe = SummaryCancellationProbe() let snapshot = try await Self.withTimeout(.seconds(10)) { try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .milliseconds(50), fetchBalanceData: { _ in @@ -393,6 +525,7 @@ struct DeepSeekUsageFetcherTests { let startedAt = ContinuousClock.now let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .milliseconds(20), fetchBalanceData: { _ in @@ -419,6 +552,7 @@ struct DeepSeekUsageFetcherTests { func `balance returns when optional usage summary fails closed`() async throws { let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -432,6 +566,54 @@ struct DeepSeekUsageFetcherTests { #expect(snapshot.usageSummary == nil) } + @Test + func `Platform balance returns when optional usage summary fails`() async throws { + let snapshot = try await DeepSeekUsageFetcher._fetchPlatformUsageForTesting( + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(2), + fetchBalance: { + DeepSeekUsageSnapshot( + isAvailable: true, + currency: "USD", + totalBalance: 8.06, + grantedBalance: 0, + toppedUpBalance: 8.06, + updatedAt: Date()) + }, + fetchSummary: { + throw DeepSeekUsageError.networkError("simulated failure") + }) + + #expect(snapshot.totalBalance == 8.06) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .unavailable) + } + + @Test + func `Platform balance skips detailed endpoints when optional usage is disabled`() async throws { + let counter = SummaryCallCounter() + let snapshot = try await DeepSeekUsageFetcher._fetchPlatformUsageForTesting( + includeOptionalUsage: false, + fetchBalance: { + DeepSeekUsageSnapshot( + isAvailable: true, + currency: "USD", + totalBalance: 8.06, + grantedBalance: 0, + toppedUpBalance: 8.06, + updatedAt: Date()) + }, + fetchSummary: { + await counter.increment() + return Self.sampleSummary() + }) + + #expect(snapshot.totalBalance == 8.06) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .notRequested) + #expect(await counter.value == 0) + } + @Test func `cancels optional usage summary when balance fetch fails`() async throws { let probe = SummaryCancellationProbe() @@ -439,6 +621,7 @@ struct DeepSeekUsageFetcherTests { do { _ = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -468,6 +651,7 @@ struct DeepSeekUsageFetcherTests { do { _ = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -496,6 +680,7 @@ struct DeepSeekUsageFetcherTests { let task = Task { try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(30), fetchBalanceData: { _ in @@ -533,6 +718,7 @@ struct DeepSeekUsageFetcherTests { let task = Task { try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(30), fetchBalanceData: { _ in @@ -593,6 +779,7 @@ struct DeepSeekUsageFetcherTests { let expected = Self.sampleSummary() let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( apiKey: "test-key", + platformToken: "platform-token", includeOptionalUsage: true, optionalSummaryJoinGrace: .seconds(2), fetchBalanceData: { _ in @@ -604,6 +791,68 @@ struct DeepSeekUsageFetcherTests { #expect(snapshot.totalBalance == 50.0) #expect(snapshot.usageSummary == expected) + #expect(snapshot.detailedUsageState == .available) + } + + @Test + func `API key alone reports that a web session is required`() async throws { + let summaryCalls = SummaryCallCounter() + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + platformToken: nil, + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(1), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + await summaryCalls.increment() + return Self.sampleSummary() + }) + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .webSessionRequired) + #expect(await summaryCalls.value == 0) + } + + @Test + func `platform token is separate from the balance API key`() async throws { + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "balance-api-key", + platformToken: "browser-user-token", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(1), + fetchBalanceData: { key in + #expect(key == "balance-api-key") + return Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { token in + #expect(token == "browser-user-token") + return Self.sampleSummary() + }) + + #expect(snapshot.usageSummary != nil) + #expect(snapshot.detailedUsageState == .available) + } + + @Test + func `invalid platform token preserves balance and requests sign in`() async throws { + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "balance-api-key", + platformToken: "expired-browser-token", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(1), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + throw DeepSeekUsageError.invalidPlatformToken + }) + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == nil) + #expect(snapshot.detailedUsageState == .webSessionRequired) } private static func utcDate(year: Int, month: Int, day: Int) -> Date? { diff --git a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift index 3a17f9b107..47fee0791c 100644 --- a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift +++ b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift @@ -25,7 +25,11 @@ struct MenuCardDeepSeekTests { updatedAt: now) } - private static func makeSnapshot(now: Date, usageSummary: DeepSeekUsageSummary? = nil) -> UsageSnapshot { + private static func makeSnapshot( + now: Date, + usageSummary: DeepSeekUsageSummary? = nil, + detailedUsageState: DeepSeekDetailedUsageState? = nil) -> UsageSnapshot + { DeepSeekUsageSnapshot( isAvailable: true, currency: "USD", @@ -33,6 +37,7 @@ struct MenuCardDeepSeekTests { grantedBalance: 0, toppedUpBalance: 9.32, usageSummary: usageSummary, + detailedUsageState: detailedUsageState, updatedAt: now) .toUsageSnapshot() } @@ -114,6 +119,168 @@ struct MenuCardDeepSeekTests { #expect(model.usageNotes.isEmpty) } + @Test + func `model explains unavailable optional deepseek usage`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes == ["Detailed usage unavailable."]) + } + + @Test + func `model shows balance without stale deepseek usage while refreshing`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, usageSummary: Self.sampleDeepSeekSummary(now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: true, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes.isEmpty) + let balance = try #require(model.metrics.first) + #expect(balance.title == "Balance") + #expect(balance.statusText == "$9.32 (Paid: $9.32 / Granted: $0.00)") + } + + @Test + func `model explains that detailed usage needs a platform session`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, detailedUsageState: .webSessionRequired) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes == ["Sign in to DeepSeek Platform in Chrome for detailed usage."]) + } + + @Test + func `browser only sign in remains visible when optional usage is hidden`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = DeepSeekUsageSnapshot( + hasBalance: false, + isAvailable: false, + currency: "USD", + totalBalance: 0, + grantedBalance: 0, + toppedUpBalance: 0, + detailedUsageState: .webSessionRequired, + updatedAt: now) + .toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.usageNotes == ["Sign in to DeepSeek Platform in Chrome for detailed usage."]) + } + + @Test + func `model asks for a profile when multiple deepseek sessions are valid`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, detailedUsageState: .profileSelectionRequired) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes == ["Select a DeepSeek Chrome profile in Settings."]) + } + @Test func `model shows optional deepseek usage when extras enabled`() throws { let now = Date() @@ -140,7 +307,7 @@ struct MenuCardDeepSeekTests { hidePersonalInfo: false, now: now)) - #expect(model.inlineUsageDashboard?.accessibilityLabel == "DeepSeek 30 day token usage trend") + #expect(model.inlineUsageDashboard?.accessibilityLabel == "DeepSeek this month token usage trend") #expect(model.usageNotes.contains { $0.contains("Today:") }) } } diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 0261a393d3..80adfcb04b 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -586,6 +586,41 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.deepseekToken(environment: env) == nil) } + @Test + func `projects the legacy DeepSeek Platform token and stable profile identifier`() { + let config = ProviderConfig( + id: .deepseek, + apiKey: "legacy-api-key", + cookieHeader: "browser-platform-token", + deepseekProfileID: "/profiles/Profile 2", + deepseekProfileScope: "account-id") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .deepseek, + config: config) + + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == nil) + #expect(env[DeepSeekSettingsReader.platformTokenEnvironmentKey] == "browser-platform-token") + #expect(env[DeepSeekSettingsReader.profileIDEnvironmentKey] == "chrome:Profile 2") + #expect(env[DeepSeekSettingsReader.profileScopeEnvironmentKey] == "account-id") + } + + @Test + func `normalization preserves a legacy DeepSeek browser token and canonicalizes the profile path`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .deepseek, + cookieHeader: "browser-platform-token", + deepseekProfileID: "/profiles/Profile 2", + deepseekProfileScope: " account-id "), + ]).normalized() + let deepseek = try #require(config.providerConfig(for: .deepseek)) + + #expect(deepseek.cookieHeader == "browser-platform-token") + #expect(deepseek.deepseekProfileID == "chrome:Profile 2") + #expect(deepseek.deepseekProfileScope == "account-id") + } + @Test func `applies API key override for kilo`() { let config = ProviderConfig(id: .kilo, apiKey: "kilo-token") diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 120fd77f66..66047b095e 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -333,7 +333,492 @@ struct ProviderSettingsDescriptorTests { #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) } - private func makeSettingsFixture(suite: String) throws -> ProviderSettingsFixture { + @Test + func `deepseek profile picker contains only validated profiles and persists selection`() throws { + let apiKey = "test-deepseek-api-key" + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-profiles", + environmentBase: [DeepSeekSettingsReader.apiKeyEnvironmentKey: apiKey]) + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + let context = fixture.settingsContext(provider: .deepseek) + + let picker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + #expect(picker.options.map(\.id) == ["", "chrome:Default", "chrome:Profile 2"]) + #expect(picker.binding.wrappedValue.isEmpty) + let configRevision = fixture.settings.configRevision + let backgroundWorkRevision = fixture.settings.backgroundWorkSettingsRevision + let providerConfigRevision = fixture.settings.providerConfigRevision(for: .deepseek) + let snapshot = fixture.store.snapshots[.deepseek] + + picker.binding.wrappedValue = "chrome:Profile 2" + #expect(fixture.settings.deepseekProfileID(apiKey: apiKey) == "chrome:Profile 2") + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileID == "chrome:Profile 2") + let expectedScope = try #require(DeepSeekSettingsReader.profileScope( + selectedTokenAccountID: nil, + apiKey: apiKey)) + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileScope == expectedScope) + #expect(fixture.settings.configRevision == configRevision) + #expect(fixture.settings.backgroundWorkSettingsRevision == backgroundWorkRevision) + #expect(fixture.settings.providerConfigRevision(for: .deepseek) == providerConfigRevision + 1) + #expect(fixture.store.snapshots[.deepseek]?.updatedAt == snapshot?.updatedAt) + #expect(fixture.store.snapshots[.deepseek]?.deepseekPlatformProfiles.map(\.id) == [ + "chrome:Default", + "chrome:Profile 2", + ]) + } + + @Test + func `deepseek browser only profile selection persists without an API key`() async throws { + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-browser-only-profile") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06 (Paid: $8.06 / Granted: $0.00)"), + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + + let picker = try #require(DeepSeekProviderImplementation() + .settingsPickers(context: fixture.settingsContext(provider: .deepseek)).first) + picker.binding.wrappedValue = "chrome:Profile 2" + + #expect(fixture.settings.deepseekProfileID(apiKey: nil) == "chrome:Profile 2") + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileScope != nil) + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription == "Refreshing") + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .failure(DeepSeekUsageError.networkError("offline")), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription == "Unavailable") + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription?.contains("$8.06") == false) + } + + @Test + func `deepseek profile picker stays visible while switching profiles`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-profile-switch") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + fixture.store.lastKnownResetSnapshots[.deepseek] = snapshot + fixture.store.snapshots.removeValue(forKey: .deepseek) + fixture.store.refreshingProviders.insert(.deepseek) + let context = fixture.settingsContext(provider: .deepseek) + + let picker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + #expect(picker.options.map(\.id) == ["", "chrome:Default", "chrome:Profile 2"]) + #expect(picker.binding.wrappedValue.isEmpty) + #expect(picker.dynamicSubtitle?() == "Refreshing") + #expect(!(picker.isEnabled?() ?? true)) + } + + @Test + func `deepseek browser profile cancellation does not leave refreshing behind`() async throws { + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-browser-cancelled-transition") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition(preservingBalance: false) + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome(result: .failure(CancellationError()), attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot?.primary?.resetDescription == "Unavailable") + } + + @Test + func `deepseek settings keeps balance while live snapshot is switching`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-balance-switch") + fixture.store.lastKnownResetSnapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$9.32 (Paid: $9.32 / Granted: $0.00)"), + secondary: nil, + updatedAt: Date()) + fixture.store.snapshots.removeValue(forKey: .deepseek) + fixture.store.refreshingProviders.insert(.deepseek) + + let model = ProvidersPane(settings: fixture.settings, store: fixture.store) + ._test_menuCardModel(for: .deepseek) + + let balance = try #require(model.metrics.first) + #expect(balance.title == "Balance") + #expect(balance.statusText == "$9.32 (Paid: $9.32 / Granted: $0.00)") + #expect(model.usageNotes.isEmpty) + #expect(model.inlineUsageDashboard == nil) + #expect(model.placeholder == nil) + } + + @Test + func `deepseek profile transition survives selected api token cache invalidation`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-token-transition") + fixture.settings.addTokenAccount(provider: .deepseek, label: "cv", token: "test-token") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06 (Paid: $8.06 / Granted: $0.00)"), + secondary: nil, + deepseekUsage: DeepSeekUsageSummary( + todayTokens: 100, + currentMonthTokens: 100, + todayCost: 0.1, + currentMonthCost: 0.1, + requestCount: 1, + currentMonthRequestCount: 1, + topModel: "deepseek-chat", + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date()), + deepseekDetailedUsageState: .available, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + fixture.store.snapshots[.deepseek] = snapshot + fixture.store.lastKnownResetSnapshots[.deepseek] = snapshot + let context = fixture.settingsContext(provider: .deepseek) + let picker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + + picker.binding.wrappedValue = "chrome:Profile 2" + fixture.store.refreshingProviders.insert(.deepseek) + fixture.store.reconcileSelectedTokenAccountSnapshotBeforeRefresh( + provider: .deepseek, + accounts: fixture.settings.tokenAccounts(for: .deepseek)) + + #expect(fixture.store.snapshots[.deepseek] == nil) + #expect(fixture.store.lastKnownResetSnapshots[.deepseek] == nil) + let model = ProvidersPane(settings: fixture.settings, store: fixture.store) + ._test_menuCardModel(for: .deepseek) + #expect(model.metrics.first?.statusText == "$8.06 (Paid: $8.06 / Granted: $0.00)") + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes.isEmpty) + #expect(!ProvidersPane(settings: fixture.settings, store: fixture.store) + ._test_providerSubtitle(.deepseek).contains("usage not fetched yet")) + let transitionPicker = try #require(DeepSeekProviderImplementation().settingsPickers(context: context).first) + #expect(transitionPicker.options.map(\.id) == ["chrome:Default", "chrome:Profile 2"]) + #expect(!(transitionPicker.isEnabled?() ?? true)) + + fixture.store.refreshingProviders.remove(.deepseek) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.primary != nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.deepseekUsage == nil) + + fixture.store.clearDeepSeekProfileTransition() + #expect(fixture.store.presentationSnapshot(for: .deepseek) == nil) + } + + @Test + func `deepseek selected account success clears its profile transition`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-transition-success") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + let refreshed = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$7.50"), + secondary: nil, + updatedAt: Date()) + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: refreshed, + credits: nil, + dashboard: nil, + sourceLabel: "api", + strategyID: "deepseek.api", + strategyKind: .apiToken)), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot == nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.primary?.resetDescription == "$7.50") + } + + @Test + func `deepseek timeout keeps the validated profile catalog with the refreshed balance`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-timeout-catalog") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + deepseekDetailedUsageState: .available, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + let refreshedBalance = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$7.50"), + secondary: nil, + deepseekDetailedUsageState: .unavailable, + deepseekPlatformProfiles: [], + updatedAt: Date()) + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: refreshedBalance, + credits: nil, + dashboard: nil, + sourceLabel: "api", + strategyID: "deepseek.api", + strategyKind: .apiToken)), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot == nil) + #expect(fixture.store.snapshots[.deepseek]?.primary?.resetDescription == "$7.50") + #expect(fixture.store.snapshots[.deepseek]?.deepseekPlatformProfiles.map(\.id) == [ + "chrome:Default", + "chrome:Profile 2", + ]) + let picker = try #require(DeepSeekProviderImplementation() + .settingsPickers(context: fixture.settingsContext(provider: .deepseek)).first) + #expect(picker.options.map(\.id) == ["", "chrome:Default", "chrome:Profile 2"]) + } + + @Test + func `deepseek selected account failure preserves its balance only transition`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-transition-failure") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + deepseekUsage: DeepSeekUsageSummary( + todayTokens: 100, + currentMonthTokens: 100, + todayCost: nil, + currentMonthCost: nil, + requestCount: 1, + currentMonthRequestCount: 1, + topModel: nil, + categoryBreakdown: [], + daily: [], + currency: "USD", + updatedAt: Date()), + deepseekDetailedUsageState: .available, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .failure(DeepSeekUsageError.apiError("offline")), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + + #expect(fixture.store.deepseekProfileTransitionSnapshot != nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.primary?.resetDescription == "$8.06") + #expect(fixture.store.presentationSnapshot(for: .deepseek)?.deepseekUsage == nil) + } + + @Test + func `disabling deepseek clears a failed profile transition`() async throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-disable-transition") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06"), + secondary: nil, + updatedAt: Date()) + fixture.store.beginDeepSeekProfileTransition() + await fixture.store.applySelectedOutcome( + ProviderFetchOutcome( + result: .failure(DeepSeekUsageError.apiError("offline")), + attempts: []), + provider: .deepseek, + account: nil, + fallbackSnapshot: nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek) != nil) + + fixture.store.clearDisabledProviderState(enabledProviders: []) + + #expect(fixture.store.deepseekProfileTransitionSnapshot == nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek) == nil) + } + + @Test + func `deepseek requires explicit replacement when the stored profile expires`() throws { + let apiKey = "test-deepseek-api-key" + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-deepseek-expired-selection", + environmentBase: [DeepSeekSettingsReader.apiKeyEnvironmentKey: apiKey]) + fixture.settings.setDeepSeekProfileID("chrome:Default", apiKey: apiKey) + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekDetailedUsageState: .profileSelectionRequired, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Profile 2", name: "Chrome — Work"), + ], + updatedAt: Date()) + + let picker = try #require(DeepSeekProviderImplementation() + .settingsPickers(context: fixture.settingsContext(provider: .deepseek)).first) + #expect(picker.options.map(\.id) == ["", "chrome:Profile 2"]) + #expect(picker.binding.wrappedValue.isEmpty) + } + + @Test + func `deepseek profile transition does not cross api token account selection`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-account-transition") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Personal", token: "token-1") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Work", token: "token-2") + let workAccount = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$8.06 Work"), + secondary: nil, + updatedAt: Date()) + fixture.settings.setDeepSeekProfileID("chrome:Profile 2", apiKey: workAccount.token) + fixture.store.beginDeepSeekProfileTransition() + + fixture.settings.setActiveTokenAccountIndex(0, for: .deepseek) + let personalAccount = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + #expect(personalAccount.id != workAccount.id) + fixture.store.reconcileSelectedTokenAccountSnapshotBeforeRefresh( + provider: .deepseek, + accounts: fixture.settings.tokenAccounts(for: .deepseek)) + + #expect(fixture.settings.deepseekProfileID(apiKey: personalAccount.token).isEmpty) + #expect(fixture.store.deepseekProfileTransitionSnapshot != nil) + #expect(fixture.store.presentationSnapshot(for: .deepseek) == nil) + } + + @Test + func `replacing a deepseek key in the same account clears its profile selection`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-replaced-key") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Account", token: "old-key") + let account = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + fixture.settings.setDeepSeekProfileID("chrome:Default", apiKey: account.token) + #expect(fixture.settings.deepseekProfileID(apiKey: account.token) == "chrome:Default") + + fixture.settings.updateTokenAccount( + provider: .deepseek, + accountID: account.id, + token: "new-key") + + #expect(fixture.settings.deepseekProfileID(apiKey: "new-key").isEmpty) + #expect(fixture.settings.providerConfig(for: .deepseek)?.sanitizedDeepSeekProfileID == "chrome:Default") + } + + @Test + func `deepseek detailed usage runs only for the active api token account`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-account-usage") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Personal", token: "token-1") + fixture.settings.addTokenAccount(provider: .deepseek, label: "Work", token: "token-2") + let accounts = fixture.settings.tokenAccounts(for: .deepseek) + let active = try #require(fixture.settings.selectedTokenAccount(for: .deepseek)) + let inactive = try #require(accounts.first(where: { $0.id != active.id })) + + #expect(ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: .deepseek, + settings: fixture.settings, + override: TokenAccountOverride(provider: .deepseek, account: active))) + #expect(!ProviderTokenAccountSelection.shouldIncludeOptionalUsage( + provider: .deepseek, + settings: fixture.settings, + override: TokenAccountOverride(provider: .deepseek, account: inactive))) + } + + @Test + func `provider settings labels an empty transition as refreshing`() { + #expect(ProviderMetricsInlineView.placeholderText( + isEnabled: true, + isRefreshing: true, + modelPlaceholder: nil) == "Refreshing") + #expect(ProviderMetricsInlineView.placeholderText( + isEnabled: true, + isRefreshing: false, + modelPlaceholder: nil) == "No usage yet") + } + + @Test + func `deepseek hides profile picker when only one validated profile remains`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepseek-single-profile") + fixture.store.snapshots[.deepseek] = UsageSnapshot( + primary: nil, + secondary: nil, + deepseekPlatformProfiles: [ + DeepSeekPlatformProfile(id: "chrome:Default", name: "Chrome — Personal"), + ], + updatedAt: Date()) + let context = fixture.settingsContext(provider: .deepseek) + + #expect(DeepSeekProviderImplementation().settingsPickers(context: context).isEmpty) + } + + private func makeSettingsFixture( + suite: String, + environmentBase: [String: String] = [:]) throws -> ProviderSettingsFixture + { let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) let settings = SettingsStore( @@ -344,7 +829,8 @@ struct ProviderSettingsDescriptorTests { let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + settings: settings, + environmentBase: environmentBase) return ProviderSettingsFixture(settings: settings, store: store) } diff --git a/docs/deepseek.md b/docs/deepseek.md index 1db239ed36..bb84ac6390 100644 --- a/docs/deepseek.md +++ b/docs/deepseek.md @@ -1,30 +1,75 @@ --- -summary: "DeepSeek provider data sources: API key + balance endpoint." +summary: "DeepSeek provider data sources: API key, balance, and optional detailed usage endpoints." read_when: - Adding or tweaking DeepSeek balance parsing + - Adding or tweaking DeepSeek detailed usage parsing - Updating API key handling - Documenting new provider behavior --- # DeepSeek provider -DeepSeek is API-only. Balance is reported by `GET https://api.deepseek.com/user/balance`, -so CodexBar only needs a valid API key to show your remaining credit balance. +CodexBar can use either a DeepSeek API key or a signed-in DeepSeek Platform session for the remaining credit balance. +Detailed cost and token usage comes from the Platform session; an API key cannot authenticate the private dashboard +endpoints. ## Data sources -1. **API key** supplied via `DEEPSEEK_API_KEY` / `DEEPSEEK_KEY`, or selected from DeepSeek token accounts in `~/.codexbar/config.json`. -2. **Balance endpoint** +1. **Optional API key** supplied via `DEEPSEEK_API_KEY` / `DEEPSEEK_KEY`, or selected from DeepSeek token accounts in `~/.codexbar/config.json`. +2. **API-key balance endpoint** - `GET https://api.deepseek.com/user/balance` - Request headers: `Authorization: Bearer `, `Accept: application/json` - Response contains `is_available`, and a `balance_infos` array with per-currency entries (`total_balance`, `granted_balance`, `topped_up_balance`). +3. **Platform-session balance endpoint** + - `GET https://platform.deepseek.com/api/v0/users/get_user_summary` + - Request headers: `Authorization: Bearer `, `Accept: application/json` + - Used as the balance source when no API key is configured. +4. **Optional detailed usage endpoints** + - `GET https://platform.deepseek.com/api/v0/usage/amount?month=&year=` + - `GET https://platform.deepseek.com/api/v0/usage/cost?month=&year=` + - Request headers: `Authorization: Bearer `, `Accept: application/json` + - These are private dashboard endpoints rather than documented public API endpoints and may change without notice. + +## Platform session + +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. + +The legacy config value remains a compatibility fallback so upgrading cannot silently erase a working session. New +automatic imports are never written back to config. + +CodexBar checks every Chrome profile containing a parseable `userToken` against DeepSeek. Rejected or expired +sessions are omitted. Settings shows a **Chrome profile** picker containing only valid sessions. With no API key, one +valid session is selected automatically; with multiple valid sessions, CodexBar reuses the saved choice or asks once. +When an API key supplies the balance, a new or changed API credential requires an explicit session selection before +website usage is combined with it. CodexBar persists a stable browser/profile identifier, not an absolute home-directory path, and +keeps automatically imported tokens in memory only. The choice is scoped to a non-reversible fingerprint of the +active API credential and saved-account slot, so replacing a key or switching accounts cannot silently reuse an old +browser session. Validation results are cached briefly so normal +refreshes do not probe every profile, and a temporary network failure does not erase a previously validated profile. +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 +`40003` are treated as expired sessions. ## Usage details - The menu card shows total balance with the paid vs. granted breakdown: e.g. `$50.00 (Paid: $40.00 / Granted: $10.00)`. - The API separates granted balance from topped-up balance; CodexBar labels these as granted vs. paid credit. +- With optional extra usage enabled, the menu shows today's and the current month's cost and tokens, + request counts, cache/input/output categories, the top model, and a current-month token chart. +- The amount and cost requests run concurrently. After balance arrives, CodexBar waits up to five seconds for + automatic Chrome resolution and detailed usage. The deadline remains bounded even if a local Chrome read does not + respond to cancellation. If the optional work fails or times out, the balance and previously validated profile list + remain available while the menu reports that detailed usage is unavailable. +- With multiple configured API keys, browser-derived detailed usage is shown only with the active API-key account; + other account cards remain balance-only so website usage is never duplicated across accounts. - When multiple currencies are present, USD is shown preferentially. - If total balance is zero, CodexBar shows an add-credits message. If balance is nonzero but `is_available` is false, it shows "Balance unavailable for API calls". - There is no session or weekly window — DeepSeek does not expose per-window quota via API. @@ -34,6 +79,7 @@ so CodexBar only needs a valid API key to show your remaining credit balance. - `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift` (descriptor + fetch strategy) - `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift` (HTTP client + JSON parser) +- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift` (Chrome Platform session import) - `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift` (env var resolution) - `Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift` (provider activation and token-account visibility) - `Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift` (DeepSeek token-account injection)