Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
111 changes: 111 additions & 0 deletions docs/providers/openrouter.md
Original file line number Diff line number Diff line change
@@ -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 <api_key>` |
| 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.` |
4 changes: 4 additions & 0 deletions plugins/openrouter/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
139 changes: 139 additions & 0 deletions plugins/openrouter/plugin.js
Original file line number Diff line number Diff line change
@@ -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 }
})()
18 changes: 18 additions & 0 deletions plugins/openrouter/plugin.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
Loading
Loading