feat(quota): add OpenCode Go usage tracking (#2155)

* feat(quota): add OpenCode Go usage tracking

* fix(quota): align OpenCode Go VS Code parsing
This commit is contained in:
Bohdan Triapitsyn
2026-07-12 14:48:50 +03:00
committed by GitHub
parent b4f50e0a01
commit 3d90eddcaf
26 changed files with 658 additions and 1 deletions
@@ -0,0 +1,110 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
interface CredentialStatus {
configured: boolean;
workspaceId?: string;
authCookieMasked?: string;
}
export const OpenCodeGoCredentials: React.FC = () => {
const { t } = useI18n();
const [status, setStatus] = React.useState<CredentialStatus | null>(null);
const [workspaceId, setWorkspaceId] = React.useState('');
const [authCookie, setAuthCookie] = React.useState('');
const [busy, setBusy] = React.useState<'save' | 'validate' | 'delete' | null>(null);
const workspaceInputRef = React.useRef<HTMLInputElement>(null);
const cookieInputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
const loadStatus = async () => {
try {
const response = await runtimeFetch('/api/quota/credentials/opencode-go');
if (!response.ok) throw new Error('load failed');
const next = await response.json() as CredentialStatus;
setStatus(next);
setWorkspaceId(next.workspaceId ?? '');
} catch {
setStatus({ configured: false });
}
};
void loadStatus();
}, []);
const save = async () => {
const submittedWorkspaceId = workspaceInputRef.current?.value.trim() ?? workspaceId.trim();
const submittedAuthCookie = cookieInputRef.current?.value.trim() ?? authCookie.trim();
setBusy('save');
try {
const response = await runtimeFetch('/api/quota/credentials/opencode-go', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId: submittedWorkspaceId, authCookie: submittedAuthCookie }),
});
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error);
setStatus(payload);
setAuthCookie('');
toast.success(t('settings.providers.page.openCodeGo.saved'));
} catch (error) {
const message = error instanceof Error ? error.message : '';
toast.error(message || t('settings.providers.page.openCodeGo.saveFailed'));
} finally { setBusy(null); }
};
const validate = async () => {
setBusy('validate');
try {
const response = await runtimeFetch('/api/quota/credentials/opencode-go/validate', { method: 'POST' });
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || t('settings.providers.page.openCodeGo.invalid'));
toast.success(t('settings.providers.page.openCodeGo.valid'));
} catch (error) {
const message = error instanceof Error ? error.message : '';
toast.error(message || t('settings.providers.page.openCodeGo.invalid'));
} finally { setBusy(null); }
};
const remove = async () => {
setBusy('delete');
try {
const response = await runtimeFetch('/api/quota/credentials/opencode-go', { method: 'DELETE' });
if (!response.ok) throw new Error('delete failed');
setStatus({ configured: false });
setWorkspaceId('');
setAuthCookie('');
toast.success(t('settings.providers.page.openCodeGo.deleted'));
} catch {
toast.error(t('settings.providers.page.openCodeGo.deleteFailed'));
} finally { setBusy(null); }
};
return (
<div data-settings-item="providers.opencode-go-credentials" className="mb-8">
<div className="mb-1 px-1">
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.openCodeGo.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.openCodeGo.description')}</p>
</div>
<section className="space-y-3 px-2 pb-2 pt-0">
<label className="block typography-ui-label text-foreground">
{t('settings.providers.page.openCodeGo.workspaceId')}
<Input ref={workspaceInputRef} className="mt-1 h-7 font-mono text-xs" value={workspaceId} onChange={(event) => setWorkspaceId(event.target.value)} placeholder="wrk_..." />
</label>
<label className="block typography-ui-label text-foreground">
{t('settings.providers.page.openCodeGo.authCookie')}
<Input ref={cookieInputRef} className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={authCookie} onChange={(event) => setAuthCookie(event.target.value)} placeholder={status?.authCookieMasked ?? 'auth=...'} />
</label>
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.openCodeGo.help')}</p>
<div className="flex flex-wrap gap-2">
<Button size="xs" onClick={save} disabled={Boolean(busy)}>{status?.configured ? t('settings.providers.page.openCodeGo.replace') : t('settings.providers.page.openCodeGo.save')}</Button>
{status?.configured && <Button variant="outline" size="xs" onClick={validate} disabled={Boolean(busy)}>{t('settings.providers.page.openCodeGo.validate')}</Button>}
{status?.configured && <Button variant="destructive" size="xs" onClick={remove} disabled={Boolean(busy)}>{t('settings.providers.page.openCodeGo.delete')}</Button>}
</div>
</section>
</div>
);
};
@@ -24,6 +24,7 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { opencodeClient } from '@/lib/opencode/client';
import { shouldLoadAvailableProviders } from './providerAvailability';
import { OpenCodeGoCredentials } from './OpenCodeGoCredentials';
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
notation: 'compact',
@@ -946,6 +947,8 @@ export const ProvidersPage: React.FC = () => {
</section>
</div>
{(selectedProvider.id === 'opencode' || selectedProvider.id === 'opencode-go') && <OpenCodeGoCredentials />}
{/* Connection Details */}
<div data-settings-item="providers.connection-details" className="mb-8">
<div className="mb-1 px-1">
@@ -1,4 +1,19 @@
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go usage tracking',
'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.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',
'settings.providers.page.openCodeGo.validate': 'Validate',
'settings.providers.page.openCodeGo.delete': 'Delete',
'settings.providers.page.openCodeGo.saved': 'OpenCode Go credentials saved.',
'settings.providers.page.openCodeGo.saveFailed': 'Could not validate OpenCode Go credentials.',
'settings.providers.page.openCodeGo.valid': 'OpenCode Go credentials are valid.',
'settings.providers.page.openCodeGo.invalid': 'OpenCode Go credentials are invalid or expired.',
'settings.providers.page.openCodeGo.deleted': 'OpenCode Go credentials deleted.',
'settings.providers.page.openCodeGo.deleteFailed': 'Could not delete OpenCode Go credentials.',
'settings.appearance.language.label': 'Language',
'settings.appearance.language.description': 'Choose the interface language.',
'settings.appearance.language.select': 'Select language',
@@ -1,4 +1,19 @@
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Seguimiento de uso de OpenCode Go',
'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.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',
'settings.providers.page.openCodeGo.validate': 'Validar',
'settings.providers.page.openCodeGo.delete': 'Eliminar',
'settings.providers.page.openCodeGo.saved': 'Credenciales de OpenCode Go guardadas.',
'settings.providers.page.openCodeGo.saveFailed': 'No se pudieron validar las credenciales de OpenCode Go.',
'settings.providers.page.openCodeGo.valid': 'Las credenciales de OpenCode Go son válidas.',
'settings.providers.page.openCodeGo.invalid': 'Las credenciales de OpenCode Go no son válidas o han caducado.',
'settings.providers.page.openCodeGo.deleted': 'Credenciales de OpenCode Go eliminadas.',
'settings.providers.page.openCodeGo.deleteFailed': 'No se pudieron eliminar las credenciales de OpenCode Go.',
"settings.appearance.language.label": "Idioma",
"settings.appearance.language.description": "Elige el idioma de la interfaz.",
"settings.appearance.language.select": "Seleccionar idioma",
@@ -1,4 +1,19 @@
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Suivi de lutilisation dOpenCode Go',
'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 lespace de travail',
'settings.providers.page.openCodeGo.authCookie': 'Cookie dauthentification',
'settings.providers.page.openCodeGo.help': 'Copiez lID depuis lURL du tableau de bord et le cookie auth depuis les outils du navigateur. OpenChamber nanalyse jamais les cookies du navigateur.',
'settings.providers.page.openCodeGo.save': 'Enregistrer et valider',
'settings.providers.page.openCodeGo.replace': 'Remplacer',
'settings.providers.page.openCodeGo.validate': 'Valider',
'settings.providers.page.openCodeGo.delete': 'Supprimer',
'settings.providers.page.openCodeGo.saved': 'Identifiants OpenCode Go enregistrés.',
'settings.providers.page.openCodeGo.saveFailed': 'Impossible de valider les identifiants OpenCode Go.',
'settings.providers.page.openCodeGo.valid': 'Les identifiants OpenCode Go sont valides.',
'settings.providers.page.openCodeGo.invalid': 'Les identifiants OpenCode Go sont invalides ou expirés.',
'settings.providers.page.openCodeGo.deleted': 'Identifiants OpenCode Go supprimés.',
'settings.providers.page.openCodeGo.deleteFailed': 'Impossible de supprimer les identifiants OpenCode Go.',
'settings.appearance.language.label': 'Langue',
'settings.appearance.language.description': 'Choisissez la langue de l\'interface.',
'settings.appearance.language.select': 'Sélectionnez la langue',
@@ -1,4 +1,19 @@
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 使用量追跡',
'settings.providers.page.openCodeGo.description': 'OpenCode Go ダッシュボードを接続して、ローリング、週間、月間のクォータを表示します。',
'settings.providers.page.openCodeGo.workspaceId': 'ワークスペース ID',
'settings.providers.page.openCodeGo.authCookie': '認証 Cookie',
'settings.providers.page.openCodeGo.help': 'ダッシュボード URL からワークスペース ID を、ブラウザーの開発者ツールから auth Cookie をコピーしてください。OpenChamber がブラウザーの Cookie を走査することはありません。',
'settings.providers.page.openCodeGo.save': '保存して検証',
'settings.providers.page.openCodeGo.replace': '置換',
'settings.providers.page.openCodeGo.validate': '検証',
'settings.providers.page.openCodeGo.delete': '削除',
'settings.providers.page.openCodeGo.saved': 'OpenCode Go の認証情報を保存しました。',
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go の認証情報を検証できませんでした。',
'settings.providers.page.openCodeGo.valid': 'OpenCode Go の認証情報は有効です。',
'settings.providers.page.openCodeGo.invalid': 'OpenCode Go の認証情報が無効または期限切れです。',
'settings.providers.page.openCodeGo.deleted': 'OpenCode Go の認証情報を削除しました。',
'settings.providers.page.openCodeGo.deleteFailed': 'OpenCode Go の認証情報を削除できませんでした。',
'settings.appearance.language.label': '言語',
'settings.appearance.language.description': 'インターフェース言語を選択します。',
'settings.appearance.language.select': '言語を選択',
@@ -1,4 +1,19 @@
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 사용량 추적',
'settings.providers.page.openCodeGo.description': 'OpenCode Go 대시보드를 연결하여 롤링, 주간 및 월간 할당량을 표시합니다.',
'settings.providers.page.openCodeGo.workspaceId': '워크스페이스 ID',
'settings.providers.page.openCodeGo.authCookie': '인증 쿠키',
'settings.providers.page.openCodeGo.help': '대시보드 URL에서 워크스페이스 ID를, 브라우저 개발자 도구에서 auth 쿠키를 복사하세요. OpenChamber는 브라우저 쿠키 저장소를 검색하지 않습니다.',
'settings.providers.page.openCodeGo.save': '저장 및 검증',
'settings.providers.page.openCodeGo.replace': '교체',
'settings.providers.page.openCodeGo.validate': '검증',
'settings.providers.page.openCodeGo.delete': '삭제',
'settings.providers.page.openCodeGo.saved': 'OpenCode Go 인증 정보를 저장했습니다.',
'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go 인증 정보를 검증할 수 없습니다.',
'settings.providers.page.openCodeGo.valid': 'OpenCode Go 인증 정보가 유효합니다.',
'settings.providers.page.openCodeGo.invalid': 'OpenCode Go 인증 정보가 유효하지 않거나 만료되었습니다.',
'settings.providers.page.openCodeGo.deleted': 'OpenCode Go 인증 정보를 삭제했습니다.',
'settings.providers.page.openCodeGo.deleteFailed': 'OpenCode Go 인증 정보를 삭제할 수 없습니다.',
'settings.appearance.language.label': '언어',
'settings.appearance.language.description': '인터페이스 언어를 선택하세요.',
'settings.appearance.language.select': '언어 선택',
@@ -1,4 +1,19 @@
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Śledzenie użycia OpenCode Go',
'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.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',
'settings.providers.page.openCodeGo.validate': 'Sprawdź',
'settings.providers.page.openCodeGo.delete': 'Usuń',
'settings.providers.page.openCodeGo.saved': 'Dane OpenCode Go zostały zapisane.',
'settings.providers.page.openCodeGo.saveFailed': 'Nie udało się sprawdzić danych OpenCode Go.',
'settings.providers.page.openCodeGo.valid': 'Dane OpenCode Go są prawidłowe.',
'settings.providers.page.openCodeGo.invalid': 'Dane OpenCode Go są nieprawidłowe lub wygasły.',
'settings.providers.page.openCodeGo.deleted': 'Dane OpenCode Go zostały usunięte.',
'settings.providers.page.openCodeGo.deleteFailed': 'Nie udało się usunąć danych OpenCode Go.',
'settings.appearance.language.label': 'Język',
'settings.appearance.language.description': 'Wybierz język interfejsu.',
'settings.agents.modelSelector.actions.addToFavorites': 'Dodaj do ulubionych',
@@ -1,4 +1,19 @@
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Monitoramento de uso do OpenCode Go',
'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.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',
'settings.providers.page.openCodeGo.validate': 'Validar',
'settings.providers.page.openCodeGo.delete': 'Excluir',
'settings.providers.page.openCodeGo.saved': 'Credenciais do OpenCode Go salvas.',
'settings.providers.page.openCodeGo.saveFailed': 'Não foi possível validar as credenciais do OpenCode Go.',
'settings.providers.page.openCodeGo.valid': 'As credenciais do OpenCode Go são válidas.',
'settings.providers.page.openCodeGo.invalid': 'As credenciais do OpenCode Go são inválidas ou expiraram.',
'settings.providers.page.openCodeGo.deleted': 'Credenciais do OpenCode Go excluídas.',
'settings.providers.page.openCodeGo.deleteFailed': 'Não foi possível excluir as credenciais do OpenCode Go.',
"settings.appearance.language.label": "Idioma",
"settings.appearance.language.description": "Escolha o idioma da interface.",
"settings.appearance.language.select": "Selecionar idioma",
@@ -1,4 +1,19 @@
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Відстеження використання OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Підключіть панель OpenCode Go, щоб бачити ковзну, тижневу та місячну квоту.',
'settings.providers.page.openCodeGo.workspaceId': 'ID робочого простору',
'settings.providers.page.openCodeGo.authCookie': 'Cookie авторизації',
'settings.providers.page.openCodeGo.help': 'Скопіюйте ID робочого простору з URL панелі, а cookie auth з інструментів розробника браузера. OpenChamber ніколи не сканує сховище cookie браузера.',
'settings.providers.page.openCodeGo.save': 'Зберегти й перевірити',
'settings.providers.page.openCodeGo.replace': 'Замінити',
'settings.providers.page.openCodeGo.validate': 'Перевірити',
'settings.providers.page.openCodeGo.delete': 'Видалити',
'settings.providers.page.openCodeGo.saved': 'Дані OpenCode Go збережено.',
'settings.providers.page.openCodeGo.saveFailed': 'Не вдалося перевірити дані OpenCode Go.',
'settings.providers.page.openCodeGo.valid': 'Дані OpenCode Go дійсні.',
'settings.providers.page.openCodeGo.invalid': 'Дані OpenCode Go недійсні або протерміновані.',
'settings.providers.page.openCodeGo.deleted': 'Дані OpenCode Go видалено.',
'settings.providers.page.openCodeGo.deleteFailed': 'Не вдалося видалити дані OpenCode Go.',
"settings.appearance.language.label": "Мова",
"settings.appearance.language.description": "Виберіть мову інтерфейсу.",
"settings.appearance.language.select": "Виберіть мову",
@@ -1,4 +1,19 @@
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量跟踪',
'settings.providers.page.openCodeGo.description': '连接 OpenCode Go 控制面板以显示滚动、每周和每月配额。',
'settings.providers.page.openCodeGo.workspaceId': '工作区 ID',
'settings.providers.page.openCodeGo.authCookie': '身份验证 Cookie',
'settings.providers.page.openCodeGo.help': '从控制面板网址复制工作区 ID,并从浏览器开发者工具复制 auth Cookie。OpenChamber 绝不会扫描浏览器 Cookie 存储。',
'settings.providers.page.openCodeGo.save': '保存并验证',
'settings.providers.page.openCodeGo.replace': '替换',
'settings.providers.page.openCodeGo.validate': '验证',
'settings.providers.page.openCodeGo.delete': '删除',
'settings.providers.page.openCodeGo.saved': '已保存 OpenCode Go 凭据。',
'settings.providers.page.openCodeGo.saveFailed': '无法验证 OpenCode Go 凭据。',
'settings.providers.page.openCodeGo.valid': 'OpenCode Go 凭据有效。',
'settings.providers.page.openCodeGo.invalid': 'OpenCode Go 凭据无效或已过期。',
'settings.providers.page.openCodeGo.deleted': '已删除 OpenCode Go 凭据。',
'settings.providers.page.openCodeGo.deleteFailed': '无法删除 OpenCode Go 凭据。',
'settings.appearance.language.label': '语言',
'settings.appearance.language.description': '选择界面语言。',
'settings.appearance.language.select': '选择语言',
@@ -1,4 +1,19 @@
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量追蹤',
'settings.providers.page.openCodeGo.description': '連接 OpenCode Go 控制面板以顯示滾動、每週和每月配額。',
'settings.providers.page.openCodeGo.workspaceId': '工作區 ID',
'settings.providers.page.openCodeGo.authCookie': '驗證 Cookie',
'settings.providers.page.openCodeGo.help': '從控制面板網址複製工作區 ID,並從瀏覽器開發者工具複製 auth Cookie。OpenChamber 絕不會掃描瀏覽器 Cookie 儲存空間。',
'settings.providers.page.openCodeGo.save': '儲存並驗證',
'settings.providers.page.openCodeGo.replace': '取代',
'settings.providers.page.openCodeGo.validate': '驗證',
'settings.providers.page.openCodeGo.delete': '刪除',
'settings.providers.page.openCodeGo.saved': '已儲存 OpenCode Go 憑證。',
'settings.providers.page.openCodeGo.saveFailed': '無法驗證 OpenCode Go 憑證。',
'settings.providers.page.openCodeGo.valid': 'OpenCode Go 憑證有效。',
'settings.providers.page.openCodeGo.invalid': 'OpenCode Go 憑證無效或已過期。',
'settings.providers.page.openCodeGo.deleted': '已刪除 OpenCode Go 憑證。',
'settings.providers.page.openCodeGo.deleteFailed': '無法刪除 OpenCode Go 憑證。',
'settings.appearance.language.label': '語言',
'settings.appearance.language.description': '選擇介面語言。',
'settings.appearance.language.select': '選擇語言',
@@ -20,4 +20,5 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
{ id: 'minimax-coding-plan', name: 'MiniMax Coding Plan (minimax.io)' },
{ id: 'ollama-cloud', name: 'Ollama Cloud' },
{ id: 'wafer', name: 'Wafer.ai' },
{ id: 'opencode-go', name: 'OpenCode Go' },
];
+2 -1
View File
@@ -14,7 +14,8 @@ export type QuotaProviderId =
| 'minimax-coding-plan'
| 'minimax-cn-coding-plan'
| 'ollama-cloud'
| 'wafer';
| 'wafer'
| 'opencode-go';
export interface UsageWindow {
usedPercent: number | null;
@@ -6,6 +6,7 @@ import { randomUUID } from 'crypto';
import { removeProviderConfig, getProviderSources } from './opencodeConfig';
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
import { deleteOpenCodeGoCredential, fetchOpenCodeGoUsage, getOpenCodeGoCredentialStatus, normalizeOpenCodeGoCredential, readOpenCodeGoCredential, writeOpenCodeGoCredential } from './opencodeGoQuota';
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
import type { BridgeContext, BridgeResponse } from './bridge';
@@ -481,6 +482,29 @@ export async function handleSystemBridgeMessage(
}
}
case 'api:quota:opencode-go-credentials': {
const { method, credential: input } = (payload || {}) as { method?: string; credential?: unknown };
try {
if (method === 'GET') return { id, type, success: true, data: getOpenCodeGoCredentialStatus() };
if (method === 'DELETE') { deleteOpenCodeGoCredential(); return { id, type, success: true, data: { configured: false } }; }
if (method === 'PUT') {
const credential = normalizeOpenCodeGoCredential(input);
if (!credential) return { id, type, success: false, error: 'Workspace ID and auth cookie are required' };
await fetchOpenCodeGoUsage(credential);
return { id, type, success: true, data: writeOpenCodeGoCredential(credential) };
}
if (method === 'VALIDATE') {
const credential = readOpenCodeGoCredential();
if (!credential) return { id, type, success: false, error: 'Not configured' };
await fetchOpenCodeGoUsage(credential);
return { id, type, success: true, data: { valid: true } };
}
return { id, type, success: false, error: 'Unsupported method' };
} catch (error) {
return { id, type, success: false, error: error instanceof Error ? error.message : String(error) };
}
}
case 'api:quota:get': {
const { providerId } = (payload || {}) as { providerId?: string };
if (!providerId) {
+76
View File
@@ -0,0 +1,76 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
type OpenCodeGoCredential = { workspaceId: string; authCookie: string };
const targetPath = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota', 'opencode-go.json');
export const normalizeOpenCodeGoCredential = (value: unknown): OpenCodeGoCredential | null => {
if (!value || typeof value !== 'object') return null;
const data = value as Record<string, unknown>;
const workspaceId = typeof data.workspaceId === 'string' ? data.workspaceId.trim() : '';
let authCookie = typeof data.authCookie === 'string' ? data.authCookie.trim() : '';
if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim();
return workspaceId && authCookie && !/[\r\n]/.test(workspaceId + authCookie) ? { workspaceId, authCookie } : null;
};
export const readOpenCodeGoCredential = (): OpenCodeGoCredential | null => {
try {
return normalizeOpenCodeGoCredential(JSON.parse(fs.readFileSync(targetPath(), 'utf8')));
} catch (error) {
if ((error as { code?: string }).code !== 'ENOENT') {
console.warn('Failed to read OpenCode Go credentials');
}
return null;
}
};
export const getOpenCodeGoCredentialStatus = () => {
const value = readOpenCodeGoCredential();
return value ? { configured: true, workspaceId: value.workspaceId, authCookieMasked: '••••••••' } : { configured: false };
};
export const writeOpenCodeGoCredential = (value: OpenCodeGoCredential) => {
const target = targetPath();
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
try {
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
fs.chmodSync(temporary, 0o600);
fs.renameSync(temporary, target);
fs.chmodSync(target, 0o600);
} finally {
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
}
return getOpenCodeGoCredentialStatus();
};
export const deleteOpenCodeGoCredential = () => { try { fs.unlinkSync(targetPath()); } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error; } };
const toWindow = (usedPercent: number, resetInSec: number) => ({
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,
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}` }, signal: AbortSignal.timeout(15_000) });
if (response.status === 401 || response.status === 403 || (response.redirected && /\/auth(?:\/|$|\?)/.test(new URL(response.url).pathname))) 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('&quot;', '"').replaceAll('&#34;', '"').replaceAll('\\u0022', '"').replaceAll('\\"', '"');
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);
}
if (!Object.keys(windows).length) throw new Error('OpenCode Go usage data could not be parsed');
return windows;
};
+11
View File
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fetchOpenCodeGoUsage, readOpenCodeGoCredential } from './opencodeGoQuota';
type AuthEntry = Record<string, unknown> | string;
type AuthFile = Record<string, AuthEntry>;
@@ -388,6 +389,7 @@ const durationToSeconds = (duration?: number, unit?: string) => {
export const listConfiguredQuotaProviders = () => {
const auth = readAuthFile();
const configured = new Set<string>();
if (readOpenCodeGoCredential()) configured.add('opencode-go');
const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude']));
if (anthropicAuth && ((anthropicAuth as Record<string, unknown>).access || (anthropicAuth as Record<string, unknown>).token)) {
@@ -1892,6 +1894,15 @@ export const fetchQuotaForProvider = async (providerId: string): Promise<Provide
return fetchZhipuaiCodingPlanQuota();
case 'wafer':
return fetchWaferQuota();
case 'opencode-go': {
const credential = readOpenCodeGoCredential();
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) } });
} catch (error) {
return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
}
}
default:
return buildResult({
providerId,
+12
View File
@@ -1041,6 +1041,18 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
}
}
if (pathname === '/api/quota/credentials/opencode-go' || pathname === '/api/quota/credentials/opencode-go/validate') {
try {
const body = method === 'PUT' ? await extractJsonBody(input, init, method) : undefined;
const bridgeMethod = pathname.endsWith('/validate') ? 'VALIDATE' : method;
const data = await sendBridgeMessage('api:quota:opencode-go-credentials', { method: bridgeMethod, credential: body });
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
}
const quotaMatch = pathname.match(/^\/api\/quota\/([^/]+)$/);
if (quotaMatch && method === 'GET') {
const providerId = decodeURIComponent(quotaMatch[1]);
@@ -31,6 +31,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` | Cookie file at `~/.config/ollama-quota/cookie` (raw session cookie string) |
| `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/` |
## Internal-only provider module
- `providers/openai.js` exists for logic parity/reuse but is intentionally not registered for dispatcher ID routing.
@@ -44,6 +45,8 @@ 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 credentials are explicitly supplied by the user through Settings. OpenChamber never scans browser cookie stores. The server validates the credential before an atomic `0600` write and never returns the cookie through its API.
## Add a new provider (quick steps)
1. Choose module shape based on complexity:
- Simple providers: create `packages/web/server/lib/quota/providers/<provider>.js`.
@@ -0,0 +1,60 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const credentialsPath = () => path.join(
process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber'),
'quota',
'opencode-go.json',
);
export const normalizeOpenCodeGoCredential = (value) => {
const workspaceId = typeof value?.workspaceId === 'string' ? value.workspaceId.trim() : '';
let authCookie = typeof value?.authCookie === 'string' ? value.authCookie.trim() : '';
if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim();
if (!workspaceId || !authCookie || /[\r\n]/.test(workspaceId) || /[\r\n]/.test(authCookie)) {
return null;
}
return { workspaceId, authCookie };
};
export const readOpenCodeGoCredential = () => {
try {
const parsed = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
return normalizeOpenCodeGoCredential(parsed);
} catch (error) {
if (error?.code !== 'ENOENT') console.warn('Failed to read OpenCode Go credentials');
return null;
}
};
export const getOpenCodeGoCredentialStatus = () => {
const credential = readOpenCodeGoCredential();
return credential ? { configured: true, workspaceId: credential.workspaceId, authCookieMasked: '••••••••' } : { configured: false };
};
export const writeOpenCodeGoCredential = (value) => {
const credential = normalizeOpenCodeGoCredential(value);
if (!credential) throw new Error('Workspace ID and auth cookie are required');
const target = credentialsPath();
const directory = path.dirname(target);
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
try {
fs.writeFileSync(temporary, `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 });
fs.chmodSync(temporary, 0o600);
fs.renameSync(temporary, target);
fs.chmodSync(target, 0o600);
} finally {
try { fs.unlinkSync(temporary); } catch {}
}
return getOpenCodeGoCredentialStatus();
};
export const deleteOpenCodeGoCredential = () => {
try { fs.unlinkSync(credentialsPath()); } catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
};
@@ -0,0 +1,32 @@
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', authCookieMasked: '••••••••' });
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 });
});
@@ -22,6 +22,7 @@ import * as minimaxCodingPlan from './minimax-coding-plan.js';
import * as minimaxCnCodingPlan from './minimax-cn-coding-plan.js';
import * as ollamaCloud from './ollama-cloud.js';
import * as wafer from './wafer.js';
import * as opencodeGo from './opencode-go.js';
const registry = {
claude: {
@@ -113,6 +114,12 @@ const registry = {
providerName: wafer.providerName,
isConfigured: wafer.isConfigured,
fetchQuota: wafer.fetchQuota
},
'opencode-go': {
providerId: opencodeGo.providerId,
providerName: opencodeGo.providerName,
isConfigured: opencodeGo.isConfigured,
fetchQuota: opencodeGo.fetchQuota
}
};
@@ -0,0 +1,69 @@
import { readOpenCodeGoCredential } from '../opencode-go-credentials.js';
import { buildResult, 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 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('&quot;', '"').replaceAll('&#34;', '"').replaceAll('\\u0022', '"').replaceAll('\\"', '"');
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;
windows[key] = toUsageWindow({
usedPercent: Math.min(100, Math.max(0, usedPercent)),
resetAt: now + Math.max(0, resetInSec) * 1000,
windowSeconds: null,
});
}
return windows;
};
export const fetchOpenCodeGoUsage = async (credential, fetchImpl = fetch) => {
const response = await fetchImpl(`https://opencode.ai/workspace/${encodeURIComponent(credential.workspaceId)}/go`, {
headers: {
Accept: 'text/html,application/xhtml+xml',
Cookie: `auth=${credential.authCookie}`,
'User-Agent': 'OpenChamber quota provider',
},
signal: AbortSignal.timeout(15_000),
});
if (response.status === 401 || response.status === 403 || (response.redirected && /\/auth(?:\/|$|\?)/.test(new URL(response.url).pathname))) {
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 (Object.keys(windows).length === 0) throw new Error('OpenCode Go usage data could not be parsed');
return windows;
};
export const isConfigured = () => Boolean(readOpenCodeGoCredential());
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);
return buildResult({ providerId, providerName, ok: true, configured: true, usage: { windows } });
} catch (error) {
return buildResult({ providerId, providerName, ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
}
};
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'bun:test';
import { fetchOpenCodeGoUsage, parseOpenCodeGoUsage } from './opencode-go.js';
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);
expect(windows['5h'].usedPercent).toBe(25);
expect(windows['5h'].resetAt).toBe(61_000);
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');
});
});
+41
View File
@@ -1,3 +1,13 @@
import express from 'express';
import {
deleteOpenCodeGoCredential,
getOpenCodeGoCredentialStatus,
normalizeOpenCodeGoCredential,
readOpenCodeGoCredential,
writeOpenCodeGoCredential,
} from './opencode-go-credentials.js';
import { fetchOpenCodeGoUsage } from './providers/opencode-go.js';
export function registerQuotaRoutes(app, { getQuotaProviders }) {
app.get('/api/quota/providers', async (_req, res) => {
try {
@@ -10,6 +20,37 @@ export function registerQuotaRoutes(app, { getQuotaProviders }) {
}
});
app.get('/api/quota/credentials/opencode-go', (_req, res) => {
res.json(getOpenCodeGoCredentialStatus());
});
app.put('/api/quota/credentials/opencode-go', express.json({ limit: '16kb' }), async (req, res) => {
try {
const credential = normalizeOpenCodeGoCredential(req.body);
if (!credential) return res.status(400).json({ error: 'Workspace ID and auth cookie are required' });
await fetchOpenCodeGoUsage(credential);
res.json(writeOpenCodeGoCredential(credential));
} catch (error) {
res.status(400).json({ error: error instanceof Error ? error.message : 'Credential validation failed' });
}
});
app.post('/api/quota/credentials/opencode-go/validate', async (_req, res) => {
try {
const credential = readOpenCodeGoCredential();
if (!credential) return res.status(404).json({ error: 'Not configured' });
await fetchOpenCodeGoUsage(credential);
res.json({ valid: true });
} catch (error) {
res.status(400).json({ valid: false, error: error instanceof Error ? error.message : 'Credential validation failed' });
}
});
app.delete('/api/quota/credentials/opencode-go', (_req, res) => {
deleteOpenCodeGoCredential();
res.json({ configured: false });
});
app.get('/api/quota/:providerId', async (req, res) => {
try {
const { providerId } = req.params;
@@ -0,0 +1,40 @@
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', authCookieMasked: '••••••••' });
} finally {
globalThis.fetch = originalFetch;
server.close();
}
});
});