Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,6 @@ private_encrypted.pem

# Captured network traces (may contain session cookies/tokens)
*.har

# Kotlin compiler daemon session artifacts
.kotlin/
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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 |
Expand Down Expand Up @@ -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).
Expand Down
13 changes: 13 additions & 0 deletions src/main/kotlin/org/zhavoronkov/tokenpulse/model/ConnectionType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/org/zhavoronkov/tokenpulse/model/Provider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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)

// Extract balance_infos array. Note: DeepSeek's `is_available` field
// is informational — it means "account has enough balance to make
// API calls" (i.e., non-zero balance). It is NOT an auth/connectivity
// signal. A brand-new key with $0.00 balance returns is_available=false
// but is a perfectly valid key, so we always parse the balance data.
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down Expand Up @@ -536,6 +537,8 @@ class AccountEditDialog(
ConnectionType.CLINE_API ->
"Cline personal API key. <b>Note:</b> 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 <b>Provisioning Key</b> required. Click \"Get API Key →\" to open the OpenRouter settings."
ConnectionType.OPENROUTER_PLUGIN ->
Expand Down
Loading
Loading