feat: switch OpenCode Go usage to API
OpenCode Go now reads quota usage with a bearer API key from OpenCode auth.json Removes the old workspace ID and browser cookie credential flow Deletes legacy OpenCode Go credential files during upgrade
This commit is contained in:
@@ -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<string, string>);
|
||||
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) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type={name === 'workspaceId' ? 'text' : 'password'} autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
|
||||
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>;
|
||||
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 === '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'))}
|
||||
|
||||
@@ -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 = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedProviderId === 'opencode-go' || selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
|
||||
{(selectedProviderId === 'ollama-cloud' || selectedProviderId === 'cursor') && (
|
||||
<QuotaCredentials providerId={selectedProviderId} providerName={providerName} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '置換',
|
||||
|
||||
@@ -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': '교체',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Замінити',
|
||||
|
||||
@@ -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': '替换',
|
||||
|
||||
@@ -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': '取代',
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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<string, { percent?: unknown; resetsAt?: unknown }> } | null;
|
||||
const windows: Record<string, ReturnType<typeof toWindow>> = {};
|
||||
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;
|
||||
|
||||
@@ -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<string, string>;
|
||||
const providers = new Set<ManagedProvider>(['opencode-go', 'ollama-cloud', 'cursor']);
|
||||
const providers = new Set<ManagedProvider>(['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<string, unknown> : {};
|
||||
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');
|
||||
|
||||
@@ -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<unknown>, 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<string, string>).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 })));
|
||||
|
||||
@@ -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, unknown> | string;
|
||||
@@ -746,7 +746,8 @@ export const listConfiguredQuotaProviders = () => {
|
||||
// Managed credentials remain enumerable; unreadable auth cannot establish xAI configuration.
|
||||
}
|
||||
const configured = new Set<string>();
|
||||
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<Provide
|
||||
case 'wafer':
|
||||
return fetchWaferQuota();
|
||||
case 'opencode-go': {
|
||||
const credential = readCredential('opencode-go') as { workspaceId: string; authCookie: string } | null;
|
||||
if (!credential) return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: false, error: 'Not configured' });
|
||||
try {
|
||||
return buildResult({ providerId, providerName: 'OpenCode Go', ok: true, configured: true, usage: { windows: await fetchOpenCodeGoUsage(credential) } });
|
||||
deleteLegacyOpenCodeGoCredential();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(readAuthFile(), ['opencode-go']));
|
||||
const apiKey = typeof entry?.key === 'string' ? entry.key : typeof entry?.token === 'string' ? entry.token : null;
|
||||
if (!apiKey) return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: false, error: 'Not configured' });
|
||||
return buildResult({ providerId, providerName: 'OpenCode Go', ok: true, configured: true, usage: { windows: await fetchOpenCodeGoUsage({ apiKey }) } });
|
||||
} catch (error) {
|
||||
return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
|
||||
}
|
||||
|
||||
@@ -1064,7 +1064,7 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
|
||||
}
|
||||
}
|
||||
|
||||
const quotaCredentialMatch = pathname.match(/^\/api\/quota\/credentials\/(opencode-go|ollama-cloud|cursor)(?:\/(validate|import))?$/);
|
||||
const quotaCredentialMatch = pathname.match(/^\/api\/quota\/credentials\/(ollama-cloud|cursor)(?:\/(validate|import))?$/);
|
||||
if (quotaCredentialMatch) {
|
||||
try {
|
||||
const body = method === 'PUT' ? await extractJsonBody(input, init, method) : undefined;
|
||||
|
||||
@@ -33,7 +33,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` / `providers/minimax-shared.js` | `minimax-cn-coding-plan` |
|
||||
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Manual cookie stored under `~/.config/openchamber/quota/` |
|
||||
| `wafer` | Wafer.ai | `providers/wafer.js` | `wafer`, `wafer-ai`, `wafer_ai`, `wafer.ai` |
|
||||
| `opencode-go` | OpenCode Go | `providers/opencode-go.js` | Manual workspace ID and auth cookie stored under `~/.config/openchamber/quota/` |
|
||||
| `opencode-go` | OpenCode Go | `providers/opencode-go.js` | `opencode-go` API key from OpenCode `auth.json` |
|
||||
| `neuralwatt` | NeuralWatt | `providers/neuralwatt.js` | `neuralwatt` (API key under `key` or `token`) |
|
||||
| `xai` | xAI | `providers/xai.js` | `xai` OAuth entry in OpenCode `auth.json` |
|
||||
|
||||
@@ -49,7 +49,9 @@ 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.
|
||||
|
||||
OpenCode Go, Ollama Cloud, and Cursor credentials are explicitly managed through Settings. The server validates 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.
|
||||
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.
|
||||
|
||||
On the first OpenCode Go usage refresh after upgrading, OpenChamber deletes the obsolete `quota/opencode-go.json` credential file without reading its cookie value.
|
||||
|
||||
## Add a new provider (quick steps)
|
||||
1. Choose module shape based on complexity:
|
||||
|
||||
@@ -3,12 +3,6 @@ import { deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from
|
||||
const clean = (value) => 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: '••••••••' };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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');
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user