From 57af9ec17481790b9301072662bdf632f56b21e0 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 7 Apr 2026 15:25:46 +0700 Subject: [PATCH] feat: add OpenRouter provider plugin Track OpenRouter credits and spend from OpenUsage so users can monitor remaining balance alongside monthly and all-time usage. Made-with: Cursor --- README.md | 1 + docs/providers/openrouter.md | 111 ++++++++++ plugins/openrouter/icon.svg | 4 + plugins/openrouter/plugin.js | 139 ++++++++++++ plugins/openrouter/plugin.json | 18 ++ plugins/openrouter/plugin.test.js | 268 ++++++++++++++++++++++++ src-tauri/src/plugin_engine/host_api.rs | 3 +- 7 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 docs/providers/openrouter.md create mode 100644 plugins/openrouter/icon.svg create mode 100644 plugins/openrouter/plugin.js create mode 100644 plugins/openrouter/plugin.json create mode 100644 plugins/openrouter/plugin.test.js diff --git a/README.md b/README.md index 44bae57f..b17803b2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Kimi Code**](docs/providers/kimi.md) / session, weekly - [**MiniMax**](docs/providers/minimax.md) / coding plan session - [**OpenCode Go**](docs/providers/opencode-go.md) / 5h, weekly, monthly spend limits +- [**OpenRouter**](docs/providers/openrouter.md) / credits, this month, all time - [**Windsurf**](docs/providers/windsurf.md) / prompt credits, flex credits - [**Z.ai**](docs/providers/zai.md) / session, weekly, web searches diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md new file mode 100644 index 00000000..c42cbad0 --- /dev/null +++ b/docs/providers/openrouter.md @@ -0,0 +1,111 @@ +# OpenRouter + +Tracks [OpenRouter](https://openrouter.ai) credit balance and spend. + +## Overview + +- **Protocol:** HTTPS (JSON) +- **Endpoint:** `GET https://openrouter.ai/api/v1/key` +- **Auth:** API key via environment variable (`OPENROUTER_API_KEY`) +- **Usage values:** USD spend for current month and all time +- **Credits model:** the plugin first tries the account credits endpoint, then falls back to per-key remaining limit fields + +## Setup + +1. Create or copy an API key from the [OpenRouter keys page](https://openrouter.ai/settings/keys) +2. Set `OPENROUTER_API_KEY` + +OpenUsage is a GUI app. A one-off `export ...` in a terminal session will not be visible when you launch OpenUsage from +Spotlight/Launchpad. Persist it, then restart OpenUsage. + +zsh (`~/.zshrc`): + +```bash +export OPENROUTER_API_KEY="YOUR_API_KEY" +``` + +fish (universal var): + +```fish +set -Ux OPENROUTER_API_KEY "YOUR_API_KEY" +``` + +3. Enable the OpenRouter plugin in OpenUsage settings + +## Endpoint + +### GET /api/v1/key + +Returns metadata and spend totals for the current API key. + +#### Headers + +| Header | Required | Value | +|--------|----------|-------| +| Authorization | yes | `Bearer ` | +| Accept | yes | `application/json` | + +#### Response + +```json +{ + "data": { + "label": "OpenClaw", + "limit": 25, + "usage": 4.5, + "usage_daily": 0.5, + "usage_weekly": 1.25, + "usage_monthly": 2.75, + "limit_remaining": 20.5, + "is_free_tier": false + } +} +``` + +Used fields: + +- `limit` — spending cap in USD +- `usage` — lifetime spend in USD +- `usage_monthly` — current UTC month spend in USD +- `limit_remaining` — remaining spend under the configured cap +- `is_free_tier` — whether the key is on OpenRouter's free tier + +### GET /api/v1/credits + +OpenRouter's docs describe this as a management-key endpoint, but the plugin tries it opportunistically and falls back cleanly if the key cannot access it. When available, it returns total credits purchased and total credits used for the authenticated user. + +Example response: + +```json +{ + "data": { + "total_credits": 110, + "total_usage": 12.5 + } +} +``` + +## Displayed Lines + +| Line | Description | +|------|-------------| +| Credits | Remaining account credits when `/credits` is available; otherwise remaining per-key limit; otherwise `No key limit` | +| This Month | Current UTC month spend | +| All Time | Lifetime spend | + +## Notes + +- The plugin uses `/api/v1/key` for all keys. +- The plugin also tries `/api/v1/credits`; if the key cannot access it, OpenUsage falls back to per-key limit fields from `/api/v1/key`. +- OpenRouter also exposes `/api/v1/activity`, but that still returned `403` in live testing for a standard key and is not used here. +- The plan label is simplified to `Free` or `Paid`. + +## Errors + +| Condition | Message | +|-----------|---------| +| No API key | `No OPENROUTER_API_KEY found. Set up environment variable first.` | +| 401/403 | `API key invalid. Check your OpenRouter API key.` | +| HTTP error | `Usage request failed (HTTP {status}). Try again later.` | +| Network error | `Usage request failed. Check your connection.` | +| Invalid JSON | `Usage response invalid. Try again later.` | diff --git a/plugins/openrouter/icon.svg b/plugins/openrouter/icon.svg new file mode 100644 index 00000000..397b3534 --- /dev/null +++ b/plugins/openrouter/icon.svg @@ -0,0 +1,4 @@ + + OpenRouter + + diff --git a/plugins/openrouter/plugin.js b/plugins/openrouter/plugin.js new file mode 100644 index 00000000..3eb5d51a --- /dev/null +++ b/plugins/openrouter/plugin.js @@ -0,0 +1,139 @@ +(function () { + const KEY_URL = "https://openrouter.ai/api/v1/key" + const CREDITS_URL = "https://openrouter.ai/api/v1/credits" + + function loadApiKey(ctx) { + const apiKey = ctx.host.env.get("OPENROUTER_API_KEY") + if (typeof apiKey === "string" && apiKey.trim()) return apiKey.trim() + return null + } + + function readNumber(value) { + if (value === null || value === undefined) return null + if (typeof value === "string" && !value.trim()) return null + const n = Number(value) + return Number.isFinite(n) ? n : null + } + + function formatUsd(amount) { + const rounded = Math.round(Math.max(0, amount) * 100) / 100 + return "$" + rounded.toFixed(2) + } + + function trimString(value) { + if (typeof value !== "string") return null + const trimmed = value.trim() + return trimmed ? trimmed : null + } + + function fetchKeyInfo(ctx, apiKey) { + let resp + try { + resp = ctx.util.request({ + method: "GET", + url: KEY_URL, + headers: { + Authorization: "Bearer " + apiKey, + Accept: "application/json", + }, + timeoutMs: 10000, + }) + } catch (e) { + ctx.host.log.error("key request exception: " + String(e)) + throw "Usage request failed. Check your connection." + } + + if (ctx.util.isAuthStatus(resp.status)) { + throw "API key invalid. Check your OpenRouter API key." + } + + if (resp.status < 200 || resp.status >= 300) { + throw "Usage request failed (HTTP " + String(resp.status) + "). Try again later." + } + + const parsed = ctx.util.tryParseJson(resp.bodyText) + if (!parsed || typeof parsed !== "object" || !parsed.data || typeof parsed.data !== "object") { + throw "Usage response invalid. Try again later." + } + + return parsed.data + } + + function fetchCredits(ctx, apiKey) { + let resp + try { + resp = ctx.util.request({ + method: "GET", + url: CREDITS_URL, + headers: { + Authorization: "Bearer " + apiKey, + Accept: "application/json", + }, + timeoutMs: 10000, + }) + } catch (e) { + ctx.host.log.warn("credits request exception: " + String(e)) + return null + } + + if (ctx.util.isAuthStatus(resp.status)) return null + if (resp.status < 200 || resp.status >= 300) return null + + const parsed = ctx.util.tryParseJson(resp.bodyText) + if (!parsed || typeof parsed !== "object" || !parsed.data || typeof parsed.data !== "object") { + return null + } + + return parsed.data + } + + function buildPlanLabel(data) { + if (data.is_free_tier === true) return "Free" + return "Paid" + } + + function pushMoneyLine(ctx, lines, label, amount, suffix) { + if (amount === null) return + lines.push(ctx.line.text({ label: label, value: formatUsd(amount) + suffix })) + } + + function probe(ctx) { + const apiKey = loadApiKey(ctx) + if (!apiKey) { + throw "No OPENROUTER_API_KEY found. Set up environment variable first." + } + + const data = fetchKeyInfo(ctx, apiKey) + const lines = [] + + const limit = readNumber(data.limit) + const remaining = readNumber(data.limit_remaining) + const usage = readNumber(data.usage) + const daily = readNumber(data.usage_daily) + const weekly = readNumber(data.usage_weekly) + const monthly = readNumber(data.usage_monthly) + const credits = fetchCredits(ctx, apiKey) + const totalCredits = credits ? readNumber(credits.total_credits) : null + const totalUsage = credits ? readNumber(credits.total_usage) : null + const accountCreditsRemaining = + totalCredits !== null && totalUsage !== null ? Math.max(0, totalCredits - totalUsage) : null + const keyCreditsRemaining = + remaining !== null ? Math.max(0, remaining) : limit !== null && usage !== null ? Math.max(0, limit - usage) : null + + if (accountCreditsRemaining !== null) { + lines.push(ctx.line.text({ label: "Credits", value: formatUsd(accountCreditsRemaining) + " left" })) + } else if (keyCreditsRemaining !== null) { + lines.push(ctx.line.text({ label: "Credits", value: formatUsd(keyCreditsRemaining) + " left" })) + } else { + lines.push(ctx.line.text({ label: "Credits", value: "No key limit" })) + } + + pushMoneyLine(ctx, lines, "This Month", monthly, "") + pushMoneyLine(ctx, lines, "All Time", totalUsage !== null ? totalUsage : usage, "") + + const plan = buildPlanLabel(data) + return plan ? { plan: plan, lines: lines } : { lines: lines } + } + + globalThis.__openusage_plugin = { id: "openrouter", probe: probe } +})() diff --git a/plugins/openrouter/plugin.json b/plugins/openrouter/plugin.json new file mode 100644 index 00000000..c3349d13 --- /dev/null +++ b/plugins/openrouter/plugin.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "id": "openrouter", + "name": "OpenRouter", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#6666f1", + "links": [ + { "label": "Dashboard", "url": "https://openrouter.ai/activity" }, + { "label": "Keys", "url": "https://openrouter.ai/settings/keys" } + ], + "lines": [ + { "type": "text", "label": "Credits", "scope": "overview" }, + { "type": "text", "label": "This Month", "scope": "overview" }, + { "type": "text", "label": "All Time", "scope": "overview" } + ] +} diff --git a/plugins/openrouter/plugin.test.js b/plugins/openrouter/plugin.test.js new file mode 100644 index 00000000..1a9cc0b3 --- /dev/null +++ b/plugins/openrouter/plugin.test.js @@ -0,0 +1,268 @@ +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 mockEnvWithKey = (ctx, key = "sk-or-test") => { + ctx.host.env.get.mockImplementation((name) => (name === "OPENROUTER_API_KEY" ? key : null)) +} + +const KEY_RESPONSE = { + data: { + label: "OpenClaw", + limit: 25, + usage: 4.5, + usage_daily: 0.5, + usage_weekly: 1.25, + usage_monthly: 2.75, + limit_remaining: 20.5, + is_free_tier: false, + }, +} + +const CREDIT_CAPABLE_KEY_RESPONSE = { + data: { + label: "sk-or-v1-abcd...wxyz", + is_management_key: false, + is_provisioning_key: false, + limit: null, + limit_reset: null, + limit_remaining: null, + include_byok_in_limit: false, + usage: 0, + usage_daily: 0, + usage_weekly: 0, + usage_monthly: 0, + byok_usage: 0, + byok_usage_daily: 0, + byok_usage_weekly: 0, + byok_usage_monthly: 0, + is_free_tier: false, + }, +} + +const CREDITS_RESPONSE = { + data: { + total_credits: 110, + total_usage: 12.5, + }, +} + +const mockHttpJson = (ctx, payload = KEY_RESPONSE, status = 200) => { + ctx.host.http.request.mockReturnValue({ + status: status, + bodyText: JSON.stringify(payload), + }) +} + +describe("openrouter plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + it("throws when OPENROUTER_API_KEY is missing", async () => { + const ctx = makeCtx() + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("No OPENROUTER_API_KEY found. Set up environment variable first.") + }) + + it("requests the current key endpoint with bearer auth", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx, "sk-or-live") + mockHttpJson(ctx) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + expect(ctx.host.http.request).toHaveBeenCalledWith({ + method: "GET", + url: "https://openrouter.ai/api/v1/key", + headers: { + Authorization: "Bearer sk-or-live", + Accept: "application/json", + }, + timeoutMs: 10000, + }) + }) + + it("renders credits, this month, and all time text lines", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx) + mockHttpJson(ctx) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Paid") + expect(result.lines).toEqual([ + { type: "text", label: "Credits", value: "$20.50 left" }, + { type: "text", label: "This Month", value: "$2.75" }, + { type: "text", label: "All Time", value: "$4.50" }, + ]) + }) + + it("uses limit_remaining when usage is unavailable", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx) + mockHttpJson(ctx, { + data: { + label: "Recovered Usage", + limit: 10, + usage: null, + usage_daily: 0, + usage_weekly: 0, + usage_monthly: 0, + limit_remaining: 7.5, + is_free_tier: false, + }, + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const credits = result.lines.find((line) => line.label === "Credits") + + expect(credits).toEqual({ type: "text", label: "Credits", value: "$7.50 left" }) + }) + + it("shows no key limit when no spending limit is configured", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx) + mockHttpJson(ctx, { + data: { + label: "Unlimited", + limit: null, + usage: 4.5, + usage_daily: 0.5, + usage_weekly: 1.25, + usage_monthly: 2.75, + limit_remaining: null, + is_free_tier: false, + }, + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines[0]).toEqual({ + type: "text", + label: "Credits", + value: "No key limit", + }) + expect(result.lines[1]).toEqual({ type: "text", label: "This Month", value: "$2.75" }) + }) + + it("uses /credits when the endpoint is available", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx) + ctx.host.http.request.mockImplementation((opts) => { + if (opts.url === "https://openrouter.ai/api/v1/key") { + return { status: 200, bodyText: JSON.stringify(CREDIT_CAPABLE_KEY_RESPONSE) } + } + if (opts.url === "https://openrouter.ai/api/v1/credits") { + return { status: 200, bodyText: JSON.stringify(CREDITS_RESPONSE) } + } + throw new Error("unexpected url " + opts.url) + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines[0]).toEqual({ + type: "text", + label: "Credits", + value: "$97.50 left", + }) + expect(result.lines.find((line) => line.label === "All Time")).toEqual({ + type: "text", + label: "All Time", + value: "$12.50", + }) + }) + + it("prefers the Free plan label for free tier keys", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx) + mockHttpJson(ctx, { + data: { + label: "Personal", + limit: 5, + usage: 1, + usage_daily: 0, + usage_weekly: 0.25, + usage_monthly: 0.5, + limit_remaining: 4, + is_free_tier: true, + }, + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Free") + }) + + it("keeps zero-usage values instead of dropping them", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx) + mockHttpJson(ctx, { + data: { + label: "Zeroed", + limit: 10, + usage: 0, + usage_daily: 0, + usage_weekly: 0, + usage_monthly: 0, + limit_remaining: 10, + is_free_tier: false, + }, + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines).toContainEqual({ type: "text", label: "This Month", value: "$0.00" }) + expect(result.lines).toContainEqual({ type: "text", label: "All Time", value: "$0.00" }) + }) + + it("throws on auth failures", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx) + ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("API key invalid. Check your OpenRouter API key.") + }) + + it("throws on non-2xx responses", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx) + ctx.host.http.request.mockReturnValue({ status: 500, bodyText: "" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Usage request failed (HTTP 500). Try again later.") + }) + + it("throws on network failures", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx) + ctx.host.http.request.mockImplementation(() => { + throw new Error("socket hang up") + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Usage request failed. Check your connection.") + }) + + it("throws on invalid JSON", async () => { + const ctx = makeCtx() + mockEnvWithKey(ctx) + ctx.host.http.request.mockReturnValue({ status: 200, bodyText: "not-json" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Usage response invalid. Try again later.") + }) +}) diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index ef184ad9..6829cb2d 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -11,7 +11,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", @@ -26,6 +26,7 @@ const WHITELISTED_ENV_VARS: [&str; 16] = [ "MINIMAX_API_KEY", "MINIMAX_API_TOKEN", "MINIMAX_CN_API_KEY", + "OPENROUTER_API_KEY", "SYNTHETIC_API_KEY", "PI_CODING_AGENT_DIR", ];