diff --git a/packages/ui/src/components/sections/usage/QuotaCredentials.tsx b/packages/ui/src/components/sections/usage/QuotaCredentials.tsx index d4b95bcd..abcdc8aa 100644 --- a/packages/ui/src/components/sections/usage/QuotaCredentials.tsx +++ b/packages/ui/src/components/sections/usage/QuotaCredentials.tsx @@ -5,8 +5,8 @@ import { toast } from '@/components/ui'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; -type ProviderId = 'opencode-go' | 'ollama-cloud' | 'cursor'; -type Status = { configured: boolean; workspaceId?: string; secretMasked?: string }; +type ProviderId = 'ollama-cloud' | 'cursor'; +type Status = { configured: boolean; secretMasked?: string }; export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: string }> = ({ providerId, providerName }) => { const { t } = useI18n(); @@ -17,7 +17,7 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: React.useEffect(() => { void runtimeFetch(route).then(async (response) => { if (!response.ok) throw new Error(); const next = await response.json() as Status; - setStatus(next); setValues(next.workspaceId ? { workspaceId: next.workspaceId } : {}); + setStatus(next); setValues({}); }).catch(() => setStatus({ configured: false })); }, [route]); const request = async (path: string, method: string, body?: object) => { setBusy(true); @@ -26,17 +26,15 @@ export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payload?.error); if (payload?.configured !== undefined) setStatus(payload); - setValues((current) => current.workspaceId ? { workspaceId: current.workspaceId } : {} as Record); + setValues({}); toast.success(t('settings.providers.page.quotaCredentials.saved', { provider: 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: string, label: string, placeholder: string) => ; return

{providerName}

- {providerId === 'opencode-go' && field('workspaceId', t('settings.providers.page.openCodeGo.workspaceId'), 'wrk_...')} - {providerId === 'opencode-go' && field('authCookie', t('settings.providers.page.openCodeGo.authCookie'), 'auth=...')} {providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')} {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 0f0693a9..0dac1033 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 === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor'; + const hasCredentialsForm = selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor'; const handleDropdownToggle = React.useCallback((enabled: boolean) => { if (!selectedProviderId) { return; @@ -199,7 +199,7 @@ export const UsagePage: React.FC = () => {
)} - {(selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && ( + {(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 459372e4..872d2ed9 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -3,6 +3,7 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.description': 'Verbinden Sie das OpenCode Go Dashboard, um rollierenden, wöchentlichen und monatlichen Verbrauch anzuzeigen.', 'settings.providers.page.openCodeGo.workspaceId': 'Workspace-ID', 'settings.providers.page.openCodeGo.authCookie': 'Auth-Cookie', + 'settings.providers.page.openCodeGo.apiKey': 'API-Schlüssel', 'settings.providers.page.openCodeGo.help': 'Kopieren Sie die Workspace-ID aus der Dashboard-URL und den Auth-Cookie aus den Browser-Entwicklertools. OpenChamber liest niemals den Browser-Cookie-Speicher aus.', 'settings.providers.page.openCodeGo.save': 'Speichern und validieren', 'settings.providers.page.openCodeGo.replace': 'Ersetzen', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 23a1df0e..499353b2 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -3,6 +3,7 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.description': 'Connect the OpenCode Go dashboard to show rolling, weekly, and monthly quota.', 'settings.providers.page.openCodeGo.workspaceId': 'Workspace ID', 'settings.providers.page.openCodeGo.authCookie': 'Auth cookie', + 'settings.providers.page.openCodeGo.apiKey': 'API key', 'settings.providers.page.openCodeGo.help': 'Copy the workspace ID from the dashboard URL and the auth cookie from your browser developer tools. OpenChamber never scans browser cookie storage.', 'settings.providers.page.openCodeGo.save': 'Save and validate', 'settings.providers.page.openCodeGo.replace': 'Replace', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index f9585207..b2262fa0 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -3,6 +3,7 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.description': 'Conecta el panel de OpenCode Go para ver las cuotas móvil, semanal y mensual.', 'settings.providers.page.openCodeGo.workspaceId': 'ID del espacio de trabajo', 'settings.providers.page.openCodeGo.authCookie': 'Cookie de autenticación', + 'settings.providers.page.openCodeGo.apiKey': 'Clave API', 'settings.providers.page.openCodeGo.help': 'Copia el ID desde la URL del panel y la cookie auth desde las herramientas del navegador. OpenChamber nunca examina las cookies del navegador.', 'settings.providers.page.openCodeGo.save': 'Guardar y validar', 'settings.providers.page.openCodeGo.replace': 'Reemplazar', diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 019b795c..ae5b7d92 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -3,6 +3,7 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.description': 'Connectez le tableau de bord OpenCode Go pour afficher les quotas glissant, hebdomadaire et mensuel.', 'settings.providers.page.openCodeGo.workspaceId': 'ID de l’espace de travail', 'settings.providers.page.openCodeGo.authCookie': 'Cookie d’authentification', + 'settings.providers.page.openCodeGo.apiKey': 'Clé API', 'settings.providers.page.openCodeGo.help': 'Copiez l’ID depuis l’URL du tableau de bord et le cookie auth depuis les outils du navigateur. OpenChamber n’analyse jamais les cookies du navigateur.', 'settings.providers.page.openCodeGo.save': 'Enregistrer et valider', 'settings.providers.page.openCodeGo.replace': 'Remplacer', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index b74a27e0..fd19b80f 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -3,6 +3,7 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.description': 'OpenCode Go ダッシュボードを接続して、ローリング、週間、月間のクォータを表示します。', 'settings.providers.page.openCodeGo.workspaceId': 'ワークスペース ID', 'settings.providers.page.openCodeGo.authCookie': '認証 Cookie', + 'settings.providers.page.openCodeGo.apiKey': 'API キー', 'settings.providers.page.openCodeGo.help': 'ダッシュボード URL からワークスペース ID を、ブラウザーの開発者ツールから auth Cookie をコピーしてください。OpenChamber がブラウザーの Cookie を走査することはありません。', 'settings.providers.page.openCodeGo.save': '保存して検証', 'settings.providers.page.openCodeGo.replace': '置換', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 8b962f49..be12784b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -3,6 +3,7 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.description': 'OpenCode Go 대시보드를 연결하여 롤링, 주간 및 월간 할당량을 표시합니다.', 'settings.providers.page.openCodeGo.workspaceId': '워크스페이스 ID', 'settings.providers.page.openCodeGo.authCookie': '인증 쿠키', + 'settings.providers.page.openCodeGo.apiKey': 'API 키', 'settings.providers.page.openCodeGo.help': '대시보드 URL에서 워크스페이스 ID를, 브라우저 개발자 도구에서 auth 쿠키를 복사하세요. OpenChamber는 브라우저 쿠키 저장소를 검색하지 않습니다.', 'settings.providers.page.openCodeGo.save': '저장 및 검증', 'settings.providers.page.openCodeGo.replace': '교체', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 1f076910..e31c95eb 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -3,6 +3,7 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.description': 'Połącz panel OpenCode Go, aby wyświetlać limity kroczące, tygodniowe i miesięczne.', 'settings.providers.page.openCodeGo.workspaceId': 'ID przestrzeni roboczej', 'settings.providers.page.openCodeGo.authCookie': 'Plik cookie uwierzytelniania', + 'settings.providers.page.openCodeGo.apiKey': 'Klucz API', 'settings.providers.page.openCodeGo.help': 'Skopiuj ID z adresu panelu i cookie auth z narzędzi deweloperskich przeglądarki. OpenChamber nigdy nie skanuje plików cookie przeglądarki.', 'settings.providers.page.openCodeGo.save': 'Zapisz i sprawdź', 'settings.providers.page.openCodeGo.replace': 'Zastąp', 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 f81f9970..e8382415 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -3,6 +3,7 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.description': 'Conecte o painel do OpenCode Go para exibir as cotas móvel, semanal e mensal.', 'settings.providers.page.openCodeGo.workspaceId': 'ID do workspace', 'settings.providers.page.openCodeGo.authCookie': 'Cookie de autenticação', + 'settings.providers.page.openCodeGo.apiKey': 'Chave de API', 'settings.providers.page.openCodeGo.help': 'Copie o ID da URL do painel e o cookie auth das ferramentas do navegador. O OpenChamber nunca examina os cookies do navegador.', 'settings.providers.page.openCodeGo.save': 'Salvar e validar', 'settings.providers.page.openCodeGo.replace': 'Substituir', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 58025659..9bb05e81 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -3,6 +3,7 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.description': 'Підключіть панель OpenCode Go, щоб бачити ковзну, тижневу та місячну квоту.', 'settings.providers.page.openCodeGo.workspaceId': 'ID робочого простору', 'settings.providers.page.openCodeGo.authCookie': 'Cookie авторизації', + 'settings.providers.page.openCodeGo.apiKey': 'Ключ API', 'settings.providers.page.openCodeGo.help': 'Скопіюйте ID робочого простору з URL панелі, а cookie auth з інструментів розробника браузера. OpenChamber ніколи не сканує сховище cookie браузера.', 'settings.providers.page.openCodeGo.save': 'Зберегти й перевірити', 'settings.providers.page.openCodeGo.replace': 'Замінити', 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 21944e53..684cbf09 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -3,6 +3,7 @@ export const settingsDict = { 'settings.providers.page.openCodeGo.description': '连接 OpenCode Go 控制面板以显示滚动、每周和每月配额。', 'settings.providers.page.openCodeGo.workspaceId': '工作区 ID', 'settings.providers.page.openCodeGo.authCookie': '身份验证 Cookie', + 'settings.providers.page.openCodeGo.apiKey': 'API 密钥', 'settings.providers.page.openCodeGo.help': '从控制面板网址复制工作区 ID,并从浏览器开发者工具复制 auth Cookie。OpenChamber 绝不会扫描浏览器 Cookie 存储。', 'settings.providers.page.openCodeGo.save': '保存并验证', 'settings.providers.page.openCodeGo.replace': '替换', 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 e61fe185..6c250c4b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -3,6 +3,7 @@ 'settings.providers.page.openCodeGo.description': '連接 OpenCode Go 控制面板以顯示滾動、每週和每月配額。', 'settings.providers.page.openCodeGo.workspaceId': '工作區 ID', 'settings.providers.page.openCodeGo.authCookie': '驗證 Cookie', + 'settings.providers.page.openCodeGo.apiKey': 'API 金鑰', 'settings.providers.page.openCodeGo.help': '從控制面板網址複製工作區 ID,並從瀏覽器開發者工具複製 auth Cookie。OpenChamber 絕不會掃描瀏覽器 Cookie 儲存空間。', 'settings.providers.page.openCodeGo.save': '儲存並驗證', 'settings.providers.page.openCodeGo.replace': '取代', diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index d21bc44a..a84ce257 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -6,7 +6,6 @@ import { randomUUID } from 'crypto'; import { removeProviderConfig, getProviderSources, upsertProviderConfig } from './opencodeConfig'; import { getProviderAuth, removeProviderAuth } from './opencodeAuth'; import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders'; -import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials'; import { getSessionActivitySnapshot } from './sessionActivityWatcher'; import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime'; @@ -554,7 +553,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 || !['opencode-go', 'ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' }; + if (!providerId || !['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') { @@ -566,15 +565,13 @@ export async function handleSystemBridgeMessage( if (method === 'PUT') { const credential = normalizeCredential(providerId, input); if (!credential) return { id, type, success: false, error: 'Invalid credential' }; - if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string }); - else await validateCredential(providerId, credential); + await validateCredential(providerId, credential); return { id, type, success: true, data: writeCredential(providerId, credential) }; } if (method === 'VALIDATE') { const credential = readCredential(providerId); if (!credential) return { id, type, success: false, error: 'Not configured' }; - if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string }); - else await validateCredential(providerId, credential); + await validateCredential(providerId, credential); return { id, type, success: true, data: { valid: true } }; } return { id, type, success: false, error: 'Unsupported method' }; diff --git a/packages/vscode/src/opencodeGoQuota.ts b/packages/vscode/src/opencodeGoQuota.ts index 6102ae0c..eb9739c2 100644 --- a/packages/vscode/src/opencodeGoQuota.ts +++ b/packages/vscode/src/opencodeGoQuota.ts @@ -1,27 +1,25 @@ -type OpenCodeGoCredential = { workspaceId: string; authCookie: string }; +type OpenCodeGoCredential = { apiKey: string }; -const toWindow = (usedPercent: number, resetInSec: number) => ({ +const toWindow = (usedPercent: number, resetAt: string) => ({ usedPercent: Math.min(100, Math.max(0, usedPercent)), remainingPercent: 100 - Math.min(100, Math.max(0, usedPercent)), windowSeconds: null, - resetAfterSeconds: Math.max(0, resetInSec), - resetAt: Date.now() + Math.max(0, resetInSec) * 1000, + resetAfterSeconds: Math.max(0, Math.floor((new Date(resetAt).getTime() - Date.now()) / 1000)), + resetAt: new Date(resetAt).getTime(), resetAtFormatted: null, resetAfterFormatted: null, }); export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => { - const response = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(credential.workspaceId)}/go`, { headers: { Accept: 'text/html', Cookie: `auth=${credential.authCookie}` }, redirect: 'manual', signal: AbortSignal.timeout(15_000) }); + const response = await fetch('https://opencode.ai/zen/go/v1/usage', { headers: { Accept: 'application/json', Authorization: `Bearer ${credential.apiKey}` }, signal: AbortSignal.timeout(15_000) }); if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed'); - if (!response.ok) throw new Error(`OpenCode Go dashboard returned HTTP ${response.status}`); - const html = (await response.text()).replaceAll('"', '"').replaceAll('"', '"').replaceAll('\\u0022', '"').replaceAll('\\"', '"'); + if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`); + const payload = await response.json().catch(() => null) as { usage?: Record } | null; const windows: Record> = {}; - for (const [key, field] of Object.entries({ '5h': 'rollingUsage', weekly: 'weeklyUsage', monthly: 'monthlyUsage' })) { - const body = html.match(new RegExp(`["']?${field}["']?\\s*:\\s*(?:\\$R\\[\\d+\\]\\s*=\\s*)?\\{([^{}]*)\\}`, 's'))?.[1]; - if (!body) continue; - const used = Number(body.match(/usagePercent\s*:\s*["']?(-?\d+(?:\.\d+)?)/)?.[1]); - const reset = Number(body.match(/resetInSec\s*:\s*["']?(-?\d+(?:\.\d+)?)/)?.[1]); - if (Number.isFinite(used) && Number.isFinite(reset)) windows[key] = toWindow(used, reset); + for (const [key, apiKey] of Object.entries({ '5h': 'rolling', weekly: 'weekly', monthly: 'monthly' })) { + const entry = payload?.usage?.[apiKey]; + if (typeof entry?.percent !== 'number' || !Number.isFinite(entry.percent) || typeof entry.resetsAt !== 'string' || !Number.isFinite(new Date(entry.resetsAt).getTime())) continue; + windows[key] = toWindow(entry.percent, entry.resetsAt); } if (!Object.keys(windows).length) throw new Error('OpenCode Go usage data could not be parsed'); return windows; diff --git a/packages/vscode/src/quotaCredentials.ts b/packages/vscode/src/quotaCredentials.ts index 10ba6631..3aa185dc 100644 --- a/packages/vscode/src/quotaCredentials.ts +++ b/packages/vscode/src/quotaCredentials.ts @@ -3,9 +3,9 @@ import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; -export type ManagedProvider = 'opencode-go' | 'ollama-cloud' | 'cursor'; +export type ManagedProvider = 'ollama-cloud' | 'cursor'; export type ManagedCredential = Record; -const providers = new Set(['opencode-go', 'ollama-cloud', 'cursor']); +const providers = new Set(['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,12 +15,6 @@ 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 === 'opencode-go') { - const workspaceId = clean(data.workspaceId); - let authCookie = clean(data.authCookie); - if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim(); - return workspaceId && authCookie ? { workspaceId, authCookie } : null; - } if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null; const accessToken = clean(data.accessToken); const refreshToken = clean(data.refreshToken); @@ -34,7 +28,7 @@ export const readCredential = (provider: ManagedProvider) => { export const credentialStatus = (provider: ManagedProvider) => { const value = readCredential(provider); if (!value) return { configured: false }; - return { configured: true, ...(provider === 'opencode-go' ? { workspaceId: value.workspaceId } : {}), ...(provider === 'cursor' ? { hasRefreshToken: Boolean(value.refreshToken) } : {}), secretMasked: '••••••••' }; + return { configured: true, ...(provider === 'cursor' ? { hasRefreshToken: Boolean(value.refreshToken) } : {}), secretMasked: '••••••••' }; }; export const writeCredential = (provider: ManagedProvider, value: ManagedCredential) => { const dir = directory(); const file = target(provider); const temp = `${file}.${process.pid}.${Date.now()}.tmp`; @@ -44,6 +38,9 @@ export const writeCredential = (provider: ManagedProvider, value: ManagedCredent return credentialStatus(provider); }; export const deleteCredential = (provider: ManagedProvider) => { try { fs.unlinkSync(target(provider)); } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error; } }; +export const deleteLegacyOpenCodeGoCredential = () => { + try { fs.unlinkSync(path.join(directory(), 'opencode-go.json')); } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error; } +}; export const importCursorCredential = () => { const db = path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'); diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 94451eac..a2e314c0 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -1,6 +1,12 @@ -import { afterEach, beforeEach, describe, test } from 'node:test'; +import { after, afterEach, beforeEach, describe, test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const previousQuotaDataDirectory = process.env.OPENCHAMBER_DATA_DIR; +const temporaryQuotaDataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-quota-')); +process.env.OPENCHAMBER_DATA_DIR = temporaryQuotaDataDirectory; // readAuthFile reads ~/.local/share/opencode/auth.json via fs.readFileSync. // Stub fs to serve a known auth entry so the providers treat themselves as @@ -10,6 +16,7 @@ const AUTH = JSON.stringify({ openai: { access: 'test-token' }, crof: { key: 'test-token' }, neuralwatt: { key: 'test-token' }, + 'opencode-go': { key: 'test-token' }, 'zai-coding-plan': { key: 'test-token' }, deepseek: { key: 'test-token' }, }); @@ -20,6 +27,12 @@ import { fetchQuotaForProvider } from './quotaProviders'; type MockResponseInit = { ok?: boolean; status?: number }; +after(() => { + if (previousQuotaDataDirectory === undefined) delete process.env.OPENCHAMBER_DATA_DIR; + else process.env.OPENCHAMBER_DATA_DIR = previousQuotaDataDirectory; + fs.rmSync(temporaryQuotaDataDirectory, { recursive: true, force: true }); +}); + const mockResponse = (body: unknown, init: MockResponseInit = {}): Response => ({ ok: 'ok' in init ? init.ok! : true, status: init.status ?? 200, @@ -69,6 +82,26 @@ const stubFetchFailing = (json: () => Promise, init: MockResponseInit): globalThis.fetch = (async () => ({ json, ...init }) as unknown as Response) as typeof fetch; }; +describe('OpenCode Go quota provider (VS Code parity)', () => { + test('uses the opencode-go key from auth.json', async () => { + let request: RequestInit | undefined; + const legacyPath = path.join(temporaryQuotaDataDirectory, 'quota', 'opencode-go.json'); + fs.mkdirSync(path.dirname(legacyPath), { recursive: true }); + fs.writeFileSync(legacyPath, '{not valid json'); + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + request = init; + return mockResponse({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } }); + }) as typeof fetch; + + const result = await fetchQuotaForProvider('opencode-go'); + + assert.equal(result.ok, true); + assert.equal((request?.headers as Record).Authorization, 'Bearer test-token'); + assert.equal(result.usage!.windows['5h']!.usedPercent, 25); + assert.throws(() => fs.statSync(legacyPath)); + }); +}); + describe('Crof quota provider (VS Code parity)', () => { test('reports credits balance as valueLabel with null percent', async () => { stubFetchReturning(() => Promise.resolve(mockResponse({ usable_requests: 450, credits: 12.3456 }))); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 7aa3bd73..32c54441 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; -import { readCredential } from './quotaCredentials'; +import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials'; import { getProviderAuth, updateProviderAuth } from './opencodeAuth'; type AuthEntry = Record | string; @@ -746,7 +746,8 @@ export const listConfiguredQuotaProviders = () => { // Managed credentials remain enumerable; unreadable auth cannot establish xAI configuration. } const configured = new Set(); - if (readCredential('opencode-go')) configured.add('opencode-go'); + const openCodeGoAuth = normalizeAuthEntry(getAuthEntry(auth, ['opencode-go'])); + 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'); @@ -2735,10 +2736,12 @@ export const fetchQuotaForProvider = async (providerId: string): Promise typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : ''; export const normalizers = { - 'opencode-go': (value) => { - const workspaceId = clean(value?.workspaceId); - let authCookie = clean(value?.authCookie); - if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim(); - return workspaceId && authCookie ? { workspaceId, authCookie } : null; - }, 'ollama-cloud': (value) => { const cookie = clean(value?.cookie); return cookie ? { cookie } : null; @@ -35,7 +29,6 @@ export const writeManagedCredential = (providerId, value) => { export const getManagedCredentialStatus = (providerId) => { const credential = readManagedCredential(providerId); if (!credential) return { configured: false }; - if (providerId === 'opencode-go') return { configured: true, workspaceId: credential.workspaceId, secretMasked: '••••••••' }; if (providerId === 'cursor') return { configured: true, hasRefreshToken: Boolean(credential.refreshToken), secretMasked: '••••••••' }; return { configured: true, secretMasked: '••••••••' }; }; diff --git a/packages/web/server/lib/quota/credentials/store.js b/packages/web/server/lib/quota/credentials/store.js index d8d30b29..a83b2008 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(['opencode-go', 'ollama-cloud', 'cursor']); +const MANAGED_QUOTA_PROVIDERS = new Set(['ollama-cloud', 'cursor']); const credentialsDirectory = () => path.join( process.env.OPENCHAMBER_DATA_DIR @@ -46,3 +46,13 @@ export const deleteQuotaCredential = (providerId) => { if (error?.code !== 'ENOENT') throw error; } }; + +// OpenCode Go used to store a browser auth cookie here. Its usage API now uses +// OpenCode's auth.json API key, so remove the obsolete secret without reading it. +export const deleteLegacyOpenCodeGoCredential = () => { + try { + fs.unlinkSync(path.join(credentialsDirectory(), 'opencode-go.json')); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +}; diff --git a/packages/web/server/lib/quota/credentials/store.test.js b/packages/web/server/lib/quota/credentials/store.test.js index c40ca8c4..6ad09d25 100644 --- a/packages/web/server/lib/quota/credentials/store.test.js +++ b/packages/web/server/lib/quota/credentials/store.test.js @@ -2,7 +2,7 @@ import { afterAll, describe, expect, it } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from './store.js'; +import { deleteLegacyOpenCodeGoCredential, deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from './store.js'; const previousDataDir = process.env.OPENCHAMBER_DATA_DIR; const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-quota-store-')); @@ -17,6 +17,14 @@ describe('quota credential store', () => { expect(() => writeQuotaCredential('../escape', {})).toThrow('Unsupported credential provider'); deleteQuotaCredential('ollama-cloud'); }); + + it('removes the obsolete OpenCode Go credential without parsing it', () => { + const legacyPath = path.join(temporaryDirectory, 'quota', 'opencode-go.json'); + fs.mkdirSync(path.dirname(legacyPath), { recursive: true }); + fs.writeFileSync(legacyPath, '{not valid json', { mode: 0o600 }); + deleteLegacyOpenCodeGoCredential(); + expect(fs.existsSync(legacyPath)).toBe(false); + }); }); afterAll(() => { diff --git a/packages/web/server/lib/quota/opencode-go-credentials.js b/packages/web/server/lib/quota/opencode-go-credentials.js deleted file mode 100644 index d2ff5b48..00000000 --- a/packages/web/server/lib/quota/opencode-go-credentials.js +++ /dev/null @@ -1,11 +0,0 @@ -import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js'; - -export const normalizeOpenCodeGoCredential = normalizers['opencode-go']; - -export const readOpenCodeGoCredential = () => readManagedCredential('opencode-go'); - -export const getOpenCodeGoCredentialStatus = () => getManagedCredentialStatus('opencode-go'); - -export const writeOpenCodeGoCredential = (value) => writeManagedCredential('opencode-go', value); - -export const deleteOpenCodeGoCredential = () => deleteManagedCredential('opencode-go'); diff --git a/packages/web/server/lib/quota/opencode-go-credentials.test.js b/packages/web/server/lib/quota/opencode-go-credentials.test.js deleted file mode 100644 index 59a3ae75..00000000 --- a/packages/web/server/lib/quota/opencode-go-credentials.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import { afterAll, afterEach, describe, expect, it } from 'bun:test'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { deleteOpenCodeGoCredential, getOpenCodeGoCredentialStatus, readOpenCodeGoCredential, writeOpenCodeGoCredential } from './opencode-go-credentials.js'; - -const previousDataDir = process.env.OPENCHAMBER_DATA_DIR; -const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-go-')); -process.env.OPENCHAMBER_DATA_DIR = temporaryDirectory; - -afterEach(() => deleteOpenCodeGoCredential()); - -describe('OpenCode Go credential store', () => { - it('normalizes, masks, and stores credentials with owner-only permissions', () => { - const status = writeOpenCodeGoCredential({ workspaceId: ' wrk_test ', authCookie: ' auth=secret ' }); - expect(status).toEqual({ configured: true, workspaceId: 'wrk_test', secretMasked: '••••••••' }); - expect(readOpenCodeGoCredential()).toEqual({ workspaceId: 'wrk_test', authCookie: 'secret' }); - expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'opencode-go.json')).mode & 0o777).toBe(0o600); - }); - - it('removes credentials without exposing prior values', () => { - writeOpenCodeGoCredential({ workspaceId: 'wrk_test', authCookie: 'secret' }); - deleteOpenCodeGoCredential(); - expect(getOpenCodeGoCredentialStatus()).toEqual({ configured: false }); - }); -}); - -afterAll(() => { - if (previousDataDir === undefined) delete process.env.OPENCHAMBER_DATA_DIR; - else process.env.OPENCHAMBER_DATA_DIR = previousDataDir; - fs.rmSync(temporaryDirectory, { recursive: true, force: true }); -}); diff --git a/packages/web/server/lib/quota/providers/opencode-go.js b/packages/web/server/lib/quota/providers/opencode-go.js index 0fb6263e..2642cf26 100644 --- a/packages/web/server/lib/quota/providers/opencode-go.js +++ b/packages/web/server/lib/quota/providers/opencode-go.js @@ -1,68 +1,68 @@ -import { readOpenCodeGoCredential } from '../opencode-go-credentials.js'; -import { buildResult, toUsageWindow } from '../utils/index.js'; +import { readAuthFile } from '../../opencode/auth.js'; +import { deleteLegacyOpenCodeGoCredential } from '../credentials/store.js'; +import { buildResult, getAuthEntry, normalizeAuthEntry, toUsageWindow } from '../utils/index.js'; export const providerId = 'opencode-go'; export const providerName = 'OpenCode Go'; export const aliases = ['opencode-go']; -const patterns = { - '5h': 'rollingUsage', - weekly: 'weeklyUsage', - monthly: 'monthlyUsage', +const windowsByApiKey = { + '5h': 'rolling', + weekly: 'weekly', + monthly: 'monthly', }; -const captureNumber = (name, body) => { - const match = body.match(new RegExp(`["']?${name}["']?\\s*:\\s*["']?(-?\\d+(?:\\.\\d+)?)`)); - const value = match ? Number(match[1]) : null; - return Number.isFinite(value) ? value : null; -}; - -export const parseOpenCodeGoUsage = (html, now = Date.now()) => { - if (typeof html !== 'string') return {}; - const normalized = html.replaceAll('"', '"').replaceAll('"', '"').replaceAll('\\u0022', '"').replaceAll('\\"', '"'); +export const parseOpenCodeGoUsage = (payload) => { + const usage = payload && typeof payload === 'object' ? payload.usage : null; + if (!usage || typeof usage !== 'object') return {}; const windows = {}; - for (const [key, field] of Object.entries(patterns)) { - const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const match = normalized.match(new RegExp(`["']?${escaped}["']?\\s*:\\s*(?:\\$R\\[\\d+\\]\\s*=\\s*)?\\{([^{}]*)\\}`, 's')); - if (!match) continue; - const usedPercent = captureNumber('usagePercent', match[1]); - const resetInSec = captureNumber('resetInSec', match[1]); - if (usedPercent === null || resetInSec === null) continue; + for (const [key, apiKey] of Object.entries(windowsByApiKey)) { + const entry = usage[apiKey]; + if (!entry || typeof entry !== 'object') continue; + const usedPercent = entry.percent; + const resetAt = entry.resetsAt; + if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) continue; + if (typeof resetAt !== 'string' || !Number.isFinite(new Date(resetAt).getTime())) continue; windows[key] = toUsageWindow({ usedPercent: Math.min(100, Math.max(0, usedPercent)), - resetAt: now + Math.max(0, resetInSec) * 1000, + resetAt, windowSeconds: null, }); } return windows; }; -export const fetchOpenCodeGoUsage = async (credential, fetchImpl = fetch) => { - const response = await fetchImpl(`https://opencode.ai/workspace/${encodeURIComponent(credential.workspaceId)}/go`, { +export const fetchOpenCodeGoUsage = async (apiKey, fetchImpl = fetch) => { + const response = await fetchImpl('https://opencode.ai/zen/go/v1/usage', { headers: { - Accept: 'text/html,application/xhtml+xml', - Cookie: `auth=${credential.authCookie}`, + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, 'User-Agent': 'OpenChamber quota provider', }, - redirect: 'manual', signal: AbortSignal.timeout(15_000), }); - if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) { + if (response.status === 401 || response.status === 403) { throw new Error('OpenCode Go authentication failed'); } - if (!response.ok) throw new Error(`OpenCode Go dashboard returned HTTP ${response.status}`); - const windows = parseOpenCodeGoUsage(await response.text()); + if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`); + const windows = parseOpenCodeGoUsage(await response.json().catch(() => null)); if (Object.keys(windows).length === 0) throw new Error('OpenCode Go usage data could not be parsed'); return windows; }; -export const isConfigured = () => Boolean(readOpenCodeGoCredential()); +const getApiKey = () => { + const entry = normalizeAuthEntry(getAuthEntry(readAuthFile(), aliases)); + return entry?.key ?? entry?.token ?? null; +}; + +export const isConfigured = () => Boolean(getApiKey()); export const fetchQuota = async () => { - const credential = readOpenCodeGoCredential(); - if (!credential) return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' }); try { - const windows = await fetchOpenCodeGoUsage(credential); + deleteLegacyOpenCodeGoCredential(); + const apiKey = getApiKey(); + if (!apiKey) return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' }); + const windows = await fetchOpenCodeGoUsage(apiKey); 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/opencode-go.test.js b/packages/web/server/lib/quota/providers/opencode-go.test.js index f3dbe739..1bae1eb0 100644 --- a/packages/web/server/lib/quota/providers/opencode-go.test.js +++ b/packages/web/server/lib/quota/providers/opencode-go.test.js @@ -1,17 +1,62 @@ -import { describe, expect, it } from 'bun:test'; -import { fetchOpenCodeGoUsage, parseOpenCodeGoUsage } from './opencode-go.js'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; + +const previousDataDirectory = process.env.OPENCHAMBER_DATA_DIR; +const temporaryDataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-opencode-go-')); +process.env.OPENCHAMBER_DATA_DIR = temporaryDataDirectory; + +vi.mock('../../opencode/auth.js', () => ({ + readAuthFile: () => ({ 'opencode-go': { key: 'test-key' } }), +})); + +import { fetchOpenCodeGoUsage, fetchQuota, parseOpenCodeGoUsage } from './opencode-go.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +afterAll(() => { + if (previousDataDirectory === undefined) delete process.env.OPENCHAMBER_DATA_DIR; + else process.env.OPENCHAMBER_DATA_DIR = previousDataDirectory; + fs.rmSync(temporaryDataDirectory, { recursive: true, force: true }); +}); describe('OpenCode Go quota provider', () => { - it('parses partial SSR usage windows in either field order', () => { - const windows = parseOpenCodeGoUsage('rollingUsage:$R[1]={usagePercent:25,resetInSec:60} weeklyUsage:$R[2]={resetInSec:120,usagePercent:40}', 1_000); + it('parses partial API usage windows', () => { + const windows = parseOpenCodeGoUsage({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' }, weekly: { percent: 40, resetsAt: '2026-08-19T12:00:00.000Z' } } }); expect(windows['5h'].usedPercent).toBe(25); - expect(windows['5h'].resetAt).toBe(61_000); + expect(windows['5h'].resetAt).toBe('2026-08-12T12:00:00.000Z'); expect(windows.weekly.usedPercent).toBe(40); expect(windows.monthly).toBeUndefined(); }); it('does not expose credentials in authentication errors', async () => { - const credential = { workspaceId: 'wrk_test', authCookie: 'secret' }; - await expect(fetchOpenCodeGoUsage(credential, async () => new Response('', { status: 403 }))).rejects.toThrow('authentication failed'); + await expect(fetchOpenCodeGoUsage('secret', async () => new Response('', { status: 403 }))).rejects.toThrow('authentication failed'); + }); + + it('uses the Go usage API with bearer authentication', async () => { + let request; + const usage = await fetchOpenCodeGoUsage('secret', async (url, options) => { + request = { url, options }; + return new Response(JSON.stringify({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } })); + }); + expect(request.url).toBe('https://opencode.ai/zen/go/v1/usage'); + expect(request.options.headers).toMatchObject({ Accept: 'application/json', Authorization: 'Bearer secret' }); + expect(request.options.headers.Cookie).toBeUndefined(); + expect(usage['5h'].usedPercent).toBe(25); + }); + + it('reads the API key from the OpenCode auth file', async () => { + const legacyPath = path.join(temporaryDataDirectory, 'quota', 'opencode-go.json'); + fs.mkdirSync(path.dirname(legacyPath), { recursive: true }); + fs.writeFileSync(legacyPath, '{not valid json', { mode: 0o600 }); + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } }))); + vi.stubGlobal('fetch', fetchMock); + const result = await fetchQuota(); + expect(result).toMatchObject({ providerId: 'opencode-go', ok: true, configured: true }); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer test-key'); + expect(fs.existsSync(legacyPath)).toBe(false); }); }); diff --git a/packages/web/server/lib/quota/routes.js b/packages/web/server/lib/quota/routes.js index 8e37438e..570fb253 100644 --- a/packages/web/server/lib/quota/routes.js +++ b/packages/web/server/lib/quota/routes.js @@ -1,11 +1,9 @@ import express from 'express'; import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js'; -import { fetchOpenCodeGoUsage } from './providers/opencode-go.js'; import { fetchOllamaCloudUsage } from './providers/ollama-cloud.js'; import { importCursorCredential, validateCursorCredential } from './providers/cursor.js'; const validators = { - 'opencode-go': fetchOpenCodeGoUsage, 'ollama-cloud': fetchOllamaCloudUsage, cursor: validateCursorCredential, }; diff --git a/packages/web/server/lib/quota/routes.test.js b/packages/web/server/lib/quota/routes.test.js deleted file mode 100644 index c46b3f0e..00000000 --- a/packages/web/server/lib/quota/routes.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import { afterAll, describe, expect, it, mock } from 'bun:test'; -import express from 'express'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { registerQuotaRoutes } from './routes.js'; - -const previousDataDir = process.env.OPENCHAMBER_DATA_DIR; -const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-go-routes-')); -process.env.OPENCHAMBER_DATA_DIR = temporaryDirectory; - -afterAll(() => { - if (previousDataDir === undefined) delete process.env.OPENCHAMBER_DATA_DIR; - else process.env.OPENCHAMBER_DATA_DIR = previousDataDir; - fs.rmSync(temporaryDirectory, { recursive: true, force: true }); -}); - -describe('OpenCode Go credential routes', () => { - it('parses a JSON credential payload before validation', async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = mock(async () => new Response('rollingUsage:$R[1]={usagePercent:25,resetInSec:60}')); - const app = express(); - registerQuotaRoutes(app, { getQuotaProviders: async () => ({}) }); - const server = app.listen(0); - try { - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('Test server did not start'); - const response = await originalFetch(`http://127.0.0.1:${address.port}/api/quota/credentials/opencode-go`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId: 'wrk_test', authCookie: 'auth=secret' }), - }); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ configured: true, workspaceId: 'wrk_test', secretMasked: '••••••••' }); - } finally { - globalThis.fetch = originalFetch; - server.close(); - } - }); -});