@sriinnu/runic 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.swiftformat +50 -0
- package/.swiftlint.yml +160 -0
- package/CHANGELOG.md +307 -0
- package/CONTRIBUTING.md +456 -0
- package/Icon.icns +0 -0
- package/Icon.icon/Assets/runic.png +0 -0
- package/Icon.icon/icon.json +36 -0
- package/LICENSE +21 -0
- package/Package.resolved +51 -0
- package/Package.swift +136 -0
- package/Packages/Helix/LICENSE +21 -0
- package/Packages/Helix/Package.swift +29 -0
- package/Packages/Helix/README.md +976 -0
- package/Packages/Helix/Sources/Helix/Binding.swift +111 -0
- package/Packages/Helix/Sources/Helix/CommandParser.swift +160 -0
- package/Packages/Helix/Sources/Helix/CommandSignature.swift +117 -0
- package/Packages/Helix/Sources/Helix/ExpressibleFromArgument.swift +72 -0
- package/Packages/Helix/Sources/Helix/HelpGenerator.swift +175 -0
- package/Packages/Helix/Sources/Helix/NameSpecification.swift +98 -0
- package/Packages/Helix/Sources/Helix/ParsableCommand.swift +204 -0
- package/Packages/Helix/Sources/Helix/Platform/DarwinPlatformContext.swift +44 -0
- package/Packages/Helix/Sources/Helix/Platform/LinuxPlatformContext.swift +34 -0
- package/Packages/Helix/Sources/Helix/Platform/PlatformContext+Default.swift +91 -0
- package/Packages/Helix/Sources/Helix/Platform/PlatformContext.swift +94 -0
- package/Packages/Helix/Sources/Helix/Platform/PlatformPath.swift +167 -0
- package/Packages/Helix/Sources/Helix/Platform/Stdio.swift +99 -0
- package/Packages/Helix/Sources/Helix/Platform/WebPlatformContext.swift +146 -0
- package/Packages/Helix/Sources/Helix/Platform/WindowsPlatformContext.swift +97 -0
- package/Packages/Helix/Sources/Helix/Program.swift +204 -0
- package/Packages/Helix/Sources/Helix/PropertyWrappers.swift +367 -0
- package/Packages/Silo/LICENSE +21 -0
- package/Packages/Silo/Package.swift +34 -0
- package/Packages/Silo/README.md +466 -0
- package/Packages/Silo/Sources/Silo/BinaryCookiesReader.swift +169 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieAnalytics.swift +365 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieBackends.swift +594 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieClient.swift +326 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieExport.swift +154 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieImport.swift +119 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieMockStore.swift +121 -0
- package/Packages/Silo/Sources/Silo/BrowserCookieModels.swift +391 -0
- package/Packages/Silo/Sources/Silo/ChromiumCookieReader.swift +240 -0
- package/Packages/Silo/Sources/Silo/CryptoSupport.swift +86 -0
- package/Packages/Silo/Sources/Silo/FirefoxCookieReader.swift +172 -0
- package/Packages/Silo/Sources/Silo/LinuxChromiumCookieReader.swift +172 -0
- package/Packages/Silo/Sources/Silo/LinuxSecretService.swift +105 -0
- package/Packages/Silo/Sources/Silo/MacOSChromiumCookieReader.swift +191 -0
- package/Packages/Silo/Sources/Silo/SQLiteSnapshot.swift +41 -0
- package/Packages/Silo/Sources/Silo/WindowsChromiumCookieReader.swift +110 -0
- package/README.md +109 -0
- package/RuniciOS/App/RuniciOSApp.swift +105 -0
- package/RuniciOS/Core/iOSUsageStore.swift +192 -0
- package/RuniciOS/Views/ProviderDetailView.swift +312 -0
- package/RuniciOS/Views/ProviderListView.swift +228 -0
- package/RuniciOS/Widgets/LargeWidget.swift +367 -0
- package/RuniciOS/Widgets/LiveActivity.swift +342 -0
- package/RuniciOS/Widgets/LockScreenWidget.swift +273 -0
- package/RuniciOS/Widgets/MediumWidget.swift +370 -0
- package/RuniciOS/Widgets/RunicWidget.swift +274 -0
- package/RuniciOS/Widgets/SmallWidget.swift +281 -0
- package/RuniciOS/Widgets/WidgetModels.swift +285 -0
- package/RuniciOS/Widgets/WidgetTimeline.swift +270 -0
- package/Scripts/CREATION-SUMMARY.md +367 -0
- package/Scripts/DIRECTORY-STRUCTURE.md +358 -0
- package/Scripts/GETTING-STARTED-CHECKLIST.md +364 -0
- package/Scripts/INDEX.md +365 -0
- package/Scripts/QUICK-REFERENCE.md +164 -0
- package/Scripts/README-SCRIPTS.md +926 -0
- package/Scripts/build-android.sh +526 -0
- package/Scripts/build-ios.sh +529 -0
- package/Scripts/build-macos.sh +603 -0
- package/Scripts/build-windows.sh +504 -0
- package/Scripts/build_icon.sh +49 -0
- package/Scripts/changelog-to-html.sh +89 -0
- package/Scripts/check-release-assets.sh +10 -0
- package/Scripts/ci-config.yml +474 -0
- package/Scripts/compile_and_run.sh +219 -0
- package/Scripts/compile_and_run_adhoc.sh +4 -0
- package/Scripts/docs-list.mjs +119 -0
- package/Scripts/make_appcast.sh +58 -0
- package/Scripts/package_app.sh +409 -0
- package/Scripts/release-lib.sh +214 -0
- package/Scripts/release.sh +76 -0
- package/Scripts/setup-all.sh +387 -0
- package/Scripts/setup-ios.sh +404 -0
- package/Scripts/setup-react-native.sh +523 -0
- package/Scripts/sign-and-notarize.sh +122 -0
- package/Scripts/test_live_update.sh +38 -0
- package/Scripts/validate_changelog.sh +23 -0
- package/Scripts/verify_appcast.sh +92 -0
- package/Sources/Runic/About.swift +93 -0
- package/Sources/Runic/AppNotifications.swift +115 -0
- package/Sources/Runic/Controllers/StatusItemController+Actions.swift +657 -0
- package/Sources/Runic/Controllers/StatusItemController+Animation.swift +518 -0
- package/Sources/Runic/Controllers/StatusItemController+ChartSubmenus.swift +810 -0
- package/Sources/Runic/Controllers/StatusItemController+CustomProviders.swift +87 -0
- package/Sources/Runic/Controllers/StatusItemController+Menu.swift +1379 -0
- package/Sources/Runic/Controllers/StatusItemController+MenuCards.swift +337 -0
- package/Sources/Runic/Controllers/StatusItemController+MenuHelpers.swift +395 -0
- package/Sources/Runic/Controllers/StatusItemController.swift +360 -0
- package/Sources/Runic/Core/BudgetNotificationManager.swift +80 -0
- package/Sources/Runic/Core/Login/ClaudeLoginRunner.swift +103 -0
- package/Sources/Runic/Core/Login/CodexLoginRunner.swift +154 -0
- package/Sources/Runic/Core/Login/CursorLoginRunner.swift +206 -0
- package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +95 -0
- package/Sources/Runic/Core/Rendering/IconRenderer.swift +1216 -0
- package/Sources/Runic/Core/Rendering/IconView.swift +120 -0
- package/Sources/Runic/Core/Rendering/ProviderBrandIcon.swift +71 -0
- package/Sources/Runic/Core/RunicAnimation.swift +53 -0
- package/Sources/Runic/Core/RunicColors.swift +73 -0
- package/Sources/Runic/Core/RunicCornerRadius.swift +15 -0
- package/Sources/Runic/Core/RunicSpacing.swift +68 -0
- package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/CohereTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/CustomProviderStore.swift +158 -0
- package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/GroqTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/KimiTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +143 -0
- package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +143 -0
- package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/MistralTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/ProviderToggleStore.swift +30 -0
- package/Sources/Runic/Core/Stores/QwenTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/SettingsStore.swift +1702 -0
- package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/UsageStore+Accessors.swift +20 -0
- package/Sources/Runic/Core/Stores/UsageStore+Status.swift +189 -0
- package/Sources/Runic/Core/Stores/UsageStore+TokenCost.swift +33 -0
- package/Sources/Runic/Core/Stores/UsageStore+WidgetSnapshot.swift +67 -0
- package/Sources/Runic/Core/Stores/UsageStore.swift +3564 -0
- package/Sources/Runic/Core/Stores/XAITokenStore.swift +143 -0
- package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +143 -0
- package/Sources/Runic/Core/UsageExporter.swift +193 -0
- package/Sources/Runic/Date+RelativeDescription.swift +21 -0
- package/Sources/Runic/HiddenWindowView.swift +32 -0
- package/Sources/Runic/InstallOrigin.swift +9 -0
- package/Sources/Runic/KeyboardShortcuts+Names.swift +6 -0
- package/Sources/Runic/MouseLocationReader.swift +65 -0
- package/Sources/Runic/Notifications+Runic.swift +6 -0
- package/Sources/Runic/OpenAICreditsPurchaseWindowController.swift +503 -0
- package/Sources/Runic/ProviderRegistry.swift +100 -0
- package/Sources/Runic/ProviderSwitcherButtons.swift +267 -0
- package/Sources/Runic/Providers/Antigravity/AntigravityLoginFlow.swift +11 -0
- package/Sources/Runic/Providers/Antigravity/AntigravityProviderImplementation.swift +14 -0
- package/Sources/Runic/Providers/Auggie/AuggieProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Azure/AzureProviderImplementation.swift +52 -0
- package/Sources/Runic/Providers/Bedrock/BedrockProviderImplementation.swift +42 -0
- package/Sources/Runic/Providers/Cerebras/CerebrasProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Claude/ClaudeLoginFlow.swift +26 -0
- package/Sources/Runic/Providers/Claude/ClaudeProviderImplementation.swift +14 -0
- package/Sources/Runic/Providers/Codex/CodexLoginFlow.swift +18 -0
- package/Sources/Runic/Providers/Codex/CodexProviderImplementation.swift +15 -0
- package/Sources/Runic/Providers/Cohere/CohereProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Copilot/CopilotLoginFlow.swift +193 -0
- package/Sources/Runic/Providers/Copilot/CopilotProviderImplementation.swift +110 -0
- package/Sources/Runic/Providers/Cursor/CursorLoginFlow.swift +28 -0
- package/Sources/Runic/Providers/Cursor/CursorProviderImplementation.swift +15 -0
- package/Sources/Runic/Providers/DeepSeek/DeepSeekProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Factory/FactoryLoginFlow.swift +12 -0
- package/Sources/Runic/Providers/Factory/FactoryProviderImplementation.swift +15 -0
- package/Sources/Runic/Providers/Fireworks/FireworksProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Gemini/GeminiLoginFlow.swift +20 -0
- package/Sources/Runic/Providers/Gemini/GeminiProviderImplementation.swift +15 -0
- package/Sources/Runic/Providers/Groq/GroqProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Kimi/KimiProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/MiniMax/MiniMaxProviderImplementation.swift +32 -0
- package/Sources/Runic/Providers/Mistral/MistralProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/OpenRouter/OpenRouterProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Perplexity/PerplexityProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/SambaNova/SambaNovaProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Shared/ProviderCatalog.swift +15 -0
- package/Sources/Runic/Providers/Shared/ProviderImplementation.swift +48 -0
- package/Sources/Runic/Providers/Shared/ProviderImplementationRegistry.swift +66 -0
- package/Sources/Runic/Providers/Shared/ProviderLoginFlow.swift +12 -0
- package/Sources/Runic/Providers/Shared/ProviderSettingsDescriptors.swift +99 -0
- package/Sources/Runic/Providers/Shared/SystemSettingsLinks.swift +18 -0
- package/Sources/Runic/Providers/Together/TogetherProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/XAI/XAIProviderImplementation.swift +24 -0
- package/Sources/Runic/Providers/Zai/ZaiProviderImplementation.swift +24 -0
- package/Sources/Runic/Resources/ProviderIcon-antigravity.svg +2 -0
- package/Sources/Runic/Resources/ProviderIcon-auggie.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-azure.svg +5 -0
- package/Sources/Runic/Resources/ProviderIcon-bedrock.svg +7 -0
- package/Sources/Runic/Resources/ProviderIcon-cerebras.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-claude.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-codex.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-cohere.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-copilot.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-cursor.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-deepseek.svg +4 -0
- package/Sources/Runic/Resources/ProviderIcon-factory.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-fireworks.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-gemini.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-groq.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-kimi.svg +4 -0
- package/Sources/Runic/Resources/ProviderIcon-minimax.svg +1 -0
- package/Sources/Runic/Resources/ProviderIcon-mistral.svg +5 -0
- package/Sources/Runic/Resources/ProviderIcon-openrouter.svg +7 -0
- package/Sources/Runic/Resources/ProviderIcon-perplexity.svg +4 -0
- package/Sources/Runic/Resources/ProviderIcon-qwen.svg +9 -0
- package/Sources/Runic/Resources/ProviderIcon-sambanova.svg +4 -0
- package/Sources/Runic/Resources/ProviderIcon-together.svg +5 -0
- package/Sources/Runic/Resources/ProviderIcon-vertexai.svg +17 -0
- package/Sources/Runic/Resources/ProviderIcon-xai.svg +3 -0
- package/Sources/Runic/Resources/ProviderIcon-zai.svg +5 -0
- package/Sources/Runic/Resources/RunicMenubarIcon.svg +7 -0
- package/Sources/Runic/RunicApp.swift +357 -0
- package/Sources/Runic/SessionQuotaNotifications.swift +58 -0
- package/Sources/Runic/UsagePaceText.swift +52 -0
- package/Sources/Runic/UsageProgressBar.swift +224 -0
- package/Sources/Runic/Utilities/DisplayLink.swift +96 -0
- package/Sources/Runic/Utilities/LoadingPattern.swift +60 -0
- package/Sources/Runic/Utilities/PerformanceConstants.swift +85 -0
- package/Sources/Runic/Views/Menu/AlertsMenuView.swift +256 -0
- package/Sources/Runic/Views/Menu/ChartPanelStyle.swift +22 -0
- package/Sources/Runic/Views/Menu/CostComparativeChartMenuView.swift +275 -0
- package/Sources/Runic/Views/Menu/CostHistoryChartMenuView.swift +377 -0
- package/Sources/Runic/Views/Menu/CreditsHistoryChartMenuView.swift +312 -0
- package/Sources/Runic/Views/Menu/EfficiencyMetricsMenuView.swift +312 -0
- package/Sources/Runic/Views/Menu/HeroStatCardsMenuView.swift +228 -0
- package/Sources/Runic/Views/Menu/HourlyActivityChartMenuView.swift +228 -0
- package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +162 -0
- package/Sources/Runic/Views/Menu/MenuCardEntranceModifier.swift +64 -0
- package/Sources/Runic/Views/Menu/MenuCardSidebarView.swift +268 -0
- package/Sources/Runic/Views/Menu/MenuCardSkeletonView.swift +104 -0
- package/Sources/Runic/Views/Menu/MenuCardView.swift +1900 -0
- package/Sources/Runic/Views/Menu/MenuContent.swift +150 -0
- package/Sources/Runic/Views/Menu/MenuDescriptor.swift +369 -0
- package/Sources/Runic/Views/Menu/MenuHighlightStyle.swift +42 -0
- package/Sources/Runic/Views/Menu/ModelBreakdownMenuView.swift +235 -0
- package/Sources/Runic/Views/Menu/OverviewMenuView.swift +141 -0
- package/Sources/Runic/Views/Menu/ProjectBreakdownMenuView.swift +209 -0
- package/Sources/Runic/Views/Menu/ProjectBudgetMenuView.swift +220 -0
- package/Sources/Runic/Views/Menu/ProviderTabBarView.swift +88 -0
- package/Sources/Runic/Views/Menu/SubscriptionUtilizationChartMenuView.swift +201 -0
- package/Sources/Runic/Views/Menu/UsageBreakdownChartMenuView.swift +398 -0
- package/Sources/Runic/Views/Menu/UsageHeatmapMenuView.swift +232 -0
- package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +417 -0
- package/Sources/Runic/Views/Menu/UsageWindowComparisonChartMenuView.swift +190 -0
- package/Sources/Runic/Views/Menu/WeeklyActivityChartMenuView.swift +132 -0
- package/Sources/Runic/Views/Performance/PerformanceDashboardView.swift +618 -0
- package/Sources/Runic/Views/Preferences/LiquidComponents.swift +305 -0
- package/Sources/Runic/Views/Preferences/PreferencesAboutPane.swift +271 -0
- package/Sources/Runic/Views/Preferences/PreferencesAdvancedPane.swift +268 -0
- package/Sources/Runic/Views/Preferences/PreferencesAlertsPane.swift +422 -0
- package/Sources/Runic/Views/Preferences/PreferencesAnalyticsPane.swift +641 -0
- package/Sources/Runic/Views/Preferences/PreferencesBudgetsPane.swift +344 -0
- package/Sources/Runic/Views/Preferences/PreferencesComponents.swift +230 -0
- package/Sources/Runic/Views/Preferences/PreferencesCustomProvidersPane.swift +322 -0
- package/Sources/Runic/Views/Preferences/PreferencesDebugPane.swift +469 -0
- package/Sources/Runic/Views/Preferences/PreferencesGeneralPane.swift +231 -0
- package/Sources/Runic/Views/Preferences/PreferencesHelpPane.swift +50 -0
- package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +497 -0
- package/Sources/Runic/Views/Preferences/PreferencesPerformancePane.swift +288 -0
- package/Sources/Runic/Views/Preferences/PreferencesProvidersPane.swift +3435 -0
- package/Sources/Runic/Views/Preferences/PreferencesSelection.swift +25 -0
- package/Sources/Runic/Views/Preferences/PreferencesSyncPane.swift +59 -0
- package/Sources/Runic/Views/Preferences/PreferencesView.swift +206 -0
- package/Sources/Runic/Views/Rating/QualityRatingView.swift +238 -0
- package/Sources/Runic/Views/Team/TeamDashboardView.swift +371 -0
- package/Sources/Runic/Views/Team/TeamInviteSheet.swift +175 -0
- package/Sources/Runic/Views/Team/TeamManagementView.swift +761 -0
- package/Sources/Runic/Views/Team/TeamMemberRow.swift +181 -0
- package/Sources/RunicCLI/CLIEntry.swift +942 -0
- package/Sources/RunicCLI/CLIRenderer.swift +87 -0
- package/Sources/RunicCLI/Commands/EnhancedUsageCommand.swift +588 -0
- package/Sources/RunicCLI/Commands/ModelsCommand.swift +362 -0
- package/Sources/RunicCLI/Commands/ProjectsCommand.swift +439 -0
- package/Sources/RunicCLI/Commands/ResetCommand.swift +429 -0
- package/Sources/RunicCLI/Commands/UsageLedgerSourceFactory.swift +168 -0
- package/Sources/RunicClaudeWatchdog/main.swift +122 -0
- package/Sources/RunicClaudeWebProbe/main.swift +62 -0
- package/Sources/RunicCore/AlertRuleStore.swift +187 -0
- package/Sources/RunicCore/BrowserCookieImportOrder.swift +25 -0
- package/Sources/RunicCore/CopilotUsageModels.swift +84 -0
- package/Sources/RunicCore/CostUsageFetcher.swift +119 -0
- package/Sources/RunicCore/CostUsageModels.swift +479 -0
- package/Sources/RunicCore/CreditsModels.swift +50 -0
- package/Sources/RunicCore/CustomProviders/CustomProviderModels.swift +269 -0
- package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +622 -0
- package/Sources/RunicCore/CustomProviders/ProviderTemplates.swift +500 -0
- package/Sources/RunicCore/Host/PTY/TTYCommandRunner.swift +679 -0
- package/Sources/RunicCore/Host/Process/SubprocessRunner.swift +135 -0
- package/Sources/RunicCore/Integrations/GitHubIntegration.swift +173 -0
- package/Sources/RunicCore/Logging/JSONStderrLogHandler.swift +79 -0
- package/Sources/RunicCore/Logging/OSLogLogHandler.swift +86 -0
- package/Sources/RunicCore/Logging/RunicLog.swift +123 -0
- package/Sources/RunicCore/Models/EnhancedUsageModels.swift +371 -0
- package/Sources/RunicCore/OpenAIDashboardModels.swift +210 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +676 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardFetcher.swift +492 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +51 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardParser.swift +483 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift +558 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +204 -0
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift +78 -0
- package/Sources/RunicCore/PathEnvironment.swift +482 -0
- package/Sources/RunicCore/Performance/PerformanceModels.swift +371 -0
- package/Sources/RunicCore/Performance/PerformanceStorageImpl.swift +409 -0
- package/Sources/RunicCore/Policy/UsagePolicyEngine.swift +178 -0
- package/Sources/RunicCore/ProjectBudgetStore.swift +99 -0
- package/Sources/RunicCore/ProviderCostSnapshot.swift +29 -0
- package/Sources/RunicCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +66 -0
- package/Sources/RunicCore/Providers/Antigravity/AntigravityStatusProbe.swift +685 -0
- package/Sources/RunicCore/Providers/Auggie/AuggieProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/Auggie/AuggieUsageFetcher.swift +142 -0
- package/Sources/RunicCore/Providers/Azure/AzureProviderDescriptor.swift +141 -0
- package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +186 -0
- package/Sources/RunicCore/Providers/Bedrock/BedrockProviderDescriptor.swift +126 -0
- package/Sources/RunicCore/Providers/Bedrock/BedrockUsageFetcher.swift +168 -0
- package/Sources/RunicCore/Providers/Cerebras/CerebrasProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/Cerebras/CerebrasUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeCLISession.swift +332 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +180 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +137 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeProviderDescriptor.swift +207 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeStatusProbe.swift +654 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeUsageDataSource.swift +28 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeUsageFetcher.swift +480 -0
- package/Sources/RunicCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +780 -0
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +146 -0
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +269 -0
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift +100 -0
- package/Sources/RunicCore/Providers/Codex/CodexProviderDescriptor.swift +243 -0
- package/Sources/RunicCore/Providers/Codex/CodexStatusProbe.swift +125 -0
- package/Sources/RunicCore/Providers/Codex/CodexUsageDataSource.swift +24 -0
- package/Sources/RunicCore/Providers/Codex/CodexWebDashboardStrategy.swift +181 -0
- package/Sources/RunicCore/Providers/Cohere/CohereProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/Cohere/CohereUsageFetcher.swift +85 -0
- package/Sources/RunicCore/Providers/Copilot/CopilotDeviceFlow.swift +123 -0
- package/Sources/RunicCore/Providers/Copilot/CopilotGitHubCLITokenReader.swift +114 -0
- package/Sources/RunicCore/Providers/Copilot/CopilotProviderDescriptor.swift +100 -0
- package/Sources/RunicCore/Providers/Copilot/CopilotUsageFetcher.swift +291 -0
- package/Sources/RunicCore/Providers/Copilot/CopilotVSCodeTokenReader.swift +386 -0
- package/Sources/RunicCore/Providers/Cursor/CursorProviderDescriptor.swift +65 -0
- package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +594 -0
- package/Sources/RunicCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +85 -0
- package/Sources/RunicCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +108 -0
- package/Sources/RunicCore/Providers/Factory/FactoryLocalStorageImporter.swift +398 -0
- package/Sources/RunicCore/Providers/Factory/FactoryProviderDescriptor.swift +65 -0
- package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +1275 -0
- package/Sources/RunicCore/Providers/Fireworks/FireworksProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/Fireworks/FireworksUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/Gemini/GeminiProviderDescriptor.swift +65 -0
- package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +835 -0
- package/Sources/RunicCore/Providers/Groq/GroqProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/Groq/GroqUsageFetcher.swift +71 -0
- package/Sources/RunicCore/Providers/Kimi/KimiProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/Kimi/KimiUsageFetcher.swift +77 -0
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +92 -0
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxSettingsReader.swift +54 -0
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +171 -0
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxWebUsageFetcher.swift +911 -0
- package/Sources/RunicCore/Providers/Mistral/MistralProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/Mistral/MistralUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +87 -0
- package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +207 -0
- package/Sources/RunicCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/Perplexity/PerplexityUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/ProviderBranding.swift +25 -0
- package/Sources/RunicCore/Providers/ProviderCLIConfig.swift +17 -0
- package/Sources/RunicCore/Providers/ProviderDescriptor.swift +129 -0
- package/Sources/RunicCore/Providers/ProviderFetchPlan.swift +263 -0
- package/Sources/RunicCore/Providers/ProviderSettingsSnapshot.swift +98 -0
- package/Sources/RunicCore/Providers/ProviderTokenResolver.swift +501 -0
- package/Sources/RunicCore/Providers/ProviderVersionDetector.swift +68 -0
- package/Sources/RunicCore/Providers/Providers.swift +184 -0
- package/Sources/RunicCore/Providers/Qwen/QwenProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/Qwen/QwenSettingsReader.swift +30 -0
- package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +317 -0
- package/Sources/RunicCore/Providers/SambaNova/SambaNovaProviderDescriptor.swift +84 -0
- package/Sources/RunicCore/Providers/SambaNova/SambaNovaUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/Together/TogetherProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/Together/TogetherUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +123 -0
- package/Sources/RunicCore/Providers/VertexAI/VertexAISettingsReader.swift +48 -0
- package/Sources/RunicCore/Providers/VertexAI/VertexAIUsageFetcher.swift +144 -0
- package/Sources/RunicCore/Providers/XAI/XAIProviderDescriptor.swift +83 -0
- package/Sources/RunicCore/Providers/XAI/XAIUsageFetcher.swift +75 -0
- package/Sources/RunicCore/Providers/Zai/ZaiProviderDescriptor.swift +72 -0
- package/Sources/RunicCore/Providers/Zai/ZaiSettingsReader.swift +41 -0
- package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +724 -0
- package/Sources/RunicCore/Sync/BackgroundSyncManager.swift +385 -0
- package/Sources/RunicCore/Sync/SyncConflictResolver.swift +310 -0
- package/Sources/RunicCore/Sync/SyncProtocol.swift +288 -0
- package/Sources/RunicCore/Sync/SyncRecord.swift +405 -0
- package/Sources/RunicCore/Sync/SyncUsageExample.swift +294 -0
- package/Sources/RunicCore/Sync/TeamSyncRecords.swift +775 -0
- package/Sources/RunicCore/Sync/iCloudSyncEngine.swift +333 -0
- package/Sources/RunicCore/Team/TeamModels.swift +583 -0
- package/Sources/RunicCore/Team/TeamShowbackExporter.swift +495 -0
- package/Sources/RunicCore/Team/TeamStore.swift +792 -0
- package/Sources/RunicCore/TextParsing.swift +55 -0
- package/Sources/RunicCore/UsageFetcher.swift +694 -0
- package/Sources/RunicCore/UsageFormatter.swift +410 -0
- package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +329 -0
- package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +448 -0
- package/Sources/RunicCore/UsageLedger/OTelGenAILedgerAdapter.swift +550 -0
- package/Sources/RunicCore/UsageLedger/UsageLedger.swift +29 -0
- package/Sources/RunicCore/UsageLedger/UsageLedgerAggregator.swift +660 -0
- package/Sources/RunicCore/UsageLedger/UsageLedgerInsightsAdvisor.swift +209 -0
- package/Sources/RunicCore/UsageLedger/UsageLedgerModels.swift +417 -0
- package/Sources/RunicCore/UsageLedger/UsageLedgerProjectIdentity.swift +245 -0
- package/Sources/RunicCore/UsagePace.swift +81 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageCache.swift +73 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageJsonl.swift +74 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsagePricing.swift +208 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +455 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +132 -0
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner.swift +589 -0
- package/Sources/RunicCore/WidgetSnapshot.swift +183 -0
- package/Sources/RunicMacroSupport/ProviderRegistrationMacros.swift +14 -0
- package/Sources/RunicMacros/ProviderRegistrationMacros.swift +216 -0
- package/Sources/RunicWidget/RunicWidgetBundle.swift +79 -0
- package/Sources/RunicWidget/RunicWidgetProvider.swift +283 -0
- package/Sources/RunicWidget/RunicWidgetViews.swift +680 -0
- package/Tests/RunicTests/AntigravityStatusProbeTests.swift +50 -0
- package/Tests/RunicTests/AppDelegateTests.swift +43 -0
- package/Tests/RunicTests/BrowserCookieOrderLabelTests.swift +22 -0
- package/Tests/RunicTests/CLIArgumentParsingTests.swift +70 -0
- package/Tests/RunicTests/CLICostTests.swift +65 -0
- package/Tests/RunicTests/CLIProviderSelectionTests.swift +22 -0
- package/Tests/RunicTests/CLISnapshotTests.swift +72 -0
- package/Tests/RunicTests/CLIWebFallbackTests.swift +58 -0
- package/Tests/RunicTests/ClaudeOAuthTests.swift +164 -0
- package/Tests/RunicTests/ClaudeResilienceTests.swift +30 -0
- package/Tests/RunicTests/ClaudeUsageTests.swift +370 -0
- package/Tests/RunicTests/CodexOAuthTests.swift +122 -0
- package/Tests/RunicTests/CodexUsageLogSourceTests.swift +82 -0
- package/Tests/RunicTests/CopilotTokenResolverTests.swift +75 -0
- package/Tests/RunicTests/CopilotVSCodeTokenReaderTests.swift +75 -0
- package/Tests/RunicTests/CostUsageDecodingTests.swift +394 -0
- package/Tests/RunicTests/CostUsagePricingTests.swift +50 -0
- package/Tests/RunicTests/CostUsageScannerTests.swift +700 -0
- package/Tests/RunicTests/CursorStatusProbeTests.swift +323 -0
- package/Tests/RunicTests/FactoryStatusProbeTests.swift +69 -0
- package/Tests/RunicTests/GeminiAPITestHelpers.swift +94 -0
- package/Tests/RunicTests/GeminiLoginAlertTests.swift +28 -0
- package/Tests/RunicTests/GeminiStatusProbeAPITests.swift +283 -0
- package/Tests/RunicTests/GeminiStatusProbePlanTests.swift +279 -0
- package/Tests/RunicTests/GeminiStatusProbeTests.swift +233 -0
- package/Tests/RunicTests/GeminiTestEnvironment.swift +87 -0
- package/Tests/RunicTests/GoogleWorkspaceStatusTests.swift +73 -0
- package/Tests/RunicTests/InstallOriginTests.swift +18 -0
- package/Tests/RunicTests/KeyboardShortcutsBundleTests.swift +9 -0
- package/Tests/RunicTests/LiveAccountTests.swift +24 -0
- package/Tests/RunicTests/LoadingPatternTests.swift +35 -0
- package/Tests/RunicTests/MenuCardModelTests.swift +545 -0
- package/Tests/RunicTests/MiniMaxCookieHeaderStoreTestSupport.swift +8 -0
- package/Tests/RunicTests/MiniMaxGroupIDStoreTestSupport.swift +8 -0
- package/Tests/RunicTests/MiniMaxParsingTests.swift +68 -0
- package/Tests/RunicTests/MiniMaxTokenStoreTestSupport.swift +8 -0
- package/Tests/RunicTests/OTelGenAILedgerAdapterTests.swift +112 -0
- package/Tests/RunicTests/OpenAIDashboardBrowserCookieImporterTests.swift +17 -0
- package/Tests/RunicTests/OpenAIDashboardFetcherCreditsWaitTests.swift +75 -0
- package/Tests/RunicTests/OpenAIDashboardOffscreenHostTests.swift +27 -0
- package/Tests/RunicTests/OpenAIDashboardParserTests.swift +134 -0
- package/Tests/RunicTests/OpenAIWebAccountSwitchTests.swift +53 -0
- package/Tests/RunicTests/OpenRouterTokenStoreTestSupport.swift +92 -0
- package/Tests/RunicTests/PathBuilderTests.swift +237 -0
- package/Tests/RunicTests/ProviderFetchPipelineTests.swift +160 -0
- package/Tests/RunicTests/ProviderIconResourcesTests.swift +38 -0
- package/Tests/RunicTests/ProviderMetadataStatusLinkTests.swift +16 -0
- package/Tests/RunicTests/ProviderRegistryTests.swift +29 -0
- package/Tests/RunicTests/ProviderSettingsDescriptorTests.swift +202 -0
- package/Tests/RunicTests/ProviderToggleStoreTests.swift +51 -0
- package/Tests/RunicTests/RunicTests.swift +120 -0
- package/Tests/RunicTests/SessionQuotaNotificationLogicTests.swift +36 -0
- package/Tests/RunicTests/SettingsStoreTests.swift +161 -0
- package/Tests/RunicTests/StatusItemAnimationTests.swift +49 -0
- package/Tests/RunicTests/StatusMenuTests.swift +434 -0
- package/Tests/RunicTests/StatusProbeTests.swift +377 -0
- package/Tests/RunicTests/SubprocessRunnerTests.swift +20 -0
- package/Tests/RunicTests/SubscriptionDetectionTests.swift +65 -0
- package/Tests/RunicTests/TTYCommandRunnerTests.swift +148 -0
- package/Tests/RunicTests/TTYIntegrationTests.swift +41 -0
- package/Tests/RunicTests/TeamShowbackExporterTests.swift +159 -0
- package/Tests/RunicTests/TextParsingTests.swift +12 -0
- package/Tests/RunicTests/UsageFormatterTests.swift +112 -0
- package/Tests/RunicTests/UsageLedgerAnomalyDetectionTests.swift +172 -0
- package/Tests/RunicTests/UsageLedgerForecastTests.swift +213 -0
- package/Tests/RunicTests/UsageLedgerInsightsAdvisorTests.swift +83 -0
- package/Tests/RunicTests/UsageLedgerProjectNameTests.swift +170 -0
- package/Tests/RunicTests/UsagePaceTests.swift +77 -0
- package/Tests/RunicTests/UsagePaceTextTests.swift +109 -0
- package/Tests/RunicTests/UsagePolicyEngineTests.swift +91 -0
- package/Tests/RunicTests/WidgetSnapshotTests.swift +45 -0
- package/Tests/RunicTests/ZaiProviderTests.swift +61 -0
- package/Tests/RunicTests/ZaiTokenStoreTestSupport.swift +6 -0
- package/TestsLinux/PlatformGatingTests.swift +46 -0
- package/appcast.xml +105 -0
- package/assets/logo-concept-stack.svg +24 -0
- package/assets/logo-minimal-bars.svg +15 -0
- package/assets/logo.svg +20 -0
- package/assets/logo2.svg +43 -0
- package/bin/docs-list +5 -0
- package/bin/install-runic-cli.sh +32 -0
- package/build-and-run.sh +369 -0
- package/docs/.nojekyll +1 -0
- package/docs/CNAME +1 -0
- package/docs/antigravity.md +75 -0
- package/docs/architecture.md +32 -0
- package/docs/claude.md +93 -0
- package/docs/cli-commands/integration_guide.md +371 -0
- package/docs/cli-commands/quick_reference.md +362 -0
- package/docs/cli-commands/readme.md +361 -0
- package/docs/cli.md +156 -0
- package/docs/cloudkit_sync_guide.md +495 -0
- package/docs/codebase_review.md +288 -0
- package/docs/codex-oauth.md +635 -0
- package/docs/codex.md +104 -0
- package/docs/codexbar.png +0 -0
- package/docs/copilot.md +44 -0
- package/docs/cursor.md +46 -0
- package/docs/design/designcomparisonchart.md +510 -0
- package/docs/design/implementationguide.md +792 -0
- package/docs/design/menubaricondesignconcepts.md +796 -0
- package/docs/design/mockups/hybridiconconcept.svg +62 -0
- package/docs/design/mockups/infinityiconconcept.svg +41 -0
- package/docs/design/mockups/infinityiconstates.svg +129 -0
- package/docs/design/mockups/wavelogorefined.svg +48 -0
- package/docs/design/quick_summary.md +426 -0
- package/docs/design/readme.md +478 -0
- package/docs/design/sizespecifications.md +575 -0
- package/docs/design-system.md +836 -0
- package/docs/factory.md +103 -0
- package/docs/gemini.md +73 -0
- package/docs/icon.md +37 -0
- package/docs/icon.png +0 -0
- package/docs/index.html +175 -0
- package/docs/keychain_fix.md +274 -0
- package/docs/minimax.md +71 -0
- package/docs/packaging.md +25 -0
- package/docs/progress-bar-improvements.md +240 -0
- package/docs/progress-bar-quick-reference.md +283 -0
- package/docs/progress-bar-technical-details.md +432 -0
- package/docs/progress-bar-visual-test.swift +375 -0
- package/docs/provider.md +202 -0
- package/docs/providers.md +86 -0
- package/docs/refactor/macros.md +35 -0
- package/docs/refresh-loop.md +22 -0
- package/docs/releasing-homebrew.md +35 -0
- package/docs/releasing.md +121 -0
- package/docs/research/concrete_todo_2026-02-22.md +47 -0
- package/docs/research/core-feature-research.md +248 -0
- package/docs/research/feature_uiux_roadmap_2026-02-22.md +202 -0
- package/docs/research/parity_matrix.md +49 -0
- package/docs/research/provider_expansion_candidates_2026-02-22.md +80 -0
- package/docs/research/provider_model_token_project_matrix_2026-02-23.md +46 -0
- package/docs/research/release_gate_checklist.md +57 -0
- package/docs/site.css +505 -0
- package/docs/sparkle.md +28 -0
- package/docs/status.md +28 -0
- package/docs/sync/implementation.md +393 -0
- package/docs/sync/readme.md +317 -0
- package/docs/sync_files_summary.md +391 -0
- package/docs/sync_quick_reference.md +225 -0
- package/docs/todo.md +11 -0
- package/docs/ui.md +29 -0
- package/docs/web-integration.md +9 -0
- package/docs/widgets.md +85 -0
- package/docs/workflows/error-handling-improvements.md +434 -0
- package/docs/workflows/folder_structure.md +299 -0
- package/docs/workflows/loading_states.md +446 -0
- package/docs/workflows/loading_states_examples.md +565 -0
- package/docs/workflows/loading_states_flow.md +430 -0
- package/docs/workflows/performance_optimizations.md +457 -0
- package/docs/workflows/phase1-cli-testing-report.md +313 -0
- package/docs/workflows/quick_reference.md +228 -0
- package/docs/workflows/readme.md +59 -0
- package/docs/workflows/recent_improvements.md +275 -0
- package/docs/workflows/sync_enablement_summary.md +400 -0
- package/docs/zai.md +43 -0
- package/package.json +36 -0
- package/runic.png +0 -0
- package/version.env +2 -0
|
@@ -0,0 +1,3564 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import RunicCore
|
|
3
|
+
import Foundation
|
|
4
|
+
import Observation
|
|
5
|
+
import Silo
|
|
6
|
+
import CoreGraphics
|
|
7
|
+
|
|
8
|
+
/// **UsageStore** - Main state management for AI provider usage tracking
|
|
9
|
+
///
|
|
10
|
+
/// **Purpose:**
|
|
11
|
+
/// Central store managing all provider usage data, snapshots, errors, and refresh logic.
|
|
12
|
+
/// This is the single source of truth for the app's state.
|
|
13
|
+
///
|
|
14
|
+
/// **Responsibilities:**
|
|
15
|
+
/// - Fetch usage snapshots from all providers (Claude, Codex, Cursor, Gemini, etc.)
|
|
16
|
+
/// - Manage authentication state and cookies via Silo
|
|
17
|
+
/// - Track token usage across all providers
|
|
18
|
+
/// - Coordinate "ping" operations (manual and automatic)
|
|
19
|
+
/// - Handle errors and stale data detection
|
|
20
|
+
/// - Provide Observable state for SwiftUI views
|
|
21
|
+
///
|
|
22
|
+
/// **Performance Philosophy:**
|
|
23
|
+
/// - **Zero Token Leakage** - Always use cookies/cached data first before API calls
|
|
24
|
+
/// - **Single Ping Strategy** - One ping per provider per session, cache rest
|
|
25
|
+
/// - **Stale Detection** - Mark data stale after 5 minutes (PerformanceConstants.staleDuration)
|
|
26
|
+
///
|
|
27
|
+
/// **Known Issues:**
|
|
28
|
+
/// - **God Class** - 2089 lines, needs refactoring (see PLAN.md)
|
|
29
|
+
/// - Planned split: `UsageStateStore`, `UsageFetchingActor`, `TokenUsageService`
|
|
30
|
+
///
|
|
31
|
+
/// **Dependencies:**
|
|
32
|
+
/// - `RunicCore` - Provider descriptors and status probes
|
|
33
|
+
/// - `Silo` - Browser cookie access
|
|
34
|
+
/// - `Observation` - SwiftUI state updates
|
|
35
|
+
///
|
|
36
|
+
/// **Usage:**
|
|
37
|
+
/// ```swift
|
|
38
|
+
/// @Observable
|
|
39
|
+
/// final class UsageStore {
|
|
40
|
+
/// var snapshots: [UsageProvider: ProviderSnapshot] = [:]
|
|
41
|
+
/// var errors: [UsageProvider: Error] = [:]
|
|
42
|
+
///
|
|
43
|
+
/// func ping(provider: UsageProvider) async { ... }
|
|
44
|
+
/// }
|
|
45
|
+
/// ```
|
|
46
|
+
|
|
47
|
+
// MARK: - Observation helpers
|
|
48
|
+
|
|
49
|
+
@MainActor
|
|
50
|
+
extension UsageStore {
|
|
51
|
+
var menuObservationToken: Int {
|
|
52
|
+
_ = self.snapshots
|
|
53
|
+
_ = self.errors
|
|
54
|
+
_ = self.lastSourceLabels
|
|
55
|
+
_ = self.lastFetchAttempts
|
|
56
|
+
_ = self.tokenSnapshots
|
|
57
|
+
_ = self.tokenErrors
|
|
58
|
+
_ = self.tokenRefreshInFlight
|
|
59
|
+
_ = self.customProviderSnapshots
|
|
60
|
+
_ = self.customProviderErrors
|
|
61
|
+
_ = self.ledgerDailySummaries
|
|
62
|
+
_ = self.ledgerAllDailySummaries
|
|
63
|
+
_ = self.ledgerHourlySummaries
|
|
64
|
+
_ = self.ledgerActiveBlocks
|
|
65
|
+
_ = self.ledgerTopModels
|
|
66
|
+
_ = self.ledgerTopProjects
|
|
67
|
+
_ = self.ledgerModelBreakdowns
|
|
68
|
+
_ = self.ledgerProjectBreakdowns
|
|
69
|
+
_ = self.ledgerSpendForecasts
|
|
70
|
+
_ = self.ledgerProjectSpendForecasts
|
|
71
|
+
_ = self.ledgerTopProjectSpendForecasts
|
|
72
|
+
_ = self.ledgerAnomalies
|
|
73
|
+
_ = self.ledgerErrors
|
|
74
|
+
_ = self.ledgerUpdatedAt
|
|
75
|
+
_ = self.credits
|
|
76
|
+
_ = self.lastCreditsError
|
|
77
|
+
_ = self.openAIDashboard
|
|
78
|
+
_ = self.lastOpenAIDashboardError
|
|
79
|
+
_ = self.openAIDashboardRequiresLogin
|
|
80
|
+
_ = self.openAIDashboardCookieImportStatus
|
|
81
|
+
_ = self.openAIDashboardCookieImportDebugLog
|
|
82
|
+
_ = self.codexVersion
|
|
83
|
+
_ = self.claudeVersion
|
|
84
|
+
_ = self.geminiVersion
|
|
85
|
+
_ = self.zaiVersion
|
|
86
|
+
_ = self.antigravityVersion
|
|
87
|
+
_ = self.isRefreshing
|
|
88
|
+
_ = self.refreshingProviders
|
|
89
|
+
_ = self.pathDebugInfo
|
|
90
|
+
_ = self.statuses
|
|
91
|
+
_ = self.probeLogs
|
|
92
|
+
_ = self.autoRefreshSuspensionReason
|
|
93
|
+
_ = self.lastRefreshTrigger
|
|
94
|
+
_ = self.lastRefreshAt
|
|
95
|
+
_ = self.lastAutoRefreshDisableReason
|
|
96
|
+
_ = self.lastAutoRefreshDisableAt
|
|
97
|
+
return 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func observeSettingsChanges() {
|
|
101
|
+
withObservationTracking {
|
|
102
|
+
_ = self.settings.refreshFrequency
|
|
103
|
+
_ = self.settings.autoDisableRefreshWhenIdleEnabled
|
|
104
|
+
_ = self.settings.autoDisableRefreshWhenIdleMinutes
|
|
105
|
+
_ = self.settings.autoDisableRefreshOnSleepEnabled
|
|
106
|
+
_ = self.settings.autoRefreshWarningEnabled
|
|
107
|
+
_ = self.settings.autoRefreshWarningThreshold
|
|
108
|
+
_ = self.settings.autoSuspendInactiveProvidersEnabled
|
|
109
|
+
_ = self.settings.autoSuspendInactiveProvidersMinutes
|
|
110
|
+
_ = self.settings.statusChecksEnabled
|
|
111
|
+
_ = self.settings.sessionQuotaNotificationsEnabled
|
|
112
|
+
_ = self.settings.usageBarsShowUsed
|
|
113
|
+
_ = self.settings.costUsageEnabled
|
|
114
|
+
_ = self.settings.randomBlinkEnabled
|
|
115
|
+
_ = self.settings.claudeWebExtrasEnabled
|
|
116
|
+
_ = self.settings.claudeUsageDataSource
|
|
117
|
+
_ = self.settings.mergeIcons
|
|
118
|
+
_ = self.settings.debugLoadingPattern
|
|
119
|
+
} onChange: { [weak self] in
|
|
120
|
+
Task { @MainActor [weak self] in
|
|
121
|
+
guard let self else { return }
|
|
122
|
+
self.observeSettingsChanges()
|
|
123
|
+
await self.handleSettingsChange()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// MARK: - OpenAI web error messaging
|
|
130
|
+
|
|
131
|
+
extension UsageStore {
|
|
132
|
+
private func openAIDashboardFriendlyError(
|
|
133
|
+
body: String,
|
|
134
|
+
targetEmail: String?,
|
|
135
|
+
cookieImportStatus: String?) -> String?
|
|
136
|
+
{
|
|
137
|
+
let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
138
|
+
let status = cookieImportStatus?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
139
|
+
if trimmed.isEmpty {
|
|
140
|
+
return [
|
|
141
|
+
"OpenAI web dashboard returned an empty page.",
|
|
142
|
+
"Sign in to chatgpt.com and re-enable “Access OpenAI via web”.",
|
|
143
|
+
].joined(separator: " ")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let lower = trimmed.lowercased()
|
|
147
|
+
let looksLikePublicLanding = lower.contains("skip to content")
|
|
148
|
+
&& (lower.contains("about") || lower.contains("openai") || lower.contains("chatgpt"))
|
|
149
|
+
let looksLoggedOut = lower.contains("sign in")
|
|
150
|
+
|| lower.contains("log in")
|
|
151
|
+
|| lower.contains("create account")
|
|
152
|
+
|| lower.contains("continue with google")
|
|
153
|
+
|| lower.contains("continue with apple")
|
|
154
|
+
|| lower.contains("continue with microsoft")
|
|
155
|
+
|
|
156
|
+
guard looksLikePublicLanding || looksLoggedOut else { return nil }
|
|
157
|
+
let emailLabel = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
158
|
+
let targetLabel = (emailLabel?.isEmpty == false) ? emailLabel! : "your OpenAI account"
|
|
159
|
+
if let status, !status.isEmpty {
|
|
160
|
+
if status.contains("Browser cookies do not match Codex account")
|
|
161
|
+
|| status.contains("Browser cookie import failed")
|
|
162
|
+
{
|
|
163
|
+
return [
|
|
164
|
+
status,
|
|
165
|
+
"Sign in to chatgpt.com as \(targetLabel), then re-enable “Access OpenAI via web”.",
|
|
166
|
+
].joined(separator: " ")
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return [
|
|
170
|
+
"OpenAI web dashboard returned a public page (not signed in).",
|
|
171
|
+
"Sign in to chatgpt.com as \(targetLabel), then re-enable “Access OpenAI via web”.",
|
|
172
|
+
].joined(separator: " ")
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
enum ProviderStatusIndicator: String {
|
|
177
|
+
case none
|
|
178
|
+
case minor
|
|
179
|
+
case major
|
|
180
|
+
case critical
|
|
181
|
+
case maintenance
|
|
182
|
+
case unknown
|
|
183
|
+
|
|
184
|
+
var hasIssue: Bool {
|
|
185
|
+
switch self {
|
|
186
|
+
case .none: false
|
|
187
|
+
default: true
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
var label: String {
|
|
192
|
+
switch self {
|
|
193
|
+
case .none: "Operational"
|
|
194
|
+
case .minor: "Partial outage"
|
|
195
|
+
case .major: "Major outage"
|
|
196
|
+
case .critical: "Critical issue"
|
|
197
|
+
case .maintenance: "Maintenance"
|
|
198
|
+
case .unknown: "Status unknown"
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
enum RefreshTrigger: String {
|
|
204
|
+
case manual
|
|
205
|
+
case menuOpen
|
|
206
|
+
case autoTimer
|
|
207
|
+
case settingsChange
|
|
208
|
+
case login
|
|
209
|
+
case resume
|
|
210
|
+
case startup
|
|
211
|
+
|
|
212
|
+
var isAuto: Bool {
|
|
213
|
+
switch self {
|
|
214
|
+
case .manual, .login: false
|
|
215
|
+
case .menuOpen, .autoTimer, .settingsChange, .resume, .startup: true
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
var menuLabel: String {
|
|
220
|
+
switch self {
|
|
221
|
+
case .manual: "Manual"
|
|
222
|
+
case .menuOpen: "Menu open"
|
|
223
|
+
case .autoTimer: "Auto"
|
|
224
|
+
case .settingsChange: "Settings"
|
|
225
|
+
case .login: "Login"
|
|
226
|
+
case .resume: "Resume"
|
|
227
|
+
case .startup: "Startup"
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
enum AutoRefreshSuspensionReason: String {
|
|
233
|
+
case systemSleep
|
|
234
|
+
case screenSleep
|
|
235
|
+
case sessionInactive
|
|
236
|
+
|
|
237
|
+
var label: String {
|
|
238
|
+
switch self {
|
|
239
|
+
case .systemSleep: "Sleeping"
|
|
240
|
+
case .screenSleep: "Display asleep"
|
|
241
|
+
case .sessionInactive: "Session inactive"
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
enum AutoRefreshDisableReason: String {
|
|
247
|
+
case idle
|
|
248
|
+
case systemSleep
|
|
249
|
+
case screenSleep
|
|
250
|
+
case sessionInactive
|
|
251
|
+
|
|
252
|
+
var label: String {
|
|
253
|
+
switch self {
|
|
254
|
+
case .idle: "idle"
|
|
255
|
+
case .systemSleep: "sleep"
|
|
256
|
+
case .screenSleep: "display sleep"
|
|
257
|
+
case .sessionInactive: "lock"
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#if DEBUG
|
|
263
|
+
extension UsageStore {
|
|
264
|
+
func _setSnapshotForTesting(_ snapshot: UsageSnapshot?, provider: UsageProvider) {
|
|
265
|
+
self.snapshots[provider] = snapshot?.scoped(to: provider)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
func _setTokenSnapshotForTesting(_ snapshot: CostUsageTokenSnapshot?, provider: UsageProvider) {
|
|
269
|
+
self.tokenSnapshots[provider] = snapshot
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func _setTokenErrorForTesting(_ error: String?, provider: UsageProvider) {
|
|
273
|
+
self.tokenErrors[provider] = error
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func _setErrorForTesting(_ error: String?, provider: UsageProvider) {
|
|
277
|
+
self.errors[provider] = error
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
#endif
|
|
281
|
+
|
|
282
|
+
struct ProviderStatus {
|
|
283
|
+
let indicator: ProviderStatusIndicator
|
|
284
|
+
let description: String?
|
|
285
|
+
let updatedAt: Date?
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Tracks consecutive failures so we can ignore a single flake when we previously had fresh data.
|
|
289
|
+
struct ConsecutiveFailureGate {
|
|
290
|
+
private(set) var streak: Int = 0
|
|
291
|
+
|
|
292
|
+
mutating func recordSuccess() {
|
|
293
|
+
self.streak = 0
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
mutating func reset() {
|
|
297
|
+
self.streak = 0
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// Returns true when the caller should surface the error to the UI.
|
|
301
|
+
mutating func shouldSurfaceError(onFailureWithPriorData hadPriorData: Bool) -> Bool {
|
|
302
|
+
self.streak += 1
|
|
303
|
+
if hadPriorData, self.streak == 1 { return false }
|
|
304
|
+
return true
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
struct UsageLedgerAnomalySummary: Sendable, Hashable {
|
|
309
|
+
enum Severity: Int, Sendable, Hashable {
|
|
310
|
+
case elevated = 1
|
|
311
|
+
case high = 2
|
|
312
|
+
case critical = 3
|
|
313
|
+
|
|
314
|
+
var label: String {
|
|
315
|
+
switch self {
|
|
316
|
+
case .elevated: "Elevated"
|
|
317
|
+
case .high: "High"
|
|
318
|
+
case .critical: "Critical"
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
struct MetricAnomaly: Sendable, Hashable {
|
|
324
|
+
enum Metric: String, Sendable, Hashable {
|
|
325
|
+
case tokens
|
|
326
|
+
case spend
|
|
327
|
+
|
|
328
|
+
var label: String {
|
|
329
|
+
switch self {
|
|
330
|
+
case .tokens: "tokens"
|
|
331
|
+
case .spend: "spend"
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let metric: Metric
|
|
337
|
+
let severity: Severity
|
|
338
|
+
let todayValue: Double
|
|
339
|
+
let baselineAverage: Double
|
|
340
|
+
let percentIncrease: Double
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
struct Explanation: Sendable, Hashable {
|
|
344
|
+
struct Factor: Sendable, Hashable {
|
|
345
|
+
let metric: MetricAnomaly.Metric
|
|
346
|
+
let severity: Severity
|
|
347
|
+
let percentIncrease: Double
|
|
348
|
+
let detail: String
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let headline: String
|
|
352
|
+
let details: [String]
|
|
353
|
+
let primaryFactor: Factor
|
|
354
|
+
let contributingFactors: [Factor]
|
|
355
|
+
}
|
|
356
|
+
let provider: UsageProvider
|
|
357
|
+
let baselineDays: Int
|
|
358
|
+
let tokenAnomaly: MetricAnomaly?
|
|
359
|
+
let spendAnomaly: MetricAnomaly?
|
|
360
|
+
|
|
361
|
+
var primaryAnomaly: MetricAnomaly? {
|
|
362
|
+
[self.tokenAnomaly, self.spendAnomaly]
|
|
363
|
+
.compactMap { $0 }
|
|
364
|
+
.max { lhs, rhs in
|
|
365
|
+
if lhs.severity != rhs.severity {
|
|
366
|
+
return lhs.severity.rawValue < rhs.severity.rawValue
|
|
367
|
+
}
|
|
368
|
+
return lhs.percentIncrease < rhs.percentIncrease
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
func secondaryAnomaly(excluding metric: MetricAnomaly.Metric) -> MetricAnomaly? {
|
|
373
|
+
let candidates = [self.tokenAnomaly, self.spendAnomaly].compactMap { $0 }
|
|
374
|
+
guard !candidates.isEmpty else { return nil }
|
|
375
|
+
return candidates.first { $0.metric != metric }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
var explanation: Explanation? {
|
|
379
|
+
guard let primary = self.primaryAnomaly else { return nil }
|
|
380
|
+
let primaryFactor = self.factor(from: primary)
|
|
381
|
+
var contributingFactors: [Explanation.Factor] = []
|
|
382
|
+
if let secondary = self.secondaryAnomaly(excluding: primary.metric) {
|
|
383
|
+
contributingFactors.append(self.factor(from: secondary))
|
|
384
|
+
}
|
|
385
|
+
return Explanation(
|
|
386
|
+
headline: "Anomaly: \(primary.severity.label) \(primary.metric.label) spike",
|
|
387
|
+
details: [primaryFactor.detail] + contributingFactors.map(\.detail),
|
|
388
|
+
primaryFactor: primaryFactor,
|
|
389
|
+
contributingFactors: contributingFactors)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private func factor(from anomaly: MetricAnomaly) -> Explanation.Factor {
|
|
393
|
+
Explanation.Factor(
|
|
394
|
+
metric: anomaly.metric,
|
|
395
|
+
severity: anomaly.severity,
|
|
396
|
+
percentIncrease: anomaly.percentIncrease,
|
|
397
|
+
detail: self.metricDetail(for: anomaly))
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private func metricDetail(for anomaly: MetricAnomaly) -> String {
|
|
401
|
+
let percentText = "\(Int((anomaly.percentIncrease * 100).rounded()))%"
|
|
402
|
+
let baselineLabel = "\(self.baselineDays)d avg"
|
|
403
|
+
switch anomaly.metric {
|
|
404
|
+
case .tokens:
|
|
405
|
+
let todayTokens = UsageFormatter.tokenCountString(Int(anomaly.todayValue.rounded()))
|
|
406
|
+
let baselineTokens = UsageFormatter.tokenCountString(Int(anomaly.baselineAverage.rounded()))
|
|
407
|
+
return "Tokens \(todayTokens) today · +\(percentText) vs \(baselineLabel) \(baselineTokens)"
|
|
408
|
+
case .spend:
|
|
409
|
+
let todaySpend = UsageFormatter.usdString(anomaly.todayValue)
|
|
410
|
+
let baselineSpend = UsageFormatter.usdString(anomaly.baselineAverage)
|
|
411
|
+
return "Spend \(todaySpend) today · +\(percentText) vs \(baselineLabel) \(baselineSpend)"
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
enum UsageLedgerAnomalyDetector {
|
|
417
|
+
private static let baselineDayCount = 7
|
|
418
|
+
private static let minimumIncreaseThreshold = 0.60
|
|
419
|
+
|
|
420
|
+
private struct DayUsageBucket {
|
|
421
|
+
var tokens: Int = 0
|
|
422
|
+
var costSum: Double = 0
|
|
423
|
+
var hasCost: Bool = false
|
|
424
|
+
|
|
425
|
+
mutating func consume(_ summary: UsageLedgerDailySummary) {
|
|
426
|
+
self.tokens += summary.totals.totalTokens
|
|
427
|
+
if let cost = summary.totals.costUSD {
|
|
428
|
+
self.costSum += cost
|
|
429
|
+
self.hasCost = true
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
var costUSD: Double? {
|
|
434
|
+
self.hasCost ? self.costSum : nil
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
static func summaries(
|
|
439
|
+
dailySummaries: [UsageLedgerDailySummary],
|
|
440
|
+
now: Date,
|
|
441
|
+
calendar: Calendar? = nil) -> [UsageProvider: UsageLedgerAnomalySummary]
|
|
442
|
+
{
|
|
443
|
+
var calendar = calendar ?? Calendar(identifier: .gregorian)
|
|
444
|
+
calendar.timeZone = TimeZone.current
|
|
445
|
+
let todayStart = calendar.startOfDay(for: now)
|
|
446
|
+
|
|
447
|
+
var bucketsByProvider: [UsageProvider: [Date: DayUsageBucket]] = [:]
|
|
448
|
+
for summary in dailySummaries {
|
|
449
|
+
let dayStart = calendar.startOfDay(for: summary.dayStart)
|
|
450
|
+
bucketsByProvider[summary.provider, default: [:]][dayStart, default: DayUsageBucket()].consume(summary)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
var summariesByProvider: [UsageProvider: UsageLedgerAnomalySummary] = [:]
|
|
454
|
+
for (provider, dayBuckets) in bucketsByProvider {
|
|
455
|
+
guard let todayBucket = dayBuckets[todayStart] else { continue }
|
|
456
|
+
|
|
457
|
+
var baselineBuckets: [DayUsageBucket] = []
|
|
458
|
+
baselineBuckets.reserveCapacity(self.baselineDayCount)
|
|
459
|
+
for dayOffset in 1...self.baselineDayCount {
|
|
460
|
+
guard let baselineDay = calendar.date(byAdding: .day, value: -dayOffset, to: todayStart),
|
|
461
|
+
let bucket = dayBuckets[baselineDay]
|
|
462
|
+
else {
|
|
463
|
+
baselineBuckets.removeAll(keepingCapacity: true)
|
|
464
|
+
break
|
|
465
|
+
}
|
|
466
|
+
baselineBuckets.append(bucket)
|
|
467
|
+
}
|
|
468
|
+
guard baselineBuckets.count == self.baselineDayCount else { continue }
|
|
469
|
+
|
|
470
|
+
let tokenAnomaly = self.metricAnomaly(
|
|
471
|
+
metric: .tokens,
|
|
472
|
+
todayValue: Double(todayBucket.tokens),
|
|
473
|
+
baselineValues: baselineBuckets.map { Double($0.tokens) })
|
|
474
|
+
|
|
475
|
+
var spendAnomaly: UsageLedgerAnomalySummary.MetricAnomaly?
|
|
476
|
+
if let todayCost = todayBucket.costUSD {
|
|
477
|
+
spendAnomaly = self.metricAnomaly(
|
|
478
|
+
metric: .spend,
|
|
479
|
+
todayValue: todayCost,
|
|
480
|
+
baselineValues: baselineBuckets.compactMap(\.costUSD))
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if tokenAnomaly != nil || spendAnomaly != nil {
|
|
484
|
+
summariesByProvider[provider] = UsageLedgerAnomalySummary(
|
|
485
|
+
provider: provider,
|
|
486
|
+
baselineDays: self.baselineDayCount,
|
|
487
|
+
tokenAnomaly: tokenAnomaly,
|
|
488
|
+
spendAnomaly: spendAnomaly)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return summariesByProvider
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private static func metricAnomaly(
|
|
496
|
+
metric: UsageLedgerAnomalySummary.MetricAnomaly.Metric,
|
|
497
|
+
todayValue: Double,
|
|
498
|
+
baselineValues: [Double]) -> UsageLedgerAnomalySummary.MetricAnomaly?
|
|
499
|
+
{
|
|
500
|
+
guard baselineValues.count == self.baselineDayCount else { return nil }
|
|
501
|
+
guard baselineValues.allSatisfy(\.isFinite) else { return nil }
|
|
502
|
+
guard todayValue.isFinite else { return nil }
|
|
503
|
+
|
|
504
|
+
let baselineAverage = baselineValues.reduce(0, +) / Double(baselineValues.count)
|
|
505
|
+
guard baselineAverage > 0 else { return nil }
|
|
506
|
+
|
|
507
|
+
let increase = (todayValue - baselineAverage) / baselineAverage
|
|
508
|
+
guard let severity = self.severity(for: increase) else { return nil }
|
|
509
|
+
return UsageLedgerAnomalySummary.MetricAnomaly(
|
|
510
|
+
metric: metric,
|
|
511
|
+
severity: severity,
|
|
512
|
+
todayValue: todayValue,
|
|
513
|
+
baselineAverage: baselineAverage,
|
|
514
|
+
percentIncrease: increase)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private static func severity(for increase: Double) -> UsageLedgerAnomalySummary.Severity? {
|
|
518
|
+
guard increase.isFinite, increase >= self.minimumIncreaseThreshold else { return nil }
|
|
519
|
+
if increase >= 2.0 { return .critical }
|
|
520
|
+
if increase >= 1.0 { return .high }
|
|
521
|
+
return .elevated
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
struct ProviderHistoryDaySnapshot: Sendable, Hashable, Identifiable {
|
|
526
|
+
let dayStart: Date
|
|
527
|
+
let totals: UsageLedgerTotals
|
|
528
|
+
let requestCount: Int
|
|
529
|
+
let modelsUsed: [String]
|
|
530
|
+
let topModel: UsageLedgerModelSummary?
|
|
531
|
+
let topProject: UsageLedgerProjectSummary?
|
|
532
|
+
let modelSummaries: [UsageLedgerModelSummary]
|
|
533
|
+
let projectSummaries: [UsageLedgerProjectSummary]
|
|
534
|
+
|
|
535
|
+
var id: Date { self.dayStart }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
struct ProviderHistoryMonthSnapshot: Sendable, Hashable {
|
|
539
|
+
let provider: UsageProvider
|
|
540
|
+
let monthStart: Date
|
|
541
|
+
let generatedAt: Date
|
|
542
|
+
let days: [ProviderHistoryDaySnapshot]
|
|
543
|
+
let isSupported: Bool
|
|
544
|
+
let note: String?
|
|
545
|
+
let error: String?
|
|
546
|
+
}
|
|
547
|
+
@MainActor
|
|
548
|
+
@Observable
|
|
549
|
+
final class UsageStore {
|
|
550
|
+
private(set) var snapshots: [UsageProvider: UsageSnapshot] = [:]
|
|
551
|
+
private(set) var errors: [UsageProvider: String] = [:]
|
|
552
|
+
private(set) var lastSourceLabels: [UsageProvider: String] = [:]
|
|
553
|
+
private(set) var lastFetchAttempts: [UsageProvider: [ProviderFetchAttempt]] = [:]
|
|
554
|
+
private(set) var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:]
|
|
555
|
+
// Custom provider snapshots
|
|
556
|
+
private(set) var customProviderSnapshots: [String: CustomProviderSnapshot] = [:]
|
|
557
|
+
private(set) var customProviderErrors: [String: String] = [:]
|
|
558
|
+
private(set) var tokenErrors: [UsageProvider: String] = [:]
|
|
559
|
+
private(set) var tokenRefreshInFlight: Set<UsageProvider> = []
|
|
560
|
+
private(set) var ledgerDailySummaries: [UsageProvider: UsageLedgerDailySummary] = [:]
|
|
561
|
+
private(set) var ledgerAllDailySummaries: [UsageProvider: [UsageLedgerDailySummary]] = [:]
|
|
562
|
+
private(set) var ledgerHourlySummaries: [UsageProvider: [UsageLedgerHourlySummary]] = [:]
|
|
563
|
+
private(set) var ledgerActiveBlocks: [UsageProvider: UsageLedgerBlockSummary] = [:]
|
|
564
|
+
private(set) var ledgerTopModels: [UsageProvider: UsageLedgerModelSummary] = [:]
|
|
565
|
+
private(set) var ledgerTopProjects: [UsageProvider: UsageLedgerProjectSummary] = [:]
|
|
566
|
+
private(set) var ledgerModelBreakdowns: [UsageProvider: [UsageLedgerModelSummary]] = [:]
|
|
567
|
+
private(set) var ledgerProjectBreakdowns: [UsageProvider: [UsageLedgerProjectSummary]] = [:]
|
|
568
|
+
private(set) var ledgerSpendForecasts: [UsageProvider: UsageLedgerSpendForecast] = [:]
|
|
569
|
+
private(set) var ledgerProjectSpendForecasts: [UsageProvider: [UsageLedgerSpendForecast]] = [:]
|
|
570
|
+
private(set) var ledgerTopProjectSpendForecasts: [UsageProvider: UsageLedgerSpendForecast] = [:]
|
|
571
|
+
private(set) var ledgerAnomalies: [UsageProvider: UsageLedgerAnomalySummary] = [:]
|
|
572
|
+
private(set) var ledgerErrors: [UsageProvider: String] = [:]
|
|
573
|
+
private(set) var ledgerUpdatedAt: [UsageProvider: Date] = [:]
|
|
574
|
+
var credits: CreditsSnapshot?
|
|
575
|
+
var lastCreditsError: String?
|
|
576
|
+
var openAIDashboard: OpenAIDashboardSnapshot?
|
|
577
|
+
var lastOpenAIDashboardError: String?
|
|
578
|
+
private(set) var openAIDashboardRequiresLogin: Bool = false
|
|
579
|
+
var openAIDashboardCookieImportStatus: String?
|
|
580
|
+
var openAIDashboardCookieImportDebugLog: String?
|
|
581
|
+
var codexVersion: String?
|
|
582
|
+
var claudeVersion: String?
|
|
583
|
+
var geminiVersion: String?
|
|
584
|
+
var zaiVersion: String?
|
|
585
|
+
var antigravityVersion: String?
|
|
586
|
+
var cursorVersion: String?
|
|
587
|
+
var isRefreshing = false
|
|
588
|
+
private(set) var refreshingProviders: Set<UsageProvider> = []
|
|
589
|
+
var debugForceAnimation = false
|
|
590
|
+
var pathDebugInfo: PathDebugSnapshot = .empty
|
|
591
|
+
private(set) var statuses: [UsageProvider: ProviderStatus] = [:]
|
|
592
|
+
private(set) var probeLogs: [UsageProvider: String] = [:]
|
|
593
|
+
@ObservationIgnored private var lastCreditsSnapshot: CreditsSnapshot?
|
|
594
|
+
@ObservationIgnored private var creditsFailureStreak: Int = 0
|
|
595
|
+
@ObservationIgnored private var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot?
|
|
596
|
+
@ObservationIgnored private var lastOpenAIDashboardTargetEmail: String?
|
|
597
|
+
@ObservationIgnored private var lastOpenAIDashboardCookieImportAttemptAt: Date?
|
|
598
|
+
@ObservationIgnored private var lastOpenAIDashboardCookieImportEmail: String?
|
|
599
|
+
@ObservationIgnored private var openAIWebAccountDidChange: Bool = false
|
|
600
|
+
private var autoRefreshSuspensionReason: AutoRefreshSuspensionReason?
|
|
601
|
+
private(set) var lastRefreshTrigger: RefreshTrigger?
|
|
602
|
+
private(set) var lastRefreshAt: Date?
|
|
603
|
+
private(set) var lastAutoRefreshDisableReason: AutoRefreshDisableReason?
|
|
604
|
+
private(set) var lastAutoRefreshDisableAt: Date?
|
|
605
|
+
@ObservationIgnored private var autoRefreshRunCount: Int = 0
|
|
606
|
+
@ObservationIgnored private var autoRefreshWarningSent: Bool = false
|
|
607
|
+
@ObservationIgnored private var suppressNextSettingsRefresh: Bool = false
|
|
608
|
+
@ObservationIgnored private var lastUsageDeltaAt: [UsageProvider: Date] = [:]
|
|
609
|
+
@ObservationIgnored private var lastLedgerActivityAt: [UsageProvider: Date] = [:]
|
|
610
|
+
@ObservationIgnored private var lastRefreshFrequency: RefreshFrequency = .manual
|
|
611
|
+
@ObservationIgnored private var lastAutoRefreshWarningEnabled: Bool = true
|
|
612
|
+
@ObservationIgnored private var lastAutoRefreshWarningThreshold: Int = 10
|
|
613
|
+
|
|
614
|
+
@ObservationIgnored private let codexFetcher: UsageFetcher
|
|
615
|
+
@ObservationIgnored private let claudeFetcher: any ClaudeUsageFetching
|
|
616
|
+
@ObservationIgnored private let costUsageFetcher: CostUsageFetcher
|
|
617
|
+
@ObservationIgnored private let registry: ProviderRegistry
|
|
618
|
+
@ObservationIgnored private let settings: SettingsStore
|
|
619
|
+
@ObservationIgnored private let sessionQuotaNotifier: SessionQuotaNotifier
|
|
620
|
+
@ObservationIgnored private let sessionQuotaLogger = RunicLog.logger("sessionQuota")
|
|
621
|
+
@ObservationIgnored private let openAIWebLogger = RunicLog.logger("openai-web")
|
|
622
|
+
@ObservationIgnored private let tokenCostLogger = RunicLog.logger("token-cost")
|
|
623
|
+
@ObservationIgnored private var openAIWebDebugLines: [String] = []
|
|
624
|
+
@ObservationIgnored private var failureGates: [UsageProvider: ConsecutiveFailureGate] = [:]
|
|
625
|
+
@ObservationIgnored private var tokenFailureGates: [UsageProvider: ConsecutiveFailureGate] = [:]
|
|
626
|
+
@ObservationIgnored private var providerSpecs: [UsageProvider: ProviderSpec] = [:]
|
|
627
|
+
@ObservationIgnored private let providerMetadata: [UsageProvider: ProviderMetadata]
|
|
628
|
+
@ObservationIgnored private var timerTask: Task<Void, Never>?
|
|
629
|
+
@ObservationIgnored private var tokenTimerTask: Task<Void, Never>?
|
|
630
|
+
@ObservationIgnored private var tokenRefreshSequenceTask: Task<Void, Never>?
|
|
631
|
+
@ObservationIgnored private var ledgerRefreshTask: Task<Void, Never>?
|
|
632
|
+
@ObservationIgnored private var providerHistoryMonthCache: [UsageProvider: [String: ProviderHistoryMonthCacheEntry]] = [:]
|
|
633
|
+
@ObservationIgnored private var lastKnownSessionRemaining: [UsageProvider: Double] = [:]
|
|
634
|
+
@ObservationIgnored private(set) var lastTokenFetchAt: [UsageProvider: Date] = [:]
|
|
635
|
+
@ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60
|
|
636
|
+
@ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60
|
|
637
|
+
@ObservationIgnored private let ledgerRefreshTTL: TimeInterval = 90
|
|
638
|
+
@ObservationIgnored private let ledgerMaxAgeDays: Int = 3
|
|
639
|
+
@ObservationIgnored private let providerHistoryCacheTTL: TimeInterval = 90
|
|
640
|
+
@ObservationIgnored private let providerHistoryMaxScanDays: Int = 180
|
|
641
|
+
@ObservationIgnored nonisolated(unsafe) private let performanceStorage: PerformanceStorageImpl?
|
|
642
|
+
|
|
643
|
+
init(
|
|
644
|
+
fetcher: UsageFetcher,
|
|
645
|
+
claudeFetcher: any ClaudeUsageFetching = ClaudeUsageFetcher(),
|
|
646
|
+
costUsageFetcher: CostUsageFetcher = CostUsageFetcher(),
|
|
647
|
+
settings: SettingsStore,
|
|
648
|
+
registry: ProviderRegistry = .shared,
|
|
649
|
+
sessionQuotaNotifier: SessionQuotaNotifier = SessionQuotaNotifier())
|
|
650
|
+
{
|
|
651
|
+
self.codexFetcher = fetcher
|
|
652
|
+
self.claudeFetcher = claudeFetcher
|
|
653
|
+
self.costUsageFetcher = costUsageFetcher
|
|
654
|
+
self.settings = settings
|
|
655
|
+
self.registry = registry
|
|
656
|
+
self.sessionQuotaNotifier = sessionQuotaNotifier
|
|
657
|
+
self.providerMetadata = registry.metadata
|
|
658
|
+
self.lastRefreshFrequency = settings.refreshFrequency
|
|
659
|
+
|
|
660
|
+
// Initialize performance tracking (optional, gracefully fails if unavailable)
|
|
661
|
+
self.performanceStorage = try? PerformanceStorageImpl()
|
|
662
|
+
self.lastAutoRefreshWarningEnabled = settings.autoRefreshWarningEnabled
|
|
663
|
+
self.lastAutoRefreshWarningThreshold = settings.autoRefreshWarningThreshold
|
|
664
|
+
self
|
|
665
|
+
.failureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases
|
|
666
|
+
.map { ($0, ConsecutiveFailureGate()) })
|
|
667
|
+
self.tokenFailureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases
|
|
668
|
+
.map { ($0, ConsecutiveFailureGate()) })
|
|
669
|
+
self.providerSpecs = registry.specs(
|
|
670
|
+
settings: settings,
|
|
671
|
+
metadata: self.providerMetadata,
|
|
672
|
+
codexFetcher: fetcher,
|
|
673
|
+
claudeFetcher: claudeFetcher)
|
|
674
|
+
self.bindSettings()
|
|
675
|
+
self.detectVersions()
|
|
676
|
+
self.refreshPathDebugInfo()
|
|
677
|
+
LoginShellPathCache.shared.captureOnce { [weak self] _ in
|
|
678
|
+
Task { @MainActor in self?.refreshPathDebugInfo() }
|
|
679
|
+
}
|
|
680
|
+
if self.settings.refreshFrequency != .manual {
|
|
681
|
+
Task { await self.refresh(trigger: .startup) }
|
|
682
|
+
}
|
|
683
|
+
self.startTimer()
|
|
684
|
+
self.startTokenTimer()
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/// Returns the login method (plan type) for the specified provider, if available.
|
|
688
|
+
private func loginMethod(for provider: UsageProvider) -> String? {
|
|
689
|
+
self.snapshots[provider]?.loginMethod(for: provider)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/// Returns true if the Claude account appears to be a subscription (Max, Pro, Ultra, Team).
|
|
693
|
+
/// Returns false for API users or when plan cannot be determined.
|
|
694
|
+
func isClaudeSubscription() -> Bool {
|
|
695
|
+
Self.isSubscriptionPlan(self.loginMethod(for: .claude))
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/// Determines if a login method string indicates a Claude subscription plan.
|
|
699
|
+
/// Known subscription indicators: Max, Pro, Ultra, Team (case-insensitive).
|
|
700
|
+
nonisolated static func isSubscriptionPlan(_ loginMethod: String?) -> Bool {
|
|
701
|
+
guard let method = loginMethod?.lowercased(), !method.isEmpty else {
|
|
702
|
+
return false
|
|
703
|
+
}
|
|
704
|
+
let subscriptionIndicators = ["max", "pro", "ultra", "team"]
|
|
705
|
+
return subscriptionIndicators.contains { method.contains($0) }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
func version(for provider: UsageProvider) -> String? {
|
|
709
|
+
switch provider {
|
|
710
|
+
case .codex: self.codexVersion
|
|
711
|
+
case .claude: self.claudeVersion
|
|
712
|
+
case .zai: self.zaiVersion
|
|
713
|
+
case .gemini: self.geminiVersion
|
|
714
|
+
case .antigravity: self.antigravityVersion
|
|
715
|
+
case .cursor: self.cursorVersion
|
|
716
|
+
case .factory: nil
|
|
717
|
+
case .copilot: nil
|
|
718
|
+
case .minimax: nil
|
|
719
|
+
case .openrouter: nil
|
|
720
|
+
case .groq: nil
|
|
721
|
+
case .deepseek: nil
|
|
722
|
+
case .fireworks: nil
|
|
723
|
+
case .mistral: nil
|
|
724
|
+
case .perplexity: nil
|
|
725
|
+
case .kimi: nil
|
|
726
|
+
case .auggie: nil
|
|
727
|
+
case .together: nil
|
|
728
|
+
case .cohere: nil
|
|
729
|
+
case .xai: nil
|
|
730
|
+
case .cerebras: nil
|
|
731
|
+
case .sambanova: nil
|
|
732
|
+
case .azure: nil
|
|
733
|
+
case .bedrock: nil
|
|
734
|
+
case .vertexai: nil
|
|
735
|
+
case .qwen: nil
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
var preferredSnapshot: UsageSnapshot? {
|
|
740
|
+
for provider in self.enabledProviders() {
|
|
741
|
+
if let snap = self.snapshots[provider] { return snap }
|
|
742
|
+
}
|
|
743
|
+
return nil
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
var iconStyle: IconStyle {
|
|
747
|
+
let enabled = self.enabledProviders()
|
|
748
|
+
if enabled.count > 1 { return .combined }
|
|
749
|
+
if let provider = enabled.first {
|
|
750
|
+
return self.style(for: provider)
|
|
751
|
+
}
|
|
752
|
+
return .codex
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
var isStale: Bool {
|
|
756
|
+
self.enabledProviders().contains { provider in
|
|
757
|
+
switch provider {
|
|
758
|
+
case .codex:
|
|
759
|
+
self.lastCodexError != nil
|
|
760
|
+
case .claude:
|
|
761
|
+
self.lastClaudeError != nil
|
|
762
|
+
default:
|
|
763
|
+
self.errors[provider] != nil
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
func autoRefreshStatusLine() -> String? {
|
|
769
|
+
if self.settings.refreshFrequency == .manual {
|
|
770
|
+
if let reason = self.lastAutoRefreshDisableReason {
|
|
771
|
+
return "Auto-refresh: Manual (switched after \(reason.label))"
|
|
772
|
+
}
|
|
773
|
+
return "Auto-refresh: Manual"
|
|
774
|
+
}
|
|
775
|
+
if let reason = self.autoRefreshSuspensionReason {
|
|
776
|
+
return "Auto-refresh: Paused (\(reason.label))"
|
|
777
|
+
}
|
|
778
|
+
return "Auto-refresh: \(self.settings.refreshFrequency.label)"
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
func setAutoRefreshSuspended(_ reason: AutoRefreshSuspensionReason?) {
|
|
782
|
+
self.autoRefreshSuspensionReason = reason
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
func disableAutoRefreshForSystemPause(_ reason: AutoRefreshSuspensionReason) {
|
|
786
|
+
let detail = self.systemPauseDetailText(reason)
|
|
787
|
+
self.disableAutoRefresh(
|
|
788
|
+
reason: self.disableReason(for: reason),
|
|
789
|
+
idPrefix: "auto-refresh-system",
|
|
790
|
+
body: "Runic switched auto-refresh to Manual because \(detail).")
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
func handleAutoRefreshSystemPause(_ reason: AutoRefreshSuspensionReason) {
|
|
794
|
+
if self.settings.autoDisableRefreshOnSleepEnabled {
|
|
795
|
+
self.disableAutoRefreshForSystemPause(reason)
|
|
796
|
+
} else {
|
|
797
|
+
self.setAutoRefreshSuspended(reason)
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
func lastRefreshStatusLine(now: Date = .now) -> String? {
|
|
802
|
+
guard let trigger = self.lastRefreshTrigger,
|
|
803
|
+
let refreshedAt = self.lastRefreshAt else { return nil }
|
|
804
|
+
let relative = refreshedAt.relativeDescription(now: now)
|
|
805
|
+
return "Last refresh: \(trigger.menuLabel) • \(relative)"
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
func autoRefreshSwitchLine(now: Date = .now) -> String? {
|
|
809
|
+
guard self.settings.refreshFrequency == .manual,
|
|
810
|
+
let reason = self.lastAutoRefreshDisableReason,
|
|
811
|
+
let disabledAt = self.lastAutoRefreshDisableAt else { return nil }
|
|
812
|
+
let relative = disabledAt.relativeDescription(now: now)
|
|
813
|
+
return "Auto-refresh switched to Manual after \(reason.label) • \(relative)"
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
func autoRefreshDisableBadgeText() -> String? {
|
|
817
|
+
guard self.settings.refreshFrequency == .manual,
|
|
818
|
+
let reason = self.lastAutoRefreshDisableReason else { return nil }
|
|
819
|
+
return "Manual because of \(reason.label)"
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
func enabledProviders() -> [UsageProvider] {
|
|
823
|
+
// Use cached enablement to avoid repeated UserDefaults lookups in animation ticks.
|
|
824
|
+
let enabled = self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata)
|
|
825
|
+
return enabled.filter { self.isProviderAvailable($0) }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
var statusChecksEnabled: Bool {
|
|
829
|
+
self.settings.statusChecksEnabled
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
func metadata(for provider: UsageProvider) -> ProviderMetadata {
|
|
833
|
+
self.providerMetadata[provider]!
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private var codexBrowserCookieOrder: BrowserCookieImportOrder {
|
|
837
|
+
self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
func snapshot(for provider: UsageProvider) -> UsageSnapshot? {
|
|
841
|
+
self.snapshots[provider]
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
func sourceLabel(for provider: UsageProvider) -> String {
|
|
845
|
+
var label = self.lastSourceLabels[provider] ?? ""
|
|
846
|
+
if label.isEmpty {
|
|
847
|
+
let descriptor = ProviderDescriptorRegistry.descriptor(for: provider)
|
|
848
|
+
let modes = descriptor.fetchPlan.sourceModes
|
|
849
|
+
if modes.count == 1, let mode = modes.first {
|
|
850
|
+
label = mode.rawValue
|
|
851
|
+
} else if provider == .claude {
|
|
852
|
+
label = self.settings.claudeUsageDataSource.rawValue
|
|
853
|
+
} else {
|
|
854
|
+
label = "auto"
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// When OpenAI web extras are active, show a blended label like `oauth + openai-web`.
|
|
859
|
+
if provider == .codex,
|
|
860
|
+
self.settings.openAIWebAccessEnabled,
|
|
861
|
+
self.openAIDashboard != nil,
|
|
862
|
+
!self.openAIDashboardRequiresLogin,
|
|
863
|
+
!label.contains("openai-web")
|
|
864
|
+
{
|
|
865
|
+
return "\(label) + openai-web"
|
|
866
|
+
}
|
|
867
|
+
return label
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
func fetchAttempts(for provider: UsageProvider) -> [ProviderFetchAttempt] {
|
|
871
|
+
self.lastFetchAttempts[provider] ?? []
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
func style(for provider: UsageProvider) -> IconStyle {
|
|
875
|
+
self.providerSpecs[provider]?.style ?? .codex
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
func isStale(provider: UsageProvider) -> Bool {
|
|
879
|
+
self.errors[provider] != nil
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
func isEnabled(_ provider: UsageProvider) -> Bool {
|
|
883
|
+
let enabled = self.settings.isProviderEnabledCached(
|
|
884
|
+
provider: provider,
|
|
885
|
+
metadataByProvider: self.providerMetadata)
|
|
886
|
+
guard enabled else { return false }
|
|
887
|
+
return self.isProviderAvailable(provider)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private func isProviderAvailable(_ provider: UsageProvider) -> Bool {
|
|
891
|
+
if provider == .zai {
|
|
892
|
+
if ZaiSettingsReader.apiToken(environment: ProcessInfo.processInfo.environment) != nil {
|
|
893
|
+
return true
|
|
894
|
+
}
|
|
895
|
+
return !self.settings.zaiAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
896
|
+
}
|
|
897
|
+
return true
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
func ledgerDailySummary(for provider: UsageProvider) -> UsageLedgerDailySummary? {
|
|
901
|
+
self.ledgerDailySummaries[provider]
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
func ledgerAllDailySummary(for provider: UsageProvider) -> [UsageLedgerDailySummary] {
|
|
905
|
+
self.ledgerAllDailySummaries[provider] ?? []
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
func ledgerHourlySummary(for provider: UsageProvider) -> [UsageLedgerHourlySummary] {
|
|
909
|
+
self.ledgerHourlySummaries[provider] ?? []
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
func ledgerActiveBlock(for provider: UsageProvider) -> UsageLedgerBlockSummary? {
|
|
913
|
+
self.ledgerActiveBlocks[provider]
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
func ledgerTopModel(for provider: UsageProvider) -> UsageLedgerModelSummary? {
|
|
917
|
+
self.ledgerTopModels[provider]
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
func ledgerTopProject(for provider: UsageProvider) -> UsageLedgerProjectSummary? {
|
|
921
|
+
self.ledgerTopProjects[provider]
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
func ledgerModelBreakdown(for provider: UsageProvider) -> [UsageLedgerModelSummary] {
|
|
925
|
+
self.ledgerModelBreakdowns[provider] ?? []
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
func ledgerProjectBreakdown(for provider: UsageProvider) -> [UsageLedgerProjectSummary] {
|
|
929
|
+
self.ledgerProjectBreakdowns[provider] ?? []
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
func ledgerSpendForecast(for provider: UsageProvider) -> UsageLedgerSpendForecast? {
|
|
933
|
+
self.ledgerSpendForecasts[provider]
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
func ledgerProjectSpendForecasts(for provider: UsageProvider) -> [UsageLedgerSpendForecast] {
|
|
937
|
+
self.ledgerProjectSpendForecasts[provider] ?? []
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
func ledgerTopProjectSpendForecast(for provider: UsageProvider) -> UsageLedgerSpendForecast? {
|
|
941
|
+
self.ledgerTopProjectSpendForecasts[provider]
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
func ledgerAnomalySummary(for provider: UsageProvider) -> UsageLedgerAnomalySummary? {
|
|
945
|
+
self.ledgerAnomalies[provider]
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
func ledgerError(for provider: UsageProvider) -> String? {
|
|
950
|
+
self.ledgerErrors[provider]
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
func ledgerUpdatedAt(for provider: UsageProvider) -> Date? {
|
|
954
|
+
self.ledgerUpdatedAt[provider]
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
func ledgerReliabilityScore(for provider: UsageProvider) -> UsageLedgerReliabilityScore? {
|
|
958
|
+
UsageLedgerInsightsAdvisor.reliabilityScore(
|
|
959
|
+
provider: provider,
|
|
960
|
+
daily: self.ledgerDailySummary(for: provider),
|
|
961
|
+
activeBlock: self.ledgerActiveBlock(for: provider),
|
|
962
|
+
modelBreakdown: self.ledgerModelBreakdown(for: provider),
|
|
963
|
+
projectBreakdown: self.ledgerProjectBreakdown(for: provider),
|
|
964
|
+
providerError: self.error(for: provider),
|
|
965
|
+
ledgerError: self.ledgerError(for: provider))
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
func ledgerRoutingRecommendation(for provider: UsageProvider) -> UsageLedgerRoutingRecommendation? {
|
|
969
|
+
UsageLedgerInsightsAdvisor.routingRecommendation(
|
|
970
|
+
modelBreakdown: self.ledgerModelBreakdown(for: provider))
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
func providerHistoryMonth(
|
|
974
|
+
provider: UsageProvider,
|
|
975
|
+
monthStart: Date,
|
|
976
|
+
forceRefresh: Bool = false) async -> ProviderHistoryMonthSnapshot
|
|
977
|
+
{
|
|
978
|
+
var calendar = Calendar(identifier: .gregorian)
|
|
979
|
+
calendar.timeZone = TimeZone.current
|
|
980
|
+
let normalizedMonthStart = self.normalizedMonthStart(monthStart, calendar: calendar)
|
|
981
|
+
let now = Date()
|
|
982
|
+
let cacheKey = self.historyMonthCacheKey(for: normalizedMonthStart)
|
|
983
|
+
|
|
984
|
+
if !forceRefresh,
|
|
985
|
+
let cached = self.providerHistoryMonthCache[provider]?[cacheKey],
|
|
986
|
+
now.timeIntervalSince(cached.fetchedAt) <= self.providerHistoryCacheTTL
|
|
987
|
+
{
|
|
988
|
+
return cached.snapshot
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
let scanDays = self.providerHistoryScanDays(
|
|
992
|
+
monthStart: normalizedMonthStart,
|
|
993
|
+
now: now,
|
|
994
|
+
calendar: calendar)
|
|
995
|
+
|
|
996
|
+
guard let source = self.providerHistorySource(
|
|
997
|
+
provider: provider,
|
|
998
|
+
now: now,
|
|
999
|
+
maxAgeDays: scanDays)
|
|
1000
|
+
else {
|
|
1001
|
+
let unsupported = Self.unsupportedHistorySnapshot(
|
|
1002
|
+
provider: provider,
|
|
1003
|
+
monthStart: normalizedMonthStart,
|
|
1004
|
+
generatedAt: now)
|
|
1005
|
+
self.providerHistoryMonthCache[provider, default: [:]][cacheKey] = ProviderHistoryMonthCacheEntry(
|
|
1006
|
+
fetchedAt: now,
|
|
1007
|
+
snapshot: unsupported)
|
|
1008
|
+
return unsupported
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
let note = self.providerHistoryNote(scanDays: scanDays)
|
|
1012
|
+
|
|
1013
|
+
let snapshot = await Task.detached(priority: .utility) {
|
|
1014
|
+
let timeZone = TimeZone.current
|
|
1015
|
+
var calendar = Calendar(identifier: .gregorian)
|
|
1016
|
+
calendar.timeZone = timeZone
|
|
1017
|
+
|
|
1018
|
+
do {
|
|
1019
|
+
let loaded = try await source.loadEntries()
|
|
1020
|
+
let providerEntries = loaded.filter { $0.provider == provider }
|
|
1021
|
+
let monthEntries = providerEntries.filter {
|
|
1022
|
+
calendar.isDate($0.timestamp, equalTo: normalizedMonthStart, toGranularity: .month)
|
|
1023
|
+
}
|
|
1024
|
+
let days = Self.providerHistoryDays(entries: monthEntries, timeZone: timeZone)
|
|
1025
|
+
return ProviderHistoryMonthSnapshot(
|
|
1026
|
+
provider: provider,
|
|
1027
|
+
monthStart: normalizedMonthStart,
|
|
1028
|
+
generatedAt: now,
|
|
1029
|
+
days: days,
|
|
1030
|
+
isSupported: true,
|
|
1031
|
+
note: note,
|
|
1032
|
+
error: nil)
|
|
1033
|
+
} catch {
|
|
1034
|
+
return ProviderHistoryMonthSnapshot(
|
|
1035
|
+
provider: provider,
|
|
1036
|
+
monthStart: normalizedMonthStart,
|
|
1037
|
+
generatedAt: now,
|
|
1038
|
+
days: [],
|
|
1039
|
+
isSupported: true,
|
|
1040
|
+
note: note,
|
|
1041
|
+
error: error.localizedDescription)
|
|
1042
|
+
}
|
|
1043
|
+
}.value
|
|
1044
|
+
|
|
1045
|
+
self.providerHistoryMonthCache[provider, default: [:]][cacheKey] = ProviderHistoryMonthCacheEntry(
|
|
1046
|
+
fetchedAt: now,
|
|
1047
|
+
snapshot: snapshot)
|
|
1048
|
+
return snapshot
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private func normalizedMonthStart(_ date: Date, calendar: Calendar) -> Date {
|
|
1052
|
+
let components = calendar.dateComponents([.year, .month], from: date)
|
|
1053
|
+
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private func historyMonthCacheKey(for monthStart: Date) -> String {
|
|
1057
|
+
var calendar = Calendar(identifier: .gregorian)
|
|
1058
|
+
calendar.timeZone = TimeZone.current
|
|
1059
|
+
let components = calendar.dateComponents([.year, .month], from: monthStart)
|
|
1060
|
+
return String(format: "%04d-%02d", components.year ?? 1970, components.month ?? 1)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
private func providerHistoryScanDays(monthStart: Date, now: Date, calendar: Calendar) -> Int {
|
|
1064
|
+
let start = calendar.startOfDay(for: monthStart)
|
|
1065
|
+
let end = calendar.startOfDay(for: now)
|
|
1066
|
+
let daysBetween = max(0, calendar.dateComponents([.day], from: start, to: end).day ?? 0)
|
|
1067
|
+
let target = max(35, daysBetween + 7)
|
|
1068
|
+
return min(self.providerHistoryMaxScanDays, target)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private func providerHistoryNote(scanDays: Int) -> String? {
|
|
1072
|
+
guard scanDays >= self.providerHistoryMaxScanDays else { return nil }
|
|
1073
|
+
return "History scans up to the most recent \(scanDays) days for performance."
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
private func providerHistorySource(
|
|
1077
|
+
provider: UsageProvider,
|
|
1078
|
+
now: Date,
|
|
1079
|
+
maxAgeDays: Int) -> (any UsageLedgerSource)?
|
|
1080
|
+
{
|
|
1081
|
+
switch provider {
|
|
1082
|
+
case .claude:
|
|
1083
|
+
return ClaudeUsageLogSource(maxAgeDays: maxAgeDays, now: now)
|
|
1084
|
+
case .codex:
|
|
1085
|
+
return CodexUsageLogSource(maxAgeDays: maxAgeDays, now: now)
|
|
1086
|
+
case .copilot,
|
|
1087
|
+
.gemini,
|
|
1088
|
+
.antigravity,
|
|
1089
|
+
.cursor,
|
|
1090
|
+
.factory,
|
|
1091
|
+
.zai,
|
|
1092
|
+
.minimax,
|
|
1093
|
+
.openrouter,
|
|
1094
|
+
.groq,
|
|
1095
|
+
.deepseek,
|
|
1096
|
+
.fireworks,
|
|
1097
|
+
.mistral,
|
|
1098
|
+
.perplexity,
|
|
1099
|
+
.kimi,
|
|
1100
|
+
.auggie,
|
|
1101
|
+
.together,
|
|
1102
|
+
.cohere,
|
|
1103
|
+
.xai,
|
|
1104
|
+
.cerebras,
|
|
1105
|
+
.sambanova,
|
|
1106
|
+
.azure,
|
|
1107
|
+
.bedrock:
|
|
1108
|
+
return self.providerOTelHistorySource(provider: provider, now: now, maxAgeDays: maxAgeDays)
|
|
1109
|
+
@unknown default:
|
|
1110
|
+
return nil
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
private func providerOTelHistorySource(
|
|
1115
|
+
provider: UsageProvider,
|
|
1116
|
+
now: Date,
|
|
1117
|
+
maxAgeDays: Int) -> (any UsageLedgerSource)?
|
|
1118
|
+
{
|
|
1119
|
+
let files = self.otelLedgerFiles(for: provider)
|
|
1120
|
+
guard !files.isEmpty else { return nil }
|
|
1121
|
+
|
|
1122
|
+
let cutoff = Calendar.current.date(byAdding: .day, value: -max(1, maxAgeDays), to: now)
|
|
1123
|
+
let options = OTelGenAIIngestionOptions(
|
|
1124
|
+
enabled: true,
|
|
1125
|
+
allowExperimentalSemanticConventions: true,
|
|
1126
|
+
defaultProvider: provider,
|
|
1127
|
+
source: .api)
|
|
1128
|
+
return UsageLedgerProviderFilterSource(
|
|
1129
|
+
source: OTelGenAIFileLedgerSource(files: files, options: options),
|
|
1130
|
+
provider: provider,
|
|
1131
|
+
minTimestamp: cutoff)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private struct UsageLedgerProviderFilterSource: UsageLedgerSource, Sendable {
|
|
1135
|
+
private let source: any UsageLedgerSource
|
|
1136
|
+
private let provider: UsageProvider
|
|
1137
|
+
private let minTimestamp: Date?
|
|
1138
|
+
|
|
1139
|
+
init(source: any UsageLedgerSource, provider: UsageProvider, minTimestamp: Date?) {
|
|
1140
|
+
self.source = source
|
|
1141
|
+
self.provider = provider
|
|
1142
|
+
self.minTimestamp = minTimestamp
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
func loadEntries() async throws -> [UsageLedgerEntry] {
|
|
1146
|
+
let entries = try await self.source.loadEntries()
|
|
1147
|
+
return entries.filter { entry in
|
|
1148
|
+
if entry.provider != self.provider { return false }
|
|
1149
|
+
if let minTimestamp, entry.timestamp < minTimestamp { return false }
|
|
1150
|
+
return true
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
private func otelLedgerFiles(for provider: UsageProvider) -> [URL] {
|
|
1156
|
+
let env = ProcessInfo.processInfo.environment
|
|
1157
|
+
let providerKey = provider.rawValue
|
|
1158
|
+
.replacingOccurrences(of: "-", with: "_")
|
|
1159
|
+
.uppercased()
|
|
1160
|
+
|
|
1161
|
+
let candidatePaths = [
|
|
1162
|
+
Self.splitPathList(env["RUNIC_OTEL_GENAI_LOG_PATHS"]),
|
|
1163
|
+
Self.splitPathList(env["RUNIC_OTEL_GENAI_LOG_PATH"]),
|
|
1164
|
+
Self.splitPathList(env["RUNIC_\(providerKey)_OTEL_GENAI_LOG_PATHS"]),
|
|
1165
|
+
Self.splitPathList(env["RUNIC_\(providerKey)_OTEL_GENAI_LOG_PATH"]),
|
|
1166
|
+
Self.splitPathList(env["RUNIC_\(providerKey)_OTEL_LOG_PATHS"]),
|
|
1167
|
+
Self.splitPathList(env["RUNIC_\(providerKey)_OTEL_LOG_PATH"]),
|
|
1168
|
+
].flatMap { $0 }
|
|
1169
|
+
|
|
1170
|
+
let urls = candidatePaths.compactMap(Self.expandTildePath)
|
|
1171
|
+
return Self.discoverOTelLedgerFiles(from: urls)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
private static func splitPathList(_ raw: String?) -> [String] {
|
|
1175
|
+
guard let raw else { return [] }
|
|
1176
|
+
return raw
|
|
1177
|
+
.split(whereSeparator: { $0 == "," || $0 == ";" || $0 == "\n" })
|
|
1178
|
+
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
1179
|
+
.filter { !$0.isEmpty }
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
private static func expandTildePath(_ rawPath: String) -> URL? {
|
|
1183
|
+
let fileManager = FileManager.default
|
|
1184
|
+
if rawPath.hasPrefix("~/") {
|
|
1185
|
+
return fileManager.homeDirectoryForCurrentUser
|
|
1186
|
+
.appendingPathComponent(String(rawPath.dropFirst(2)), isDirectory: true)
|
|
1187
|
+
}
|
|
1188
|
+
return URL(fileURLWithPath: rawPath, isDirectory: true)
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private static func discoverOTelLedgerFiles(from paths: [URL]) -> [URL] {
|
|
1192
|
+
var found: [URL] = []
|
|
1193
|
+
var seen: Set<String> = []
|
|
1194
|
+
|
|
1195
|
+
for path in paths {
|
|
1196
|
+
if Self.isSupportedOTelFile(path) {
|
|
1197
|
+
if seen.insert(path.standardizedFileURL.path).inserted {
|
|
1198
|
+
found.append(path.standardizedFileURL)
|
|
1199
|
+
}
|
|
1200
|
+
continue
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
for file in Self.scanOTelDirectory(path) where seen.insert(file.standardizedFileURL.path).inserted {
|
|
1204
|
+
found.append(file.standardizedFileURL)
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
return found
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
private static func isSupportedOTelFile(_ url: URL) -> Bool {
|
|
1212
|
+
var isDirectory: ObjCBool = false
|
|
1213
|
+
let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
|
1214
|
+
guard exists && !isDirectory.boolValue else { return false }
|
|
1215
|
+
|
|
1216
|
+
let ext = url.pathExtension.lowercased()
|
|
1217
|
+
return ext == "json" || ext == "jsonl"
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
private static func scanOTelDirectory(_ directory: URL) -> [URL] {
|
|
1221
|
+
var isDirectory: ObjCBool = false
|
|
1222
|
+
guard FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory), isDirectory.boolValue
|
|
1223
|
+
else { return [] }
|
|
1224
|
+
|
|
1225
|
+
guard let enumerator = FileManager.default.enumerator(
|
|
1226
|
+
at: directory,
|
|
1227
|
+
includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey],
|
|
1228
|
+
options: [.skipsHiddenFiles])
|
|
1229
|
+
else { return [] }
|
|
1230
|
+
|
|
1231
|
+
var files: [URL] = []
|
|
1232
|
+
for case let file as URL in enumerator where Self.isSupportedOTelFile(file) {
|
|
1233
|
+
files.append(file)
|
|
1234
|
+
}
|
|
1235
|
+
return files
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
private static func unsupportedHistorySnapshot(
|
|
1239
|
+
provider: UsageProvider,
|
|
1240
|
+
monthStart: Date,
|
|
1241
|
+
generatedAt: Date) -> ProviderHistoryMonthSnapshot
|
|
1242
|
+
{
|
|
1243
|
+
ProviderHistoryMonthSnapshot(
|
|
1244
|
+
provider: provider,
|
|
1245
|
+
monthStart: monthStart,
|
|
1246
|
+
generatedAt: generatedAt,
|
|
1247
|
+
days: [],
|
|
1248
|
+
isSupported: false,
|
|
1249
|
+
note: "History is currently available for Claude/Codex local ledgers and configured OTel sources.",
|
|
1250
|
+
error: nil)
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
nonisolated private static func providerHistoryDays(
|
|
1254
|
+
entries: [UsageLedgerEntry],
|
|
1255
|
+
timeZone: TimeZone) -> [ProviderHistoryDaySnapshot]
|
|
1256
|
+
{
|
|
1257
|
+
guard !entries.isEmpty else { return [] }
|
|
1258
|
+
|
|
1259
|
+
var calendar = Calendar(identifier: .gregorian)
|
|
1260
|
+
calendar.timeZone = timeZone
|
|
1261
|
+
|
|
1262
|
+
let dailySummaries = UsageLedgerAggregator.dailySummaries(
|
|
1263
|
+
entries: entries,
|
|
1264
|
+
timeZone: timeZone,
|
|
1265
|
+
groupByProject: false)
|
|
1266
|
+
.sorted { $0.dayStart < $1.dayStart }
|
|
1267
|
+
|
|
1268
|
+
var entriesByDay: [Date: [UsageLedgerEntry]] = [:]
|
|
1269
|
+
for entry in entries {
|
|
1270
|
+
let dayStart = calendar.startOfDay(for: entry.timestamp)
|
|
1271
|
+
entriesByDay[dayStart, default: []].append(entry)
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
return dailySummaries.map { summary in
|
|
1275
|
+
let dayEntries = entriesByDay[summary.dayStart] ?? []
|
|
1276
|
+
let modelSummaries = UsageLedgerAggregator.modelSummaries(entries: dayEntries)
|
|
1277
|
+
let projectSummaries = UsageLedgerAggregator.projectSummaries(entries: dayEntries)
|
|
1278
|
+
let topModel = modelSummaries.first
|
|
1279
|
+
let topProject = projectSummaries.first
|
|
1280
|
+
return ProviderHistoryDaySnapshot(
|
|
1281
|
+
dayStart: summary.dayStart,
|
|
1282
|
+
totals: summary.totals,
|
|
1283
|
+
requestCount: dayEntries.count,
|
|
1284
|
+
modelsUsed: summary.modelsUsed,
|
|
1285
|
+
topModel: topModel,
|
|
1286
|
+
topProject: topProject,
|
|
1287
|
+
modelSummaries: modelSummaries,
|
|
1288
|
+
projectSummaries: projectSummaries)
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
func refresh(trigger: RefreshTrigger = .manual, forceTokenUsage: Bool = false) async {
|
|
1293
|
+
guard !self.isRefreshing else { return }
|
|
1294
|
+
let now = Date()
|
|
1295
|
+
if trigger.isAuto, !self.shouldRunAutoRefresh(trigger: trigger, now: now) {
|
|
1296
|
+
return
|
|
1297
|
+
}
|
|
1298
|
+
if trigger.isAuto {
|
|
1299
|
+
self.recordAutoRefreshRunIfNeeded(trigger: trigger)
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
self.lastRefreshTrigger = trigger
|
|
1303
|
+
self.lastRefreshAt = now
|
|
1304
|
+
self.isRefreshing = true
|
|
1305
|
+
defer { self.isRefreshing = false }
|
|
1306
|
+
|
|
1307
|
+
let inactiveProviders = trigger.isAuto ? self.inactiveProviders(now: now) : []
|
|
1308
|
+
let skipCodexExtras = trigger.isAuto && inactiveProviders.contains(.codex)
|
|
1309
|
+
|
|
1310
|
+
await withTaskGroup(of: Void.self) { group in
|
|
1311
|
+
for provider in UsageProvider.allCases {
|
|
1312
|
+
if inactiveProviders.contains(provider) { continue }
|
|
1313
|
+
group.addTask { await self.refreshProvider(provider, trigger: trigger) }
|
|
1314
|
+
group.addTask { await self.refreshStatus(provider, trigger: trigger) }
|
|
1315
|
+
}
|
|
1316
|
+
if !skipCodexExtras {
|
|
1317
|
+
group.addTask { await self.refreshCreditsIfNeeded() }
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Token-cost usage can be slow; run it outside the refresh group so we don't block menu updates.
|
|
1322
|
+
self.scheduleTokenRefresh(force: forceTokenUsage, trigger: trigger, inactiveProviders: inactiveProviders)
|
|
1323
|
+
|
|
1324
|
+
// OpenAI web scrape depends on the current Codex account email (which can change after login/account switch).
|
|
1325
|
+
// Run this after Codex usage refresh so we don't accidentally scrape with stale credentials.
|
|
1326
|
+
if !skipCodexExtras {
|
|
1327
|
+
await self.refreshOpenAIDashboardIfNeeded(force: forceTokenUsage)
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if self.openAIDashboardRequiresLogin, !skipCodexExtras {
|
|
1331
|
+
await self.refreshProvider(.codex, trigger: trigger)
|
|
1332
|
+
await self.refreshCreditsIfNeeded()
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
self.scheduleLedgerRefresh(force: forceTokenUsage, inactiveProviders: inactiveProviders)
|
|
1336
|
+
|
|
1337
|
+
// Refresh custom providers
|
|
1338
|
+
await self.refreshCustomProviders()
|
|
1339
|
+
|
|
1340
|
+
self.persistWidgetSnapshot(reason: "refresh")
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/// Refresh all enabled custom providers
|
|
1344
|
+
func refreshCustomProviders() async {
|
|
1345
|
+
let providers = CustomProviderStore.getEnabledProviders()
|
|
1346
|
+
await withTaskGroup(of: Void.self) { group in
|
|
1347
|
+
for provider in providers {
|
|
1348
|
+
group.addTask {
|
|
1349
|
+
await self.refreshCustomProvider(id: provider.id)
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/// Refresh a single custom provider
|
|
1356
|
+
func refreshCustomProvider(id: String) async {
|
|
1357
|
+
guard let config = CustomProviderStore.getProvider(id: id), config.enabled else {
|
|
1358
|
+
return
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
let fetcher = GenericProviderFetcher(config: config)
|
|
1362
|
+
let startTime = Date()
|
|
1363
|
+
let requestID = UUID().uuidString
|
|
1364
|
+
|
|
1365
|
+
do {
|
|
1366
|
+
let usageData = try await fetcher.fetchUsage()
|
|
1367
|
+
let endTime = Date()
|
|
1368
|
+
|
|
1369
|
+
// Track successful latency
|
|
1370
|
+
await self.trackLatency(
|
|
1371
|
+
provider: .codex, // TODO: Add custom provider tracking
|
|
1372
|
+
requestID: requestID,
|
|
1373
|
+
startTime: startTime,
|
|
1374
|
+
endTime: endTime,
|
|
1375
|
+
success: true
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
let snapshot = CustomProviderSnapshot.from(usageData: usageData.toCustomUsageData(), config: config)
|
|
1379
|
+
await MainActor.run {
|
|
1380
|
+
self.customProviderSnapshots[id] = snapshot
|
|
1381
|
+
self.customProviderErrors.removeValue(forKey: id)
|
|
1382
|
+
}
|
|
1383
|
+
} catch {
|
|
1384
|
+
let endTime = Date()
|
|
1385
|
+
|
|
1386
|
+
// Track failed latency and error
|
|
1387
|
+
await self.trackLatency(
|
|
1388
|
+
provider: .codex, // TODO: Add custom provider tracking
|
|
1389
|
+
requestID: requestID,
|
|
1390
|
+
startTime: startTime,
|
|
1391
|
+
endTime: endTime,
|
|
1392
|
+
success: false
|
|
1393
|
+
)
|
|
1394
|
+
await self.trackError(provider: .codex, error: error) // TODO: Add custom provider tracking
|
|
1395
|
+
|
|
1396
|
+
await MainActor.run {
|
|
1397
|
+
self.customProviderErrors[id] = error.localizedDescription
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/// Clear a custom provider snapshot
|
|
1403
|
+
func clearCustomProviderSnapshot(id: String) {
|
|
1404
|
+
self.customProviderSnapshots.removeValue(forKey: id)
|
|
1405
|
+
self.customProviderErrors.removeValue(forKey: id)
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot.
|
|
1409
|
+
func replayLoadingAnimation(duration: TimeInterval = 3) {
|
|
1410
|
+
let current = self.preferredSnapshot
|
|
1411
|
+
self.snapshots.removeAll()
|
|
1412
|
+
self.debugForceAnimation = true
|
|
1413
|
+
Task { @MainActor in
|
|
1414
|
+
try? await Task.sleep(for: .seconds(duration))
|
|
1415
|
+
if let current, let provider = self.enabledProviders().first {
|
|
1416
|
+
self.snapshots[provider] = current
|
|
1417
|
+
}
|
|
1418
|
+
self.debugForceAnimation = false
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// MARK: - Private
|
|
1423
|
+
|
|
1424
|
+
private func bindSettings() {
|
|
1425
|
+
self.observeSettingsChanges()
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
private func handleSettingsChange() async {
|
|
1429
|
+
let refreshChanged = self.settings.refreshFrequency != self.lastRefreshFrequency
|
|
1430
|
+
let warningEnabledChanged = self.settings.autoRefreshWarningEnabled != self.lastAutoRefreshWarningEnabled
|
|
1431
|
+
let warningThresholdChanged = self.settings.autoRefreshWarningThreshold
|
|
1432
|
+
!= self.lastAutoRefreshWarningThreshold
|
|
1433
|
+
if refreshChanged || warningEnabledChanged || warningThresholdChanged {
|
|
1434
|
+
self.resetAutoRefreshWarningState()
|
|
1435
|
+
self.lastRefreshFrequency = self.settings.refreshFrequency
|
|
1436
|
+
self.lastAutoRefreshWarningEnabled = self.settings.autoRefreshWarningEnabled
|
|
1437
|
+
self.lastAutoRefreshWarningThreshold = self.settings.autoRefreshWarningThreshold
|
|
1438
|
+
}
|
|
1439
|
+
if refreshChanged, !self.suppressNextSettingsRefresh {
|
|
1440
|
+
self.clearAutoRefreshDisableReason()
|
|
1441
|
+
}
|
|
1442
|
+
self.startTimer()
|
|
1443
|
+
self.startTokenTimer()
|
|
1444
|
+
guard !self.suppressNextSettingsRefresh else {
|
|
1445
|
+
self.suppressNextSettingsRefresh = false
|
|
1446
|
+
return
|
|
1447
|
+
}
|
|
1448
|
+
await self.refresh(trigger: .settingsChange)
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
private func startTimer() {
|
|
1452
|
+
self.timerTask?.cancel()
|
|
1453
|
+
guard let wait = self.settings.refreshFrequency.seconds else { return }
|
|
1454
|
+
|
|
1455
|
+
// Background poller so the menu stays responsive; canceled when settings change or store deallocates.
|
|
1456
|
+
self.timerTask = Task.detached(priority: .utility) { [weak self] in
|
|
1457
|
+
while !Task.isCancelled {
|
|
1458
|
+
try? await Task.sleep(for: .seconds(wait))
|
|
1459
|
+
await self?.refresh(trigger: .autoTimer)
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
private func startTokenTimer() {
|
|
1465
|
+
self.tokenTimerTask?.cancel()
|
|
1466
|
+
guard self.settings.refreshFrequency != .manual else { return }
|
|
1467
|
+
guard self.settings.costUsageEnabled else { return }
|
|
1468
|
+
let wait = self.tokenFetchTTL
|
|
1469
|
+
self.tokenTimerTask = Task.detached(priority: .utility) { [weak self] in
|
|
1470
|
+
while !Task.isCancelled {
|
|
1471
|
+
try? await Task.sleep(for: .seconds(wait))
|
|
1472
|
+
let inactive = await self?.inactiveProviders(now: Date()) ?? []
|
|
1473
|
+
await self?.scheduleTokenRefresh(
|
|
1474
|
+
force: false,
|
|
1475
|
+
trigger: .autoTimer,
|
|
1476
|
+
inactiveProviders: inactive)
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
private func shouldRunAutoRefresh(trigger: RefreshTrigger, now: Date) -> Bool {
|
|
1482
|
+
guard trigger.isAuto else { return true }
|
|
1483
|
+
guard self.settings.refreshFrequency != .manual else { return false }
|
|
1484
|
+
if self.autoRefreshSuspensionReason != nil { return false }
|
|
1485
|
+
|
|
1486
|
+
if self.settings.autoDisableRefreshWhenIdleEnabled,
|
|
1487
|
+
let idleSeconds = self.currentIdleSeconds()
|
|
1488
|
+
{
|
|
1489
|
+
let thresholdMinutes = max(1, self.settings.autoDisableRefreshWhenIdleMinutes)
|
|
1490
|
+
let thresholdSeconds = TimeInterval(thresholdMinutes) * 60
|
|
1491
|
+
if idleSeconds >= thresholdSeconds {
|
|
1492
|
+
self.disableAutoRefreshForIdle(thresholdMinutes: thresholdMinutes)
|
|
1493
|
+
return false
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return true
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
private func disableAutoRefreshForIdle(thresholdMinutes: Int) {
|
|
1500
|
+
self.disableAutoRefresh(
|
|
1501
|
+
reason: .idle,
|
|
1502
|
+
idPrefix: "auto-refresh-idle",
|
|
1503
|
+
body: "Runic switched auto-refresh to Manual after \(thresholdMinutes) minutes of inactivity. "
|
|
1504
|
+
+ "Use Refresh in the menu when you want new data.")
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
private func disableAutoRefresh(reason: AutoRefreshDisableReason, idPrefix: String, body: String) {
|
|
1508
|
+
guard self.settings.refreshFrequency != .manual else { return }
|
|
1509
|
+
self.suppressNextSettingsRefresh = true
|
|
1510
|
+
self.settings.refreshFrequency = .manual
|
|
1511
|
+
self.autoRefreshSuspensionReason = nil
|
|
1512
|
+
self.lastAutoRefreshDisableReason = reason
|
|
1513
|
+
self.lastAutoRefreshDisableAt = Date()
|
|
1514
|
+
AppNotifications.shared.post(
|
|
1515
|
+
idPrefix: idPrefix,
|
|
1516
|
+
title: "Auto-refresh switched to Manual",
|
|
1517
|
+
body: body)
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
private func clearAutoRefreshDisableReason() {
|
|
1521
|
+
self.lastAutoRefreshDisableReason = nil
|
|
1522
|
+
self.lastAutoRefreshDisableAt = nil
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
private func systemPauseDetailText(_ reason: AutoRefreshSuspensionReason) -> String {
|
|
1526
|
+
switch reason {
|
|
1527
|
+
case .systemSleep:
|
|
1528
|
+
return "your Mac went to sleep"
|
|
1529
|
+
case .screenSleep:
|
|
1530
|
+
return "the display went to sleep"
|
|
1531
|
+
case .sessionInactive:
|
|
1532
|
+
return "your session became inactive"
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
private func disableReason(for reason: AutoRefreshSuspensionReason) -> AutoRefreshDisableReason {
|
|
1537
|
+
switch reason {
|
|
1538
|
+
case .systemSleep: .systemSleep
|
|
1539
|
+
case .screenSleep: .screenSleep
|
|
1540
|
+
case .sessionInactive: .sessionInactive
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
private func currentIdleSeconds() -> TimeInterval? {
|
|
1545
|
+
#if os(macOS)
|
|
1546
|
+
guard let anyInputType = CGEventType(rawValue: UInt32.max) else { return nil }
|
|
1547
|
+
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyInputType)
|
|
1548
|
+
return seconds >= 0 ? seconds : nil
|
|
1549
|
+
#else
|
|
1550
|
+
return nil
|
|
1551
|
+
#endif
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
private func recordAutoRefreshRunIfNeeded(trigger: RefreshTrigger) {
|
|
1555
|
+
guard trigger == .autoTimer else { return }
|
|
1556
|
+
guard self.settings.autoRefreshWarningEnabled else { return }
|
|
1557
|
+
guard !self.autoRefreshWarningSent else { return }
|
|
1558
|
+
let threshold = max(1, self.settings.autoRefreshWarningThreshold)
|
|
1559
|
+
self.autoRefreshRunCount += 1
|
|
1560
|
+
guard self.autoRefreshRunCount >= threshold else { return }
|
|
1561
|
+
self.autoRefreshWarningSent = true
|
|
1562
|
+
AppNotifications.shared.post(
|
|
1563
|
+
idPrefix: "auto-refresh-warning",
|
|
1564
|
+
title: "Auto-refresh is enabled",
|
|
1565
|
+
body: "Runic has auto-refreshed \(threshold) times. Auto-refresh can touch CLIs, "
|
|
1566
|
+
+ "logs, or browser cookies depending on provider settings. Switch to Manual if you prefer "
|
|
1567
|
+
+ "click-only refresh.")
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
private func resetAutoRefreshWarningState() {
|
|
1571
|
+
self.autoRefreshRunCount = 0
|
|
1572
|
+
self.autoRefreshWarningSent = false
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
private func inactiveProviders(now: Date) -> Set<UsageProvider> {
|
|
1576
|
+
guard self.settings.autoSuspendInactiveProvidersEnabled else { return [] }
|
|
1577
|
+
let thresholdMinutes = max(1, self.settings.autoSuspendInactiveProvidersMinutes)
|
|
1578
|
+
let thresholdSeconds = TimeInterval(thresholdMinutes) * 60
|
|
1579
|
+
|
|
1580
|
+
var inactive: Set<UsageProvider> = []
|
|
1581
|
+
for provider in UsageProvider.allCases {
|
|
1582
|
+
guard self.isEnabled(provider) else { continue }
|
|
1583
|
+
guard let lastActivity = self.lastActivityAt(for: provider) else { continue }
|
|
1584
|
+
if now.timeIntervalSince(lastActivity) >= thresholdSeconds {
|
|
1585
|
+
inactive.insert(provider)
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
return inactive
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
private func lastActivityAt(for provider: UsageProvider) -> Date? {
|
|
1592
|
+
let ledger = self.lastLedgerActivityAt[provider]
|
|
1593
|
+
let delta = self.lastUsageDeltaAt[provider]
|
|
1594
|
+
switch (ledger, delta) {
|
|
1595
|
+
case let (l?, d?):
|
|
1596
|
+
return max(l, d)
|
|
1597
|
+
case let (l?, nil):
|
|
1598
|
+
return l
|
|
1599
|
+
case let (nil, d?):
|
|
1600
|
+
return d
|
|
1601
|
+
case (nil, nil):
|
|
1602
|
+
return nil
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
private func recordUsageActivity(
|
|
1607
|
+
provider: UsageProvider,
|
|
1608
|
+
previous: UsageSnapshot?,
|
|
1609
|
+
current: UsageSnapshot)
|
|
1610
|
+
{
|
|
1611
|
+
if previous == nil || self.usageChanged(previous: previous, current: current) {
|
|
1612
|
+
self.lastUsageDeltaAt[provider] = current.updatedAt
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
private func usageChanged(previous: UsageSnapshot?, current: UsageSnapshot) -> Bool {
|
|
1617
|
+
guard let previous else { return true }
|
|
1618
|
+
|
|
1619
|
+
func windowChanged(_ lhs: RateWindow?, _ rhs: RateWindow?) -> Bool {
|
|
1620
|
+
switch (lhs, rhs) {
|
|
1621
|
+
case (nil, nil):
|
|
1622
|
+
return false
|
|
1623
|
+
case let (l?, r?):
|
|
1624
|
+
return abs(l.usedPercent - r.usedPercent) >= 0.1
|
|
1625
|
+
case (nil, _), (_, nil):
|
|
1626
|
+
return true
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
if windowChanged(previous.primary, current.primary) { return true }
|
|
1631
|
+
if windowChanged(previous.secondary, current.secondary) { return true }
|
|
1632
|
+
if windowChanged(previous.tertiary, current.tertiary) { return true }
|
|
1633
|
+
|
|
1634
|
+
switch (previous.providerCost, current.providerCost) {
|
|
1635
|
+
case (nil, nil):
|
|
1636
|
+
return false
|
|
1637
|
+
case let (lhs?, rhs?):
|
|
1638
|
+
if lhs.used != rhs.used { return true }
|
|
1639
|
+
if lhs.limit != rhs.limit { return true }
|
|
1640
|
+
return false
|
|
1641
|
+
case (nil, _), (_, nil):
|
|
1642
|
+
return true
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
private func scheduleLedgerRefresh(
|
|
1647
|
+
force: Bool,
|
|
1648
|
+
inactiveProviders: Set<UsageProvider>)
|
|
1649
|
+
{
|
|
1650
|
+
let now = Date()
|
|
1651
|
+
let sources = self.ledgerSources(now: now, inactiveProviders: inactiveProviders)
|
|
1652
|
+
let providers = sources.map(\.0)
|
|
1653
|
+
if providers.isEmpty { return }
|
|
1654
|
+
if !force {
|
|
1655
|
+
let shouldRefresh = providers.contains { provider in
|
|
1656
|
+
guard let last = self.ledgerUpdatedAt[provider] else { return true }
|
|
1657
|
+
return now.timeIntervalSince(last) >= self.ledgerRefreshTTL
|
|
1658
|
+
}
|
|
1659
|
+
if !shouldRefresh { return }
|
|
1660
|
+
}
|
|
1661
|
+
if self.ledgerRefreshTask != nil { return }
|
|
1662
|
+
|
|
1663
|
+
self.ledgerRefreshTask = Task.detached(priority: .utility) { [weak self] in
|
|
1664
|
+
guard let self else { return }
|
|
1665
|
+
let result = await self.loadLedgerInsights(sources: sources, now: now)
|
|
1666
|
+
await MainActor.run { [weak self] in
|
|
1667
|
+
guard let self else { return }
|
|
1668
|
+
self.ledgerRefreshTask = nil
|
|
1669
|
+
for provider in result.providers {
|
|
1670
|
+
if let error = result.errorsByProvider[provider] {
|
|
1671
|
+
self.ledgerErrors[provider] = error
|
|
1672
|
+
} else {
|
|
1673
|
+
self.ledgerErrors.removeValue(forKey: provider)
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
if let daily = result.dailyByProvider[provider] {
|
|
1677
|
+
self.ledgerDailySummaries[provider] = daily
|
|
1678
|
+
} else {
|
|
1679
|
+
self.ledgerDailySummaries.removeValue(forKey: provider)
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
if let allDaily = result.allDailySummariesByProvider[provider], !allDaily.isEmpty {
|
|
1683
|
+
self.ledgerAllDailySummaries[provider] = allDaily
|
|
1684
|
+
} else {
|
|
1685
|
+
self.ledgerAllDailySummaries.removeValue(forKey: provider)
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if let hourly = result.hourlySummariesByProvider[provider], !hourly.isEmpty {
|
|
1689
|
+
self.ledgerHourlySummaries[provider] = hourly
|
|
1690
|
+
} else {
|
|
1691
|
+
self.ledgerHourlySummaries.removeValue(forKey: provider)
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
if let block = result.activeBlocksByProvider[provider] {
|
|
1695
|
+
self.ledgerActiveBlocks[provider] = block
|
|
1696
|
+
} else {
|
|
1697
|
+
self.ledgerActiveBlocks.removeValue(forKey: provider)
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
if let topModel = result.topModelsByProvider[provider] {
|
|
1701
|
+
self.ledgerTopModels[provider] = topModel
|
|
1702
|
+
} else {
|
|
1703
|
+
self.ledgerTopModels.removeValue(forKey: provider)
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
if let topProject = result.topProjectsByProvider[provider] {
|
|
1707
|
+
self.ledgerTopProjects[provider] = topProject
|
|
1708
|
+
} else {
|
|
1709
|
+
self.ledgerTopProjects.removeValue(forKey: provider)
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
if let breakdown = result.modelBreakdownsByProvider[provider] {
|
|
1713
|
+
self.ledgerModelBreakdowns[provider] = breakdown
|
|
1714
|
+
} else {
|
|
1715
|
+
self.ledgerModelBreakdowns.removeValue(forKey: provider)
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
if let breakdown = result.projectBreakdownsByProvider[provider] {
|
|
1719
|
+
self.ledgerProjectBreakdowns[provider] = breakdown
|
|
1720
|
+
} else {
|
|
1721
|
+
self.ledgerProjectBreakdowns.removeValue(forKey: provider)
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
if let forecast = result.spendForecastsByProvider[provider] {
|
|
1725
|
+
self.ledgerSpendForecasts[provider] = forecast
|
|
1726
|
+
} else {
|
|
1727
|
+
self.ledgerSpendForecasts.removeValue(forKey: provider)
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
if let forecasts = result.projectSpendForecastsByProvider[provider] {
|
|
1731
|
+
self.ledgerProjectSpendForecasts[provider] = forecasts
|
|
1732
|
+
} else {
|
|
1733
|
+
self.ledgerProjectSpendForecasts.removeValue(forKey: provider)
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
if let forecast = result.topProjectSpendForecastsByProvider[provider] {
|
|
1737
|
+
self.ledgerTopProjectSpendForecasts[provider] = forecast
|
|
1738
|
+
} else {
|
|
1739
|
+
self.ledgerTopProjectSpendForecasts.removeValue(forKey: provider)
|
|
1740
|
+
}
|
|
1741
|
+
if let anomaly = result.anomaliesByProvider[provider] {
|
|
1742
|
+
self.ledgerAnomalies[provider] = anomaly
|
|
1743
|
+
} else {
|
|
1744
|
+
self.ledgerAnomalies.removeValue(forKey: provider)
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
if let lastActivity = result.lastActivityByProvider[provider] {
|
|
1748
|
+
self.lastLedgerActivityAt[provider] = lastActivity
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
self.ledgerUpdatedAt[provider] = result.updatedAt
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// Budget breach notifications
|
|
1755
|
+
if self.settings.budgetNotificationsEnabled {
|
|
1756
|
+
BudgetNotificationManager.shared.checkAndNotify(
|
|
1757
|
+
forecasts: self.ledgerProjectSpendForecasts,
|
|
1758
|
+
settings: self.settings)
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
private func scheduleTokenRefresh(
|
|
1765
|
+
force: Bool,
|
|
1766
|
+
trigger: RefreshTrigger,
|
|
1767
|
+
inactiveProviders: Set<UsageProvider>)
|
|
1768
|
+
{
|
|
1769
|
+
if trigger.isAuto, !self.shouldRunAutoRefresh(trigger: trigger, now: Date()) {
|
|
1770
|
+
return
|
|
1771
|
+
}
|
|
1772
|
+
if force {
|
|
1773
|
+
self.tokenRefreshSequenceTask?.cancel()
|
|
1774
|
+
self.tokenRefreshSequenceTask = nil
|
|
1775
|
+
} else if self.tokenRefreshSequenceTask != nil {
|
|
1776
|
+
return
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
self.tokenRefreshSequenceTask = Task(priority: .utility) { [weak self] in
|
|
1780
|
+
guard let self else { return }
|
|
1781
|
+
defer {
|
|
1782
|
+
Task { @MainActor [weak self] in
|
|
1783
|
+
self?.tokenRefreshSequenceTask = nil
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
for provider in UsageProvider.allCases {
|
|
1787
|
+
if Task.isCancelled { break }
|
|
1788
|
+
await self.refreshTokenUsage(
|
|
1789
|
+
provider,
|
|
1790
|
+
force: force,
|
|
1791
|
+
trigger: trigger,
|
|
1792
|
+
inactiveProviders: inactiveProviders)
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
deinit {
|
|
1798
|
+
self.timerTask?.cancel()
|
|
1799
|
+
self.tokenTimerTask?.cancel()
|
|
1800
|
+
self.tokenRefreshSequenceTask?.cancel()
|
|
1801
|
+
self.ledgerRefreshTask?.cancel()
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
private struct LedgerRefreshResult: Sendable {
|
|
1805
|
+
let dailyByProvider: [UsageProvider: UsageLedgerDailySummary]
|
|
1806
|
+
let allDailySummariesByProvider: [UsageProvider: [UsageLedgerDailySummary]]
|
|
1807
|
+
let hourlySummariesByProvider: [UsageProvider: [UsageLedgerHourlySummary]]
|
|
1808
|
+
let activeBlocksByProvider: [UsageProvider: UsageLedgerBlockSummary]
|
|
1809
|
+
let topModelsByProvider: [UsageProvider: UsageLedgerModelSummary]
|
|
1810
|
+
let topProjectsByProvider: [UsageProvider: UsageLedgerProjectSummary]
|
|
1811
|
+
let modelBreakdownsByProvider: [UsageProvider: [UsageLedgerModelSummary]]
|
|
1812
|
+
let projectBreakdownsByProvider: [UsageProvider: [UsageLedgerProjectSummary]]
|
|
1813
|
+
let spendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast]
|
|
1814
|
+
let projectSpendForecastsByProvider: [UsageProvider: [UsageLedgerSpendForecast]]
|
|
1815
|
+
let topProjectSpendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast]
|
|
1816
|
+
let anomaliesByProvider: [UsageProvider: UsageLedgerAnomalySummary]
|
|
1817
|
+
let errorsByProvider: [UsageProvider: String]
|
|
1818
|
+
let lastActivityByProvider: [UsageProvider: Date]
|
|
1819
|
+
let updatedAt: Date
|
|
1820
|
+
let providers: [UsageProvider]
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
private struct ProviderHistoryMonthCacheEntry {
|
|
1824
|
+
let fetchedAt: Date
|
|
1825
|
+
let snapshot: ProviderHistoryMonthSnapshot
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
private func ledgerSources(
|
|
1829
|
+
now: Date,
|
|
1830
|
+
inactiveProviders: Set<UsageProvider>) -> [(UsageProvider, any UsageLedgerSource)]
|
|
1831
|
+
{
|
|
1832
|
+
return UsageProvider.allCases.compactMap { provider in
|
|
1833
|
+
guard self.isEnabled(provider), !inactiveProviders.contains(provider) else { return nil }
|
|
1834
|
+
guard let source = self.providerHistorySource(
|
|
1835
|
+
provider: provider,
|
|
1836
|
+
now: now,
|
|
1837
|
+
maxAgeDays: self.ledgerMaxAgeDays)
|
|
1838
|
+
else { return nil }
|
|
1839
|
+
return (provider, source)
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
private func loadLedgerInsights(
|
|
1844
|
+
sources: [(UsageProvider, any UsageLedgerSource)],
|
|
1845
|
+
now: Date) async -> LedgerRefreshResult
|
|
1846
|
+
{
|
|
1847
|
+
guard !sources.isEmpty else {
|
|
1848
|
+
return LedgerRefreshResult(
|
|
1849
|
+
dailyByProvider: [:],
|
|
1850
|
+
allDailySummariesByProvider: [:],
|
|
1851
|
+
hourlySummariesByProvider: [:],
|
|
1852
|
+
activeBlocksByProvider: [:],
|
|
1853
|
+
topModelsByProvider: [:],
|
|
1854
|
+
topProjectsByProvider: [:],
|
|
1855
|
+
modelBreakdownsByProvider: [:],
|
|
1856
|
+
projectBreakdownsByProvider: [:],
|
|
1857
|
+
spendForecastsByProvider: [:],
|
|
1858
|
+
projectSpendForecastsByProvider: [:],
|
|
1859
|
+
topProjectSpendForecastsByProvider: [:],
|
|
1860
|
+
anomaliesByProvider: [:],
|
|
1861
|
+
errorsByProvider: [:],
|
|
1862
|
+
lastActivityByProvider: [:],
|
|
1863
|
+
updatedAt: now,
|
|
1864
|
+
providers: [])
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
let providers = sources.map(\.0)
|
|
1868
|
+
var entries: [UsageLedgerEntry] = []
|
|
1869
|
+
var errors: [UsageProvider: String] = [:]
|
|
1870
|
+
|
|
1871
|
+
await withTaskGroup(of: (UsageProvider, Result<[UsageLedgerEntry], Error>).self) { group in
|
|
1872
|
+
for (provider, source) in sources {
|
|
1873
|
+
group.addTask {
|
|
1874
|
+
do {
|
|
1875
|
+
let loaded = try await source.loadEntries()
|
|
1876
|
+
return (provider, .success(loaded))
|
|
1877
|
+
} catch {
|
|
1878
|
+
return (provider, .failure(error))
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
for await (provider, result) in group {
|
|
1884
|
+
switch result {
|
|
1885
|
+
case let .success(loaded):
|
|
1886
|
+
entries.append(contentsOf: loaded)
|
|
1887
|
+
case let .failure(error):
|
|
1888
|
+
errors[provider] = error.localizedDescription
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
var lastActivityByProvider: [UsageProvider: Date] = [:]
|
|
1894
|
+
for entry in entries {
|
|
1895
|
+
if let current = lastActivityByProvider[entry.provider] {
|
|
1896
|
+
if entry.timestamp > current { lastActivityByProvider[entry.provider] = entry.timestamp }
|
|
1897
|
+
} else {
|
|
1898
|
+
lastActivityByProvider[entry.provider] = entry.timestamp
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
let timeZone = TimeZone.current
|
|
1903
|
+
var calendar = Calendar(identifier: .gregorian)
|
|
1904
|
+
calendar.timeZone = timeZone
|
|
1905
|
+
let todayStart = calendar.startOfDay(for: now)
|
|
1906
|
+
|
|
1907
|
+
let dailySummaries = UsageLedgerAggregator.dailySummaries(
|
|
1908
|
+
entries: entries,
|
|
1909
|
+
timeZone: timeZone,
|
|
1910
|
+
groupByProject: false)
|
|
1911
|
+
var dailyByProvider: [UsageProvider: UsageLedgerDailySummary] = [:]
|
|
1912
|
+
var allDailySummariesByProvider: [UsageProvider: [UsageLedgerDailySummary]] = [:]
|
|
1913
|
+
for summary in dailySummaries {
|
|
1914
|
+
allDailySummariesByProvider[summary.provider, default: []].append(summary)
|
|
1915
|
+
if summary.dayStart == todayStart {
|
|
1916
|
+
dailyByProvider[summary.provider] = summary
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
let hourlySummaries = UsageLedgerAggregator.hourlySummaries(
|
|
1921
|
+
entries: entries,
|
|
1922
|
+
timeZone: timeZone,
|
|
1923
|
+
groupByProject: false)
|
|
1924
|
+
var hourlySummariesByProvider: [UsageProvider: [UsageLedgerHourlySummary]] = [:]
|
|
1925
|
+
for summary in hourlySummaries {
|
|
1926
|
+
hourlySummariesByProvider[summary.provider, default: []].append(summary)
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
let blocks = UsageLedgerAggregator.blockSummaries(entries: entries, blockHours: 5, now: now)
|
|
1930
|
+
var activeByProvider: [UsageProvider: UsageLedgerBlockSummary] = [:]
|
|
1931
|
+
for block in blocks where block.isActive {
|
|
1932
|
+
if activeByProvider[block.provider] == nil {
|
|
1933
|
+
activeByProvider[block.provider] = block
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
let todayEntries = entries.filter { calendar.isDate($0.timestamp, inSameDayAs: now) }
|
|
1938
|
+
let budgetProjectNames = self.projectNameOverridesFromBudgets()
|
|
1939
|
+
let modelSummaries = UsageLedgerAggregator.modelSummaries(entries: todayEntries)
|
|
1940
|
+
var topModelsByProvider: [UsageProvider: UsageLedgerModelSummary] = [:]
|
|
1941
|
+
for summary in modelSummaries {
|
|
1942
|
+
if topModelsByProvider[summary.provider] == nil {
|
|
1943
|
+
topModelsByProvider[summary.provider] = summary
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
let projectSummaries = UsageLedgerAggregator.projectSummaries(entries: todayEntries)
|
|
1948
|
+
.map { self.resolvedProjectSummary($0, budgetProjectNames: budgetProjectNames) }
|
|
1949
|
+
var topProjectsByProvider: [UsageProvider: UsageLedgerProjectSummary] = [:]
|
|
1950
|
+
for summary in projectSummaries {
|
|
1951
|
+
if topProjectsByProvider[summary.provider] == nil {
|
|
1952
|
+
topProjectsByProvider[summary.provider] = summary
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
let modelBreakdowns = UsageLedgerAggregator.modelSummaries(entries: todayEntries, groupByProject: true)
|
|
1957
|
+
.map { self.resolvedModelSummary($0, budgetProjectNames: budgetProjectNames) }
|
|
1958
|
+
var modelBreakdownsByProvider: [UsageProvider: [UsageLedgerModelSummary]] = [:]
|
|
1959
|
+
for summary in modelBreakdowns {
|
|
1960
|
+
modelBreakdownsByProvider[summary.provider, default: []].append(summary)
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
var projectBreakdownsByProvider: [UsageProvider: [UsageLedgerProjectSummary]] = [:]
|
|
1964
|
+
for summary in projectSummaries {
|
|
1965
|
+
projectBreakdownsByProvider[summary.provider, default: []].append(summary)
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
let providerForecasts = UsageLedgerAggregator.providerSpendForecasts(
|
|
1969
|
+
entries: entries,
|
|
1970
|
+
now: now,
|
|
1971
|
+
timeZone: timeZone)
|
|
1972
|
+
var spendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast] = [:]
|
|
1973
|
+
for forecast in providerForecasts where spendForecastsByProvider[forecast.provider] == nil {
|
|
1974
|
+
spendForecastsByProvider[forecast.provider] = forecast
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
let budgetLimitsByProjectID = self.activeBudgetLimitsByProjectID()
|
|
1978
|
+
let projectForecasts = UsageLedgerAggregator.projectSpendForecasts(
|
|
1979
|
+
entries: entries,
|
|
1980
|
+
now: now,
|
|
1981
|
+
timeZone: timeZone)
|
|
1982
|
+
.map {
|
|
1983
|
+
self.resolvedSpendForecast(
|
|
1984
|
+
$0,
|
|
1985
|
+
budgetProjectNames: budgetProjectNames,
|
|
1986
|
+
budgetLimitsByProjectID: budgetLimitsByProjectID)
|
|
1987
|
+
}
|
|
1988
|
+
var projectSpendForecastsByProvider: [UsageProvider: [UsageLedgerSpendForecast]] = [:]
|
|
1989
|
+
for forecast in projectForecasts {
|
|
1990
|
+
projectSpendForecastsByProvider[forecast.provider, default: []].append(forecast)
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
var topProjectSpendForecastsByProvider: [UsageProvider: UsageLedgerSpendForecast] = [:]
|
|
1994
|
+
for (provider, summary) in topProjectsByProvider {
|
|
1995
|
+
guard let forecasts = projectSpendForecastsByProvider[provider] else { continue }
|
|
1996
|
+
if let matched = self.matchingProjectForecast(for: summary, forecasts: forecasts) {
|
|
1997
|
+
topProjectSpendForecastsByProvider[provider] = matched
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
let anomaliesByProvider = UsageLedgerAnomalyDetector.summaries(
|
|
2001
|
+
dailySummaries: dailySummaries,
|
|
2002
|
+
now: now,
|
|
2003
|
+
calendar: calendar)
|
|
2004
|
+
|
|
2005
|
+
return LedgerRefreshResult(
|
|
2006
|
+
dailyByProvider: dailyByProvider,
|
|
2007
|
+
allDailySummariesByProvider: allDailySummariesByProvider,
|
|
2008
|
+
hourlySummariesByProvider: hourlySummariesByProvider,
|
|
2009
|
+
activeBlocksByProvider: activeByProvider,
|
|
2010
|
+
topModelsByProvider: topModelsByProvider,
|
|
2011
|
+
topProjectsByProvider: topProjectsByProvider,
|
|
2012
|
+
modelBreakdownsByProvider: modelBreakdownsByProvider,
|
|
2013
|
+
projectBreakdownsByProvider: projectBreakdownsByProvider,
|
|
2014
|
+
spendForecastsByProvider: spendForecastsByProvider,
|
|
2015
|
+
projectSpendForecastsByProvider: projectSpendForecastsByProvider,
|
|
2016
|
+
topProjectSpendForecastsByProvider: topProjectSpendForecastsByProvider,
|
|
2017
|
+
anomaliesByProvider: anomaliesByProvider,
|
|
2018
|
+
errorsByProvider: errors,
|
|
2019
|
+
lastActivityByProvider: lastActivityByProvider,
|
|
2020
|
+
updatedAt: now,
|
|
2021
|
+
providers: providers)
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
private func projectNameOverridesFromBudgets() -> [String: String] {
|
|
2025
|
+
var namesByProjectID: [String: String] = [:]
|
|
2026
|
+
for budget in ProjectBudgetStore.getAllBudgets() {
|
|
2027
|
+
let trimmed = budget.projectName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2028
|
+
if let trimmed, !trimmed.isEmpty {
|
|
2029
|
+
namesByProjectID[budget.projectID] = trimmed
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
return namesByProjectID
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
private func activeBudgetLimitsByProjectID() -> [String: Double] {
|
|
2036
|
+
var limitsByProjectID: [String: Double] = [:]
|
|
2037
|
+
for budget in ProjectBudgetStore.getAllBudgets() where budget.enabled && budget.monthlyLimit > 0 {
|
|
2038
|
+
limitsByProjectID[budget.projectID] = budget.monthlyLimit
|
|
2039
|
+
}
|
|
2040
|
+
return limitsByProjectID
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
private func resolvedSpendForecast(
|
|
2044
|
+
_ forecast: UsageLedgerSpendForecast,
|
|
2045
|
+
budgetProjectNames: [String: String],
|
|
2046
|
+
budgetLimitsByProjectID: [String: Double]) -> UsageLedgerSpendForecast
|
|
2047
|
+
{
|
|
2048
|
+
let budgetName = forecast.projectID.flatMap { budgetProjectNames[$0] }
|
|
2049
|
+
let identity = UsageLedgerProjectIdentityResolver.resolve(
|
|
2050
|
+
provider: forecast.provider,
|
|
2051
|
+
projectID: forecast.projectID,
|
|
2052
|
+
projectName: forecast.projectName,
|
|
2053
|
+
budgetNameOverride: budgetName)
|
|
2054
|
+
let resolved = UsageLedgerSpendForecast(
|
|
2055
|
+
provider: forecast.provider,
|
|
2056
|
+
projectKey: forecast.projectKey ?? identity.key,
|
|
2057
|
+
projectID: identity.projectID ?? forecast.projectID,
|
|
2058
|
+
projectName: identity.displayName ?? forecast.projectName,
|
|
2059
|
+
observedDays: forecast.observedDays,
|
|
2060
|
+
observedCostUSD: forecast.observedCostUSD,
|
|
2061
|
+
averageDailyCostUSD: forecast.averageDailyCostUSD,
|
|
2062
|
+
projected30DayCostUSD: forecast.projected30DayCostUSD,
|
|
2063
|
+
projectedCostP50USD: forecast.projectedCostP50USD,
|
|
2064
|
+
projectedCostP80USD: forecast.projectedCostP80USD,
|
|
2065
|
+
projectedCostP95USD: forecast.projectedCostP95USD,
|
|
2066
|
+
projectionDays: forecast.projectionDays,
|
|
2067
|
+
budgetLimitUSD: forecast.budgetLimitUSD,
|
|
2068
|
+
budgetETAInDays: forecast.budgetETAInDays,
|
|
2069
|
+
budgetWillBreach: forecast.budgetWillBreach)
|
|
2070
|
+
let budgetLimit = resolved.projectID.flatMap { budgetLimitsByProjectID[$0] }
|
|
2071
|
+
return resolved.applyingBudget(monthlyLimitUSD: budgetLimit)
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
private func matchingProjectForecast(
|
|
2075
|
+
for summary: UsageLedgerProjectSummary,
|
|
2076
|
+
forecasts: [UsageLedgerSpendForecast]) -> UsageLedgerSpendForecast?
|
|
2077
|
+
{
|
|
2078
|
+
if let key = summary.projectKey,
|
|
2079
|
+
let byKey = forecasts.first(where: { $0.projectKey == key })
|
|
2080
|
+
{
|
|
2081
|
+
return byKey
|
|
2082
|
+
}
|
|
2083
|
+
if let projectID = summary.projectID,
|
|
2084
|
+
let byID = forecasts.first(where: { $0.projectID == projectID })
|
|
2085
|
+
{
|
|
2086
|
+
return byID
|
|
2087
|
+
}
|
|
2088
|
+
let summaryName = summary.projectName?
|
|
2089
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2090
|
+
.lowercased()
|
|
2091
|
+
if let summaryName, !summaryName.isEmpty {
|
|
2092
|
+
return forecasts.first {
|
|
2093
|
+
$0.projectName?
|
|
2094
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2095
|
+
.lowercased() == summaryName
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
return nil
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
private func resolvedProjectSummary(
|
|
2102
|
+
_ summary: UsageLedgerProjectSummary,
|
|
2103
|
+
budgetProjectNames: [String: String]) -> UsageLedgerProjectSummary
|
|
2104
|
+
{
|
|
2105
|
+
let budgetName = summary.projectID.flatMap { budgetProjectNames[$0] }
|
|
2106
|
+
let identity = UsageLedgerProjectIdentityResolver.resolve(
|
|
2107
|
+
provider: summary.provider,
|
|
2108
|
+
projectID: summary.projectID,
|
|
2109
|
+
projectName: summary.projectName,
|
|
2110
|
+
budgetNameOverride: budgetName)
|
|
2111
|
+
return UsageLedgerProjectSummary(
|
|
2112
|
+
provider: summary.provider,
|
|
2113
|
+
projectKey: summary.projectKey ?? identity.key,
|
|
2114
|
+
projectID: identity.projectID ?? summary.projectID,
|
|
2115
|
+
projectName: identity.displayName ?? summary.projectName,
|
|
2116
|
+
projectNameConfidence: identity.confidence,
|
|
2117
|
+
projectNameSource: identity.source,
|
|
2118
|
+
projectNameProvenance: identity.provenance,
|
|
2119
|
+
entryCount: summary.entryCount,
|
|
2120
|
+
totals: summary.totals,
|
|
2121
|
+
modelsUsed: summary.modelsUsed)
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
private func resolvedModelSummary(
|
|
2125
|
+
_ summary: UsageLedgerModelSummary,
|
|
2126
|
+
budgetProjectNames: [String: String]) -> UsageLedgerModelSummary
|
|
2127
|
+
{
|
|
2128
|
+
let budgetName = summary.projectID.flatMap { budgetProjectNames[$0] }
|
|
2129
|
+
let identity = UsageLedgerProjectIdentityResolver.resolve(
|
|
2130
|
+
provider: summary.provider,
|
|
2131
|
+
projectID: summary.projectID,
|
|
2132
|
+
projectName: summary.projectName,
|
|
2133
|
+
budgetNameOverride: budgetName)
|
|
2134
|
+
return UsageLedgerModelSummary(
|
|
2135
|
+
provider: summary.provider,
|
|
2136
|
+
projectKey: summary.projectKey ?? identity.key,
|
|
2137
|
+
projectID: identity.projectID ?? summary.projectID,
|
|
2138
|
+
projectName: identity.displayName ?? summary.projectName,
|
|
2139
|
+
projectNameConfidence: identity.confidence,
|
|
2140
|
+
projectNameSource: identity.source,
|
|
2141
|
+
projectNameProvenance: identity.provenance,
|
|
2142
|
+
model: summary.model,
|
|
2143
|
+
entryCount: summary.entryCount,
|
|
2144
|
+
totals: summary.totals)
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
private func refreshProvider(_ provider: UsageProvider, trigger _: RefreshTrigger) async {
|
|
2148
|
+
guard let spec = self.providerSpecs[provider] else { return }
|
|
2149
|
+
|
|
2150
|
+
if !spec.isEnabled() {
|
|
2151
|
+
self.refreshingProviders.remove(provider)
|
|
2152
|
+
await MainActor.run {
|
|
2153
|
+
self.snapshots.removeValue(forKey: provider)
|
|
2154
|
+
self.errors[provider] = nil
|
|
2155
|
+
self.lastSourceLabels.removeValue(forKey: provider)
|
|
2156
|
+
self.lastFetchAttempts.removeValue(forKey: provider)
|
|
2157
|
+
self.tokenSnapshots.removeValue(forKey: provider)
|
|
2158
|
+
self.tokenErrors[provider] = nil
|
|
2159
|
+
self.failureGates[provider]?.reset()
|
|
2160
|
+
self.tokenFailureGates[provider]?.reset()
|
|
2161
|
+
self.statuses.removeValue(forKey: provider)
|
|
2162
|
+
self.lastKnownSessionRemaining.removeValue(forKey: provider)
|
|
2163
|
+
self.lastTokenFetchAt.removeValue(forKey: provider)
|
|
2164
|
+
self.lastUsageDeltaAt.removeValue(forKey: provider)
|
|
2165
|
+
self.lastLedgerActivityAt.removeValue(forKey: provider)
|
|
2166
|
+
}
|
|
2167
|
+
return
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
self.refreshingProviders.insert(provider)
|
|
2171
|
+
defer { self.refreshingProviders.remove(provider) }
|
|
2172
|
+
|
|
2173
|
+
let previousSnapshot = self.snapshots[provider]
|
|
2174
|
+
let outcome = await spec.fetch()
|
|
2175
|
+
await MainActor.run {
|
|
2176
|
+
self.lastFetchAttempts[provider] = outcome.attempts
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
switch outcome.result {
|
|
2180
|
+
case let .success(result):
|
|
2181
|
+
let scoped = result.usage.scoped(to: provider)
|
|
2182
|
+
await MainActor.run {
|
|
2183
|
+
self.handleSessionQuotaTransition(provider: provider, snapshot: scoped)
|
|
2184
|
+
self.snapshots[provider] = scoped
|
|
2185
|
+
self.lastSourceLabels[provider] = result.sourceLabel
|
|
2186
|
+
self.errors[provider] = nil
|
|
2187
|
+
self.failureGates[provider]?.recordSuccess()
|
|
2188
|
+
self.recordUsageActivity(
|
|
2189
|
+
provider: provider,
|
|
2190
|
+
previous: previousSnapshot,
|
|
2191
|
+
current: scoped)
|
|
2192
|
+
}
|
|
2193
|
+
case let .failure(error):
|
|
2194
|
+
await MainActor.run {
|
|
2195
|
+
let hadPriorData = self.snapshots[provider] != nil
|
|
2196
|
+
let shouldSurface = self.failureGates[provider]?
|
|
2197
|
+
.shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
|
|
2198
|
+
if shouldSurface {
|
|
2199
|
+
self.errors[provider] = error.localizedDescription
|
|
2200
|
+
self.snapshots.removeValue(forKey: provider)
|
|
2201
|
+
} else {
|
|
2202
|
+
self.errors[provider] = nil
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
private func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) {
|
|
2209
|
+
let currentRemaining = snapshot.primary.remainingPercent
|
|
2210
|
+
let previousRemaining = self.lastKnownSessionRemaining[provider]
|
|
2211
|
+
|
|
2212
|
+
defer { self.lastKnownSessionRemaining[provider] = currentRemaining }
|
|
2213
|
+
|
|
2214
|
+
guard self.settings.sessionQuotaNotificationsEnabled else {
|
|
2215
|
+
if SessionQuotaNotificationLogic.isDepleted(currentRemaining) ||
|
|
2216
|
+
SessionQuotaNotificationLogic.isDepleted(previousRemaining)
|
|
2217
|
+
{
|
|
2218
|
+
let providerText = provider.rawValue
|
|
2219
|
+
let message =
|
|
2220
|
+
"notifications disabled: provider=\(providerText) " +
|
|
2221
|
+
"prev=\(previousRemaining ?? -1) curr=\(currentRemaining)"
|
|
2222
|
+
self.sessionQuotaLogger.debug(message)
|
|
2223
|
+
}
|
|
2224
|
+
return
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
guard previousRemaining != nil else {
|
|
2228
|
+
if SessionQuotaNotificationLogic.isDepleted(currentRemaining) {
|
|
2229
|
+
let providerText = provider.rawValue
|
|
2230
|
+
let message = "startup depleted: provider=\(providerText) curr=\(currentRemaining)"
|
|
2231
|
+
self.sessionQuotaLogger.info(message)
|
|
2232
|
+
self.sessionQuotaNotifier.post(transition: .depleted, provider: provider)
|
|
2233
|
+
}
|
|
2234
|
+
return
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
let transition = SessionQuotaNotificationLogic.transition(
|
|
2238
|
+
previousRemaining: previousRemaining,
|
|
2239
|
+
currentRemaining: currentRemaining)
|
|
2240
|
+
guard transition != .none else {
|
|
2241
|
+
if SessionQuotaNotificationLogic.isDepleted(currentRemaining) ||
|
|
2242
|
+
SessionQuotaNotificationLogic.isDepleted(previousRemaining)
|
|
2243
|
+
{
|
|
2244
|
+
let providerText = provider.rawValue
|
|
2245
|
+
let message =
|
|
2246
|
+
"no transition: provider=\(providerText) " +
|
|
2247
|
+
"prev=\(previousRemaining ?? -1) curr=\(currentRemaining)"
|
|
2248
|
+
self.sessionQuotaLogger.debug(message)
|
|
2249
|
+
}
|
|
2250
|
+
return
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
let providerText = provider.rawValue
|
|
2254
|
+
let transitionText = String(describing: transition)
|
|
2255
|
+
let message =
|
|
2256
|
+
"transition \(transitionText): provider=\(providerText) " +
|
|
2257
|
+
"prev=\(previousRemaining ?? -1) curr=\(currentRemaining)"
|
|
2258
|
+
self.sessionQuotaLogger.info(message)
|
|
2259
|
+
|
|
2260
|
+
self.sessionQuotaNotifier.post(transition: transition, provider: provider)
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
private func refreshStatus(_ provider: UsageProvider, trigger _: RefreshTrigger) async {
|
|
2264
|
+
guard self.isEnabled(provider) else { return }
|
|
2265
|
+
guard self.settings.statusChecksEnabled else { return }
|
|
2266
|
+
guard self.settings.refreshFrequency != .manual else { return }
|
|
2267
|
+
guard let meta = self.providerMetadata[provider] else { return }
|
|
2268
|
+
|
|
2269
|
+
do {
|
|
2270
|
+
let status: ProviderStatus
|
|
2271
|
+
if let urlString = meta.statusPageURL, let baseURL = URL(string: urlString) {
|
|
2272
|
+
status = try await Self.fetchStatus(from: baseURL)
|
|
2273
|
+
} else if let productID = meta.statusWorkspaceProductID {
|
|
2274
|
+
status = try await Self.fetchWorkspaceStatus(productID: productID)
|
|
2275
|
+
} else {
|
|
2276
|
+
return
|
|
2277
|
+
}
|
|
2278
|
+
await MainActor.run { self.statuses[provider] = status }
|
|
2279
|
+
} catch {
|
|
2280
|
+
// Keep the previous status to avoid flapping when the API hiccups.
|
|
2281
|
+
await MainActor.run {
|
|
2282
|
+
if self.statuses[provider] == nil {
|
|
2283
|
+
self.statuses[provider] = ProviderStatus(
|
|
2284
|
+
indicator: .unknown,
|
|
2285
|
+
description: error.localizedDescription,
|
|
2286
|
+
updatedAt: nil)
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
private func refreshCreditsIfNeeded() async {
|
|
2293
|
+
guard self.isEnabled(.codex) else { return }
|
|
2294
|
+
do {
|
|
2295
|
+
let credits = try await self.codexFetcher.loadLatestCredits()
|
|
2296
|
+
await MainActor.run {
|
|
2297
|
+
self.credits = credits
|
|
2298
|
+
self.lastCreditsError = nil
|
|
2299
|
+
self.lastCreditsSnapshot = credits
|
|
2300
|
+
self.creditsFailureStreak = 0
|
|
2301
|
+
}
|
|
2302
|
+
} catch {
|
|
2303
|
+
let message = error.localizedDescription
|
|
2304
|
+
if message.localizedCaseInsensitiveContains("data not available yet") {
|
|
2305
|
+
await MainActor.run {
|
|
2306
|
+
if let cached = self.lastCreditsSnapshot {
|
|
2307
|
+
self.credits = cached
|
|
2308
|
+
self.lastCreditsError = nil
|
|
2309
|
+
} else {
|
|
2310
|
+
self.credits = nil
|
|
2311
|
+
self.lastCreditsError = "Codex credits are still loading; will retry shortly."
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
return
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
await MainActor.run {
|
|
2318
|
+
self.creditsFailureStreak += 1
|
|
2319
|
+
if let cached = self.lastCreditsSnapshot {
|
|
2320
|
+
self.credits = cached
|
|
2321
|
+
let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened)
|
|
2322
|
+
self.lastCreditsError =
|
|
2323
|
+
"Last Codex credits refresh failed: \(message). Cached values from \(stamp)."
|
|
2324
|
+
} else {
|
|
2325
|
+
self.lastCreditsError = message
|
|
2326
|
+
self.credits = nil
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
private func applyOpenAIDashboard(_ dash: OpenAIDashboardSnapshot, targetEmail: String?) async {
|
|
2333
|
+
await MainActor.run {
|
|
2334
|
+
self.openAIDashboard = dash
|
|
2335
|
+
self.lastOpenAIDashboardError = nil
|
|
2336
|
+
self.lastOpenAIDashboardSnapshot = dash
|
|
2337
|
+
self.openAIDashboardRequiresLogin = false
|
|
2338
|
+
// Only fill gaps; OAuth/CLI remain the primary sources for usage + credits.
|
|
2339
|
+
if self.snapshots[.codex] == nil,
|
|
2340
|
+
let usage = dash.toUsageSnapshot(provider: .codex, accountEmail: targetEmail)
|
|
2341
|
+
{
|
|
2342
|
+
self.snapshots[.codex] = usage
|
|
2343
|
+
self.errors[.codex] = nil
|
|
2344
|
+
self.failureGates[.codex]?.recordSuccess()
|
|
2345
|
+
self.lastSourceLabels[.codex] = "openai-web"
|
|
2346
|
+
}
|
|
2347
|
+
if self.credits == nil, let credits = dash.toCreditsSnapshot() {
|
|
2348
|
+
self.credits = credits
|
|
2349
|
+
self.lastCreditsSnapshot = credits
|
|
2350
|
+
self.lastCreditsError = nil
|
|
2351
|
+
self.creditsFailureStreak = 0
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
if let email = targetEmail, !email.isEmpty {
|
|
2356
|
+
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(accountEmail: email, snapshot: dash))
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
private func applyOpenAIDashboardFailure(message: String) async {
|
|
2361
|
+
await MainActor.run {
|
|
2362
|
+
if let cached = self.lastOpenAIDashboardSnapshot {
|
|
2363
|
+
self.openAIDashboard = cached
|
|
2364
|
+
let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened)
|
|
2365
|
+
self.lastOpenAIDashboardError =
|
|
2366
|
+
"Last OpenAI dashboard refresh failed: \(message). Cached values from \(stamp)."
|
|
2367
|
+
} else {
|
|
2368
|
+
self.lastOpenAIDashboardError = message
|
|
2369
|
+
self.openAIDashboard = nil
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
private func refreshOpenAIDashboardIfNeeded(force: Bool = false) async {
|
|
2375
|
+
guard self.isEnabled(.codex), self.settings.openAIWebAccessEnabled else {
|
|
2376
|
+
await MainActor.run {
|
|
2377
|
+
self.openAIDashboard = nil
|
|
2378
|
+
self.lastOpenAIDashboardError = nil
|
|
2379
|
+
self.lastOpenAIDashboardSnapshot = nil
|
|
2380
|
+
self.lastOpenAIDashboardTargetEmail = nil
|
|
2381
|
+
self.openAIDashboardRequiresLogin = false
|
|
2382
|
+
self.openAIDashboardCookieImportStatus = nil
|
|
2383
|
+
self.openAIDashboardCookieImportDebugLog = nil
|
|
2384
|
+
self.lastOpenAIDashboardCookieImportAttemptAt = nil
|
|
2385
|
+
self.lastOpenAIDashboardCookieImportEmail = nil
|
|
2386
|
+
}
|
|
2387
|
+
return
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
let targetEmail = self.codexAccountEmailForOpenAIDashboard()
|
|
2391
|
+
self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail)
|
|
2392
|
+
|
|
2393
|
+
let now = Date()
|
|
2394
|
+
let minInterval = max(self.settings.refreshFrequency.seconds ?? 0, 120)
|
|
2395
|
+
if !force,
|
|
2396
|
+
!self.openAIWebAccountDidChange,
|
|
2397
|
+
self.lastOpenAIDashboardError == nil,
|
|
2398
|
+
let snapshot = self.lastOpenAIDashboardSnapshot,
|
|
2399
|
+
now.timeIntervalSince(snapshot.updatedAt) < minInterval
|
|
2400
|
+
{
|
|
2401
|
+
return
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
if self.openAIWebDebugLines.isEmpty {
|
|
2405
|
+
self.resetOpenAIWebDebugLog(context: "refresh")
|
|
2406
|
+
} else {
|
|
2407
|
+
let stamp = Date().formatted(date: .abbreviated, time: .shortened)
|
|
2408
|
+
self.logOpenAIWeb("[\(stamp)] OpenAI web refresh start")
|
|
2409
|
+
}
|
|
2410
|
+
let log: (String) -> Void = { [weak self] line in
|
|
2411
|
+
guard let self else { return }
|
|
2412
|
+
self.logOpenAIWeb(line)
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
do {
|
|
2416
|
+
let normalized = targetEmail?
|
|
2417
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2418
|
+
.lowercased()
|
|
2419
|
+
var effectiveEmail = targetEmail
|
|
2420
|
+
|
|
2421
|
+
// Use a per-email persistent `WKWebsiteDataStore` so multiple dashboard sessions can coexist.
|
|
2422
|
+
// Strategy:
|
|
2423
|
+
// - Try the existing per-email WebKit cookie store first (fast; avoids Keychain prompts).
|
|
2424
|
+
// - On login-required or account mismatch, import cookies from the configured browser order and retry once.
|
|
2425
|
+
if self.openAIWebAccountDidChange, let targetEmail, !targetEmail.isEmpty {
|
|
2426
|
+
// On account switches, proactively re-import cookies so we don't show stale data from the previous
|
|
2427
|
+
// user.
|
|
2428
|
+
if let imported = await self.importOpenAIDashboardCookiesIfNeeded(
|
|
2429
|
+
targetEmail: targetEmail,
|
|
2430
|
+
force: true)
|
|
2431
|
+
{
|
|
2432
|
+
effectiveEmail = imported
|
|
2433
|
+
}
|
|
2434
|
+
self.openAIWebAccountDidChange = false
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
var dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
|
|
2438
|
+
accountEmail: effectiveEmail,
|
|
2439
|
+
logger: log,
|
|
2440
|
+
debugDumpHTML: false)
|
|
2441
|
+
|
|
2442
|
+
if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) {
|
|
2443
|
+
if let imported = await self.importOpenAIDashboardCookiesIfNeeded(
|
|
2444
|
+
targetEmail: targetEmail,
|
|
2445
|
+
force: true)
|
|
2446
|
+
{
|
|
2447
|
+
effectiveEmail = imported
|
|
2448
|
+
}
|
|
2449
|
+
dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
|
|
2450
|
+
accountEmail: effectiveEmail,
|
|
2451
|
+
logger: log,
|
|
2452
|
+
debugDumpHTML: false)
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) {
|
|
2456
|
+
let signedIn = dash.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown"
|
|
2457
|
+
await MainActor.run {
|
|
2458
|
+
self.openAIDashboard = nil
|
|
2459
|
+
self.lastOpenAIDashboardError = [
|
|
2460
|
+
"OpenAI dashboard signed in as \(signedIn), but Codex uses \(normalized ?? "unknown").",
|
|
2461
|
+
"Switch accounts in your browser and re-enable “Access OpenAI via web”.",
|
|
2462
|
+
].joined(separator: " ")
|
|
2463
|
+
self.openAIDashboardRequiresLogin = true
|
|
2464
|
+
}
|
|
2465
|
+
return
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail)
|
|
2469
|
+
} catch let OpenAIDashboardFetcher.FetchError.noDashboardData(body) {
|
|
2470
|
+
// Often indicates a missing/stale session without an obvious login prompt. Retry once after
|
|
2471
|
+
// importing cookies from the user's browser.
|
|
2472
|
+
let targetEmail = self.codexAccountEmailForOpenAIDashboard()
|
|
2473
|
+
var effectiveEmail = targetEmail
|
|
2474
|
+
if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) {
|
|
2475
|
+
effectiveEmail = imported
|
|
2476
|
+
}
|
|
2477
|
+
do {
|
|
2478
|
+
let dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
|
|
2479
|
+
accountEmail: effectiveEmail,
|
|
2480
|
+
logger: log,
|
|
2481
|
+
debugDumpHTML: true)
|
|
2482
|
+
await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail)
|
|
2483
|
+
} catch let OpenAIDashboardFetcher.FetchError.noDashboardData(retryBody) {
|
|
2484
|
+
let finalBody = retryBody.isEmpty ? body : retryBody
|
|
2485
|
+
let message = self.openAIDashboardFriendlyError(
|
|
2486
|
+
body: finalBody,
|
|
2487
|
+
targetEmail: targetEmail,
|
|
2488
|
+
cookieImportStatus: self.openAIDashboardCookieImportStatus)
|
|
2489
|
+
?? OpenAIDashboardFetcher.FetchError.noDashboardData(body: finalBody).localizedDescription
|
|
2490
|
+
await self.applyOpenAIDashboardFailure(message: message)
|
|
2491
|
+
} catch {
|
|
2492
|
+
await self.applyOpenAIDashboardFailure(message: error.localizedDescription)
|
|
2493
|
+
}
|
|
2494
|
+
} catch OpenAIDashboardFetcher.FetchError.loginRequired {
|
|
2495
|
+
let targetEmail = self.codexAccountEmailForOpenAIDashboard()
|
|
2496
|
+
var effectiveEmail = targetEmail
|
|
2497
|
+
if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) {
|
|
2498
|
+
effectiveEmail = imported
|
|
2499
|
+
}
|
|
2500
|
+
do {
|
|
2501
|
+
let dash = try await OpenAIDashboardFetcher().loadLatestDashboard(
|
|
2502
|
+
accountEmail: effectiveEmail,
|
|
2503
|
+
logger: log,
|
|
2504
|
+
debugDumpHTML: true)
|
|
2505
|
+
await self.applyOpenAIDashboard(dash, targetEmail: effectiveEmail)
|
|
2506
|
+
} catch OpenAIDashboardFetcher.FetchError.loginRequired {
|
|
2507
|
+
await MainActor.run {
|
|
2508
|
+
self.lastOpenAIDashboardError = [
|
|
2509
|
+
"OpenAI web access requires a signed-in chatgpt.com session.",
|
|
2510
|
+
"Sign in using \(self.codexBrowserCookieOrder.loginHint), " +
|
|
2511
|
+
"then re-enable “Access OpenAI via web”.",
|
|
2512
|
+
].joined(separator: " ")
|
|
2513
|
+
self.openAIDashboard = self.lastOpenAIDashboardSnapshot
|
|
2514
|
+
self.openAIDashboardRequiresLogin = true
|
|
2515
|
+
}
|
|
2516
|
+
} catch {
|
|
2517
|
+
await self.applyOpenAIDashboardFailure(message: error.localizedDescription)
|
|
2518
|
+
}
|
|
2519
|
+
} catch {
|
|
2520
|
+
await self.applyOpenAIDashboardFailure(message: error.localizedDescription)
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
// MARK: - OpenAI web account switching
|
|
2525
|
+
|
|
2526
|
+
/// Detect Codex account email changes and clear stale OpenAI web state so the UI can't show the wrong user.
|
|
2527
|
+
/// This does not delete other per-email WebKit cookie stores (we keep multiple accounts around).
|
|
2528
|
+
func handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: String?) {
|
|
2529
|
+
let normalized = targetEmail?
|
|
2530
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2531
|
+
.lowercased()
|
|
2532
|
+
|
|
2533
|
+
guard let normalized, !normalized.isEmpty else { return }
|
|
2534
|
+
|
|
2535
|
+
let previous = self.lastOpenAIDashboardTargetEmail
|
|
2536
|
+
self.lastOpenAIDashboardTargetEmail = normalized
|
|
2537
|
+
|
|
2538
|
+
if let previous,
|
|
2539
|
+
!previous.isEmpty,
|
|
2540
|
+
previous != normalized
|
|
2541
|
+
{
|
|
2542
|
+
let stamp = Date().formatted(date: .abbreviated, time: .shortened)
|
|
2543
|
+
self.logOpenAIWeb(
|
|
2544
|
+
"[\(stamp)] Codex account changed: \(previous) → \(normalized); " +
|
|
2545
|
+
"clearing OpenAI web snapshot")
|
|
2546
|
+
self.openAIWebAccountDidChange = true
|
|
2547
|
+
self.openAIDashboard = nil
|
|
2548
|
+
self.lastOpenAIDashboardSnapshot = nil
|
|
2549
|
+
self.lastOpenAIDashboardError = nil
|
|
2550
|
+
self.openAIDashboardRequiresLogin = true
|
|
2551
|
+
self.openAIDashboardCookieImportStatus = "Codex account changed; importing browser cookies…"
|
|
2552
|
+
self.lastOpenAIDashboardCookieImportAttemptAt = nil
|
|
2553
|
+
self.lastOpenAIDashboardCookieImportEmail = nil
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
func importOpenAIDashboardBrowserCookiesNow() async {
|
|
2558
|
+
self.resetOpenAIWebDebugLog(context: "manual import")
|
|
2559
|
+
let targetEmail = self.codexAccountEmailForOpenAIDashboard()
|
|
2560
|
+
_ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true)
|
|
2561
|
+
await self.refreshOpenAIDashboardIfNeeded(force: true)
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
private func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? {
|
|
2565
|
+
let normalizedTarget = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2566
|
+
let allowAnyAccount = normalizedTarget == nil || normalizedTarget?.isEmpty == true
|
|
2567
|
+
|
|
2568
|
+
let now = Date()
|
|
2569
|
+
let lastEmail = self.lastOpenAIDashboardCookieImportEmail
|
|
2570
|
+
let lastAttempt = self.lastOpenAIDashboardCookieImportAttemptAt ?? .distantPast
|
|
2571
|
+
|
|
2572
|
+
let shouldAttempt: Bool = if force {
|
|
2573
|
+
true
|
|
2574
|
+
} else {
|
|
2575
|
+
if allowAnyAccount {
|
|
2576
|
+
now.timeIntervalSince(lastAttempt) > 300
|
|
2577
|
+
} else {
|
|
2578
|
+
self.openAIDashboardRequiresLogin &&
|
|
2579
|
+
(lastEmail?.lowercased() != normalizedTarget?.lowercased() || now
|
|
2580
|
+
.timeIntervalSince(lastAttempt) > 300)
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
guard shouldAttempt else { return normalizedTarget }
|
|
2585
|
+
self.lastOpenAIDashboardCookieImportEmail = normalizedTarget
|
|
2586
|
+
self.lastOpenAIDashboardCookieImportAttemptAt = now
|
|
2587
|
+
|
|
2588
|
+
let stamp = now.formatted(date: .abbreviated, time: .shortened)
|
|
2589
|
+
let targetLabel = normalizedTarget ?? "unknown"
|
|
2590
|
+
self.logOpenAIWeb("[\(stamp)] import start (target=\(targetLabel))")
|
|
2591
|
+
|
|
2592
|
+
do {
|
|
2593
|
+
let log: (String) -> Void = { [weak self] message in
|
|
2594
|
+
guard let self else { return }
|
|
2595
|
+
self.logOpenAIWeb(message)
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
let result = try await OpenAIDashboardBrowserCookieImporter()
|
|
2599
|
+
.importBestCookies(
|
|
2600
|
+
intoAccountEmail: normalizedTarget,
|
|
2601
|
+
allowAnyAccount: allowAnyAccount,
|
|
2602
|
+
logger: log)
|
|
2603
|
+
let effectiveEmail = result.signedInEmail?
|
|
2604
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2605
|
+
.isEmpty == false
|
|
2606
|
+
? result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2607
|
+
: normalizedTarget
|
|
2608
|
+
self.lastOpenAIDashboardCookieImportEmail = effectiveEmail ?? normalizedTarget
|
|
2609
|
+
await MainActor.run {
|
|
2610
|
+
let signed = result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2611
|
+
let matchText = result.matchesCodexEmail ? "matches Codex" : "does not match Codex"
|
|
2612
|
+
if let signed, !signed.isEmpty {
|
|
2613
|
+
self.openAIDashboardCookieImportStatus =
|
|
2614
|
+
allowAnyAccount
|
|
2615
|
+
? [
|
|
2616
|
+
"Using \(result.sourceLabel) cookies (\(result.cookieCount)).",
|
|
2617
|
+
"Signed in as \(signed).",
|
|
2618
|
+
].joined(separator: " ")
|
|
2619
|
+
: [
|
|
2620
|
+
"Using \(result.sourceLabel) cookies (\(result.cookieCount)).",
|
|
2621
|
+
"Signed in as \(signed) (\(matchText)).",
|
|
2622
|
+
].joined(separator: " ")
|
|
2623
|
+
} else {
|
|
2624
|
+
self.openAIDashboardCookieImportStatus =
|
|
2625
|
+
"Using \(result.sourceLabel) cookies (\(result.cookieCount))."
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
return effectiveEmail
|
|
2629
|
+
} catch let err as OpenAIDashboardBrowserCookieImporter.ImportError {
|
|
2630
|
+
switch err {
|
|
2631
|
+
case let .noMatchingAccount(found):
|
|
2632
|
+
let foundText: String = if found.isEmpty {
|
|
2633
|
+
"no signed-in session detected in \(self.codexBrowserCookieOrder.loginHint)"
|
|
2634
|
+
} else {
|
|
2635
|
+
found
|
|
2636
|
+
.sorted { lhs, rhs in
|
|
2637
|
+
if lhs.sourceLabel == rhs.sourceLabel { return lhs.email < rhs.email }
|
|
2638
|
+
return lhs.sourceLabel < rhs.sourceLabel
|
|
2639
|
+
}
|
|
2640
|
+
.map { "\($0.sourceLabel): \($0.email)" }
|
|
2641
|
+
.joined(separator: " • ")
|
|
2642
|
+
}
|
|
2643
|
+
self.logOpenAIWeb("[\(stamp)] import mismatch: \(foundText)")
|
|
2644
|
+
await MainActor.run {
|
|
2645
|
+
self.openAIDashboardCookieImportStatus = allowAnyAccount
|
|
2646
|
+
? [
|
|
2647
|
+
"No signed-in OpenAI web session found.",
|
|
2648
|
+
"Found \(foundText).",
|
|
2649
|
+
].joined(separator: " ")
|
|
2650
|
+
: [
|
|
2651
|
+
"Browser cookies do not match Codex account (\(normalizedTarget ?? "unknown")).",
|
|
2652
|
+
"Found \(foundText).",
|
|
2653
|
+
].joined(separator: " ")
|
|
2654
|
+
// Treat mismatch like "not logged in" for the current Codex account.
|
|
2655
|
+
self.openAIDashboardRequiresLogin = true
|
|
2656
|
+
self.openAIDashboard = nil
|
|
2657
|
+
}
|
|
2658
|
+
case .noCookiesFound,
|
|
2659
|
+
.browserAccessDenied,
|
|
2660
|
+
.dashboardStillRequiresLogin:
|
|
2661
|
+
self.logOpenAIWeb("[\(stamp)] import failed: \(err.localizedDescription)")
|
|
2662
|
+
await MainActor.run {
|
|
2663
|
+
self.openAIDashboardCookieImportStatus =
|
|
2664
|
+
"Browser cookie import failed: \(err.localizedDescription)"
|
|
2665
|
+
self.openAIDashboardRequiresLogin = true
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
} catch {
|
|
2669
|
+
self.logOpenAIWeb("[\(stamp)] import failed: \(error.localizedDescription)")
|
|
2670
|
+
await MainActor.run {
|
|
2671
|
+
self.openAIDashboardCookieImportStatus =
|
|
2672
|
+
"Browser cookie import failed: \(error.localizedDescription)"
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
return nil
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
private func resetOpenAIWebDebugLog(context: String) {
|
|
2679
|
+
let stamp = Date().formatted(date: .abbreviated, time: .shortened)
|
|
2680
|
+
self.openAIWebDebugLines.removeAll(keepingCapacity: true)
|
|
2681
|
+
self.openAIDashboardCookieImportDebugLog = nil
|
|
2682
|
+
self.logOpenAIWeb("[\(stamp)] OpenAI web \(context) start")
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
private func logOpenAIWeb(_ message: String) {
|
|
2686
|
+
self.openAIWebLogger.debug(message)
|
|
2687
|
+
self.openAIWebDebugLines.append(message)
|
|
2688
|
+
if self.openAIWebDebugLines.count > 240 {
|
|
2689
|
+
self.openAIWebDebugLines.removeFirst(self.openAIWebDebugLines.count - 240)
|
|
2690
|
+
}
|
|
2691
|
+
self.openAIDashboardCookieImportDebugLog = self.openAIWebDebugLines.joined(separator: "\n")
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
private func dashboardEmailMismatch(expected: String?, actual: String?) -> Bool {
|
|
2695
|
+
guard let expected, !expected.isEmpty else { return false }
|
|
2696
|
+
guard let raw = actual?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return false }
|
|
2697
|
+
return raw.lowercased() != expected.lowercased()
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
func codexAccountEmailForOpenAIDashboard() -> String? {
|
|
2701
|
+
let direct = self.snapshots[.codex]?.accountEmail(for: .codex)?
|
|
2702
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2703
|
+
if let direct, !direct.isEmpty { return direct }
|
|
2704
|
+
let fallback = self.codexFetcher.loadAccountInfo().email?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2705
|
+
if let fallback, !fallback.isEmpty { return fallback }
|
|
2706
|
+
let cached = self.openAIDashboard?.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2707
|
+
if let cached, !cached.isEmpty { return cached }
|
|
2708
|
+
let imported = self.lastOpenAIDashboardCookieImportEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2709
|
+
if let imported, !imported.isEmpty { return imported }
|
|
2710
|
+
return nil
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
extension UsageStore {
|
|
2715
|
+
func debugDumpClaude() async {
|
|
2716
|
+
let output = await self.claudeFetcher.debugRawProbe(model: "sonnet")
|
|
2717
|
+
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("runic-claude-probe.txt")
|
|
2718
|
+
try? output.write(to: url, atomically: true, encoding: .utf8)
|
|
2719
|
+
await MainActor.run {
|
|
2720
|
+
let snippet = String(output.prefix(180)).replacingOccurrences(of: "\n", with: " ")
|
|
2721
|
+
self.errors[.claude] = "[Claude] \(snippet) (saved: \(url.path))"
|
|
2722
|
+
NSWorkspace.shared.open(url)
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
func dumpLog(toFileFor provider: UsageProvider) async -> URL? {
|
|
2727
|
+
let text = await self.debugLog(for: provider)
|
|
2728
|
+
let filename = "runic-\(provider.rawValue)-probe.txt"
|
|
2729
|
+
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(filename)
|
|
2730
|
+
do {
|
|
2731
|
+
try text.write(to: url, atomically: true, encoding: .utf8)
|
|
2732
|
+
_ = await MainActor.run { NSWorkspace.shared.open(url) }
|
|
2733
|
+
return url
|
|
2734
|
+
} catch {
|
|
2735
|
+
await MainActor.run {
|
|
2736
|
+
self.errors[provider] = "Failed to save log: \(error.localizedDescription)"
|
|
2737
|
+
}
|
|
2738
|
+
return nil
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
func debugClaudeDump() async -> String {
|
|
2743
|
+
await ClaudeStatusProbe.latestDumps()
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
func debugLog(for provider: UsageProvider) async -> String {
|
|
2747
|
+
if let cached = self.probeLogs[provider], !cached.isEmpty {
|
|
2748
|
+
return cached
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
let settingsSnapshot = await MainActor.run {
|
|
2752
|
+
ProviderSettingsSnapshot(
|
|
2753
|
+
debugMenuEnabled: self.settings.debugMenuEnabled,
|
|
2754
|
+
codex: ProviderSettingsSnapshot.CodexProviderSettings(
|
|
2755
|
+
usageDataSource: self.settings.codexUsageDataSource),
|
|
2756
|
+
claude: ProviderSettingsSnapshot.ClaudeProviderSettings(
|
|
2757
|
+
usageDataSource: self.settings.claudeUsageDataSource,
|
|
2758
|
+
webExtrasEnabled: self.settings.claudeWebExtrasEnabled),
|
|
2759
|
+
zai: ProviderSettingsSnapshot.ZaiProviderSettings(),
|
|
2760
|
+
copilot: ProviderSettingsSnapshot.CopilotProviderSettings(
|
|
2761
|
+
apiToken: self.settings.copilotAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2762
|
+
? nil : self.settings.copilotAPIToken),
|
|
2763
|
+
azure: ProviderSettingsSnapshot.AzureProviderSettings(
|
|
2764
|
+
apiToken: self.settings.azureOpenAIAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2765
|
+
? nil : self.settings.azureOpenAIAPIToken,
|
|
2766
|
+
endpoint: self.settings.azureOpenAIEndpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2767
|
+
? nil : self.settings.azureOpenAIEndpoint,
|
|
2768
|
+
deployment: self.settings.azureOpenAIDeployment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2769
|
+
? nil : self.settings.azureOpenAIDeployment,
|
|
2770
|
+
apiVersion: self.settings.azureOpenAIAPIVersion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2771
|
+
? nil : self.settings.azureOpenAIAPIVersion),
|
|
2772
|
+
bedrock: ProviderSettingsSnapshot.BedrockProviderSettings(
|
|
2773
|
+
region: self.settings.bedrockRegion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2774
|
+
? nil : self.settings.bedrockRegion,
|
|
2775
|
+
profile: self.settings.bedrockAWSProfile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2776
|
+
? nil : self.settings.bedrockAWSProfile,
|
|
2777
|
+
modelID: self.settings.bedrockModelID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2778
|
+
? nil : self.settings.bedrockModelID),
|
|
2779
|
+
vertexai: ProviderSettingsSnapshot.VertexAIProviderSettings(
|
|
2780
|
+
project: self.settings.vertexaiProject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2781
|
+
? nil : self.settings.vertexaiProject,
|
|
2782
|
+
location: self.settings.vertexaiLocation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
2783
|
+
? nil : self.settings.vertexaiLocation))
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
let debugContext = ProviderFetchContext(
|
|
2787
|
+
runtime: .app,
|
|
2788
|
+
sourceMode: .auto,
|
|
2789
|
+
includeCredits: true,
|
|
2790
|
+
webTimeout: 15,
|
|
2791
|
+
webDebugDumpHTML: false,
|
|
2792
|
+
verbose: false,
|
|
2793
|
+
env: ProcessInfo.processInfo.environment,
|
|
2794
|
+
settings: settingsSnapshot,
|
|
2795
|
+
fetcher: self.codexFetcher,
|
|
2796
|
+
claudeFetcher: self.claudeFetcher)
|
|
2797
|
+
|
|
2798
|
+
let claudeWebExtrasEnabled = self.settings.claudeWebExtrasEnabled
|
|
2799
|
+
let claudeUsageDataSource = self.settings.claudeUsageDataSource
|
|
2800
|
+
let claudeDebugMenuEnabled = self.settings.debugMenuEnabled
|
|
2801
|
+
return await Task.detached(priority: .utility) { () -> String in
|
|
2802
|
+
switch provider {
|
|
2803
|
+
case .codex:
|
|
2804
|
+
let raw = await self.codexFetcher.debugRawRateLimits()
|
|
2805
|
+
await MainActor.run { self.probeLogs[.codex] = raw }
|
|
2806
|
+
return raw
|
|
2807
|
+
case .claude:
|
|
2808
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2809
|
+
var lines: [String] = []
|
|
2810
|
+
let hasKey = ClaudeWebAPIFetcher.hasSessionKey { msg in lines.append(msg) }
|
|
2811
|
+
let hasOAuthCredentials = (try? ClaudeOAuthCredentialsStore.load()) != nil
|
|
2812
|
+
|
|
2813
|
+
let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
|
|
2814
|
+
debugMenuEnabled: claudeDebugMenuEnabled,
|
|
2815
|
+
selectedDataSource: claudeUsageDataSource,
|
|
2816
|
+
webExtrasEnabled: claudeWebExtrasEnabled,
|
|
2817
|
+
hasWebSession: hasKey,
|
|
2818
|
+
hasOAuthCredentials: hasOAuthCredentials)
|
|
2819
|
+
|
|
2820
|
+
await MainActor.run {
|
|
2821
|
+
if self.settings.claudeUsageDataSource != strategy.dataSource {
|
|
2822
|
+
self.settings.claudeUsageDataSource = strategy.dataSource
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
lines.append("strategy=\(strategy.dataSource.rawValue)")
|
|
2827
|
+
lines.append("hasSessionKey=\(hasKey)")
|
|
2828
|
+
lines.append("hasOAuthCredentials=\(hasOAuthCredentials)")
|
|
2829
|
+
if strategy.useWebExtras {
|
|
2830
|
+
lines.append("web_extras=enabled")
|
|
2831
|
+
}
|
|
2832
|
+
lines.append("")
|
|
2833
|
+
|
|
2834
|
+
switch strategy.dataSource {
|
|
2835
|
+
case .web:
|
|
2836
|
+
do {
|
|
2837
|
+
let web = try await ClaudeWebAPIFetcher.fetchUsage { msg in lines.append(msg) }
|
|
2838
|
+
lines.append("")
|
|
2839
|
+
lines.append("Web API summary:")
|
|
2840
|
+
|
|
2841
|
+
let sessionReset = web.sessionResetsAt?.description ?? "nil"
|
|
2842
|
+
lines.append("session_used=\(web.sessionPercentUsed)% resetsAt=\(sessionReset)")
|
|
2843
|
+
|
|
2844
|
+
if let weekly = web.weeklyPercentUsed {
|
|
2845
|
+
let weeklyReset = web.weeklyResetsAt?.description ?? "nil"
|
|
2846
|
+
lines.append("weekly_used=\(weekly)% resetsAt=\(weeklyReset)")
|
|
2847
|
+
} else {
|
|
2848
|
+
lines.append("weekly_used=nil")
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
lines.append("opus_used=\(web.opusPercentUsed?.description ?? "nil")")
|
|
2852
|
+
|
|
2853
|
+
if let extra = web.extraUsageCost {
|
|
2854
|
+
let resetsAt = extra.resetsAt?.description ?? "nil"
|
|
2855
|
+
let period = extra.period ?? "nil"
|
|
2856
|
+
let line =
|
|
2857
|
+
"extra_usage used=\(extra.used) limit=\(extra.limit) " +
|
|
2858
|
+
"currency=\(extra.currencyCode) period=\(period) resetsAt=\(resetsAt)"
|
|
2859
|
+
lines.append(line)
|
|
2860
|
+
} else {
|
|
2861
|
+
lines.append("extra_usage=nil")
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
return lines.joined(separator: "\n")
|
|
2865
|
+
} catch {
|
|
2866
|
+
lines.append("Web API failed: \(error.localizedDescription)")
|
|
2867
|
+
return lines.joined(separator: "\n")
|
|
2868
|
+
}
|
|
2869
|
+
case .cli:
|
|
2870
|
+
let cli = await self.claudeFetcher.debugRawProbe(model: "sonnet")
|
|
2871
|
+
lines.append(cli)
|
|
2872
|
+
return lines.joined(separator: "\n")
|
|
2873
|
+
case .oauth:
|
|
2874
|
+
lines.append("OAuth source selected.")
|
|
2875
|
+
return lines.joined(separator: "\n")
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
await MainActor.run { self.probeLogs[.claude] = text }
|
|
2879
|
+
return text
|
|
2880
|
+
case .zai:
|
|
2881
|
+
let text = self.debugTokenSummary(
|
|
2882
|
+
provider: "zai",
|
|
2883
|
+
resolution: ProviderTokenResolver.zaiResolution())
|
|
2884
|
+
await MainActor.run { self.probeLogs[.zai] = text }
|
|
2885
|
+
return text
|
|
2886
|
+
case .gemini:
|
|
2887
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2888
|
+
await self.debugProviderProbe(
|
|
2889
|
+
provider: .gemini,
|
|
2890
|
+
context: debugContext,
|
|
2891
|
+
settings: settingsSnapshot)
|
|
2892
|
+
}
|
|
2893
|
+
await MainActor.run { self.probeLogs[.gemini] = text }
|
|
2894
|
+
return text
|
|
2895
|
+
case .antigravity:
|
|
2896
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2897
|
+
await self.debugProviderProbe(
|
|
2898
|
+
provider: .antigravity,
|
|
2899
|
+
context: debugContext,
|
|
2900
|
+
settings: settingsSnapshot)
|
|
2901
|
+
}
|
|
2902
|
+
await MainActor.run { self.probeLogs[.antigravity] = text }
|
|
2903
|
+
return text
|
|
2904
|
+
case .cursor:
|
|
2905
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2906
|
+
await self.debugProviderProbe(
|
|
2907
|
+
provider: .cursor,
|
|
2908
|
+
context: debugContext,
|
|
2909
|
+
settings: settingsSnapshot)
|
|
2910
|
+
}
|
|
2911
|
+
await MainActor.run { self.probeLogs[.cursor] = text }
|
|
2912
|
+
return text
|
|
2913
|
+
case .factory:
|
|
2914
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2915
|
+
await self.debugProviderProbe(
|
|
2916
|
+
provider: .factory,
|
|
2917
|
+
context: debugContext,
|
|
2918
|
+
settings: settingsSnapshot)
|
|
2919
|
+
}
|
|
2920
|
+
await MainActor.run { self.probeLogs[.factory] = text }
|
|
2921
|
+
return text
|
|
2922
|
+
case .copilot:
|
|
2923
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2924
|
+
await self.debugProviderProbe(
|
|
2925
|
+
provider: .copilot,
|
|
2926
|
+
context: debugContext,
|
|
2927
|
+
settings: settingsSnapshot)
|
|
2928
|
+
}
|
|
2929
|
+
await MainActor.run { self.probeLogs[.copilot] = text }
|
|
2930
|
+
return text
|
|
2931
|
+
case .minimax:
|
|
2932
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2933
|
+
await self.debugProviderProbe(
|
|
2934
|
+
provider: .minimax,
|
|
2935
|
+
context: debugContext,
|
|
2936
|
+
settings: settingsSnapshot)
|
|
2937
|
+
}
|
|
2938
|
+
await MainActor.run { self.probeLogs[.minimax] = text }
|
|
2939
|
+
return text
|
|
2940
|
+
case .openrouter:
|
|
2941
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2942
|
+
await self.debugProviderProbe(
|
|
2943
|
+
provider: .openrouter,
|
|
2944
|
+
context: debugContext,
|
|
2945
|
+
settings: settingsSnapshot)
|
|
2946
|
+
}
|
|
2947
|
+
await MainActor.run { self.probeLogs[.openrouter] = text }
|
|
2948
|
+
return text
|
|
2949
|
+
case .groq:
|
|
2950
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2951
|
+
await self.debugProviderProbe(
|
|
2952
|
+
provider: .groq,
|
|
2953
|
+
context: debugContext,
|
|
2954
|
+
settings: settingsSnapshot)
|
|
2955
|
+
}
|
|
2956
|
+
await MainActor.run { self.probeLogs[.groq] = text }
|
|
2957
|
+
return text
|
|
2958
|
+
case .deepseek:
|
|
2959
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2960
|
+
await self.debugProviderProbe(
|
|
2961
|
+
provider: .deepseek,
|
|
2962
|
+
context: debugContext,
|
|
2963
|
+
settings: settingsSnapshot)
|
|
2964
|
+
}
|
|
2965
|
+
await MainActor.run { self.probeLogs[.deepseek] = text }
|
|
2966
|
+
return text
|
|
2967
|
+
case .fireworks:
|
|
2968
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2969
|
+
await self.debugProviderProbe(
|
|
2970
|
+
provider: .fireworks,
|
|
2971
|
+
context: debugContext,
|
|
2972
|
+
settings: settingsSnapshot)
|
|
2973
|
+
}
|
|
2974
|
+
await MainActor.run { self.probeLogs[.fireworks] = text }
|
|
2975
|
+
return text
|
|
2976
|
+
case .mistral:
|
|
2977
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2978
|
+
await self.debugProviderProbe(
|
|
2979
|
+
provider: .mistral,
|
|
2980
|
+
context: debugContext,
|
|
2981
|
+
settings: settingsSnapshot)
|
|
2982
|
+
}
|
|
2983
|
+
await MainActor.run { self.probeLogs[.mistral] = text }
|
|
2984
|
+
return text
|
|
2985
|
+
case .perplexity:
|
|
2986
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2987
|
+
await self.debugProviderProbe(
|
|
2988
|
+
provider: .perplexity,
|
|
2989
|
+
context: debugContext,
|
|
2990
|
+
settings: settingsSnapshot)
|
|
2991
|
+
}
|
|
2992
|
+
await MainActor.run { self.probeLogs[.perplexity] = text }
|
|
2993
|
+
return text
|
|
2994
|
+
case .kimi:
|
|
2995
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
2996
|
+
await self.debugProviderProbe(
|
|
2997
|
+
provider: .kimi,
|
|
2998
|
+
context: debugContext,
|
|
2999
|
+
settings: settingsSnapshot)
|
|
3000
|
+
}
|
|
3001
|
+
await MainActor.run { self.probeLogs[.kimi] = text }
|
|
3002
|
+
return text
|
|
3003
|
+
case .auggie:
|
|
3004
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
3005
|
+
await self.debugProviderProbe(
|
|
3006
|
+
provider: .auggie,
|
|
3007
|
+
context: debugContext,
|
|
3008
|
+
settings: settingsSnapshot)
|
|
3009
|
+
}
|
|
3010
|
+
await MainActor.run { self.probeLogs[.auggie] = text }
|
|
3011
|
+
return text
|
|
3012
|
+
case .together:
|
|
3013
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
3014
|
+
await self.debugProviderProbe(
|
|
3015
|
+
provider: .together,
|
|
3016
|
+
context: debugContext,
|
|
3017
|
+
settings: settingsSnapshot)
|
|
3018
|
+
}
|
|
3019
|
+
await MainActor.run { self.probeLogs[.together] = text }
|
|
3020
|
+
return text
|
|
3021
|
+
case .cohere:
|
|
3022
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
3023
|
+
await self.debugProviderProbe(
|
|
3024
|
+
provider: .cohere,
|
|
3025
|
+
context: debugContext,
|
|
3026
|
+
settings: settingsSnapshot)
|
|
3027
|
+
}
|
|
3028
|
+
await MainActor.run { self.probeLogs[.cohere] = text }
|
|
3029
|
+
return text
|
|
3030
|
+
case .xai:
|
|
3031
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
3032
|
+
await self.debugProviderProbe(
|
|
3033
|
+
provider: .xai,
|
|
3034
|
+
context: debugContext,
|
|
3035
|
+
settings: settingsSnapshot)
|
|
3036
|
+
}
|
|
3037
|
+
await MainActor.run { self.probeLogs[.xai] = text }
|
|
3038
|
+
return text
|
|
3039
|
+
case .cerebras:
|
|
3040
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
3041
|
+
await self.debugProviderProbe(
|
|
3042
|
+
provider: .cerebras,
|
|
3043
|
+
context: debugContext,
|
|
3044
|
+
settings: settingsSnapshot)
|
|
3045
|
+
}
|
|
3046
|
+
await MainActor.run { self.probeLogs[.cerebras] = text }
|
|
3047
|
+
return text
|
|
3048
|
+
case .sambanova:
|
|
3049
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
3050
|
+
await self.debugProviderProbe(
|
|
3051
|
+
provider: .sambanova,
|
|
3052
|
+
context: debugContext,
|
|
3053
|
+
settings: settingsSnapshot)
|
|
3054
|
+
}
|
|
3055
|
+
await MainActor.run { self.probeLogs[.sambanova] = text }
|
|
3056
|
+
return text
|
|
3057
|
+
case .azure:
|
|
3058
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
3059
|
+
await self.debugProviderProbe(
|
|
3060
|
+
provider: .azure,
|
|
3061
|
+
context: debugContext,
|
|
3062
|
+
settings: settingsSnapshot)
|
|
3063
|
+
}
|
|
3064
|
+
await MainActor.run { self.probeLogs[.azure] = text }
|
|
3065
|
+
return text
|
|
3066
|
+
case .bedrock:
|
|
3067
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
3068
|
+
await self.debugProviderProbe(
|
|
3069
|
+
provider: .bedrock,
|
|
3070
|
+
context: debugContext,
|
|
3071
|
+
settings: settingsSnapshot)
|
|
3072
|
+
}
|
|
3073
|
+
await MainActor.run { self.probeLogs[.bedrock] = text }
|
|
3074
|
+
return text
|
|
3075
|
+
case .vertexai:
|
|
3076
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
3077
|
+
await self.debugProviderProbe(
|
|
3078
|
+
provider: .vertexai,
|
|
3079
|
+
context: debugContext,
|
|
3080
|
+
settings: settingsSnapshot)
|
|
3081
|
+
}
|
|
3082
|
+
await MainActor.run { self.probeLogs[.vertexai] = text }
|
|
3083
|
+
return text
|
|
3084
|
+
case .qwen:
|
|
3085
|
+
let text = await self.runWithTimeout(seconds: 15) {
|
|
3086
|
+
await self.debugProviderProbe(
|
|
3087
|
+
provider: .qwen,
|
|
3088
|
+
context: debugContext,
|
|
3089
|
+
settings: settingsSnapshot)
|
|
3090
|
+
}
|
|
3091
|
+
await MainActor.run { self.probeLogs[.qwen] = text }
|
|
3092
|
+
return text
|
|
3093
|
+
}
|
|
3094
|
+
}.value
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
private nonisolated func debugProviderProbe(
|
|
3098
|
+
provider: UsageProvider,
|
|
3099
|
+
context: ProviderFetchContext,
|
|
3100
|
+
settings: ProviderSettingsSnapshot) async -> String
|
|
3101
|
+
{
|
|
3102
|
+
var lines: [String] = [
|
|
3103
|
+
"provider=\(provider.rawValue)"
|
|
3104
|
+
]
|
|
3105
|
+
|
|
3106
|
+
lines.append(contentsOf: self.debugCredentialLines(for: provider, settings: settings))
|
|
3107
|
+
let descriptor = ProviderDescriptorRegistry.descriptor(for: provider)
|
|
3108
|
+
let outcome = await descriptor.fetchOutcome(context: context)
|
|
3109
|
+
lines.append(contentsOf: self.debugAttemptLines(from: outcome.attempts))
|
|
3110
|
+
|
|
3111
|
+
switch outcome.result {
|
|
3112
|
+
case let .success(result):
|
|
3113
|
+
lines.append("result=success")
|
|
3114
|
+
lines.append("strategy_id=\(result.strategyID)")
|
|
3115
|
+
lines.append("strategy_kind=\(self.debugStrategyKindLabel(result.strategyKind))")
|
|
3116
|
+
lines.append("source_label=\(result.sourceLabel)")
|
|
3117
|
+
lines.append(contentsOf: self.debugUsageSummary(result.usage))
|
|
3118
|
+
case let .failure(error):
|
|
3119
|
+
lines.append("result=failure")
|
|
3120
|
+
lines.append("error=\(error.localizedDescription)")
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
return lines.joined(separator: "\n")
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
private nonisolated func debugCredentialLines(
|
|
3127
|
+
for provider: UsageProvider,
|
|
3128
|
+
settings: ProviderSettingsSnapshot) -> [String]
|
|
3129
|
+
{
|
|
3130
|
+
switch provider {
|
|
3131
|
+
case .zai:
|
|
3132
|
+
return [self.debugTokenSummary(provider: "zai", resolution: ProviderTokenResolver.zaiResolution())]
|
|
3133
|
+
case .copilot:
|
|
3134
|
+
return [self.debugTokenSummary(provider: "copilot", resolution: ProviderTokenResolver.copilotResolution())]
|
|
3135
|
+
case .minimax:
|
|
3136
|
+
return [
|
|
3137
|
+
self.debugTokenSummary(
|
|
3138
|
+
provider: "minimax.api",
|
|
3139
|
+
resolution: ProviderTokenResolver.minimaxApiKeyResolution()),
|
|
3140
|
+
self.debugTokenSummary(
|
|
3141
|
+
provider: "minimax.cookie",
|
|
3142
|
+
resolution: ProviderTokenResolver.minimaxCookieHeaderResolution()),
|
|
3143
|
+
self.debugTokenSummary(
|
|
3144
|
+
provider: "minimax.group",
|
|
3145
|
+
resolution: ProviderTokenResolver.minimaxGroupResolution()),
|
|
3146
|
+
]
|
|
3147
|
+
case .openrouter:
|
|
3148
|
+
return [self.debugTokenSummary(provider: "openrouter", resolution: ProviderTokenResolver.openRouterResolution())]
|
|
3149
|
+
case .groq:
|
|
3150
|
+
return [self.debugTokenSummary(provider: "groq", resolution: ProviderTokenResolver.groqResolution())]
|
|
3151
|
+
case .deepseek:
|
|
3152
|
+
return [self.debugTokenSummary(provider: "deepseek", resolution: ProviderTokenResolver.deepSeekResolution())]
|
|
3153
|
+
case .fireworks:
|
|
3154
|
+
return [self.debugTokenSummary(provider: "fireworks", resolution: ProviderTokenResolver.fireworksResolution())]
|
|
3155
|
+
case .mistral:
|
|
3156
|
+
return [self.debugTokenSummary(provider: "mistral", resolution: ProviderTokenResolver.mistralResolution())]
|
|
3157
|
+
case .perplexity:
|
|
3158
|
+
return [self.debugTokenSummary(provider: "perplexity", resolution: ProviderTokenResolver.perplexityResolution())]
|
|
3159
|
+
case .kimi:
|
|
3160
|
+
return [self.debugTokenSummary(provider: "kimi", resolution: ProviderTokenResolver.kimiResolution())]
|
|
3161
|
+
case .auggie:
|
|
3162
|
+
return [self.debugTokenSummary(provider: "auggie", resolution: ProviderTokenResolver.auggieResolution())]
|
|
3163
|
+
case .together:
|
|
3164
|
+
return [self.debugTokenSummary(provider: "together", resolution: ProviderTokenResolver.togetherResolution())]
|
|
3165
|
+
case .cohere:
|
|
3166
|
+
return [self.debugTokenSummary(provider: "cohere", resolution: ProviderTokenResolver.cohereResolution())]
|
|
3167
|
+
case .xai:
|
|
3168
|
+
return [self.debugTokenSummary(provider: "xai", resolution: ProviderTokenResolver.xaiResolution())]
|
|
3169
|
+
case .cerebras:
|
|
3170
|
+
return [self.debugTokenSummary(provider: "cerebras", resolution: ProviderTokenResolver.cerebrasResolution())]
|
|
3171
|
+
case .sambanova:
|
|
3172
|
+
return [self.debugTokenSummary(provider: "sambanova", resolution: ProviderTokenResolver.sambaNovaResolution())]
|
|
3173
|
+
case .azure:
|
|
3174
|
+
var lines: [String] = [
|
|
3175
|
+
"azure.endpoint=\(self.debugFieldValue(settings.azure?.endpoint))",
|
|
3176
|
+
"azure.deployment=\(self.debugFieldValue(settings.azure?.deployment))",
|
|
3177
|
+
"azure.apiVersion=\(self.debugFieldValue(settings.azure?.apiVersion))"
|
|
3178
|
+
]
|
|
3179
|
+
lines.append(self.debugTokenSummary(provider: "azure.token", resolution: ProviderTokenResolver.azureOpenAIResolution()))
|
|
3180
|
+
return lines
|
|
3181
|
+
case .bedrock:
|
|
3182
|
+
return [
|
|
3183
|
+
"bedrock.region=\(self.debugFieldValue(settings.bedrock?.region))",
|
|
3184
|
+
"bedrock.profile=\(self.debugFieldValue(settings.bedrock?.profile))",
|
|
3185
|
+
"bedrock.model_filter=\(self.debugFieldValue(settings.bedrock?.modelID))",
|
|
3186
|
+
]
|
|
3187
|
+
case .vertexai:
|
|
3188
|
+
return [
|
|
3189
|
+
"vertexai.project=\(self.debugFieldValue(settings.vertexai?.project))",
|
|
3190
|
+
"vertexai.location=\(self.debugFieldValue(settings.vertexai?.location))",
|
|
3191
|
+
]
|
|
3192
|
+
case .qwen:
|
|
3193
|
+
return [self.debugTokenSummary(provider: "qwen", resolution: ProviderTokenResolver.qwenResolution())]
|
|
3194
|
+
case .gemini:
|
|
3195
|
+
return [
|
|
3196
|
+
"gemini.authType=\(GeminiStatusProbe.currentAuthType().rawValue)",
|
|
3197
|
+
]
|
|
3198
|
+
case .antigravity, .cursor, .factory:
|
|
3199
|
+
return ["credentials=provider_internal"]
|
|
3200
|
+
case .codex, .claude:
|
|
3201
|
+
return []
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
private nonisolated func debugAttemptLines(from attempts: [ProviderFetchAttempt]) -> [String] {
|
|
3206
|
+
var lines: [String] = []
|
|
3207
|
+
if attempts.isEmpty {
|
|
3208
|
+
lines.append("attempts=none")
|
|
3209
|
+
return lines
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
let attemptSummary = attempts.enumerated().map { index, attempt in
|
|
3213
|
+
let error = attempt.errorDescription ?? "nil"
|
|
3214
|
+
return "attempt\(index + 1): id=\(attempt.strategyID) kind=\(attempt.kind) available=\(attempt.wasAvailable) error=\(error)"
|
|
3215
|
+
}
|
|
3216
|
+
lines.append("attempts=\(attempts.count)")
|
|
3217
|
+
lines.append(contentsOf: attemptSummary)
|
|
3218
|
+
return lines
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
private nonisolated func debugUsageSummary(_ snapshot: UsageSnapshot) -> [String] {
|
|
3222
|
+
var lines: [String] = []
|
|
3223
|
+
lines.append(self.debugRateWindowSummary(label: "primary", window: snapshot.primary))
|
|
3224
|
+
if let secondary = snapshot.secondary {
|
|
3225
|
+
lines.append(self.debugRateWindowSummary(label: "secondary", window: secondary))
|
|
3226
|
+
}
|
|
3227
|
+
if let tertiary = snapshot.tertiary {
|
|
3228
|
+
lines.append(self.debugRateWindowSummary(label: "tertiary", window: tertiary))
|
|
3229
|
+
}
|
|
3230
|
+
if let identity = snapshot.identity {
|
|
3231
|
+
lines.append("identity.email=\(identity.accountEmail ?? "nil")")
|
|
3232
|
+
lines.append("identity.organization=\(identity.accountOrganization ?? "nil")")
|
|
3233
|
+
lines.append("identity.loginMethod=\(identity.loginMethod ?? "nil")")
|
|
3234
|
+
}
|
|
3235
|
+
if let providerCost = snapshot.providerCost {
|
|
3236
|
+
let resetsAt = providerCost.resetsAt?.description ?? "nil"
|
|
3237
|
+
lines.append(
|
|
3238
|
+
"provider_cost.used=\(providerCost.used) limit=\(providerCost.limit) " +
|
|
3239
|
+
"currency=\(providerCost.currencyCode) period=\(providerCost.period ?? "nil") resetsAt=\(resetsAt)")
|
|
3240
|
+
}
|
|
3241
|
+
return lines
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
private nonisolated func debugRateWindowSummary(label: String, window: RateWindow) -> String {
|
|
3245
|
+
let resetsAt = window.resetsAt?.description ?? "nil"
|
|
3246
|
+
let windowMinutes = window.windowMinutes.map { "\($0)m" } ?? "nil"
|
|
3247
|
+
let resetDescription = window.resetDescription ?? "nil"
|
|
3248
|
+
let windowLabel = window.label ?? "nil"
|
|
3249
|
+
let used = String(format: "%.2f", window.usedPercent)
|
|
3250
|
+
let remaining = String(format: "%.2f", window.remainingPercent)
|
|
3251
|
+
return "\(label).usedPercent=\(used) remainingPercent=\(remaining) window=\(windowMinutes) resetsAt=\(resetsAt) label=\(windowLabel) desc=\(resetDescription)"
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
private nonisolated func debugTokenSummary(provider: String, resolution: ProviderTokenResolution?) -> String {
|
|
3255
|
+
let tokenState = resolution == nil ? "missing" : "present"
|
|
3256
|
+
let source = resolution.flatMap { r in
|
|
3257
|
+
let key = r.sourceKey.map { "(\($0))" } ?? ""
|
|
3258
|
+
return "\(r.source.rawValue)\(key)"
|
|
3259
|
+
} ?? "nil"
|
|
3260
|
+
let length = resolution?.token.count ?? 0
|
|
3261
|
+
return "\(provider)=\(tokenState) source=\(source) length=\(length)"
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
private nonisolated func debugFieldValue(_ raw: String?) -> String {
|
|
3265
|
+
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
3266
|
+
return trimmed.isEmpty ? "missing" : trimmed
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
private nonisolated func debugStrategyKindLabel(_ kind: ProviderFetchKind) -> String {
|
|
3270
|
+
switch kind {
|
|
3271
|
+
case .cli: "cli"
|
|
3272
|
+
case .api: "api"
|
|
3273
|
+
case .web: "web"
|
|
3274
|
+
case .oauth: "oauth"
|
|
3275
|
+
case .apiToken: "apiToken"
|
|
3276
|
+
case .localProbe: "localProbe"
|
|
3277
|
+
case .webDashboard: "webDashboard"
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
private func runWithTimeout(seconds: Double, operation: @escaping @Sendable () async -> String) async -> String {
|
|
3282
|
+
await withTaskGroup(of: String?.self) { group -> String in
|
|
3283
|
+
group.addTask { await operation() }
|
|
3284
|
+
group.addTask {
|
|
3285
|
+
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
|
3286
|
+
return nil
|
|
3287
|
+
}
|
|
3288
|
+
let result = await group.next()?.flatMap(\.self)
|
|
3289
|
+
group.cancelAll()
|
|
3290
|
+
return result ?? "Probe timed out after \(Int(seconds))s"
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
private func detectVersions() {
|
|
3295
|
+
Task.detached { [claudeFetcher] in
|
|
3296
|
+
let codexVer = Self.readCLI("codex", args: ["-s", "read-only", "-a", "untrusted", "--version"])
|
|
3297
|
+
let claudeVer = claudeFetcher.detectVersion()
|
|
3298
|
+
let geminiVer = Self.readCLI("gemini", args: ["--version"])
|
|
3299
|
+
let antigravityVer = await AntigravityStatusProbe.detectVersion()
|
|
3300
|
+
await MainActor.run {
|
|
3301
|
+
self.codexVersion = codexVer
|
|
3302
|
+
self.claudeVersion = claudeVer
|
|
3303
|
+
self.geminiVersion = geminiVer
|
|
3304
|
+
self.zaiVersion = nil
|
|
3305
|
+
self.antigravityVersion = antigravityVer
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
private nonisolated static func readCLI(_ cmd: String, args: [String]) -> String? {
|
|
3311
|
+
let env = ProcessInfo.processInfo.environment
|
|
3312
|
+
var pathEnv = env
|
|
3313
|
+
pathEnv["PATH"] = PathBuilder.effectivePATH(purposes: [.rpc, .tty, .nodeTooling], env: env)
|
|
3314
|
+
let loginPATH = LoginShellPathCache.shared.current
|
|
3315
|
+
|
|
3316
|
+
let resolved: String = switch cmd {
|
|
3317
|
+
case "codex":
|
|
3318
|
+
BinaryLocator.resolveCodexBinary(env: env, loginPATH: loginPATH) ?? cmd
|
|
3319
|
+
case "gemini":
|
|
3320
|
+
BinaryLocator.resolveGeminiBinary(env: env, loginPATH: loginPATH) ?? cmd
|
|
3321
|
+
default:
|
|
3322
|
+
cmd
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
let process = Process()
|
|
3326
|
+
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
|
3327
|
+
process.arguments = [resolved] + args
|
|
3328
|
+
process.environment = pathEnv
|
|
3329
|
+
let pipe = Pipe()
|
|
3330
|
+
process.standardOutput = pipe
|
|
3331
|
+
try? process.run()
|
|
3332
|
+
process.waitUntilExit()
|
|
3333
|
+
guard process.terminationStatus == 0 else { return nil }
|
|
3334
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
3335
|
+
guard let text = String(data: data, encoding: .utf8)?
|
|
3336
|
+
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
3337
|
+
!text.isEmpty else { return nil }
|
|
3338
|
+
return text
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
private func refreshPathDebugInfo() {
|
|
3342
|
+
self.pathDebugInfo = PathBuilder.debugSnapshot(purposes: [.rpc, .tty, .nodeTooling])
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
func clearCostUsageCache() async -> String? {
|
|
3346
|
+
let errorMessage: String? = await Task.detached(priority: .utility) {
|
|
3347
|
+
let fm = FileManager.default
|
|
3348
|
+
let cacheDirs = [
|
|
3349
|
+
Self.costUsageCacheDirectory(fileManager: fm),
|
|
3350
|
+
]
|
|
3351
|
+
|
|
3352
|
+
for cacheDir in cacheDirs {
|
|
3353
|
+
do {
|
|
3354
|
+
try fm.removeItem(at: cacheDir)
|
|
3355
|
+
} catch let error as NSError {
|
|
3356
|
+
if error.domain == NSCocoaErrorDomain, error.code == NSFileNoSuchFileError { continue }
|
|
3357
|
+
return error.localizedDescription
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
return nil
|
|
3361
|
+
}.value
|
|
3362
|
+
|
|
3363
|
+
guard errorMessage == nil else { return errorMessage }
|
|
3364
|
+
|
|
3365
|
+
self.tokenSnapshots.removeAll()
|
|
3366
|
+
self.tokenErrors.removeAll()
|
|
3367
|
+
self.lastTokenFetchAt.removeAll()
|
|
3368
|
+
self.tokenFailureGates[.codex]?.reset()
|
|
3369
|
+
self.tokenFailureGates[.claude]?.reset()
|
|
3370
|
+
return nil
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
private func refreshTokenUsage(
|
|
3374
|
+
_ provider: UsageProvider,
|
|
3375
|
+
force: Bool,
|
|
3376
|
+
trigger: RefreshTrigger,
|
|
3377
|
+
inactiveProviders: Set<UsageProvider>) async
|
|
3378
|
+
{
|
|
3379
|
+
guard provider == .codex || provider == .claude else {
|
|
3380
|
+
self.tokenSnapshots.removeValue(forKey: provider)
|
|
3381
|
+
self.tokenErrors[provider] = nil
|
|
3382
|
+
self.tokenFailureGates[provider]?.reset()
|
|
3383
|
+
self.lastTokenFetchAt.removeValue(forKey: provider)
|
|
3384
|
+
return
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
guard self.settings.costUsageEnabled else {
|
|
3388
|
+
self.tokenSnapshots.removeValue(forKey: provider)
|
|
3389
|
+
self.tokenErrors[provider] = nil
|
|
3390
|
+
self.tokenFailureGates[provider]?.reset()
|
|
3391
|
+
self.lastTokenFetchAt.removeValue(forKey: provider)
|
|
3392
|
+
return
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
guard self.isEnabled(provider) else {
|
|
3396
|
+
self.tokenSnapshots.removeValue(forKey: provider)
|
|
3397
|
+
self.tokenErrors[provider] = nil
|
|
3398
|
+
self.tokenFailureGates[provider]?.reset()
|
|
3399
|
+
self.lastTokenFetchAt.removeValue(forKey: provider)
|
|
3400
|
+
return
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
if trigger.isAuto, inactiveProviders.contains(provider), !force {
|
|
3404
|
+
return
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
guard !self.tokenRefreshInFlight.contains(provider) else { return }
|
|
3408
|
+
|
|
3409
|
+
let now = Date()
|
|
3410
|
+
if !force,
|
|
3411
|
+
let last = self.lastTokenFetchAt[provider],
|
|
3412
|
+
now.timeIntervalSince(last) < self.tokenFetchTTL
|
|
3413
|
+
{
|
|
3414
|
+
return
|
|
3415
|
+
}
|
|
3416
|
+
self.lastTokenFetchAt[provider] = now
|
|
3417
|
+
self.tokenRefreshInFlight.insert(provider)
|
|
3418
|
+
defer { self.tokenRefreshInFlight.remove(provider) }
|
|
3419
|
+
|
|
3420
|
+
let startedAt = Date()
|
|
3421
|
+
let providerText = provider.rawValue
|
|
3422
|
+
self.tokenCostLogger
|
|
3423
|
+
.info("cost usage start provider=\(providerText) force=\(force)")
|
|
3424
|
+
|
|
3425
|
+
do {
|
|
3426
|
+
let fetcher = self.costUsageFetcher
|
|
3427
|
+
let timeoutSeconds = self.tokenFetchTimeout
|
|
3428
|
+
let snapshot = try await withThrowingTaskGroup(of: CostUsageTokenSnapshot.self) { group in
|
|
3429
|
+
group.addTask(priority: .utility) {
|
|
3430
|
+
try await fetcher.loadTokenSnapshot(provider: provider, now: now, forceRefresh: force)
|
|
3431
|
+
}
|
|
3432
|
+
group.addTask {
|
|
3433
|
+
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
|
|
3434
|
+
throw CostUsageError.timedOut(seconds: Int(timeoutSeconds))
|
|
3435
|
+
}
|
|
3436
|
+
defer { group.cancelAll() }
|
|
3437
|
+
guard let snapshot = try await group.next() else { throw CancellationError() }
|
|
3438
|
+
return snapshot
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
guard !snapshot.daily.isEmpty else {
|
|
3442
|
+
self.tokenSnapshots.removeValue(forKey: provider)
|
|
3443
|
+
self.tokenErrors[provider] = Self.tokenCostNoDataMessage(for: provider)
|
|
3444
|
+
self.tokenFailureGates[provider]?.recordSuccess()
|
|
3445
|
+
return
|
|
3446
|
+
}
|
|
3447
|
+
let duration = Date().timeIntervalSince(startedAt)
|
|
3448
|
+
let sessionCost = snapshot.sessionCostUSD.map(UsageFormatter.usdString) ?? "—"
|
|
3449
|
+
let monthCost = snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—"
|
|
3450
|
+
let durationText = String(format: "%.2f", duration)
|
|
3451
|
+
let message =
|
|
3452
|
+
"cost usage success provider=\(providerText) " +
|
|
3453
|
+
"duration=\(durationText)s " +
|
|
3454
|
+
"today=\(sessionCost) " +
|
|
3455
|
+
"30d=\(monthCost)"
|
|
3456
|
+
self.tokenCostLogger.info(message)
|
|
3457
|
+
self.tokenSnapshots[provider] = snapshot
|
|
3458
|
+
self.tokenErrors[provider] = nil
|
|
3459
|
+
self.tokenFailureGates[provider]?.recordSuccess()
|
|
3460
|
+
self.persistWidgetSnapshot(reason: "token-usage")
|
|
3461
|
+
} catch {
|
|
3462
|
+
if error is CancellationError { return }
|
|
3463
|
+
let duration = Date().timeIntervalSince(startedAt)
|
|
3464
|
+
let msg = error.localizedDescription
|
|
3465
|
+
let durationText = String(format: "%.2f", duration)
|
|
3466
|
+
let message = "cost usage failed provider=\(providerText) duration=\(durationText)s error=\(msg)"
|
|
3467
|
+
self.tokenCostLogger.error(message)
|
|
3468
|
+
let hadPriorData = self.tokenSnapshots[provider] != nil
|
|
3469
|
+
let shouldSurface = self.tokenFailureGates[provider]?
|
|
3470
|
+
.shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
|
|
3471
|
+
if shouldSurface {
|
|
3472
|
+
self.tokenErrors[provider] = error.localizedDescription
|
|
3473
|
+
self.tokenSnapshots.removeValue(forKey: provider)
|
|
3474
|
+
} else {
|
|
3475
|
+
self.tokenErrors[provider] = nil
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
// MARK: - Performance Tracking
|
|
3481
|
+
|
|
3482
|
+
/// Track API call latency for performance monitoring
|
|
3483
|
+
private nonisolated func trackLatency(
|
|
3484
|
+
provider: UsageProvider,
|
|
3485
|
+
requestID: String,
|
|
3486
|
+
startTime: Date,
|
|
3487
|
+
endTime: Date,
|
|
3488
|
+
success: Bool
|
|
3489
|
+
) async {
|
|
3490
|
+
guard let storage = self.performanceStorage else { return }
|
|
3491
|
+
|
|
3492
|
+
let metric = LatencyMetric(
|
|
3493
|
+
id: UUID().uuidString,
|
|
3494
|
+
requestID: requestID,
|
|
3495
|
+
provider: provider,
|
|
3496
|
+
model: nil,
|
|
3497
|
+
startTime: startTime,
|
|
3498
|
+
endTime: endTime,
|
|
3499
|
+
durationMs: Int(endTime.timeIntervalSince(startTime) * 1000),
|
|
3500
|
+
success: success,
|
|
3501
|
+
createdAt: Date()
|
|
3502
|
+
)
|
|
3503
|
+
|
|
3504
|
+
try? await storage.save(latency: metric)
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
/// Track API errors for performance monitoring
|
|
3508
|
+
private nonisolated func trackError(provider: UsageProvider, error: Error) async {
|
|
3509
|
+
guard let storage = self.performanceStorage else { return }
|
|
3510
|
+
|
|
3511
|
+
let errorType = self.classifyError(error)
|
|
3512
|
+
let errorEvent = ErrorEvent(
|
|
3513
|
+
id: UUID().uuidString,
|
|
3514
|
+
provider: provider,
|
|
3515
|
+
errorType: errorType,
|
|
3516
|
+
errorMessage: error.localizedDescription,
|
|
3517
|
+
retryCount: 0,
|
|
3518
|
+
timestamp: Date()
|
|
3519
|
+
)
|
|
3520
|
+
|
|
3521
|
+
try? await storage.save(error: errorEvent)
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
/// Classify errors for performance tracking
|
|
3525
|
+
private nonisolated func classifyError(_ error: Error) -> ErrorType {
|
|
3526
|
+
let message = error.localizedDescription.lowercased()
|
|
3527
|
+
|
|
3528
|
+
// Timeout errors
|
|
3529
|
+
if message.contains("timed out") || message.contains("timeout") {
|
|
3530
|
+
return .timeout
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
// Quota/rate limit errors
|
|
3534
|
+
if message.contains("quota") || message.contains("rate limit") || message.contains("429") {
|
|
3535
|
+
return .quota
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
// Authentication errors
|
|
3539
|
+
if message.contains("auth") || message.contains("unauthorized") ||
|
|
3540
|
+
message.contains("401") || message.contains("403")
|
|
3541
|
+
{
|
|
3542
|
+
return .auth
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
// Network errors
|
|
3546
|
+
if message.contains("network") || message.contains("connection") ||
|
|
3547
|
+
message.contains("offline") || message.contains("no internet")
|
|
3548
|
+
{
|
|
3549
|
+
return .network
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
// Parsing errors
|
|
3553
|
+
if message.contains("json") || message.contains("decode") || message.contains("parse") {
|
|
3554
|
+
return .parsing
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
// API errors
|
|
3558
|
+
if message.contains("api") || message.contains("server") {
|
|
3559
|
+
return .apiError
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
return .unknown
|
|
3563
|
+
}
|
|
3564
|
+
}
|