# pi-usage-hub

**Track provider quotas and balances in pi — `/usage-hub` panel, session stats, and a pull API for footer integration.**

[![npm version](https://img.shields.io/npm/v/pi-usage-hub?style=for-the-badge)](https://www.npmjs.com/package/pi-usage-hub)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)

## Why

Checking separate dashboards for DeepSeek, NewAPI relays, OpenAI Codex, xAI, Kiro, OpenCode Go, and ARK breaks flow. pi-usage-hub brings their quotas and balances into one TUI panel while keeping footer integration optional and pull-based.

## Install

```bash
pi install npm:pi-usage-hub
```

## Commands

| Command | Description |
|---------|-------------|
| `/usage-hub` | Show quotas and balances for detected providers |
| `/usage-hub session` | Show local session token and cost stats |
| `/usage-hub login <name>` | Open browser login for a cookie-based provider |

Inside the panel, **Tab** switches between Quota and Session. In the Quota view, **↑↓** scrolls when content overflows.

![pi-usage-hub quota panel](assets/usage-hub-panel.png)

![pi-usage-hub session panel](assets/usage-hub-session.png)

## Configuration

Create `~/.pi/agent/pi-usage-hub.json`. Provider order is preserved in the panel.

```json
{
  "providers": [
    {
      "type": "deepseek",
      "apiKey": "sk-..."
    },
    {
      "name": "xh",
      "type": "newapi",
      "matchProviders": ["xh-cc", "xh-glm"],
      "host": "https://example.com",
      "token": "...",
      "userId": "1"
    },
    {
      "name": "ocg",
      "type": "opencode-go",
      "workspaceId": "wrk_...",
      "matchProviders": ["opencode-go"]
    },
    { "type": "ark" },
    {
      "type": "xai",
      "matchProviders": ["xai-auth", "xai", "grok-cli"]
    },
    { "type": "kiro" },
    { "type": "kimi-coding" },
    { "type": "zai" },
    { "type": "codex" }
  ]
}
```

### Common fields

| Field | Required | Description |
|-------|----------|-------------|
| `type` | yes | Built-in provider factory |
| `name` | no | Instance key; defaults to `type`, then `type-2`, `type-3`, and so on |
| `matchProviders` | no | Additional `model.provider` values mapped to this entry |
| `shortLabel` / `label` | no | Override the footer label or panel title |
| `hidden` | no | Exclude from panel and footer; `/usage-hub login` still works |
| `disabled` | no | Do not register at all; on untyped entries, refuses that provider's registration |

### Provider types

| Type | Credentials | Notes | Website |
|------|-------------|-------|---------|
| `deepseek` | `apiKey` | Account balance | [deepseek](https://platform.deepseek.com) |
| `newapi` | `host`, `token`, `userId` | Balance and today's spend; supports multiple instances | [newapi](https://www.newapi.ai) |
| `opencode-go` | `workspaceId`; optional `auth` | Uses the configured auth cookie or macOS Chrome | [opencode](https://opencode.ai) |
| `ark` | optional `cookie`, `csrfToken` | Uses the configured Cookie header or macOS Chrome | [火山引擎](https://console.volcengine.com/ark) |
| `xai` | — | Reads `auth.json` or `~/.grok/auth.json` | [x.ai/grok](https://x.ai/grok) |
| `kiro` | — | Reads the Kiro OAuth entry from `auth.json` | [kiro.dev](https://kiro.dev) |
| `kimi-coding` | optional `apiKey` | 5h rolling window + weekly quota; falls back to the `kimi-coding` entry in `auth.json` (Kimi Code key, not the open-platform key) | [kimi.com/code](https://www.kimi.com/code) |
| `zai` | optional `apiKey` | 5h window, weekly, and monthly search quota; falls back to the `zai` entry in `auth.json` | [z.ai](https://z.ai) |
| `codex` | — | Codex rolling and 7-day usage windows; reads the `openai-codex` OAuth entry in `auth.json` | [OpenAI Codex](https://openai.com/codex/) |

For NewAPI, `token` is the **system access token** (not a chat `sk-` token), and `userId` is sent as `New-Api-User`. See [Authentication](https://www.newapi.ai/en/docs/api/management/auth) and [Generate access token](https://www.newapi.ai/en/docs/api/management/user-management/user-token-get).

For ARK and OpenCode Go, configured `cookie` / `auth` values take priority over Chrome. Manual credentials disable `/usage-hub login` for that entry; replace them when they expire. Automatic Chrome cookie reading is **macOS only**.

### Related auth packages

These are companion packages, not npm peer dependencies:

| Type | Companion | Purpose |
|------|-----------|---------|
| `kiro` | [`pi-provider-kiro-dev`](https://www.npmjs.com/package/pi-provider-kiro-dev) | Provides `/login kiro`, models, and the `auth.json` entry |

pi-usage-hub only reads those credentials; it does not run their OAuth flows.

Built-in providers cover the integrations used and smoke-tested by the author. To support another provider, register a custom provider from an extension, or fork the package and submit a PR.

### Add a custom provider

See [`examples/custom-provider.ts`](./examples/custom-provider.ts) for a self-contained extension that implements and registers a provider. Copy it into `~/.pi/agent/extensions/`, then adapt the endpoint, credentials, response shape, and labels.

```ts
pi.events.on("pi-usage-hub:ready", (hub) => hub.register(myProvider));
pi.events.emit("pi-usage-hub:register", myProvider);
pi.events.emit("pi-usage-hub:unregister", { key: "my-relay" });
```

Re-registering the same key overwrites (registration is idempotent), so the ready-listener + emit combination above never duplicates a provider.

### Configure a custom provider from JSON

`providers` entries **without** `type` target an externally-registered provider by `name`. Meta fields override the provider's own values at register time; `disabled: true` refuses registration; all other fields pass through untouched to `hub.getProviderConfig(name)`, so the extension can read its credentials from JSON instead of env vars:

```json
{
  "providers": [
    { "type": "kimi-coding" },
    {
      "name": "qianwen",
      "shortLabel": "QW",
      "hidden": false,
      "cookie": "login_qianwenai_ticket=..."
    }
  ]
}
```

```ts
pi.events.on("pi-usage-hub:ready", (hub) => {
  const cfg = hub.getProviderConfig("qianwen"); // { shortLabel, hidden, cookie, ... }
  const cookie = typeof cfg?.cookie === "string" ? cfg.cookie : undefined;
});
```

Untyped entries do not affect built-in name allocation; two untyped entries with the same `name` resolve last-write-wins. An untyped entry whose `name` matches a built-in entry also applies to it, so `{ "name": "kimi-coding", "hidden": true }` hides the built-in without editing its own entry.

### Register a built-in from an extension

`hub.registerBuiltin(type, cfg?)` instantiates any built-in factory with your own parameters (e.g. credentials obtained at runtime) and registers it, allocating `type`, `type-2`, … on key clash. Returns the registered key, or `null` for unknown types or `disabled: true`.

## Pull API

The hub caches results for 60 seconds and deduplicates concurrent requests. It never pushes footer text: consumers start refreshes without blocking lifecycle events, read the cached summary, and re-render when notified.

| API | Role |
|-----|------|
| `pi-usage-hub:ready` | Provides the hub; also emitted on `session_start` |
| `hub.refresh({ model?, force? })` | Refreshes the matching provider and returns its summary |
| `hub.getSummary(model?)` | Synchronously reads the cached one-line summary |
| `pi-usage-hub:updated` | Signals `{ key, summary }` after a cache update |

### Footer example

![footer integration](assets/usage-hub-footer.png)

```ts
type UsageHub = {
  getSummary(model?: { provider?: string }): string | null;
  refresh(opts?: {
    model?: { provider?: string };
    force?: boolean;
  }): Promise<string | null>;
};

let usageHub: UsageHub | null = null;
let requestRender: (() => void) | null = null;

const offReady = pi.events.on("pi-usage-hub:ready", (hub: UsageHub) => {
  usageHub = hub;
  requestRender?.();
});

const offUpdated = pi.events.on("pi-usage-hub:updated", () => {
  requestRender?.();
});

// Pi awaits lifecycle handlers; refresh in the background and re-render on pi-usage-hub:updated.
pi.on("session_start", (_event, ctx) => {
  void usageHub?.refresh({ model: ctx.model, force: true });
});
pi.on("model_select", (event) => {
  void usageHub?.refresh({ model: event.model, force: true });
});
pi.on("agent_end", (_event, ctx) => {
  void usageHub?.refresh({ model: ctx.model, force: true });
});

// Inside footer render():
// const usageText = usageHub?.getSummary(ctx.model);
// "XAI 87% · ↻ 3d 16h"

pi.on("session_shutdown", async () => {
  offReady();
  offUpdated();
  usageHub = null;
});
```

## License

MIT
