diff --git a/packages/ui/src/components/sections/providers/OpenCodeGoCredentials.tsx b/packages/ui/src/components/sections/providers/OpenCodeGoCredentials.tsx new file mode 100644 index 00000000..74052e85 --- /dev/null +++ b/packages/ui/src/components/sections/providers/OpenCodeGoCredentials.tsx @@ -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(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(null); + const cookieInputRef = React.useRef(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 ( +
+
+

{t('settings.providers.page.openCodeGo.title')}

+

{t('settings.providers.page.openCodeGo.description')}

+
+
+ + +

{t('settings.providers.page.openCodeGo.help')}

+
+ + {status?.configured && } + {status?.configured && } +
+
+
+ ); +}; diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 0d7a4e0a..bade92b8 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -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 = () => { + {(selectedProvider.id === 'opencode' || selectedProvider.id === 'opencode-go') && } + {/* Connection Details */}
diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 54be535b..91d839af 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index cade51b3..345ac5ab 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 91c3cc92..9fdc140f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1,4 +1,19 @@ export const settingsDict = { + 'settings.providers.page.openCodeGo.title': 'Suivi de l’utilisation d’OpenCode 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 l’espace de travail', + 'settings.providers.page.openCodeGo.authCookie': 'Cookie d’authentification', + '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', + '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', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 22a9ce5a..b5ef0b4f 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -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': '言語を選択', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index af525842..0d087e1b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -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': '언어 선택', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 53fa674d..47407ed5 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -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', 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 52cadff7..49ddf961 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index e9e57dfa..4c001641 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -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": "Виберіть мову", 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 b08ef6e6..fc509e12 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -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': '选择语言', 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 3c5e35e7..0b2504da 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -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': '選擇語言', diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 4b5c0a46..827915af 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -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' }, ]; diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 4fa7e761..dcd5459c 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -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; diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index 0faaac76..bb928bd5 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -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) { diff --git a/packages/vscode/src/opencodeGoQuota.ts b/packages/vscode/src/opencodeGoQuota.ts new file mode 100644 index 00000000..5e329050 --- /dev/null +++ b/packages/vscode/src/opencodeGoQuota.ts @@ -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; + 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('"', '"').replaceAll('"', '"').replaceAll('\\u0022', '"').replaceAll('\\"', '"'); + 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); + } + if (!Object.keys(windows).length) throw new Error('OpenCode Go usage data could not be parsed'); + return windows; +}; diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 3bc18af9..76331f20 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -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; type AuthFile = Record; @@ -388,6 +389,7 @@ const durationToSeconds = (duration?: number, unit?: string) => { export const listConfiguredQuotaProviders = () => { const auth = readAuthFile(); const configured = new Set(); + if (readOpenCodeGoCredential()) configured.add('opencode-go'); const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])); if (anthropicAuth && ((anthropicAuth as Record).access || (anthropicAuth as Record).token)) { @@ -1892,6 +1894,15 @@ export const fetchQuotaForProvider = async (providerId: string): Promise.js`. diff --git a/packages/web/server/lib/quota/opencode-go-credentials.js b/packages/web/server/lib/quota/opencode-go-credentials.js new file mode 100644 index 00000000..d2bf31f6 --- /dev/null +++ b/packages/web/server/lib/quota/opencode-go-credentials.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; + } +}; diff --git a/packages/web/server/lib/quota/opencode-go-credentials.test.js b/packages/web/server/lib/quota/opencode-go-credentials.test.js new file mode 100644 index 00000000..4dadfe06 --- /dev/null +++ b/packages/web/server/lib/quota/opencode-go-credentials.test.js @@ -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 }); +}); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index c27cd46f..d572f215 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -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 } }; diff --git a/packages/web/server/lib/quota/providers/opencode-go.js b/packages/web/server/lib/quota/providers/opencode-go.js new file mode 100644 index 00000000..aec2a420 --- /dev/null +++ b/packages/web/server/lib/quota/providers/opencode-go.js @@ -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('"', '"').replaceAll('"', '"').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' }); + } +}; diff --git a/packages/web/server/lib/quota/providers/opencode-go.test.js b/packages/web/server/lib/quota/providers/opencode-go.test.js new file mode 100644 index 00000000..f3dbe739 --- /dev/null +++ b/packages/web/server/lib/quota/providers/opencode-go.test.js @@ -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'); + }); +}); diff --git a/packages/web/server/lib/quota/routes.js b/packages/web/server/lib/quota/routes.js index 9f3641ee..1526a34c 100644 --- a/packages/web/server/lib/quota/routes.js +++ b/packages/web/server/lib/quota/routes.js @@ -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; diff --git a/packages/web/server/lib/quota/routes.test.js b/packages/web/server/lib/quota/routes.test.js new file mode 100644 index 00000000..f3b34ba0 --- /dev/null +++ b/packages/web/server/lib/quota/routes.test.js @@ -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(); + } + }); +});