From da6ab52a658a5121407469a1cb4e12b50acaadc6 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 1 Sep 2026 19:22:09 +0300 Subject: [PATCH] feat: add exe.dev usage tracking --- .../sections/usage/QuotaCredentials.tsx | 17 +++-- .../components/sections/usage/UsagePage.tsx | 4 +- .../ui/src/lib/i18n/messages/de.settings.ts | 2 + .../ui/src/lib/i18n/messages/en.settings.ts | 2 + .../ui/src/lib/i18n/messages/es.settings.ts | 2 + .../ui/src/lib/i18n/messages/fr.settings.ts | 2 + .../ui/src/lib/i18n/messages/ja.settings.ts | 2 + .../ui/src/lib/i18n/messages/ko.settings.ts | 2 + .../ui/src/lib/i18n/messages/pl.settings.ts | 2 + .../src/lib/i18n/messages/pt-BR.settings.ts | 2 + .../ui/src/lib/i18n/messages/tr.settings.ts | 2 + .../ui/src/lib/i18n/messages/uk.settings.ts | 2 + .../src/lib/i18n/messages/zh-CN.settings.ts | 2 + .../src/lib/i18n/messages/zh-TW.settings.ts | 2 + packages/ui/src/lib/quota/providers/index.ts | 1 + packages/ui/src/types/quota.ts | 1 + packages/vscode/src/DOCUMENTATION.md | 1 + packages/vscode/src/bridge-system-runtime.ts | 2 +- packages/vscode/src/exeDevQuota.test.ts | 30 ++++++++ packages/vscode/src/exeDevQuota.ts | 67 +++++++++++++++++ packages/vscode/src/quotaCredentials.ts | 7 +- packages/vscode/src/quotaProviders.ts | 14 ++++ .../web/server/lib/quota/DOCUMENTATION.md | 3 +- .../server/lib/quota/credentials/providers.js | 4 + .../web/server/lib/quota/credentials/store.js | 2 +- .../lib/quota/credentials/store.test.js | 8 +- .../web/server/lib/quota/providers/exe-dev.js | 75 +++++++++++++++++++ .../lib/quota/providers/exe-dev.test.js | 47 ++++++++++++ .../web/server/lib/quota/providers/index.js | 7 ++ packages/web/server/lib/quota/routes.js | 2 + 30 files changed, 300 insertions(+), 16 deletions(-) create mode 100644 packages/vscode/src/exeDevQuota.test.ts create mode 100644 packages/vscode/src/exeDevQuota.ts create mode 100644 packages/web/server/lib/quota/providers/exe-dev.js create mode 100644 packages/web/server/lib/quota/providers/exe-dev.test.js diff --git a/packages/ui/src/components/sections/usage/QuotaCredentials.tsx b/packages/ui/src/components/sections/usage/QuotaCredentials.tsx index abcdc8aa..840cc905 100644 --- a/packages/ui/src/components/sections/usage/QuotaCredentials.tsx +++ b/packages/ui/src/components/sections/usage/QuotaCredentials.tsx @@ -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(null); - const [values, setValues] = React.useState>({}); + const [values, setValues] = React.useState({}); 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) => ; + const field = (name: keyof CredentialPayload, label: string, placeholder: string) => ; return

{providerName}

+ {providerId === 'exe-dev' &&
+

{t('settings.providers.page.quotaCredentials.exeDevTokenInstructions')}

+ {EXE_DEV_TOKEN_COMMAND} +
} {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'))}
diff --git a/packages/ui/src/components/sections/usage/UsagePage.tsx b/packages/ui/src/components/sections/usage/UsagePage.tsx index 7e8e3ccc..679d73b1 100644 --- a/packages/ui/src/components/sections/usage/UsagePage.tsx +++ b/packages/ui/src/components/sections/usage/UsagePage.tsx @@ -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 = () => {
)} - {(selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && ( + {(selectedProviderId === 'exe-dev' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && ( )} diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 86dc0985..8e7ad3c5 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index e0c6468f..4b44ad31 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index b72f9e26..2b656990 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index ae7d5d4e..0fe60bea 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index a68f0fc7..729551a6 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -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 の認証情報を検証できませんでした。', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 4fdb93af..48d6aa8b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -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 인증 정보를 검증할 수 없습니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index f8272cc1..9d89d843 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 3f2827c5..9261f8ca 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index ead5621c..02651073 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -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ı.', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index f3d88411..4116ef42 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -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.', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index c0eef58a..01024ad1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -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 凭据。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index c0639769..7e5d3f25 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -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 憑證。', diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 96a4906c..749f5516 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -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' }, ]; diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 059e4b98..1524ca51 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -18,6 +18,7 @@ export type QuotaProviderId = | 'opencode-go' | 'crof' | 'deepseek' + | 'exe-dev' | 'neuralwatt' | 'xai'; diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index c89e0799..35029076 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -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. diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index 96de0bee..a99e8181 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -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') { diff --git a/packages/vscode/src/exeDevQuota.test.ts b/packages/vscode/src/exeDevQuota.test.ts new file mode 100644 index 00000000..ce026823 --- /dev/null +++ b/packages/vscode/src/exeDevQuota.test.ts @@ -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'); + }); +}); diff --git a/packages/vscode/src/exeDevQuota.ts b/packages/vscode/src/exeDevQuota.ts new file mode 100644 index 00000000..424cb7e9 --- /dev/null +++ b/packages/vscode/src/exeDevQuota.ts @@ -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 | 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; +}; diff --git a/packages/vscode/src/quotaCredentials.ts b/packages/vscode/src/quotaCredentials.ts index 3aa185dc..b7f5968d 100644 --- a/packages/vscode/src/quotaCredentials.ts +++ b/packages/vscode/src/quotaCredentials.ts @@ -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; -const providers = new Set(['ollama-cloud', 'cursor']); +const providers = new Set(['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 : {}; + 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'); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index a9ce3204..c275a07d 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -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; type AuthFile = Record; @@ -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).access || (anthropicAuth as Record).token)) { @@ -1946,6 +1948,16 @@ const fetchOllamaCloudQuota = async (): Promise => { } }; +const fetchExeDevQuota = async (): Promise => { + 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 => { 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 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; diff --git a/packages/web/server/lib/quota/credentials/store.js b/packages/web/server/lib/quota/credentials/store.js index a83b2008..3027d8d4 100644 --- a/packages/web/server/lib/quota/credentials/store.js +++ b/packages/web/server/lib/quota/credentials/store.js @@ -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 diff --git a/packages/web/server/lib/quota/credentials/store.test.js b/packages/web/server/lib/quota/credentials/store.test.js index 6ad09d25..68bf6726 100644 --- a/packages/web/server/lib/quota/credentials/store.test.js +++ b/packages/web/server/lib/quota/credentials/store.test.js @@ -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', () => { diff --git a/packages/web/server/lib/quota/providers/exe-dev.js b/packages/web/server/lib/quota/providers/exe-dev.js new file mode 100644 index 00000000..f3f9155b --- /dev/null +++ b/packages/web/server/lib/quota/providers/exe-dev.js @@ -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', + }); + } +}; diff --git a/packages/web/server/lib/quota/providers/exe-dev.test.js b/packages/web/server/lib/quota/providers/exe-dev.test.js new file mode 100644 index 00000000..5b2a5d92 --- /dev/null +++ b/packages/web/server/lib/quota/providers/exe-dev.test.js @@ -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'); + }); +}); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 3ae4cc99..b6d7be3d 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -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, diff --git a/packages/web/server/lib/quota/routes.js b/packages/web/server/lib/quota/routes.js index 570fb253..4e4f9f05 100644 --- a/packages/web/server/lib/quota/routes.js +++ b/packages/web/server/lib/quota/routes.js @@ -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, };