diff --git a/README.md b/README.md index d992761a..2c96c17e 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Kiro**](docs/providers/kiro.md) / credits, bonus credits, overages - [**Kimi Code**](docs/providers/kimi.md) / session, weekly - [**MiniMax**](docs/providers/minimax.md) / coding plan session +- [**Neuralwatt**](docs/providers/neuralwatt.md) / subscription energy, balance credits - [**OpenCode Go**](docs/providers/opencode-go.md) / 5h, weekly, monthly spend limits - [**Windsurf**](docs/providers/windsurf.md) / prompt credits, flex credits - [**Z.ai**](docs/providers/zai.md) / session, weekly, web searches diff --git a/docs/providers/neuralwatt.md b/docs/providers/neuralwatt.md new file mode 100644 index 00000000..d6482a80 --- /dev/null +++ b/docs/providers/neuralwatt.md @@ -0,0 +1,71 @@ +# Neuralwatt + +> Uses the Neuralwatt quota API with a user-provided API key. + +## Overview + +- **Protocol:** HTTPS (JSON) +- **Endpoint:** `GET https://api.neuralwatt.com/v1/quota` +- **Auth:** `Authorization: Bearer ` +- **Env var:** `NEURALWATT_API_KEY` + +## Authentication + +The plugin reads `NEURALWATT_API_KEY` from the environment. If the key is missing, it throws: + +- `Neuralwatt API key missing. Set NEURALWATT_API_KEY.` + +## Data Source + +Request: + +```http +GET /v1/quota HTTP/1.1 +Host: api.neuralwatt.com +Authorization: Bearer +Accept: application/json +User-Agent: OpenUsage +``` + +Expected payload fields: + +- `balance.credits_remaining_usd`, `balance.total_credits_usd`, `balance.credits_used_usd` +- `balance.accounting_method` (e.g. `"energy"`, `"token"`) +- `subscription.plan`, `subscription.status`, `subscription.billing_interval` +- `subscription.current_period_start`, `subscription.current_period_end` +- `subscription.kwh_included`, `subscription.kwh_used`, `subscription.kwh_remaining` +- `subscription.auto_renew`, `subscription.in_overage` + +## Usage Mapping + +- **Subscription** (overview progress line): `kwh_used` / `kwh_included` in kWh. Shown only when subscription is present and `kwh_included > 0`. +- **Balance** (overview progress line): `credits_used_usd` / `total_credits_usd` in dollars. Shown only when `total_credits_usd > 0`. +- **Method** (detail badge): `accounting_method`, capitalized. Hidden when absent. + +The **Subscription** line includes `resetsAt` and `periodDurationMs` from the subscription period dates when available; the **Balance** line does not. + +## Output + +- **Plan**: from `subscription.plan`, capitalized +- **Subscription** (overview progress line): + - `format`: count with `kWh` suffix + - `used`: `kwh_used` + - `limit`: `kwh_included` + - `resetsAt`: from `current_period_end` + - `periodDurationMs`: `current_period_end` – `current_period_start` +- **Balance** (overview progress line): + - `format`: dollars + - `used`: `credits_used_usd` + - `limit`: `total_credits_usd` +- **Method** (detail badge): capitalized `accounting_method` +- **Status** (overview badge): shown as "No usage data" (gray) when subscription and balance are both absent + +## Errors + +| Condition | Message | +|---|---| +| Missing API key | `Neuralwatt API key missing. Set NEURALWATT_API_KEY.` | +| HTTP 401/403 | `Invalid API key. Check NEURALWATT_API_KEY.` | +| Non-2xx | `Request failed (HTTP {status}). Try again later.` | +| Network failure | `Request failed. Check your connection.` | +| Unparseable payload | `Response invalid. Try again later.` | diff --git a/plugins/neuralwatt/icon.svg b/plugins/neuralwatt/icon.svg new file mode 100644 index 00000000..e011a3e5 --- /dev/null +++ b/plugins/neuralwatt/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/neuralwatt/plugin.js b/plugins/neuralwatt/plugin.js new file mode 100644 index 00000000..8954529b --- /dev/null +++ b/plugins/neuralwatt/plugin.js @@ -0,0 +1,152 @@ +(function () { + var API_KEY_ENV_VARS = ["NEURALWATT_API_KEY"] + var QUOTA_URL = "https://api.neuralwatt.com/v1/quota" + + function readNumber(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (typeof value === "string") { + var parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + return null + } + + function readString(value) { + if (typeof value !== "string") return null + var trimmed = value.trim() + return trimmed || null + } + + function parseDateMs(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (typeof value === "string") { + var parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : null + } + return null + } + + function parseSubscriptionPeriodMs(sub) { + if (!sub || !sub.current_period_start || !sub.current_period_end) return null + var startMs = parseDateMs(sub.current_period_start) + var endMs = parseDateMs(sub.current_period_end) + if (startMs !== null && endMs !== null && endMs > startMs) return endMs - startMs + return null + } + + function loadApiKey(ctx) { + for (var i = 0; i < API_KEY_ENV_VARS.length; i += 1) { + var name = API_KEY_ENV_VARS[i] + var value = null + try { + value = ctx.host.env.get(name) + } catch (e) { + ctx.host.log.warn("env read failed for " + name + ": " + String(e)) + } + if (value && typeof value === "string" && value.trim()) { + ctx.host.log.info("api key loaded from " + name) + return { value: value.trim(), source: name } + } + } + return null + } + + function probe(ctx) { + var apiKeyInfo = loadApiKey(ctx) + if (!apiKeyInfo) { + throw "Neuralwatt API key missing. Set NEURALWATT_API_KEY." + } + + var resp + try { + resp = ctx.util.request({ + method: "GET", + url: QUOTA_URL, + headers: { + Authorization: "Bearer " + apiKeyInfo.value, + Accept: "application/json", + "User-Agent": "OpenUsage", + }, + timeoutMs: 10000, + }) + } catch (e) { + throw "Request failed. Check your connection." + } + + if (ctx.util.isAuthStatus(resp.status)) { + throw "Invalid API key. Check NEURALWATT_API_KEY." + } + if (resp.status < 200 || resp.status >= 300) { + throw "Request failed (HTTP " + String(resp.status) + "). Try again later." + } + + var data = ctx.util.tryParseJson(resp.bodyText) + if (!data || typeof data !== "object") { + throw "Response invalid. Try again later." + } + + var sub = data.subscription && typeof data.subscription === "object" ? data.subscription : null + var balance = data.balance && typeof data.balance === "object" ? data.balance : null + var plan = null + var resetsAt = null + var periodDurationMs = null + + if (sub) { + if (typeof sub.plan === "string" && sub.plan) { + plan = sub.plan.charAt(0).toUpperCase() + sub.plan.slice(1) + } + if (sub.current_period_end) { + var endMs = parseDateMs(sub.current_period_end) + resetsAt = endMs !== null ? ctx.util.toIso(endMs) : null + } + periodDurationMs = parseSubscriptionPeriodMs(sub) + } + + var lines = [] + + // Subscription energy line (hidden if no subscription) + if (sub) { + var kwhIncluded = readNumber(sub.kwh_included) + var kwhUsed = readNumber(sub.kwh_used) + if (kwhIncluded !== null && kwhIncluded > 0 && kwhUsed !== null) { + var energyLine = { + label: "Subscription", + used: Math.round(kwhUsed * 10000) / 10000, + limit: Math.round(kwhIncluded * 10000) / 10000, + format: { kind: "count", suffix: "kWh" }, + } + if (resetsAt) energyLine.resetsAt = resetsAt + if (periodDurationMs) energyLine.periodDurationMs = periodDurationMs + lines.push(ctx.line.progress(energyLine)) + } + } + + // Balance line (hidden if total credits is 0) + if (balance) { + var totalCredits = readNumber(balance.total_credits_usd) + var usedCredits = readNumber(balance.credits_used_usd) + if (totalCredits !== null && totalCredits > 0 && usedCredits !== null) { + lines.push(ctx.line.progress({ + label: "Balance", + used: Math.round(usedCredits * 100) / 100, + limit: Math.round(totalCredits * 100) / 100, + format: { kind: "dollars" }, + })) + } + + // Accounting method badge + var method = readString(balance.accounting_method) + if (method) { + lines.push(ctx.line.badge({ label: "Method", text: method.charAt(0).toUpperCase() + method.slice(1) })) + } + } + + if (lines.length === 0) { + lines.push(ctx.line.badge({ label: "Status", text: "No usage data", color: "#a3a3a3" })) + } + + return { plan: plan, lines: lines } + } + + globalThis.__openusage_plugin = { id: "neuralwatt", probe: probe } +})() diff --git a/plugins/neuralwatt/plugin.json b/plugins/neuralwatt/plugin.json new file mode 100644 index 00000000..cc2f0177 --- /dev/null +++ b/plugins/neuralwatt/plugin.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "id": "neuralwatt", + "name": "Neuralwatt", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#D55934", + "lines": [ + { "type": "progress", "label": "Subscription", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Balance", "scope": "overview" }, + { "type": "badge", "label": "Method", "scope": "detail" }, + { "type": "badge", "label": "Status", "scope": "overview" } + ] +} diff --git a/plugins/neuralwatt/plugin.test.js b/plugins/neuralwatt/plugin.test.js new file mode 100644 index 00000000..02dbdfd0 --- /dev/null +++ b/plugins/neuralwatt/plugin.test.js @@ -0,0 +1,294 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +const loadPlugin = async () => { + await import("./plugin.js") + return globalThis.__openusage_plugin +} + +const FULL_RESPONSE = { + snapshot_at: "2026-04-16T18:30:00Z", + balance: { + credits_remaining_usd: 32.6774, + total_credits_usd: 52.34, + credits_used_usd: 19.6626, + accounting_method: "energy", + }, + usage: { + lifetime: { cost_usd: 243.9145, requests: 37801, tokens: 1235477176, energy_kwh: 15.6009 }, + current_month: { cost_usd: 160.1463, requests: 23902, tokens: 1116658995, energy_kwh: 9.7278 }, + }, + limits: { overage_limit_usd: null, rate_limit_tier: "standard" }, + subscription: { + plan: "standard", + status: "active", + billing_interval: "month", + current_period_start: "2026-04-11T05:05:25Z", + current_period_end: "2026-05-11T05:05:25Z", + auto_renew: true, + kwh_included: 20.0, + kwh_used: 13.9023, + kwh_remaining: 6.0977, + in_overage: false, + }, + key: { name: "my-production-key", allowance: null }, +} + +describe("neuralwatt plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + it("throws when API key is missing", async () => { + const ctx = makeCtx() + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Neuralwatt API key missing") + }) + + it("renders subscription + balance + method from full response", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-test-key" + return null + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(FULL_RESPONSE), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Standard") + expect(result.lines).toHaveLength(3) + + const sub = result.lines.find((l) => l.label === "Subscription") + expect(sub).toBeTruthy() + expect(sub.used).toBeCloseTo(13.9023, 4) + expect(sub.limit).toBeCloseTo(20, 4) + expect(sub.format).toEqual({ kind: "count", suffix: "kWh" }) + + const bal = result.lines.find((l) => l.label === "Balance") + expect(bal).toBeTruthy() + expect(bal.used).toBe(19.66) + expect(bal.limit).toBe(52.34) + expect(bal.format).toEqual({ kind: "dollars" }) + expect(bal.resetsAt).toBeUndefined() + expect(bal.periodDurationMs).toBeUndefined() + + const method = result.lines.find((l) => l.label === "Method") + expect(method).toBeTruthy() + expect(method.text).toBe("Energy") + + // Line order must match manifest: Subscription, Balance, Method + expect(result.lines.map((l) => l.label)).toEqual(["Subscription", "Balance", "Method"]) + }) + + it("includes resetsAt and periodDurationMs from subscription period", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-test-key" + return null + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(FULL_RESPONSE), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + const sub = result.lines.find((l) => l.label === "Subscription") + expect(sub.resetsAt).toBeTruthy() + expect(sub.periodDurationMs).toBeTruthy() + }) + + it("hides subscription line when subscription is null", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-test-key" + return null + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ + balance: { credits_remaining_usd: 10, total_credits_usd: 20, credits_used_usd: 10, accounting_method: "token" }, + subscription: null, + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((l) => l.label === "Subscription")).toBeUndefined() + const bal = result.lines.find((l) => l.label === "Balance") + expect(bal).toBeTruthy() + }) + + it("hides balance line when total credits is 0", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-test-key" + return null + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ + balance: { credits_remaining_usd: 0, total_credits_usd: 0, credits_used_usd: 0, accounting_method: "energy" }, + subscription: FULL_RESPONSE.subscription, + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((l) => l.label === "Balance")).toBeUndefined() + expect(result.lines.find((l) => l.label === "Subscription")).toBeTruthy() + }) + + it("hides method badge when accounting_method is missing", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-test-key" + return null + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ + balance: { credits_remaining_usd: 10, total_credits_usd: 20, credits_used_usd: 10 }, + subscription: FULL_RESPONSE.subscription, + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((l) => l.label === "Method")).toBeUndefined() + }) + + it("returns badge when no data at all", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-test-key" + return null + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ subscription: null, balance: null }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines).toEqual([{ type: "badge", label: "Status", text: "No usage data", color: "#a3a3a3" }]) + expect(result.plan).toBeNull() + }) + + it("throws on 401", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-bad-key" + return null + }) + ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Invalid API key") + }) + + it("throws on non-2xx", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-test-key" + return null + }) + ctx.host.http.request.mockReturnValue({ status: 500, bodyText: "" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("HTTP 500") + }) + + it("throws on invalid JSON", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-test-key" + return null + }) + ctx.host.http.request.mockReturnValue({ status: 200, bodyText: "not-json" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Response invalid") + }) + + it("throws on network error", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-test-key" + return null + }) + ctx.host.http.request.mockImplementation(() => { + throw new Error("network down") + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Check your connection") + }) + + it("capitalizes plan name", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-test-key" + return null + }) + const resp = JSON.parse(JSON.stringify(FULL_RESPONSE)) + resp.subscription.plan = "premium" + + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(resp), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.plan).toBe("Premium") + }) + + it("capitalizes accounting method", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-test-key" + return null + }) + const resp = JSON.parse(JSON.stringify(FULL_RESPONSE)) + resp.balance.accounting_method = "token" + + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(resp), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines.find((l) => l.label === "Method").text).toBe("Token") + }) + + it("sends correct authorization header", async () => { + const ctx = makeCtx() + ctx.host.env.get.mockImplementation((name) => { + if (name === "NEURALWATT_API_KEY") return "sk-my-key" + return null + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(FULL_RESPONSE), + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + const call = ctx.host.http.request.mock.calls[0][0] + expect(call.headers.Authorization).toBe("Bearer sk-my-key") + expect(call.url).toBe("https://api.neuralwatt.com/v1/quota") + expect(call.method).toBe("GET") + }) +}) diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index a39ac09d..40b94b94 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; 17] = [ "CODEX_HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", @@ -29,6 +29,7 @@ const WHITELISTED_ENV_VARS: [&str; 16] = [ "MINIMAX_CN_API_KEY", "SYNTHETIC_API_KEY", "PI_CODING_AGENT_DIR", + "NEURALWATT_API_KEY", ]; fn last_non_empty_trimmed_line(text: &str) -> Option {