feat: add DeepSeek pay-as-you-go balance provider - #151
feat: add DeepSeek pay-as-you-go balance provider#151dylanzonghanyang-source wants to merge 1 commit into
Conversation
✅ AI Code Review CompletedReview finished. Check the PR for inline comments. 📋 View Logs | 🤖 Model: |
There was a problem hiding this comment.
The DeepSeek integration is wired through both the app and CLI paths, and the separation between remaining balance and aggregate spend is clean. The dedicated decoding tests and injected URLSession are a solid start.
There is one real correctness blocker: balance_infos is a per-currency array, but the provider blindly renders .first. A valid multi-currency response can therefore show an arbitrary currency and misleading financial data. Please choose an explicit currency policy or render all returned balances before merging.
I also left a few smaller comments around request assertions, menu coverage, repeated auth/network plumbing, and a diagnostic-only branch that can be deleted. Those are not the merge blocker, but fixing them would keep this new provider from growing another set of one-off patterns.
Verification
- CI checks currently report success for Lint, Build and Release, Test, and CI.
- Smoke tests were not run locally because the repository has no lockfiles covered by the prefetch step, so there were no dependencies available for the fallback launch.
- No new environment variables or secrets were introduced.
Repo-wide audit
6건 — 펼쳐서 보기
- delete: Remove the abandoned status-bar implementations and their unused view model; the app uses the AppKit `StatusBarController` and `StatusBarIconView`, while these SwiftUI and multi-provider alternatives have no production call sites. [CopilotMonitor/CopilotMonitor/ViewModels/ProviderViewModel.swift, CopilotMonitor/CopilotMonitor/Views/SwiftUI/ModernStatusBarIconView.swift, CopilotMonitor/CopilotMonitor/Views/MultiProviderStatusBarIconView.swift]
- delete: Remove `OpenCodeProvider`; it is not registered in `ProviderManager.makeDefaultProviders()` and has no callers or tests, leaving a complete provider implementation as dead surface. [CopilotMonitor/CopilotMonitor/Providers/OpenCodeProvider.swift, CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift]
- delete: Remove the custom `MenuItemBuilder` DSL and its helper extensions; production menu construction does not use them and the only callers are tests that verify the test-only abstraction itself. [CopilotMonitor/CopilotMonitor/Helpers/MenuResultBuilder.swift, CopilotMonitor/CopilotMonitorTests/MenuResultBuilderTests.swift, CopilotMonitor/CopilotMonitorTests/DependencyTests.swift]
- shrink: Consolidate the identical `find_opencode_bin` implementation shared by the two OpenCode shell queries instead of maintaining two copies of the same PATH, login-shell, and fallback search rules. [scripts/query-opencode.sh, scripts/query-opencode-history.sh]
- native: Delete the repeated ad-hoc `/tmp` file logging helpers and use the existing `os.Logger` path; the repository carries multiple copies of append-only debug logging that duplicate the platform logger and add unbounded runtime artifacts. [CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift, CopilotMonitor/CopilotMonitor/App/StatusBarController.swift, CopilotMonitor/CopilotMonitor/Services/BrowserCookieService.swift, CopilotMonitor/CopilotMonitor/Services/TokenManager.swift]
- delete: Remove tracked generated artifacts that are not source or release inputs, including the stale Xcode backup, profiling output, and Python bytecode; they add repository noise and cannot participate in the application build. [CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj.bak, default.profraw, scripts/__pycache__/browser_cookies.cpython-312.pyc]
d2f83c1 to
4733d67
Compare
Add native DeepSeek balance monitoring to OpenCode Bar. - DeepSeekProvider: calls GET https://api.deepseek.com/user/balance with the API key from the OpenCode credential store (deepseek entry) and parses total_balance / granted_balance / topped_up_balance / currency / is_available - Currency policy: never pick balanceInfos.first — prefer CNY, fall back to USD, throw decodingError for anything else. Order-independent ([USD, CNY] and [CNY, USD] both resolve CNY) - Balance semantics: payAsYouGo cost stays nil (it means money spent; DeepSeek reports money remaining). The menu row renders the balance from DetailedUsage.creditsBalance with CNY formatted as ¥ via balanceCurrencySymbol; the aggregate spend total is naturally unaffected (no special-casing) - CLI: table metrics show '$xx.xx remaining' for balance-style providers; JSON emits balance/currency/grantedBalance/ toppedUpBalance when cost is nil. Providers with real cost keep the existing '$x spent' rendering - is_available: warning only when false (no behavior-free true branch) - Detail menu: Balance/Topped-up/Granted rendered through one local label/value loop; createDetailSubmenu/createDisabledLabelView made static so the menu path is directly testable - Errors: unparseable total_balance throws decodingError instead of silently falling back to 0 - Tests: mocked requests now assert endpoint URL, GET method and Bearer header; multi-currency order independence; USD-only rendering; unsupported-currency error; detail-menu row rendering; 401 propagation; is_available=false still returns data. All fetch tests use an injected fake API key so they execute in CI - Registers provider in app ProviderManager, CLI, menu builder and status bar views
4733d67 to
8ebc0a0
Compare
✅ AI Code Review CompletedReview finished. Check the PR for inline comments. 📋 View Logs | 🤖 Model: |
There was a problem hiding this comment.
This adds a real caller-visible capability: DeepSeek prepaid balances now show up in both the app and CLI without being miscounted as spend. The CNY/USD policy, balance-vs-cost separation, and response-shape tests are clean, and the CI checks are green. Nice work on the core integration.
I left six small, actionable notes around duplicated provider plumbing, the extra static-helper churn, the empty pay-as-you-go preset surface, the split balance projection, the duplicated registration list, and the still-uncovered credential-store path. None of these are merge blockers in this round.
Verification
- CI reports success for Build and Release, Lint, Test, and CI.
- Local smoke tests were not run: the prefetch found no lockfiles, and this Swift/Xcode test target requires macOS.
- No new environment variables or secrets were introduced.
- The whole-repo audit lane completed; its validated output is handled by the workflow.
DetailedUsage.hasAnyValuealready includescreditsBalance; a focused Codable round-trip test for the three new balance fields would still be a useful follow-up.
Repo-wide audit
10건 — 펼쳐서 보기
- delete: ProviderViewModel has no production or test references and duplicates provider-fetch state that is already owned by StatusBarController and ProviderManager. [CopilotMonitor/CopilotMonitor/ViewModels/ProviderViewModel.swift]
- delete: The MenuResultBuilder DSL is exercised only by its tests; production code constructs NSMenu and NSMenuItem directly, leaving a full custom result-builder surface with no application caller. [CopilotMonitor/CopilotMonitor/Helpers/MenuResultBuilder.swift, CopilotMonitor/CopilotMonitorTests/MenuResultBuilderTests.swift]
- delete: ProviderProtocol.type and ProviderType are not consumed by application code; ProviderUsage already carries the billing-model discriminator, so every provider declaration and contract member is redundant. [CopilotMonitor/CopilotMonitor/Models/ProviderProtocol.swift, CopilotMonitor/CopilotMonitor/Models/ProviderUsage.swift]
- delete: fetchAllResults is an orphan compatibility wrapper with no callers anywhere in the repository, adding an unnecessary second ProviderManager API. [CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift]
- delete: UsagePredictor checks remainingDays > 0 twice in the same function, leaving a dead branch after the first guard. [CopilotMonitor/CopilotMonitor/Services/UsagePredictor.swift]
- delete: ProviderManager writes a second file-based debug log while every operation is already sent to os.Logger, duplicating logging behavior and retaining ad-hoc /tmp file I/O. [CopilotMonitor/CopilotMonitor/Services/ProviderManager.swift]
- shrink: Five provider test files copy the same MockURLProtocol and session setup, creating one maintenance point per test instead of a shared test helper. [CopilotMonitor/CopilotMonitorTests/MiniMaxProviderTests.swift, CopilotMonitor/CopilotMonitorTests/NanoGptProviderTests.swift, CopilotMonitor/CopilotMonitorTests/GeminiCLIProviderTests.swift, CopilotMonitor/CopilotMonitorTests/DeepSeekProviderTests.swift, CopilotMonitor/CopilotMonitorTests/SyntheticProviderTests.swift]
- shrink: Z.AI repeats identical numeric decoding helpers across three response types, while the same Int/Double/String coercion logic can be kept in one local decoder helper. [CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift]
- delete: Tracked compiler and interpreter artifacts are not source or release inputs and add repository surface with no runtime value. [default.profraw, scripts/__pycache__/browser_cookies.cpython-312.pyc]
- delete: ModernApp stores isMenuPresented but never reads or writes it, leaving dead SwiftUI state in the application entry point. [CopilotMonitor/CopilotMonitor/App/ModernApp.swift]
직전 라운드 미해결 (이번 라운드 미재론)
CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift— Untested menu branch: UI regressions stay invisible (1라운드째)CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift— Repeated request plumbing: provider behavior can drift (1라운드째)CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift— Repeated balance rows: formatting can diverge (1라운드째)CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift— First currency wins: financial data can be wrong (1라운드째)CopilotMonitor/CopilotMonitor/Services/TokenManager.swift— Repeated auth lookup: accessors can diverge (1라운드째)CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift— Diagnostic-only branch: extra code adds no behavior (1라운드째)
| .chutes, .copilot, | ||
| .synthetic | ||
| .synthetic, | ||
| .deepSeek |
There was a problem hiding this comment.
Two provider registries: CLI and app can diverge
DeepSeek is now added to a hand-maintained identifier list here and separately to ProviderManager's default provider instances. Future provider additions can update one list and make CLI discovery disagree with actual fetching. Define registration once and derive identifiers from those instances, or make any intentional CLI-only filtering explicit.
| /// Fetches the account balance from the DeepSeek API | ||
| /// - Parameter apiKey: DeepSeek API key | ||
| /// - Returns: BalanceResponse containing balance_infos | ||
| private func fetchBalance(apiKey: String) async throws -> BalanceResponse { |
There was a problem hiding this comment.
Duplicated request plumbing: provider behavior can drift
This repeats the authenticated URL construction, GET, HTTP-status check, and JSON decoding flow already implemented by OpenRouterProvider.fetchCredits. Please reuse a shared authenticated JSON request helper and keep only provider-specific error context here, so header/status behavior cannot drift between providers.
| } | ||
| // Balance-style providers (cost nil) expose the remaining | ||
| // balance from details instead. | ||
| if cost == nil, let details = result.details { |
There was a problem hiding this comment.
Split balance projection: renderers can drift
This balance projection is now repeated independently in JSON formatting, table formatting, and the status-bar menu. A future balance-field change can update one renderer and silently leave the others inconsistent. Expose one shared balance-display projection, then keep each renderer responsible only for its output format.
| } | ||
|
|
||
| private func normalizedUsagePercent(_ percent: Double?) -> Double? { | ||
| private static func normalizedUsagePercent(_ percent: Double?) -> Double? { |
There was a problem hiding this comment.
Unneeded static conversion: maintenance churn
These four helpers were converted from instance methods to static, and the change rewrites a large set of callers to Self. without introducing a real type-level caller. That is pure churn on this feature path. Keep them as private instance helpers and restore the direct calls unless a concrete static use case exists.
| /// credential store (and therefore execute in CI). | ||
| private func makeProvider(statusCode: Int = 200, body: String) -> DeepSeekProvider { | ||
| let session = makeSession() | ||
| let provider = DeepSeekProvider(tokenManager: .shared, session: session, apiKey: "sk-test-fake") |
There was a problem hiding this comment.
Unasserted credential path: auth regressions stay invisible
[2라운드째 미합의]
Every fetch test injects apiKey: "sk-test-fake", so the new TokenManager.getDeepSeekAPIKey() and OpenCodeAuth.deepseek decoding path are never exercised. Add one focused auth-decoding/accessor test and one provider fetch test that omits the override; otherwise a credential-store regression leaves production fetches unauthenticated while this suite stays green.
| ] | ||
| static let tavilySearch: [SubscriptionPreset] = [] | ||
| static let braveSearch: [SubscriptionPreset] = [] | ||
| static let deepSeek: [SubscriptionPreset] = [] |
There was a problem hiding this comment.
Empty preset surface: unsupported config becomes API
DeepSeek is a pay-as-you-go provider, and the repo's design says those providers have no subscription settings. Adding a named empty preset collection creates a configuration surface that must remain empty. Remove this property and return [] directly from the .deepSeek case, or reuse the existing no-subscription path.
Combine the green Z.AI CREDIT_LIMIT implementation from PR opgginc#150 with the approved DeepSeek balance implementation from PR opgginc#151. Preserve both provider test registrations and formatter coverage.
Resolved union-style conflicts preserving both PR behaviors: - project.pbxproj: register both ZaiCodingPlanProviderTests.swift and DeepSeekProviderTests.swift in BuildFile, FileReference, Group, and SourcesBuildPhase sections. - CLIFormatterTests.swift: keep Z.AI 5h/weekly table+JSON tests AND DeepSeek CNY balance table/JSON tests AND ordinary PAYG spend regression.
Motivation
DeepSeek API is a prepaid pay-as-you-go service (CNY balance). Users have no
way to see their remaining balance in OpenCode Bar, unlike quota-based
providers.
Official DeepSeek balance API
GET https://api.deepseek.com/user/balancewithAuthorization: Bearer <API_KEY>returns:{ "is_available": true, "balance_infos": [{ "currency": "CNY", "total_balance": "103.49", "granted_balance": "0.00", "topped_up_balance": "103.49" }] }Amounts arrive as strings; the provider converts them for display.
Credential source
The API key is read from the normal OpenCode credential store
(
~/.local/share/opencode/auth.json,deepseekentry, added viaopencode auth login), same as OpenRouter / OpenCode Zen.Display behavior
DeepSeek (¥103.49)— the remaining balance isrendered from
DetailedUsage.creditsBalance, with CNY formatted via asmall
balanceCurrencySymbolhelper (USD ->$, CNY ->¥).cost = nil(cost means money spent; DeepSeekreturns money remaining), so the aggregate pay-as-you-go spend total is
naturally unaffected — no provider-specific exclusion logic.
¥xx.xx remaining; JSON emitsbalance/currency/grantedBalance/toppedUpBalanceand nocostfield. Providers with a real cost keep the existing$x spentrendering.
Error handling
authenticationFailednetworkErrorbalance_infos->decodingErrortotal_balance->decodingError(no silent 0 fallback)is_available=falseis logged as a warning;balance_infosis stillrendered (a frozen/zero balance remains informative)
Tests
All fetch tests inject a fake API key with a mocked URLSession, so they
execute in CI without any real credential:
coststays nil, details carry balance/currency/granted/topped-up
is_available=falsestill returns balance datatotal_balancethrowsdecodingErrornetworkError¥xx.xx remaining; JSON emits balancefields and omits
cost; cost-based providers keep$x spentRuntime verification
Validated on a real account. CLI smoke test on the built artifact:
The menu displays the live CNY balance and refreshes correctly. No API key
or account details included.