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:
Bohdan Triapitsyn
2026-08-12 01:49:22 +03:00
parent d1224213ca
commit 5917325d49
29 changed files with 192 additions and 182 deletions
+3 -6
View File
@@ -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' };
+11 -13
View File
@@ -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('&quot;', '"').replaceAll('&#34;', '"').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;
+6 -9
View File
@@ -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');
+34 -1
View File
@@ -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 })));
+8 -5
View File
@@ -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' });
}