From 931eba82de992bee3f091593087a9e57dfd6d9ea Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Mon, 17 Aug 2026 16:29:09 +0200 Subject: [PATCH 1/3] feat: add DeepSeek provider support - Add DEEPSEEK to Provider enum with display name and abbreviation - Add DEEPSEEK_API ConnectionType with dedicated balance endpoint - Implement DeepSeekProviderClient for GET /user/balance integration - Parses total_balance and currency from balance_infos array - Handles auth errors (401/403), rate limits (429), and network errors - Stores currency in metadata for future UI enhancements - Add DeepSeekConnectDialog for simple API key input - Wire DeepSeek into AccountEditDialog with provider URL and hint text - Register DeepSeekProviderClient in DefaultProviderRegistry - Add comprehensive unit tests (11 test cases covering success/error paths) - Update README.md with DeepSeek in provider list and auth table - Update plugin.xml to list DeepSeek as supported provider Closes # --- README.md | 7 +- .../tokenpulse/model/ConnectionType.kt | 13 + .../zhavoronkov/tokenpulse/model/Provider.kt | 3 + .../tokenpulse/provider/ProviderRegistry.kt | 3 + .../deepseek/DeepSeekProviderClient.kt | 158 +++++++++++ .../service/BalanceRefreshService.kt | 1 + .../tokenpulse/settings/Account.kt | 8 + .../tokenpulse/ui/AccountEditDialog.kt | 3 + .../tokenpulse/ui/DeepSeekConnectDialog.kt | 100 +++++++ src/main/resources/META-INF/plugin.xml | 1 + .../tokenpulse/model/ProviderTest.kt | 4 +- .../provider/DeepSeekProviderClientTest.kt | 259 ++++++++++++++++++ 12 files changed, 555 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/org/zhavoronkov/tokenpulse/provider/deepseek/DeepSeekProviderClient.kt create mode 100644 src/main/kotlin/org/zhavoronkov/tokenpulse/ui/DeepSeekConnectDialog.kt create mode 100644 src/test/kotlin/org/zhavoronkov/tokenpulse/provider/DeepSeekProviderClientTest.kt diff --git a/README.md b/README.md index 6466667..48ab39f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ keeps it fresh in the background so you never have to go looking. - **📊 One-glance aggregate balance** — combined remaining credits/tokens live in the status bar, with flexible display modes (auto, total dollars, or a single provider). -- **🤖 Seven providers, one view** — Claude Code, Codex/ChatGPT, OpenAI Platform, Cline, OpenRouter, +- **🤖 Eight providers, one view** — Claude Code, Codex/ChatGPT, OpenAI Platform, Cline, DeepSeek, OpenRouter, Nebius AI Studio, and Xiaomi MiMo (see the [table](#provider-authentication) below). - **🔄 Sessions that refresh themselves** — Nebius and Xiaomi silently re-mint their session in the background when it rotates, so a still-valid login keeps working without reconnecting. @@ -58,7 +58,7 @@ keeps it fresh in the background so you never have to go looking. 1. Open **Settings** → **Tools** → **TokenPulse**. 2. Click **+** to add a provider account: - Select the **Provider** (Claude Code, Codex/ChatGPT, OpenAI Platform, Cline, OpenRouter, - Nebius AI Studio, or Xiaomi MiMo). + DeepSeek, Nebius AI Studio, or Xiaomi MiMo). - Follow the provider-specific instructions in the dialog. 3. Configure the **Refresh Interval** (default: 15 minutes). 4. The aggregate balance appears in your status bar automatically. @@ -71,6 +71,7 @@ keeps it fresh in the background so you never have to go looking. | Codex / ChatGPT | **CLI + OAuth** | Requires `codex` CLI installed and authenticated (`codex login`) | | OpenAI Platform | **Admin API Key** (`sk-admin-...`) | https://platform.openai.com/settings/organization/admin-keys | | Cline | API Key | https://app.cline.bot/dashboard/account?tab=api-keys | +| DeepSeek | API Key | https://platform.deepseek.com/api_keys | | OpenRouter | **Provisioning Key** | https://openrouter.ai/settings/provisioning-keys | | Nebius AI Studio | **Billing Session** (cURL capture) | Click "Connect Billing Session →" and copy a `getBalance` request as cURL (see [FAQ](#how-does-nebius-authentication-work)) | | Xiaomi MiMo | **Session** (cURL capture or in-IDE sign-in) | Click "Connect Xiaomi Account →" and capture the session | @@ -175,7 +176,7 @@ on Linux). They are never written to plain-text settings files. ### The status bar shows "—" or "Error" -- **Auth Error** — for API-key providers (Cline, OpenRouter, OpenAI Platform) the key is invalid or revoked; re-generate it from the provider's dashboard and re-enter it in TokenPulse. For CLI/OAuth/session providers (Claude Code, Codex/ChatGPT, Nebius, Xiaomi MiMo) the login or captured session expired; re-run the CLI login (e.g. `claude login`) or reconnect the session — the notification tells you which action applies. +- **Auth Error** — for API-key providers (Cline, DeepSeek, OpenRouter, OpenAI Platform) the key is invalid or revoked; re-generate it from the provider's dashboard and re-enter it in TokenPulse. For CLI/OAuth/session providers (Claude Code, Codex/ChatGPT, Nebius, Xiaomi MiMo) the login or captured session expired; re-run the CLI login (e.g. `claude login`) or reconnect the session — the notification tells you which action applies. - **Rate Limited** — too many requests. Increase the refresh interval in Settings → TokenPulse. - **Error** — a network or API error. Check your internet connection and try "Refresh All" from the dashboard. - **$X.XX used** — OpenAI account showing usage data (not a balance). diff --git a/src/main/kotlin/org/zhavoronkov/tokenpulse/model/ConnectionType.kt b/src/main/kotlin/org/zhavoronkov/tokenpulse/model/ConnectionType.kt index 20d8402..0b54be1 100644 --- a/src/main/kotlin/org/zhavoronkov/tokenpulse/model/ConnectionType.kt +++ b/src/main/kotlin/org/zhavoronkov/tokenpulse/model/ConnectionType.kt @@ -63,6 +63,17 @@ enum class ConnectionType( defaultAuthType = AuthType.CLINE_API_KEY ), + /** + * DeepSeek API - personal API key for DeepSeek service. + * Provides access to the /user/balance endpoint for balance tracking. + */ + DEEPSEEK_API( + provider = Provider.DEEPSEEK, + displayName = "API Key", + description = "Personal API key from DeepSeek platform.", + defaultAuthType = AuthType.DEEPSEEK_API_KEY + ), + /** * OpenRouter Provisioning - provisioning key for credit tracking. * Note: Regular API keys do not expose credit information. @@ -160,6 +171,8 @@ enum class ConnectionType( ) // Cline has only remaining, other formats will fallback CLINE_API -> setOf(StatusBarDollarFormat.REMAINING_ONLY) + // DeepSeek has only remaining (from /user/balance total_balance); other formats will fallback + DEEPSEEK_API -> setOf(StatusBarDollarFormat.REMAINING_ONLY) // OpenAI Platform has only used, no remaining OPENAI_PLATFORM -> setOf(StatusBarDollarFormat.REMAINING_ONLY) // Will show "used" as fallback // Claude Code uses percentage from metadata (not Credits) diff --git a/src/main/kotlin/org/zhavoronkov/tokenpulse/model/Provider.kt b/src/main/kotlin/org/zhavoronkov/tokenpulse/model/Provider.kt index 7d987d0..76dccce 100644 --- a/src/main/kotlin/org/zhavoronkov/tokenpulse/model/Provider.kt +++ b/src/main/kotlin/org/zhavoronkov/tokenpulse/model/Provider.kt @@ -19,6 +19,9 @@ enum class Provider(val displayName: String, val abbreviation: String) { /** Cline - AI coding assistant with its own API. */ CLINE("Cline", "CN"), + /** DeepSeek - maker of DeepSeek-V3/R1 models. */ + DEEPSEEK("DeepSeek", "DS"), + /** OpenRouter - unified API gateway for multiple AI models. */ OPENROUTER("OpenRouter", "OR"), diff --git a/src/main/kotlin/org/zhavoronkov/tokenpulse/provider/ProviderRegistry.kt b/src/main/kotlin/org/zhavoronkov/tokenpulse/provider/ProviderRegistry.kt index cd82754..d9942c2 100644 --- a/src/main/kotlin/org/zhavoronkov/tokenpulse/provider/ProviderRegistry.kt +++ b/src/main/kotlin/org/zhavoronkov/tokenpulse/provider/ProviderRegistry.kt @@ -5,6 +5,7 @@ import okhttp3.OkHttpClient import org.zhavoronkov.tokenpulse.model.ConnectionType import org.zhavoronkov.tokenpulse.provider.anthropic.claudecode.ClaudeCodeProviderClient import org.zhavoronkov.tokenpulse.provider.cline.ClineProviderClient +import org.zhavoronkov.tokenpulse.provider.deepseek.DeepSeekProviderClient import org.zhavoronkov.tokenpulse.provider.nebius.NebiusProviderClient import org.zhavoronkov.tokenpulse.provider.nebius.NebiusSessionRefresher import org.zhavoronkov.tokenpulse.provider.openai.chatgpt.CodexProviderClient @@ -48,6 +49,7 @@ class DefaultProviderRegistry( private val openRouterClient by lazy { OpenRouterProviderClient(httpClient, gson) } private val openRouterPluginClient by lazy { OpenRouterPluginBridgeClient() } private val clineClient by lazy { ClineProviderClient(httpClient, gson) } + private val deepSeekClient by lazy { DeepSeekProviderClient(httpClient, gson) } private val nebiusClient by lazy { NebiusProviderClient( httpClient = httpClient, @@ -73,6 +75,7 @@ class DefaultProviderRegistry( ConnectionType.OPENROUTER_PROVISIONING -> openRouterClient ConnectionType.OPENROUTER_PLUGIN -> openRouterPluginClient ConnectionType.CLINE_API -> clineClient + ConnectionType.DEEPSEEK_API -> deepSeekClient ConnectionType.NEBIUS_BILLING -> nebiusClient ConnectionType.OPENAI_PLATFORM -> openAiPlatformClient ConnectionType.CODEX_CLI -> codexClient diff --git a/src/main/kotlin/org/zhavoronkov/tokenpulse/provider/deepseek/DeepSeekProviderClient.kt b/src/main/kotlin/org/zhavoronkov/tokenpulse/provider/deepseek/DeepSeekProviderClient.kt new file mode 100644 index 0000000..9287382 --- /dev/null +++ b/src/main/kotlin/org/zhavoronkov/tokenpulse/provider/deepseek/DeepSeekProviderClient.kt @@ -0,0 +1,158 @@ +package org.zhavoronkov.tokenpulse.provider.deepseek + +import com.google.gson.Gson +import com.google.gson.JsonObject +import okhttp3.OkHttpClient +import okhttp3.Request +import org.zhavoronkov.tokenpulse.model.Balance +import org.zhavoronkov.tokenpulse.model.BalanceSnapshot +import org.zhavoronkov.tokenpulse.model.ConnectionType +import org.zhavoronkov.tokenpulse.model.Credits +import org.zhavoronkov.tokenpulse.model.ProviderResult +import org.zhavoronkov.tokenpulse.provider.ProviderClient +import org.zhavoronkov.tokenpulse.settings.Account +import java.math.BigDecimal +import java.time.Instant + +/** + * Provider client for DeepSeek API. + * + * DeepSeek provides a dedicated balance endpoint: `GET /user/balance` + * that returns the user's account balance in their native currency (typically CNY or USD). + * + * ## Endpoints + * - `GET https://api.deepseek.com/user/balance` → User balance information + * + * ## Balance Representation + * DeepSeek returns balance as a string decimal in the user's currency (e.g., "5.00" CNY or USD). + * This client converts the string to BigDecimal and stores the currency in metadata for display. + * + * ## Error Handling + * - 401/403 → AuthError (invalid/expired key) + * - 429 → RateLimited (temporary, will retry) + * - 5xx → NetworkError (transient, won't trigger credential cooldown) + * - Other → NetworkError + */ +class DeepSeekProviderClient( + private val httpClient: OkHttpClient = OkHttpClient(), + private val gson: Gson = Gson(), + private val baseUrl: String = DEEPSEEK_API_BASE_URL +) : ProviderClient { + + override fun fetchBalance(account: Account, secret: String): ProviderResult { + return try { + val response = fetchUserBalance(secret) + when (response) { + is BalanceResponse.Success -> { + ProviderResult.Success( + BalanceSnapshot( + accountId = account.id, + connectionType = ConnectionType.DEEPSEEK_API, + timestamp = Instant.now(), + balance = Balance( + credits = Credits( + remaining = response.totalBalance + ) + ), + metadata = mapOf("currency" to response.currency) + ) + ) + } + is BalanceResponse.Failure.Auth -> ProviderResult.Failure.AuthError(response.message) + is BalanceResponse.Failure.RateLimited -> ProviderResult.Failure.RateLimited(response.message) + is BalanceResponse.Failure.Network -> ProviderResult.Failure.NetworkError(response.message) + is BalanceResponse.Failure.Parse -> ProviderResult.Failure.ParseError(response.message, response.cause) + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + ProviderResult.Failure.NetworkError("Failed to connect to DeepSeek", e) + } + } + + override fun testCredentials(account: Account, secret: String): ProviderResult { + return when (val response = fetchUserBalance(secret)) { + is BalanceResponse.Success -> ProviderResult.Success( + BalanceSnapshot("test", ConnectionType.DEEPSEEK_API, Balance(), timestamp = Instant.now()) + ) + is BalanceResponse.Failure.Auth -> ProviderResult.Failure.AuthError(response.message) + is BalanceResponse.Failure.RateLimited -> ProviderResult.Failure.RateLimited(response.message) + is BalanceResponse.Failure.Network -> ProviderResult.Failure.NetworkError(response.message) + is BalanceResponse.Failure.Parse -> ProviderResult.Failure.ParseError(response.message, response.cause) + } + } + + private fun fetchUserBalance(secret: String): BalanceResponse { + val request = Request.Builder() + .url("$baseUrl/user/balance") + .header("Authorization", "Bearer $secret") + .build() + + return httpClient.newCall(request).execute().use { response -> + val code = response.code + when { + response.isSuccessful -> { + val body = response.body?.string() ?: return BalanceResponse.Failure.Network("Empty response body") + parseBalanceResponse(body) + } + code == HTTP_UNAUTHORIZED || code == HTTP_FORBIDDEN -> + BalanceResponse.Failure.Auth("Invalid or expired DeepSeek API key") + code == HTTP_TOO_MANY_REQUESTS -> + BalanceResponse.Failure.RateLimited("DeepSeek API rate limit exceeded") + code >= HTTP_INTERNAL_ERROR -> + BalanceResponse.Failure.Network("DeepSeek API server error: $code") + else -> + BalanceResponse.Failure.Network("DeepSeek API error: $code") + } + } + } + + private fun parseBalanceResponse(body: String): BalanceResponse { + return try { + val jsonObject = gson.fromJson(body, JsonObject::class.java) + + // Check if the response indicates the account is available + val isAvailable = jsonObject.get("is_available")?.asBoolean ?: false + if (!isAvailable) { + return BalanceResponse.Failure.Network("DeepSeek account is not available") + } + + // Extract balance_infos array + val balanceInfos = jsonObject.getAsJsonArray("balance_infos") + if (balanceInfos == null || balanceInfos.size() == 0) { + return BalanceResponse.Failure.Parse("Missing or empty balance_infos in response", null) + } + + parseFirstBalanceEntry(balanceInfos.get(0).asJsonObject) + } catch (e: Exception) { + BalanceResponse.Failure.Parse("Failed to parse DeepSeek balance response", e) + } + } + + private fun parseFirstBalanceEntry(entry: JsonObject): BalanceResponse { + val currency = entry.get("currency")?.asString ?: "CNY" + val totalBalanceStr = entry.get("total_balance")?.asString + ?: return BalanceResponse.Failure.Parse("Missing total_balance in balance_infos", null) + + val totalBalance = totalBalanceStr.toBigDecimalOrNull() + ?: return BalanceResponse.Failure.Parse("Invalid total_balance format: $totalBalanceStr", null) + + return BalanceResponse.Success(totalBalance, currency) + } + + private sealed class BalanceResponse { + data class Success(val totalBalance: BigDecimal, val currency: String) : BalanceResponse() + sealed class Failure : BalanceResponse() { + data class Auth(val message: String) : Failure() + data class RateLimited(val message: String) : Failure() + data class Network(val message: String) : Failure() + data class Parse(val message: String, val cause: Throwable?) : Failure() + } + } + + companion object { + private const val DEEPSEEK_API_BASE_URL = "https://api.deepseek.com" + private const val HTTP_UNAUTHORIZED = 401 + private const val HTTP_FORBIDDEN = 403 + private const val HTTP_TOO_MANY_REQUESTS = 429 + private const val HTTP_INTERNAL_ERROR = 500 + } +} diff --git a/src/main/kotlin/org/zhavoronkov/tokenpulse/service/BalanceRefreshService.kt b/src/main/kotlin/org/zhavoronkov/tokenpulse/service/BalanceRefreshService.kt index 888469f..2b4fef0 100644 --- a/src/main/kotlin/org/zhavoronkov/tokenpulse/service/BalanceRefreshService.kt +++ b/src/main/kotlin/org/zhavoronkov/tokenpulse/service/BalanceRefreshService.kt @@ -341,6 +341,7 @@ internal fun composeNotificationMessage( */ internal fun isApiKeyAuth(authType: AuthType): Boolean = when (authType) { AuthType.CLINE_API_KEY, + AuthType.DEEPSEEK_API_KEY, AuthType.OPENAI_API_KEY, AuthType.XIAOMI_API_KEY, AuthType.XIAOMI_TOKEN_PLAN_KEY, diff --git a/src/main/kotlin/org/zhavoronkov/tokenpulse/settings/Account.kt b/src/main/kotlin/org/zhavoronkov/tokenpulse/settings/Account.kt index 0e694fd..c1843f0 100644 --- a/src/main/kotlin/org/zhavoronkov/tokenpulse/settings/Account.kt +++ b/src/main/kotlin/org/zhavoronkov/tokenpulse/settings/Account.kt @@ -19,6 +19,14 @@ enum class AuthType(val displayName: String) { OPENROUTER_PLUGIN_BRIDGE("Plugin Integration"), CLINE_API_KEY("API Key"), + /** + * DeepSeek personal API key. + * + * The stored secret is a raw API key string (e.g., "sk-..."). Balance is read + * from the dedicated `GET /user/balance` endpoint with a simple Bearer header. + */ + DEEPSEEK_API_KEY("API Key"), + /** * Nebius AI Studio billing session. * diff --git a/src/main/kotlin/org/zhavoronkov/tokenpulse/ui/AccountEditDialog.kt b/src/main/kotlin/org/zhavoronkov/tokenpulse/ui/AccountEditDialog.kt index b51febb..c0a096b 100644 --- a/src/main/kotlin/org/zhavoronkov/tokenpulse/ui/AccountEditDialog.kt +++ b/src/main/kotlin/org/zhavoronkov/tokenpulse/ui/AccountEditDialog.kt @@ -353,6 +353,7 @@ class AccountEditDialog( private fun getProviderUrl(): String = when (getConnectionType()) { ConnectionType.CLAUDE_CODE -> "https://claude.ai/settings/usage" ConnectionType.CLINE_API -> "https://app.cline.bot/dashboard/account?tab=api-keys" + ConnectionType.DEEPSEEK_API -> "https://platform.deepseek.com/api_keys" ConnectionType.OPENROUTER_PROVISIONING -> "https://openrouter.ai/settings/provisioning-keys" ConnectionType.OPENROUTER_PLUGIN -> "https://openrouter.ai" ConnectionType.NEBIUS_BILLING -> "https://tokenfactory.nebius.com/" @@ -536,6 +537,8 @@ class AccountEditDialog( ConnectionType.CLINE_API -> "Cline personal API key. Note: API key management is only available for Personal accounts, " + "not Organization accounts." + ConnectionType.DEEPSEEK_API -> + "DeepSeek personal API key. Used to track your remaining balance." ConnectionType.OPENROUTER_PROVISIONING -> "OpenRouter Provisioning Key required. Click \"Get API Key →\" to open the OpenRouter settings." ConnectionType.OPENROUTER_PLUGIN -> diff --git a/src/main/kotlin/org/zhavoronkov/tokenpulse/ui/DeepSeekConnectDialog.kt b/src/main/kotlin/org/zhavoronkov/tokenpulse/ui/DeepSeekConnectDialog.kt new file mode 100644 index 0000000..5473c6d --- /dev/null +++ b/src/main/kotlin/org/zhavoronkov/tokenpulse/ui/DeepSeekConnectDialog.kt @@ -0,0 +1,100 @@ +package org.zhavoronkov.tokenpulse.ui + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import org.zhavoronkov.tokenpulse.utils.Constants.FONT_SIZE_SMALL +import org.zhavoronkov.tokenpulse.utils.Constants.PASSWORD_FIELD_COLUMNS +import java.awt.Font +import javax.swing.JButton +import javax.swing.JComponent + +/** + * Dialog for connecting a DeepSeek API key. + * + * DeepSeek exposes a dedicated `GET /user/balance` endpoint that only requires + * a Bearer API key for authentication. + * + * To create an API key: + * 1. Go to https://platform.deepseek.com/api_keys + * 2. Create a new API key. + * 3. Copy the key and paste it here. + */ +class DeepSeekConnectDialog : DialogWrapper(true) { + + companion object { + const val DEEPSEEK_KEYS_URL = "https://platform.deepseek.com/api_keys" + + private const val STATUS_WAITING = "Paste your DeepSeek API key" + private const val STATUS_SUCCESS = "✓ Key captured!" + private const val STATUS_EMPTY = "Please paste a key first." + } + + var capturedApiKey: String? = null + private set + + private val statusLabel = JBLabel(STATUS_WAITING) + + private val keyField = JBPasswordField().apply { + columns = PASSWORD_FIELD_COLUMNS + font = Font(Font.MONOSPACED, Font.PLAIN, FONT_SIZE_SMALL) + toolTipText = "Paste your DeepSeek API key here" + } + + private val connectButton = JButton("Capture Key").apply { + addActionListener { attemptCapture() } + } + + private val openBrowserButton = JButton("Open API Key Page").apply { + addActionListener { BrowserUtil.browse(DEEPSEEK_KEYS_URL) } + } + + init { + title = "Connect DeepSeek API Key" + setOKButtonText("Connect") + isOKActionEnabled = false + init() + } + + override fun createCenterPanel(): JComponent = panel { + row { + label("DeepSeek API Key Connection") + } + row { + label("1. Open the DeepSeek API keys page.") + } + row { + cell(openBrowserButton) + } + row { + label("2. Create a new API key.") + } + row { + label("3. Paste the key below:") + } + row { + cell(keyField).align(AlignX.FILL) + } + row { + cell(connectButton) + cell(statusLabel).align(AlignX.FILL) + } + } + + override fun getPreferredFocusedComponent() = keyField + + private fun attemptCapture() { + val raw = String(keyField.password).trim() + if (raw.isEmpty()) { + statusLabel.text = STATUS_EMPTY + return + } + + capturedApiKey = raw + statusLabel.text = STATUS_SUCCESS + isOKActionEnabled = true + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 6e2575d..1a3826e 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -16,6 +16,7 @@