feat: add exe.dev usage tracking
This commit is contained in:
@@ -5,21 +5,23 @@ import { toast } from '@/components/ui';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
type ProviderId = 'ollama-cloud' | 'cursor';
|
||||
type ProviderId = 'exe-dev' | 'ollama-cloud' | 'cursor';
|
||||
type Status = { configured: boolean; secretMasked?: string };
|
||||
type CredentialPayload = { usageToken?: string; cookie?: string; accessToken?: string; refreshToken?: string };
|
||||
const EXE_DEV_TOKEN_COMMAND = `ssh exe.dev "ssh-key generate-api-key --label=openchamber --exp=30d --cmds='billing credits usage'"`;
|
||||
|
||||
export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: string }> = ({ providerId, providerName }) => {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = React.useState<Status | null>(null);
|
||||
const [values, setValues] = React.useState<Record<string, string>>({});
|
||||
const [values, setValues] = React.useState<CredentialPayload>({});
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const route = `/api/quota/credentials/${providerId}`;
|
||||
React.useEffect(() => { void runtimeFetch(route).then(async (response) => {
|
||||
if (!response.ok) throw new Error();
|
||||
const next = await response.json() as Status;
|
||||
const next: Status = await response.json();
|
||||
setStatus(next); setValues({});
|
||||
}).catch(() => setStatus({ configured: false })); }, [route]);
|
||||
const request = async (path: string, method: string, body?: object) => {
|
||||
const request = async (path: string, method: string, body?: CredentialPayload) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await runtimeFetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
||||
@@ -31,11 +33,16 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName:
|
||||
} catch (error) { toast.error(error instanceof Error && error.message ? error.message : t('settings.providers.page.openCodeGo.saveFailed')); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
|
||||
const field = (name: keyof CredentialPayload, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
|
||||
return <div data-settings-item={`usage.${providerId}-credentials`} className="mb-8">
|
||||
<div className="mb-1 px-1"><h3 className="typography-ui-header font-medium text-foreground">{providerName}</h3></div>
|
||||
<section className="space-y-3 px-2 pb-2 pt-0">
|
||||
{providerId === 'exe-dev' && <div className="space-y-1.5">
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.quotaCredentials.exeDevTokenInstructions')}</p>
|
||||
<code className="typography-code block whitespace-pre-wrap break-all rounded bg-muted/50 px-2 py-1.5 text-xs text-foreground">{EXE_DEV_TOKEN_COMMAND}</code>
|
||||
</div>}
|
||||
{providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')}
|
||||
{providerId === 'exe-dev' && field('usageToken', t('settings.providers.page.quotaCredentials.usageToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
|
||||
{providerId === 'cursor' && field('accessToken', t('settings.providers.page.quotaCredentials.accessToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
|
||||
{providerId === 'cursor' && field('refreshToken', t('settings.providers.page.quotaCredentials.refreshToken'), t('settings.providers.page.quotaCredentials.tokenPlaceholder'))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -80,7 +80,7 @@ export const UsagePage: React.FC = () => {
|
||||
? selectedResult.error
|
||||
: null;
|
||||
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
|
||||
const hasCredentialsForm = selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
|
||||
const hasCredentialsForm = selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor';
|
||||
const handleDropdownToggle = React.useCallback((enabled: boolean) => {
|
||||
if (!selectedProviderId) {
|
||||
return;
|
||||
@@ -204,7 +204,7 @@ export const UsagePage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
|
||||
{(selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
|
||||
<QuotaCredentials providerId={selectedProviderId} providerName={providerName} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -2136,6 +2136,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Warteschlange',
|
||||
|
||||
'settings.providers.page.quotaCredentials.accessToken': 'Zugriffstoken',
|
||||
'settings.providers.page.quotaCredentials.usageToken': 'Nutzungs-API-Token',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Führen Sie diesen Befehl im Terminal aus und fügen Sie dann das Token unten ein. Es kann nur die LLM-Guthabennutzung lesen und läuft nach 30 Tagen ab.',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': 'Aktualisierungstoken',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token einfügen',
|
||||
'settings.view.nav.group.general': 'OpenChamber',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.delete': 'Delete',
|
||||
'settings.providers.page.quotaCredentials.saved': '{provider} credentials saved.',
|
||||
'settings.providers.page.quotaCredentials.accessToken': 'Access token',
|
||||
'settings.providers.page.quotaCredentials.usageToken': 'Usage API token',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Run this command in your terminal, then paste the token below. It can only read LLM credit usage and expires after 30 days.',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': 'Refresh token',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Paste token',
|
||||
'settings.providers.page.openCodeGo.saveFailed': 'Could not validate OpenCode Go credentials.',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.delete': 'Eliminar',
|
||||
'settings.providers.page.quotaCredentials.saved': 'Credenciales de {provider} guardadas.',
|
||||
'settings.providers.page.quotaCredentials.accessToken': 'Token de acceso',
|
||||
'settings.providers.page.quotaCredentials.usageToken': 'Token de API de uso',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Ejecuta este comando en tu terminal y pega el token abajo. Solo puede leer el uso de créditos de LLM y caduca después de 30 días.',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': 'Token de actualización',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Pega el token',
|
||||
'settings.providers.page.openCodeGo.saveFailed': 'No se pudieron validar las credenciales de OpenCode Go.',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.delete': 'Supprimer',
|
||||
'settings.providers.page.quotaCredentials.saved': 'Identifiants de {provider} enregistrés.',
|
||||
'settings.providers.page.quotaCredentials.accessToken': 'Jeton d’accès',
|
||||
'settings.providers.page.quotaCredentials.usageToken': 'Jeton API d’utilisation',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Exécutez cette commande dans votre terminal, puis collez le jeton ci-dessous. Il peut uniquement lire l’utilisation des crédits LLM et expire après 30 jours.',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': 'Jeton d’actualisation',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Collez le jeton',
|
||||
'settings.providers.page.openCodeGo.saveFailed': 'Impossible de valider les identifiants OpenCode Go.',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.delete': '削除',
|
||||
'settings.providers.page.quotaCredentials.saved': '{provider} の認証情報を保存しました。',
|
||||
'settings.providers.page.quotaCredentials.accessToken': 'アクセストークン',
|
||||
'settings.providers.page.quotaCredentials.usageToken': '使用量 API トークン',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'このコマンドをターミナルで実行し、下にトークンを貼り付けてください。LLM クレジット使用量の読み取りのみが可能で、30 日後に期限切れになります。',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': '更新トークン',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'トークンを貼り付け',
|
||||
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go の認証情報を検証できませんでした。',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.delete': '삭제',
|
||||
'settings.providers.page.quotaCredentials.saved': '{provider} 인증 정보를 저장했습니다.',
|
||||
'settings.providers.page.quotaCredentials.accessToken': '액세스 토큰',
|
||||
'settings.providers.page.quotaCredentials.usageToken': '사용량 API 토큰',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '터미널에서 이 명령을 실행한 다음 아래에 토큰을 붙여 넣으세요. LLM 크레딧 사용량만 읽을 수 있으며 30일 후 만료됩니다.',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': '새로 고침 토큰',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': '토큰 붙여넣기',
|
||||
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go 인증 정보를 검증할 수 없습니다.',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.delete': 'Usuń',
|
||||
'settings.providers.page.quotaCredentials.saved': 'Dane uwierzytelniające {provider} zostały zapisane.',
|
||||
'settings.providers.page.quotaCredentials.accessToken': 'Token dostępu',
|
||||
'settings.providers.page.quotaCredentials.usageToken': 'Token API użycia',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Uruchom to polecenie w terminalu, a następnie wklej token poniżej. Może on tylko odczytywać użycie środków LLM i wygasa po 30 dniach.',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': 'Token odświeżania',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Wklej token',
|
||||
'settings.providers.page.openCodeGo.saveFailed': 'Nie udało się sprawdzić danych OpenCode Go.',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.delete': 'Excluir',
|
||||
'settings.providers.page.quotaCredentials.saved': 'Credenciais de {provider} salvas.',
|
||||
'settings.providers.page.quotaCredentials.accessToken': 'Token de acesso',
|
||||
'settings.providers.page.quotaCredentials.usageToken': 'Token da API de uso',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Execute este comando no terminal e cole o token abaixo. Ele só pode ler o uso de créditos de LLM e expira após 30 dias.',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': 'Token de atualização',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Cole o token',
|
||||
'settings.providers.page.openCodeGo.saveFailed': 'Não foi possível validar as credenciais do OpenCode Go.',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.delete': 'Sil',
|
||||
'settings.providers.page.quotaCredentials.saved': '{provider} kimlik bilgileri kaydedildi.',
|
||||
'settings.providers.page.quotaCredentials.accessToken': 'Erişim token\'ı',
|
||||
'settings.providers.page.quotaCredentials.usageToken': 'Kullanım API token\'ı',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Bu komutu terminalde çalıştırın, ardından token\'ı aşağıya yapıştırın. Yalnızca LLM kredi kullanımını okuyabilir ve 30 gün sonra sona erer.',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': 'Yenileme token\'ı',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token\'ı yapıştır',
|
||||
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go kimlik bilgileri doğrulanamadı.',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.delete': 'Видалити',
|
||||
'settings.providers.page.quotaCredentials.saved': 'Облікові дані {provider} збережено.',
|
||||
'settings.providers.page.quotaCredentials.accessToken': 'Токен доступу',
|
||||
'settings.providers.page.quotaCredentials.usageToken': 'Токен API використання',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': 'Виконайте цю команду в терміналі, а потім вставте токен нижче. Він може лише читати використання LLM-кредитів і діє 30 днів.',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': 'Токен оновлення',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Вставте токен',
|
||||
'settings.providers.page.openCodeGo.saveFailed': 'Не вдалося перевірити дані OpenCode Go.',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.delete': '删除',
|
||||
'settings.providers.page.quotaCredentials.saved': '已保存 {provider} 凭据。',
|
||||
'settings.providers.page.quotaCredentials.accessToken': '访问令牌',
|
||||
'settings.providers.page.quotaCredentials.usageToken': '用量 API 令牌',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '在终端中运行此命令,然后在下方粘贴令牌。该令牌只能读取 LLM 积分用量,并将在 30 天后过期。',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': '刷新令牌',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': '粘贴令牌',
|
||||
'settings.providers.page.openCodeGo.saveFailed': '无法验证 OpenCode Go 凭据。',
|
||||
|
||||
@@ -13,6 +13,8 @@ export const settingsDict = {
|
||||
'settings.providers.page.openCodeGo.delete': '刪除',
|
||||
'settings.providers.page.quotaCredentials.saved': '已儲存 {provider} 憑證。',
|
||||
'settings.providers.page.quotaCredentials.accessToken': '存取權杖',
|
||||
'settings.providers.page.quotaCredentials.usageToken': '用量 API 權杖',
|
||||
'settings.providers.page.quotaCredentials.exeDevTokenInstructions': '在終端機中執行此命令,然後在下方貼上權杖。該權杖只能讀取 LLM 點數用量,並將在 30 天後到期。',
|
||||
'settings.providers.page.quotaCredentials.refreshToken': '重新整理權杖',
|
||||
'settings.providers.page.quotaCredentials.tokenPlaceholder': '貼上權杖',
|
||||
'settings.providers.page.openCodeGo.saveFailed': '無法驗證 OpenCode Go 憑證。',
|
||||
|
||||
@@ -23,6 +23,7 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
|
||||
{ id: 'opencode-go', name: 'OpenCode Go' },
|
||||
{ id: 'crof', name: 'CrofAI' },
|
||||
{ id: 'deepseek', name: 'DeepSeek' },
|
||||
{ id: 'exe-dev', name: 'exe.dev' },
|
||||
{ id: 'neuralwatt', name: 'NeuralWatt' },
|
||||
{ id: 'xai', name: 'xAI' },
|
||||
];
|
||||
|
||||
@@ -18,6 +18,7 @@ export type QuotaProviderId =
|
||||
| 'opencode-go'
|
||||
| 'crof'
|
||||
| 'deepseek'
|
||||
| 'exe-dev'
|
||||
| 'neuralwatt'
|
||||
| 'xai';
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews
|
||||
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
||||
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
|
||||
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API).
|
||||
- Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider.
|
||||
|
||||
- `opencode-upgrade-runtime.ts`
|
||||
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
|
||||
|
||||
@@ -555,7 +555,7 @@ export async function handleSystemBridgeMessage(
|
||||
case 'api:quota:credentials': {
|
||||
const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown };
|
||||
try {
|
||||
if (!providerId || !['ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' };
|
||||
if (!providerId || !['exe-dev', 'ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' };
|
||||
if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) };
|
||||
if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; }
|
||||
if (method === 'IMPORT') {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fetchExeDevUsage, parseExeDevUsage } from './exeDevQuota';
|
||||
|
||||
const payload = {
|
||||
monthly_allowance_usd: 20,
|
||||
period_end: '2026-10-01T00:00:00Z',
|
||||
total_cost_usd: 0.11,
|
||||
};
|
||||
|
||||
describe('exe.dev quota', () => {
|
||||
it('parses monthly credit usage', () => {
|
||||
const windows = parseExeDevUsage(payload);
|
||||
assert.ok(windows);
|
||||
assert.ok(Math.abs((windows.monthly.usedPercent ?? 0) - 0.55) < 0.0001);
|
||||
assert.equal(windows.monthly.valueLabel, '$0.11 / $20.00');
|
||||
});
|
||||
|
||||
it('executes only the billing usage command', async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const windows = await fetchExeDevUsage('test-token', async (url, init) => {
|
||||
requests.push({ url: String(url), init });
|
||||
return Response.json(payload);
|
||||
});
|
||||
assert.equal(windows.monthly.valueLabel, '$0.11 / $20.00');
|
||||
assert.equal(requests[0]?.url, 'https://exe.dev/exec');
|
||||
assert.equal(requests[0]?.init?.body, 'billing credits usage --group=day --json');
|
||||
assert.equal(new Headers(requests[0]?.init?.headers).get('Authorization'), 'Bearer test-token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
type ExeDevUsageWindow = {
|
||||
usedPercent: number | null;
|
||||
remainingPercent: number | null;
|
||||
windowSeconds: null;
|
||||
resetAfterSeconds: number | null;
|
||||
resetAt: number;
|
||||
resetAtFormatted: string;
|
||||
resetAfterFormatted: string | null;
|
||||
valueLabel: string;
|
||||
};
|
||||
|
||||
type ExeDevUsagePayload = {
|
||||
total_cost_usd?: number | null;
|
||||
monthly_allowance_usd?: number | null;
|
||||
period_end?: string | null;
|
||||
};
|
||||
|
||||
const EXEC_URL = 'https://exe.dev/exec';
|
||||
const USAGE_COMMAND = 'billing credits usage --group=day --json';
|
||||
|
||||
const numberValue = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) return null;
|
||||
return value;
|
||||
};
|
||||
|
||||
export const parseExeDevUsage = (payload: ExeDevUsagePayload | null): Record<string, ExeDevUsageWindow> | null => {
|
||||
if (!payload) return null;
|
||||
const totalCost = numberValue(payload.total_cost_usd);
|
||||
const monthlyAllowance = numberValue(payload.monthly_allowance_usd);
|
||||
const resetAt = payload.period_end ? Date.parse(payload.period_end) : Number.NaN;
|
||||
if (totalCost === null || monthlyAllowance === null || monthlyAllowance < 0 || !Number.isFinite(resetAt)) return null;
|
||||
const usedPercent = monthlyAllowance > 0 ? Math.min(100, Math.max(0, (totalCost / monthlyAllowance) * 100)) : null;
|
||||
const remainingPercent = usedPercent === null ? null : Math.max(0, 100 - usedPercent);
|
||||
const resetAfterSeconds = Math.max(0, Math.floor((resetAt - Date.now()) / 1000));
|
||||
return {
|
||||
monthly: {
|
||||
usedPercent,
|
||||
remainingPercent,
|
||||
windowSeconds: null,
|
||||
resetAfterSeconds,
|
||||
resetAt,
|
||||
resetAtFormatted: new Date(resetAt).toLocaleString(),
|
||||
resetAfterFormatted: null,
|
||||
valueLabel: `$${totalCost.toFixed(2)} / $${monthlyAllowance.toFixed(2)}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchExeDevUsage = async (usageToken: string, fetchImpl: typeof fetch = fetch) => {
|
||||
const response = await fetchImpl(EXEC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${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 payload: ExeDevUsagePayload | null = await response.text().then((text) => JSON.parse(text)).catch(() => null);
|
||||
const windows = parseExeDevUsage(payload);
|
||||
if (!windows) throw new Error('exe.dev usage data could not be parsed');
|
||||
return windows;
|
||||
};
|
||||
@@ -2,10 +2,11 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fetchExeDevUsage } from './exeDevQuota';
|
||||
|
||||
export type ManagedProvider = 'ollama-cloud' | 'cursor';
|
||||
export type ManagedProvider = 'exe-dev' | 'ollama-cloud' | 'cursor';
|
||||
export type ManagedCredential = Record<string, string>;
|
||||
const providers = new Set<ManagedProvider>(['ollama-cloud', 'cursor']);
|
||||
const providers = new Set<ManagedProvider>(['exe-dev', 'ollama-cloud', 'cursor']);
|
||||
const directory = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota');
|
||||
const target = (provider: ManagedProvider) => {
|
||||
if (!providers.has(provider)) throw new Error('Unsupported credential provider');
|
||||
@@ -15,6 +16,7 @@ const clean = (value: unknown) => typeof value === 'string' && !/[\r\n]/.test(va
|
||||
|
||||
export const normalizeCredential = (provider: ManagedProvider, value: unknown): ManagedCredential | null => {
|
||||
const data = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||
if (provider === 'exe-dev') return clean(data.usageToken) ? { usageToken: clean(data.usageToken) } : null;
|
||||
if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null;
|
||||
const accessToken = clean(data.accessToken);
|
||||
const refreshToken = clean(data.refreshToken);
|
||||
@@ -52,6 +54,7 @@ export const importCursorCredential = () => {
|
||||
};
|
||||
|
||||
export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => {
|
||||
if (provider === 'exe-dev') await fetchExeDevUsage(credential.usageToken);
|
||||
if (provider === 'ollama-cloud') {
|
||||
const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
|
||||
if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed');
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from 'node:os';
|
||||
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
||||
import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials';
|
||||
import { getProviderAuth, updateProviderAuth } from './opencodeAuth';
|
||||
import { fetchExeDevUsage } from './exeDevQuota';
|
||||
|
||||
type AuthEntry = Record<string, unknown> | string;
|
||||
type AuthFile = Record<string, AuthEntry>;
|
||||
@@ -774,6 +775,7 @@ export const listConfiguredQuotaProviders = () => {
|
||||
if (openCodeGoAuth && (typeof openCodeGoAuth.key === 'string' || typeof openCodeGoAuth.token === 'string')) configured.add('opencode-go');
|
||||
if (readCredential('ollama-cloud')) configured.add('ollama-cloud');
|
||||
if (readCredential('cursor')) configured.add('cursor');
|
||||
if (readCredential('exe-dev')) configured.add('exe-dev');
|
||||
|
||||
const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude']));
|
||||
if (anthropicAuth && ((anthropicAuth as Record<string, unknown>).access || (anthropicAuth as Record<string, unknown>).token)) {
|
||||
@@ -1946,6 +1948,16 @@ const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchExeDevQuota = async (): Promise<ProviderResult> => {
|
||||
const usageToken = readCredential('exe-dev')?.usageToken;
|
||||
if (!usageToken) return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: false, configured: false, error: 'Not configured' });
|
||||
try {
|
||||
return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: true, configured: true, usage: { windows: await fetchExeDevUsage(usageToken) } });
|
||||
} catch (error) {
|
||||
return buildResult({ providerId: 'exe-dev', providerName: 'exe.dev', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCursorQuota = async (): Promise<ProviderResult> => {
|
||||
const accessToken = readCredential('cursor')?.accessToken;
|
||||
if (!accessToken) return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: false, error: 'Not configured' });
|
||||
@@ -2868,6 +2880,8 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise<Pro
|
||||
return fetchMiniMaxCnCodingPlanQuota();
|
||||
case 'ollama-cloud':
|
||||
return fetchOllamaCloudQuota();
|
||||
case 'exe-dev':
|
||||
return fetchExeDevQuota();
|
||||
case 'openrouter':
|
||||
return fetchOpenRouterQuota();
|
||||
case 'zai-coding-plan':
|
||||
|
||||
@@ -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