feat: add exe.dev usage tracking
This commit is contained in:
@@ -23,6 +23,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
|
||||
| `crof` | CrofAI | `providers/crof.js` | `crof` (API key under `key` or `token`) |
|
||||
| `deepseek` | DeepSeek | `providers/deepseek.js` | `deepseek` (API key under `key` or `token`) |
|
||||
| `exe-dev` | exe.dev | `providers/exe-dev.js` | Usage API token stored under `~/.config/openchamber/quota/` |
|
||||
| `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file |
|
||||
| `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
| `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
@@ -51,7 +52,7 @@ All providers should return results via shared helpers to preserve API shape:
|
||||
Provider modules must export `providerId`, `providerName`, `aliases`, `isConfigured(auth?)`, and `fetchQuota()`.
|
||||
`fetchQuota()` should return a quota result with `usage.windows` keyed by window name (for example `5h`, `7d`, `daily`) and optional provider-specific `usage.models` data.
|
||||
|
||||
Ollama Cloud and Cursor credentials are explicitly managed through Settings. OpenCode Go usage uses `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` API key from OpenCode `auth.json` as a bearer token. The server validates managed credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database.
|
||||
exe.dev, Ollama Cloud, and Cursor credentials are explicitly managed through Settings. exe.dev usage uses a separately generated HTTPS API token restricted to `billing credits usage` and aggregates every `exe-*` model provider into one monthly credit window. Generate the token with `ssh exe.dev "ssh-key generate-api-key --label=openchamber --exp=30d --cmds='billing credits usage'"`. OpenCode Go usage uses `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` API key from OpenCode `auth.json` as a bearer token. The server validates managed credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database.
|
||||
|
||||
Command Code usage resolves account scope through `GET /alpha/whoami`, then reads server-backed credit balances and five-hour/weekly limits from `GET /alpha/billing/credits?orgId=...`. Personal accounts return `org: null` and use `/alpha/billing/credits` without an `orgId`; organization accounts include their organization id. Web/Electron and VS Code read the standard `command-code` OpenCode auth entry (including OAuth `access`) or `COMMAND_CODE_API_KEY`; credentials remain in the owning runtime and are never returned to shared UI.
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from
|
||||
const clean = (value) => typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : '';
|
||||
|
||||
export const normalizers = {
|
||||
'exe-dev': (value) => {
|
||||
const usageToken = clean(value?.usageToken);
|
||||
return usageToken ? { usageToken } : null;
|
||||
},
|
||||
'ollama-cloud': (value) => {
|
||||
const cookie = clean(value?.cookie);
|
||||
return cookie ? { cookie } : null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const MANAGED_QUOTA_PROVIDERS = new Set(['ollama-cloud', 'cursor']);
|
||||
const MANAGED_QUOTA_PROVIDERS = new Set(['exe-dev', 'ollama-cloud', 'cursor']);
|
||||
|
||||
const credentialsDirectory = () => path.join(
|
||||
process.env.OPENCHAMBER_DATA_DIR
|
||||
|
||||
@@ -10,12 +10,12 @@ process.env.OPENCHAMBER_DATA_DIR = temporaryDirectory;
|
||||
|
||||
describe('quota credential store', () => {
|
||||
it('uses owner-only permissions and rejects arbitrary provider paths', () => {
|
||||
writeQuotaCredential('ollama-cloud', { cookie: 'secret' });
|
||||
writeQuotaCredential('exe-dev', { usageToken: 'secret' });
|
||||
expect(fs.statSync(path.join(temporaryDirectory, 'quota')).mode & 0o777).toBe(0o700);
|
||||
expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'ollama-cloud.json')).mode & 0o777).toBe(0o600);
|
||||
expect(readQuotaCredential('ollama-cloud', (value) => value)).toEqual({ cookie: 'secret' });
|
||||
expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'exe-dev.json')).mode & 0o777).toBe(0o600);
|
||||
expect(readQuotaCredential('exe-dev', (value) => value)).toEqual({ usageToken: 'secret' });
|
||||
expect(() => writeQuotaCredential('../escape', {})).toThrow('Unsupported credential provider');
|
||||
deleteQuotaCredential('ollama-cloud');
|
||||
deleteQuotaCredential('exe-dev');
|
||||
});
|
||||
|
||||
it('removes the obsolete OpenCode Go credential without parsing it', () => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { readManagedCredential } from '../credentials/providers.js';
|
||||
import { asObject, buildResult, formatMoney, toNumber, toTimestamp, toUsageWindow } from '../utils/index.js';
|
||||
|
||||
export const providerId = 'exe-dev';
|
||||
export const providerName = 'exe.dev';
|
||||
export const aliases = ['exe-dev'];
|
||||
const EXEC_URL = 'https://exe.dev/exec';
|
||||
const USAGE_COMMAND = 'billing credits usage --group=day --json';
|
||||
|
||||
export const parseExeDevUsage = (payload) => {
|
||||
const data = asObject(payload);
|
||||
if (!data) return null;
|
||||
|
||||
const totalCost = toNumber(data.total_cost_usd);
|
||||
const monthlyAllowance = toNumber(data.monthly_allowance_usd);
|
||||
const resetAt = toTimestamp(data.period_end);
|
||||
if (totalCost === null || monthlyAllowance === null || monthlyAllowance < 0 || resetAt === null) return null;
|
||||
|
||||
const usedPercent = monthlyAllowance > 0
|
||||
? Math.min(100, Math.max(0, (totalCost / monthlyAllowance) * 100))
|
||||
: null;
|
||||
const spent = formatMoney(totalCost);
|
||||
const allowance = formatMoney(monthlyAllowance);
|
||||
if (spent === null || allowance === null) return null;
|
||||
|
||||
return {
|
||||
monthly: toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds: null,
|
||||
resetAt,
|
||||
valueLabel: `$${spent} / $${allowance}`,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchExeDevUsage = async (credential, fetchImpl = fetch) => {
|
||||
const response = await fetchImpl(EXEC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${credential.usageToken}`,
|
||||
'Content-Type': 'text/plain',
|
||||
'User-Agent': 'OpenChamber quota provider',
|
||||
},
|
||||
body: USAGE_COMMAND,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) throw new Error('exe.dev authentication failed');
|
||||
if (!response.ok) throw new Error(`exe.dev usage API returned HTTP ${response.status}`);
|
||||
const windows = parseExeDevUsage(await response.json().catch(() => null));
|
||||
if (!windows) throw new Error('exe.dev usage data could not be parsed');
|
||||
return windows;
|
||||
};
|
||||
|
||||
export const isConfigured = () => Boolean(readManagedCredential(providerId));
|
||||
|
||||
export const fetchQuota = async () => {
|
||||
const credential = readManagedCredential(providerId);
|
||||
if (!credential) {
|
||||
return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
const windows = await fetchExeDevUsage(credential);
|
||||
return buildResult({ providerId, providerName, ok: true, configured: true, usage: { windows } });
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: error instanceof Error ? error.message : 'Request failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { fetchExeDevUsage, parseExeDevUsage } from './exe-dev.js';
|
||||
|
||||
const payload = {
|
||||
allowance_spend_usd: 0.11,
|
||||
extra_spend_usd: 0,
|
||||
group: 'day',
|
||||
month: '2026-09',
|
||||
monthly_allowance_usd: 20,
|
||||
period_end: '2026-10-01T00:00:00Z',
|
||||
period_start: '2026-09-01T00:00:00Z',
|
||||
total_cost_usd: 0.11,
|
||||
total_requests: 17,
|
||||
};
|
||||
|
||||
describe('exe.dev quota provider', () => {
|
||||
it('parses monthly credit usage', () => {
|
||||
const windows = parseExeDevUsage(payload);
|
||||
expect(windows?.monthly.usedPercent).toBeCloseTo(0.55);
|
||||
expect(windows?.monthly.valueLabel).toBe('$0.11 / $20.00');
|
||||
expect(windows?.monthly.resetAt).toBe(Date.parse('2026-10-01T00:00:00Z'));
|
||||
});
|
||||
|
||||
it('sends the scoped billing command without exposing the token elsewhere', async () => {
|
||||
const requests = [];
|
||||
const windows = await fetchExeDevUsage({ usageToken: 'test-token' }, async (url, init) => {
|
||||
requests.push({ url, init });
|
||||
return Response.json(payload);
|
||||
});
|
||||
expect(windows.monthly.valueLabel).toBe('$0.11 / $20.00');
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0].url).toBe('https://exe.dev/exec');
|
||||
expect(requests[0].init.method).toBe('POST');
|
||||
expect(requests[0].init.body).toBe('billing credits usage --group=day --json');
|
||||
expect(requests[0].init.headers.Authorization).toBe('Bearer test-token');
|
||||
});
|
||||
|
||||
it('rejects malformed successful responses', async () => {
|
||||
await expect(fetchExeDevUsage({ usageToken: 'test-token' }, async () => Response.json({})))
|
||||
.rejects.toThrow('could not be parsed');
|
||||
});
|
||||
|
||||
it('reports authentication failure without including the token', async () => {
|
||||
await expect(fetchExeDevUsage({ usageToken: 'test-token' }, async () => new Response('', { status: 401 })))
|
||||
.rejects.toThrow('exe.dev authentication failed');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import * as copilot from './copilot.js';
|
||||
import * as crof from './crof.js';
|
||||
import * as cursor from './cursor.js';
|
||||
import * as deepseek from './deepseek.js';
|
||||
import * as exeDev from './exe-dev.js';
|
||||
import * as google from './google/index.js';
|
||||
import * as kimi from './kimi.js';
|
||||
import * as nanogpt from './nanogpt.js';
|
||||
@@ -59,6 +60,12 @@ const registry = {
|
||||
isConfigured: deepseek.isConfigured,
|
||||
fetchQuota: deepseek.fetchQuota
|
||||
},
|
||||
'exe-dev': {
|
||||
providerId: exeDev.providerId,
|
||||
providerName: exeDev.providerName,
|
||||
isConfigured: exeDev.isConfigured,
|
||||
fetchQuota: exeDev.fetchQuota
|
||||
},
|
||||
google: {
|
||||
providerId: google.providerId,
|
||||
providerName: google.providerName,
|
||||
|
||||
@@ -2,8 +2,10 @@ import express from 'express';
|
||||
import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js';
|
||||
import { fetchOllamaCloudUsage } from './providers/ollama-cloud.js';
|
||||
import { importCursorCredential, validateCursorCredential } from './providers/cursor.js';
|
||||
import { fetchExeDevUsage } from './providers/exe-dev.js';
|
||||
|
||||
const validators = {
|
||||
'exe-dev': fetchExeDevUsage,
|
||||
'ollama-cloud': fetchOllamaCloudUsage,
|
||||
cursor: validateCursorCredential,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user