diff --git a/README.md b/README.md index d992761a..ab07d8ce 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits - [**Copilot**](docs/providers/copilot.md) / premium, chat, completions - [**Cursor**](docs/providers/cursor.md) / credits, total usage, auto usage, API usage, on-demand, CLI auth +- [**DeepSeek**](docs/providers/deepseek.md) / balance (USD) - [**Factory / Droid**](docs/providers/factory.md) / standard, premium tokens - [**Gemini**](docs/providers/gemini.md) / pro, flash, workspace/free/paid tier - [**JetBrains AI Assistant**](docs/providers/jetbrains-ai-assistant.md) / quota, remaining diff --git a/docs/providers/deepseek.md b/docs/providers/deepseek.md new file mode 100644 index 00000000..ffbf3559 --- /dev/null +++ b/docs/providers/deepseek.md @@ -0,0 +1,79 @@ +# DeepSeek + +> Uses the DeepSeek user balance API with a user-provided API key and starting balance. + +## Overview + +- **Protocol:** HTTPS (JSON) +- **Endpoint:** `GET https://api.deepseek.com/user/balance` +- **Auth:** `Authorization: Bearer ` +- **Balance model:** remaining USD (or CNY) balance — no time-based limit + +## Authentication + +Set the following environment variables: + +| Variable | Required | Description | +|---|---|---| +| `DEEPSEEK_API_KEY` | yes | DeepSeek API key from [platform.deepseek.com](https://platform.deepseek.com/) | +| `DEEPSEEK_INITIAL_BALANCE` | yes | Your starting balance (e.g. `10.00`). Must be > 0. | + +If any variable is missing or invalid, the plugin throws: + +- `DeepSeek API key missing. Set DEEPSEEK_API_KEY.` +- `DeepSeek initial balance missing or invalid. Set DEEPSEEK_INITIAL_BALANCE to your starting balance (e.g. 10.00).` + +## Data Source + +Request: + +```http +GET /user/balance HTTP/1.1 +Host: api.deepseek.com +Authorization: Bearer +Accept: application/json +``` + +Response: + +```jsonc +{ + "is_available": true, + "balance_infos": [ + { + "currency": "USD", // "USD" or "CNY" + "total_balance": "3.55", // string, parse as float + "granted_balance": "0.00", // non-expired granted balance + "topped_up_balance": "3.55" // topped-up balance + } + ] +} +``` + +## Usage Mapping + +- Prefer the `USD` entry in `balance_infos`. Fall back to `CNY` if no USD entry is present. +- `used = DEEPSEEK_INITIAL_BALANCE − total_balance` (clamped to ≥ 0). +- `limit = DEEPSEEK_INITIAL_BALANCE`. +- No reset timestamp — balance is a lifetime metric, not a periodic window. +- Plan name is not reported by this API. + +## Output + +- **Balance** (overview progress line): + - `label`: `Balance` + - `format`: dollars (shown as `$X.XX / $Y.YY`) + - `used`: dollars spent (initial − remaining) + - `limit`: initial balance set by user + +## Errors + +| Condition | Message | +|---|---| +| Missing API key | `DeepSeek API key missing. Set DEEPSEEK_API_KEY.` | +| Missing/invalid initial balance | `DeepSeek initial balance missing or invalid. Set DEEPSEEK_INITIAL_BALANCE to your starting balance (e.g. 10.00).` | +| HTTP 401/403 | `Session expired. Check your DeepSeek API key.` | +| Non-2xx | `Request failed (HTTP {status}). Try again later.` | +| Network failure | `Request failed. Check your connection.` | +| Unparseable response | `Could not parse usage data.` | +| No USD/CNY balance in response | `Could not find balance in response.` | diff --git a/plugins/deepseek/icon.svg b/plugins/deepseek/icon.svg new file mode 100644 index 00000000..becf0efd --- /dev/null +++ b/plugins/deepseek/icon.svg @@ -0,0 +1,4 @@ + + DeepSeek + + diff --git a/plugins/deepseek/plugin.js b/plugins/deepseek/plugin.js new file mode 100644 index 00000000..73dc719e --- /dev/null +++ b/plugins/deepseek/plugin.js @@ -0,0 +1,118 @@ +(function () { + const USAGE_URL = "https://api.deepseek.com/user/balance" + const API_KEY_ENV_VARS = ["DEEPSEEK_API_KEY"] + + function readString(value) { + if (typeof value !== "string") return null + const trimmed = value.trim() + return trimmed ? trimmed : null + } + + function readNumber(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (typeof value !== "string") return null + const trimmed = value.trim() + if (!trimmed) return null + const n = Number(trimmed) + return Number.isFinite(n) ? n : null + } + + function loadApiKey(ctx) { + for (let i = 0; i < API_KEY_ENV_VARS.length; i += 1) { + const name = API_KEY_ENV_VARS[i] + let value = null + try { + value = ctx.host.env.get(name) + } catch (e) { + ctx.host.log.warn("env read failed for " + name + ": " + String(e)) + } + const key = readString(value) + if (key) { + ctx.host.log.info("api key loaded from " + name) + return key + } + } + return null + } + + function loadInitialBalance(ctx) { + let value = null + try { + value = ctx.host.env.get("DEEPSEEK_INITIAL_BALANCE") + } catch (e) { + ctx.host.log.warn("env read failed for DEEPSEEK_INITIAL_BALANCE: " + String(e)) + } + return readNumber(value) + } + + function findBalance(balanceInfos) { + if (!Array.isArray(balanceInfos) || balanceInfos.length === 0) return null + let cnyBalance = null + for (let i = 0; i < balanceInfos.length; i += 1) { + const info = balanceInfos[i] + if (!info || typeof info !== "object") continue + const balance = readNumber(info.total_balance) + if (balance === null) continue + if (info.currency === "USD") return balance + if (info.currency === "CNY") cnyBalance = balance + } + return cnyBalance + } + + function probe(ctx) { + const apiKey = loadApiKey(ctx) + if (!apiKey) { + throw "DeepSeek API key missing. Set DEEPSEEK_API_KEY." + } + + const initialBalance = loadInitialBalance(ctx) + if (initialBalance === null || initialBalance <= 0) { + throw "DeepSeek initial balance missing or invalid. Set DEEPSEEK_INITIAL_BALANCE to your starting balance (e.g. 10.00)." + } + + let resp + try { + resp = ctx.util.request({ + method: "GET", + url: USAGE_URL, + headers: { + Authorization: "Bearer " + apiKey, + Accept: "application/json", + }, + timeoutMs: 15000, + }) + } catch (e) { + throw "Request failed. Check your connection." + } + + if (ctx.util.isAuthStatus(resp.status)) { + throw "Session expired. Check your DeepSeek API key." + } + if (resp.status < 200 || resp.status >= 300) { + throw "Request failed (HTTP " + resp.status + "). Try again later." + } + + const json = ctx.util.tryParseJson(resp.bodyText) + if (!json || typeof json !== "object") { + throw "Could not parse usage data." + } + + const remainingBalance = findBalance(json.balance_infos) + if (remainingBalance === null) { + throw "Could not find balance in response." + } + + const used = Math.max(0, initialBalance - remainingBalance) + + const line = { + label: "Balance", + used: used, + limit: initialBalance, + format: { kind: "dollars" }, + } + + return { lines: [ctx.line.progress(line)] } + } + + globalThis.__openusage_plugin = { id: "deepseek", probe } +})() diff --git a/plugins/deepseek/plugin.json b/plugins/deepseek/plugin.json new file mode 100644 index 00000000..c8adc4ac --- /dev/null +++ b/plugins/deepseek/plugin.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "id": "deepseek", + "name": "DeepSeek", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#4D6BFE", + "lines": [ + { "type": "progress", "label": "Balance", "scope": "overview", "primaryOrder": 1 } + ] +} diff --git a/plugins/deepseek/plugin.test.js b/plugins/deepseek/plugin.test.js new file mode 100644 index 00000000..33f8fb06 --- /dev/null +++ b/plugins/deepseek/plugin.test.js @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +const USAGE_URL = "https://api.deepseek.com/user/balance" + +const loadPlugin = async () => { + await import("./plugin.js") + return globalThis.__openusage_plugin +} + +function setEnv(ctx, envValues) { + ctx.host.env.get.mockImplementation((name) => + Object.prototype.hasOwnProperty.call(envValues, name) ? envValues[name] : null + ) +} + +function successPayload(overrides) { + const base = { + is_available: true, + balance_infos: [ + { + currency: "USD", + total_balance: "3.55", + granted_balance: "0.00", + topped_up_balance: "3.55", + }, + ], + } + if (!overrides) return base + return Object.assign(base, overrides) +} + +describe("deepseek plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("throws when API key is missing", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_INITIAL_BALANCE: "10" }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("DeepSeek API key missing") + }) + + it("throws when initial balance is missing", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_API_KEY: "sk-test" }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("initial balance") + }) + + it("throws when initial balance is zero", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_API_KEY: "sk-test", DEEPSEEK_INITIAL_BALANCE: "0" }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("initial balance") + }) + + it("returns progress line with dollar format", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_API_KEY: "sk-test", DEEPSEEK_INITIAL_BALANCE: "10" }) + + const payload = successPayload() + ctx.util.request = vi.fn(() => ({ + status: 200, + bodyText: JSON.stringify(payload), + })) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines).toHaveLength(1) + expect(result.lines[0]).toMatchObject({ + label: "Balance", + format: { kind: "dollars" }, + }) + // remaining = 3.55, initial = 10, used = 6.45 + expect(result.lines[0].used).toBeCloseTo(6.45, 2) + expect(result.lines[0].limit).toBe(10) + }) + + it("uses CNY balance when USD is absent", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_API_KEY: "sk-test", DEEPSEEK_INITIAL_BALANCE: "100" }) + + const payload = { + is_available: true, + balance_infos: [ + { + currency: "CNY", + total_balance: "55.00", + granted_balance: "5.00", + topped_up_balance: "50.00", + }, + ], + } + ctx.util.request = vi.fn(() => ({ + status: 200, + bodyText: JSON.stringify(payload), + })) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + // remaining = 55.00, initial = 100, used = 45.00 + expect(result.lines[0].used).toBeCloseTo(45, 2) + expect(result.lines[0].limit).toBe(100) + }) + + it("prefers USD over CNY when both are present", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_API_KEY: "sk-test", DEEPSEEK_INITIAL_BALANCE: "50" }) + + const payload = { + is_available: true, + balance_infos: [ + { currency: "CNY", total_balance: "100.00", granted_balance: "0.00", topped_up_balance: "100.00" }, + { currency: "USD", total_balance: "10.00", granted_balance: "0.00", topped_up_balance: "10.00" }, + ], + } + ctx.util.request = vi.fn(() => ({ + status: 200, + bodyText: JSON.stringify(payload), + })) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + // Prefers USD: remaining = 10.00, initial = 50, used = 40 + expect(result.lines[0].used).toBeCloseTo(40, 2) + expect(result.lines[0].limit).toBe(50) + }) + + it("throws when no USD or CNY balance present", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_API_KEY: "sk-test", DEEPSEEK_INITIAL_BALANCE: "10" }) + + const payload = { + is_available: true, + balance_infos: [ + { currency: "EUR", total_balance: "20.00", granted_balance: "0.00", topped_up_balance: "20.00" }, + ], + } + ctx.util.request = vi.fn(() => ({ + status: 200, + bodyText: JSON.stringify(payload), + })) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Could not find balance") + }) + + it("handles auth error (401)", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_API_KEY: "sk-bad", DEEPSEEK_INITIAL_BALANCE: "10" }) + + ctx.util.request = vi.fn(() => ({ status: 401, bodyText: "Unauthorized" })) + ctx.util.isAuthStatus = vi.fn((s) => s === 401 || s === 403) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Session expired") + }) + + it("handles HTTP error status", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_API_KEY: "sk-test", DEEPSEEK_INITIAL_BALANCE: "10" }) + + ctx.util.request = vi.fn(() => ({ status: 500, bodyText: "Server Error" })) + ctx.util.isAuthStatus = vi.fn(() => false) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("HTTP 500") + }) + + it("handles invalid JSON response", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_API_KEY: "sk-test", DEEPSEEK_INITIAL_BALANCE: "10" }) + + ctx.util.request = vi.fn(() => ({ status: 200, bodyText: "not json" })) + ctx.util.isAuthStatus = vi.fn(() => false) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data") + }) + + it("handles missing balance_infos", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_API_KEY: "sk-test", DEEPSEEK_INITIAL_BALANCE: "10" }) + + const payload = { is_available: true, balance_infos: [] } + ctx.util.request = vi.fn(() => ({ + status: 200, + bodyText: JSON.stringify(payload), + })) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Could not find balance") + }) + + it("reads key from all env vars", async () => { + const ctx = makeCtx() + setEnv(ctx, { DEEPSEEK_API_KEY: "sk-from-key", DEEPSEEK_INITIAL_BALANCE: "10" }) + + const payload = successPayload() + ctx.util.request = vi.fn(() => ({ + status: 200, + bodyText: JSON.stringify(payload), + })) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).not.toThrow() + }) +}) diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index a39ac09d..c3b0f74e 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Mutex, OnceLock}; -const WHITELISTED_ENV_VARS: [&str; 16] = [ +const WHITELISTED_ENV_VARS: [&str; 18] = [ "CODEX_HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", @@ -29,6 +29,8 @@ const WHITELISTED_ENV_VARS: [&str; 16] = [ "MINIMAX_CN_API_KEY", "SYNTHETIC_API_KEY", "PI_CODING_AGENT_DIR", + "DEEPSEEK_API_KEY", + "DEEPSEEK_INITIAL_BALANCE", ]; fn last_non_empty_trimmed_line(text: &str) -> Option {