Skip to content
Merged
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
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Track OpenCode quota & tokens via Toasts/Commands with zero context window pollu

![Image of quota toasts](https://github.com/slkiser/opencode-quota/blob/main/toast.png)

**Token Report Commands** - Track token usage and estimated costs across sessions using only local OpenCode SQLite history plus the bundled models.dev snapshot (no network calls).
**Token Report Commands** - Track token usage and estimated costs across sessions using local OpenCode SQLite history plus a local models.dev pricing snapshot. The plugin can refresh that local snapshot at runtime when stale.

![Image of /quota and /tokens_daily outputs](https://github.com/slkiser/opencode-quota/blob/main/quota.png)

Expand Down Expand Up @@ -77,7 +77,39 @@ Token reporting commands are the `/tokens_*` family (there is no `/token` comman
| Google Antigravity | `google-antigravity` | Multi-account via `opencode-antigravity-auth` |
| Z.ai | `zai` | OpenCode auth (automatic) |

Token pricing coverage is broader than quota provider support: `/tokens_*` maps usage against all provider/model entries present in the bundled models.dev data snapshot.
Token pricing coverage is broader than quota provider support: `/tokens_*` maps usage against provider/model entries in the active local models.dev pricing snapshot.

Pricing refresh ownership is now runtime-based:

- A bundled snapshot (`src/data/modelsdev-pricing.min.json`) is always shipped as bootstrap/offline fallback.
- At plugin runtime, when `experimental.quotaToast.enabled` is `true`, pricing refresh runs as a bounded best-effort check (once per process window, plus persisted attempt tracking) during init and before `/tokens_*` / `/quota_status` report paths.
- If the active snapshot is older than 3 days, the plugin attempts to fetch `https://models.dev/api.json`, keeps only `input`, `output`, `cache_read`, `cache_write`, and writes a refreshed local runtime snapshot.
- If fetch fails, reports continue using the last local snapshot (no hard failure).

Runtime snapshot files are stored under the OpenCode cache directory:

- `.../opencode/opencode-quota/modelsdev-pricing.runtime.min.json`
- `.../opencode/opencode-quota/modelsdev-pricing.refresh-state.json`

Runtime refresh toggles:

```sh
# Disable runtime pricing refresh
OPENCODE_QUOTA_PRICING_AUTO_REFRESH=0

# Change stale threshold (default: 3 days)
OPENCODE_QUOTA_PRICING_MAX_AGE_DAYS=5
```

Maintainer-only bundled snapshot refresh (manual):

```sh
npm run pricing:refresh
npm run pricing:refresh:if-stale
npm run build
```

`pricing:refresh:if-stale` uses the same env knobs as runtime refresh (`OPENCODE_QUOTA_PRICING_AUTO_REFRESH`, `OPENCODE_QUOTA_PRICING_MAX_AGE_DAYS`).

### Provider-Specific Setup

Expand Down Expand Up @@ -215,7 +247,7 @@ All options go under `experimental.quotaToast` in `opencode.json` or `opencode.j

| Option | Default | Description |
| ------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
| `enabled` | `true` | Enable/disable plugin |
| `enabled` | `true` | Enable/disable plugin. When `false`, `/quota`, `/quota_status`, and `/tokens_*` are strict no-ops. |
| `enableToast` | `true` | Show popup toasts |
| `toastStyle` | `classic` | Toast layout style: `classic` or `grouped` |
| `enabledProviders` | `"auto"` | Provider IDs to query, or `"auto"` to detect |
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
],
"scripts": {
"build": "tsc && node scripts/copy-data.mjs",
"pricing:refresh": "node scripts/refresh-modelsdev-pricing.mjs",
"pricing:refresh:if-stale": "node scripts/refresh-modelsdev-pricing-if-stale.mjs",
"verify:release-version": "node scripts/verify-release-version.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run",
Expand Down
95 changes: 95 additions & 0 deletions scripts/refresh-modelsdev-pricing-if-stale.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { spawn } from "child_process";
import { readFile } from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";

export const DEFAULT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;

function parseEnabled(value) {
if (!value) return true;
const normalized = value.trim().toLowerCase();
return !["0", "false", "no", "off"].includes(normalized);
}

function parseMaxAgeMs(value) {
if (!value) return DEFAULT_MAX_AGE_MS;
const days = Number(value);
if (!Number.isFinite(days) || days <= 0) return DEFAULT_MAX_AGE_MS;
return Math.floor(days * 24 * 60 * 60 * 1000);
}

export function shouldAutoRefresh(meta, nowMs, maxAgeMs = DEFAULT_MAX_AGE_MS) {
const generatedAt = Number(meta?.generatedAt);
if (!Number.isFinite(generatedAt) || generatedAt <= 0) return true;
return nowMs - generatedAt > maxAgeMs;
}

async function readSnapshotMeta() {
const snapshotUrl = new URL("../src/data/modelsdev-pricing.min.json", import.meta.url);
const raw = await readFile(snapshotUrl, "utf8");
const parsed = JSON.parse(raw);
const meta = parsed?._meta;
return meta && typeof meta === "object" ? meta : null;
}

function runRefreshScript() {
const scriptPath = fileURLToPath(new URL("./refresh-modelsdev-pricing.mjs", import.meta.url));
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [scriptPath], { stdio: "inherit" });
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) resolve(undefined);
else reject(new Error(`refresh-modelsdev-pricing.mjs exited with code ${code ?? "unknown"}`));
});
});
}

export async function main() {
if (!parseEnabled(process.env.OPENCODE_QUOTA_PRICING_AUTO_REFRESH)) {
console.log("Skipping pricing auto-refresh: OPENCODE_QUOTA_PRICING_AUTO_REFRESH disables it.");
return;
}

const maxAgeMs = parseMaxAgeMs(process.env.OPENCODE_QUOTA_PRICING_MAX_AGE_DAYS);
const nowMs = Date.now();

let meta = null;
try {
meta = await readSnapshotMeta();
} catch {
meta = null;
}

if (!shouldAutoRefresh(meta, nowMs, maxAgeMs)) {
const generatedAt = Number(meta?.generatedAt);
const ageMs = Math.max(0, nowMs - generatedAt);
console.log(
`Pricing snapshot is fresh (age ${ageMs}ms <= max ${maxAgeMs}ms). Skipping auto-refresh.`,
);
return;
}

console.log(
`Pricing snapshot is stale or missing (max age ${maxAgeMs}ms). Refreshing from models.dev...`,
);
try {
await runRefreshScript();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`Pricing auto-refresh failed (${message}). Continuing build with the existing bundled snapshot.`,
);
}
}

function isMainModule() {
if (!process.argv[1]) return false;
return path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
}

if (isMainModule()) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
141 changes: 141 additions & 0 deletions scripts/refresh-modelsdev-pricing.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { mkdir, rename, rm, writeFile } from "fs/promises";
import { dirname } from "path";
import { fileURLToPath } from "url";

const SOURCE_URL = "https://models.dev/api.json";
const DEFAULT_PROVIDERS = ["anthropic", "google", "moonshotai", "openai", "xai", "zai"];
const COST_KEYS = ["input", "output", "cache_read", "cache_write"];
const FETCH_TIMEOUT_MS = 15_000;

function parseProviderArgs(argv) {
const providerArg = argv.find((arg) => arg.startsWith("--providers="));
if (!providerArg) return DEFAULT_PROVIDERS;

const raw = providerArg.slice("--providers=".length).trim();
if (!raw) return DEFAULT_PROVIDERS;

return raw
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}

function pickCostBuckets(rawCost) {
if (!rawCost || typeof rawCost !== "object") return null;
const picked = {};

for (const key of COST_KEYS) {
const value = rawCost[key];
if (typeof value === "number" && Number.isFinite(value)) {
picked[key] = value;
}
}

return Object.keys(picked).length > 0 ? picked : null;
}

function sortObjectByKeys(obj) {
const sorted = {};
for (const key of Object.keys(obj).sort((a, b) => a.localeCompare(b))) {
sorted[key] = obj[key];
}
return sorted;
}

async function fetchModelsDevJson() {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);

let response;
try {
response = await fetch(SOURCE_URL, { signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}

if (!response.ok) {
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status} ${response.statusText}`);
}
return response.json();
}

async function writeFileAtomic(path, content) {
const dir = dirname(path);
const tmp = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;

await mkdir(dir, { recursive: true });
await writeFile(tmp, content, "utf8");

const safeRm = async (target) => {
try {
await rm(target, { force: true });
} catch {
// best-effort cleanup
}
};

try {
await rename(tmp, path);
} catch (err) {
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
const shouldRetryAsReplace =
code === "EPERM" || code === "EEXIST" || code === "EACCES" || code === "ENOTEMPTY";

if (!shouldRetryAsReplace) {
await safeRm(tmp);
throw err;
}

await safeRm(path);
await rename(tmp, path);
}
}

function buildSnapshot(api, providerIDs) {
const providers = {};

for (const providerID of providerIDs) {
const models = api?.[providerID]?.models;
if (!models || typeof models !== "object") continue;

const pricedModels = {};
for (const modelID of Object.keys(models)) {
const cost = pickCostBuckets(models[modelID]?.cost);
if (cost) pricedModels[modelID] = cost;
}

if (Object.keys(pricedModels).length > 0) {
providers[providerID] = sortObjectByKeys(pricedModels);
}
}

const providerList = Object.keys(providers).sort((a, b) => a.localeCompare(b));

return {
_meta: {
generatedAt: Date.now(),
providers: providerList,
source: SOURCE_URL,
units: "USD per 1M tokens",
},
providers,
};
}

async function main() {
const providerIDs = parseProviderArgs(process.argv.slice(2));
const api = await fetchModelsDevJson();
const snapshot = buildSnapshot(api, providerIDs);

const outPath = new URL("../src/data/modelsdev-pricing.min.json", import.meta.url);
await writeFileAtomic(fileURLToPath(outPath), `${JSON.stringify(snapshot, null, 2)}\n`);

console.log(
`Wrote ${outPath.pathname} with ${snapshot._meta.providers.length} providers and ${Object.values(snapshot.providers).reduce((sum, models) => sum + Object.keys(models).length, 0)} priced models.`,
);
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
Loading