Skip to content

feat: add DeepSeek pay-as-you-go balance provider - #151

Open
dylanzonghanyang-source wants to merge 1 commit into
opgginc:mainfrom
dylanzonghanyang-source:feat/deepseek-balance
Open

feat: add DeepSeek pay-as-you-go balance provider#151
dylanzonghanyang-source wants to merge 1 commit into
opgginc:mainfrom
dylanzonghanyang-source:feat/deepseek-balance

Conversation

@dylanzonghanyang-source

@dylanzonghanyang-source dylanzonghanyang-source commented Aug 14, 2026

Copy link
Copy Markdown

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/balance with
Authorization: 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, deepseek entry, added via
opencode auth login), same as OpenRouter / OpenCode Zen.

Display behavior

  • Pay-as-you-go menu row: DeepSeek (¥103.49) — the remaining balance is
    rendered from DetailedUsage.creditsBalance, with CNY formatted via a
    small balanceCurrencySymbol helper (USD -> $, CNY -> ¥).
  • Detail submenu: Balance / Topped-up / Granted.
  • The provider reports cost = nil (cost means money spent; DeepSeek
    returns money remaining), so the aggregate pay-as-you-go spend total is
    naturally unaffected — no provider-specific exclusion logic.
  • CLI: table metrics show ¥xx.xx remaining; JSON emits
    balance / currency / grantedBalance / toppedUpBalance and no
    cost field. Providers with a real cost keep the existing $x spent
    rendering.

Error handling

  • Missing key -> authenticationFailed
  • Non-200 -> networkError
  • Missing balance_infos -> decodingError
  • Unparseable total_balance -> decodingError (no silent 0 fallback)
  • is_available=false is logged as a warning; balance_infos is still
    rendered (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:

  • balance decoding with real string-amount shape (CNY)
  • fetch success: cost stays nil, details carry balance/currency/
    granted/topped-up
  • is_available=false still returns balance data
  • unparseable total_balance throws decodingError
  • 401 propagates as networkError
  • formatter tests: table shows ¥xx.xx remaining; JSON emits balance
    fields and omits cost; cost-based providers keep $x spent

Runtime verification

Validated on a real account. CLI smoke test on the built artifact:

opencodebar provider deepseek
  DeepSeek   Pay-as-you-go   -   ¥xx.xx remaining

opencodebar provider deepseek --json
  { "balance": <N>, "currency": "CNY",
    "grantedBalance": 0, "toppedUpBalance": <N>,
    "type": "pay-as-you-go" }

The menu displays the live CNY balance and refreshes correctly. No API key
or account details included.

@opgg-ai-devops

opgg-ai-devops Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

✅ AI Code Review Completed

Review finished. Check the PR for inline comments.


📋 View Logs | 🤖 Model: openai/gpt-5.6-luna-fast|medium

@dylanzonghanyang-source
dylanzonghanyang-source marked this pull request as draft August 14, 2026 18:54

@opgg-ai-devops opgg-ai-devops Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Comment thread CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift
Comment thread CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift Outdated
Comment thread CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift Outdated
Comment thread CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift
Comment thread CopilotMonitor/CopilotMonitor/Services/TokenManager.swift
Comment thread CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift Outdated
Comment thread CopilotMonitor/CopilotMonitorTests/DeepSeekProviderTests.swift
@dylanzonghanyang-source
dylanzonghanyang-source force-pushed the feat/deepseek-balance branch 3 times, most recently from d2f83c1 to 4733d67 Compare August 14, 2026 19:11
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
@dylanzonghanyang-source
dylanzonghanyang-source marked this pull request as ready for review August 14, 2026 19:44
@opgg-ai-devops

opgg-ai-devops Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

✅ AI Code Review Completed

Review finished. Check the PR for inline comments.


📋 View Logs | 🤖 Model: openai/gpt-5.6-luna-fast|medium

@opgg-ai-devops opgg-ai-devops Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.hasAnyValue already includes creditsBalance; 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style 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? {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Bug 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] = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Style 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.

dylanzonghanyang-source added a commit to dylanzonghanyang-source/opencode-bar that referenced this pull request Aug 15, 2026
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.
dylanzonghanyang-source added a commit to dylanzonghanyang-source/opencode-bar that referenced this pull request Aug 15, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant