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:
@@ -1,68 +1,68 @@
|
||||
import { readOpenCodeGoCredential } from '../opencode-go-credentials.js';
|
||||
import { buildResult, toUsageWindow } from '../utils/index.js';
|
||||
import { readAuthFile } from '../../opencode/auth.js';
|
||||
import { deleteLegacyOpenCodeGoCredential } from '../credentials/store.js';
|
||||
import { buildResult, getAuthEntry, normalizeAuthEntry, toUsageWindow } from '../utils/index.js';
|
||||
|
||||
export const providerId = 'opencode-go';
|
||||
export const providerName = 'OpenCode Go';
|
||||
export const aliases = ['opencode-go'];
|
||||
|
||||
const patterns = {
|
||||
'5h': 'rollingUsage',
|
||||
weekly: 'weeklyUsage',
|
||||
monthly: 'monthlyUsage',
|
||||
const windowsByApiKey = {
|
||||
'5h': 'rolling',
|
||||
weekly: 'weekly',
|
||||
monthly: 'monthly',
|
||||
};
|
||||
|
||||
const captureNumber = (name, body) => {
|
||||
const match = body.match(new RegExp(`["']?${name}["']?\\s*:\\s*["']?(-?\\d+(?:\\.\\d+)?)`));
|
||||
const value = match ? Number(match[1]) : null;
|
||||
return Number.isFinite(value) ? value : null;
|
||||
};
|
||||
|
||||
export const parseOpenCodeGoUsage = (html, now = Date.now()) => {
|
||||
if (typeof html !== 'string') return {};
|
||||
const normalized = html.replaceAll('"', '"').replaceAll('"', '"').replaceAll('\\u0022', '"').replaceAll('\\"', '"');
|
||||
export const parseOpenCodeGoUsage = (payload) => {
|
||||
const usage = payload && typeof payload === 'object' ? payload.usage : null;
|
||||
if (!usage || typeof usage !== 'object') return {};
|
||||
const windows = {};
|
||||
for (const [key, field] of Object.entries(patterns)) {
|
||||
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = normalized.match(new RegExp(`["']?${escaped}["']?\\s*:\\s*(?:\\$R\\[\\d+\\]\\s*=\\s*)?\\{([^{}]*)\\}`, 's'));
|
||||
if (!match) continue;
|
||||
const usedPercent = captureNumber('usagePercent', match[1]);
|
||||
const resetInSec = captureNumber('resetInSec', match[1]);
|
||||
if (usedPercent === null || resetInSec === null) continue;
|
||||
for (const [key, apiKey] of Object.entries(windowsByApiKey)) {
|
||||
const entry = usage[apiKey];
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const usedPercent = entry.percent;
|
||||
const resetAt = entry.resetsAt;
|
||||
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) continue;
|
||||
if (typeof resetAt !== 'string' || !Number.isFinite(new Date(resetAt).getTime())) continue;
|
||||
windows[key] = toUsageWindow({
|
||||
usedPercent: Math.min(100, Math.max(0, usedPercent)),
|
||||
resetAt: now + Math.max(0, resetInSec) * 1000,
|
||||
resetAt,
|
||||
windowSeconds: null,
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
};
|
||||
|
||||
export const fetchOpenCodeGoUsage = async (credential, fetchImpl = fetch) => {
|
||||
const response = await fetchImpl(`https://opencode.ai/workspace/${encodeURIComponent(credential.workspaceId)}/go`, {
|
||||
export const fetchOpenCodeGoUsage = async (apiKey, fetchImpl = fetch) => {
|
||||
const response = await fetchImpl('https://opencode.ai/zen/go/v1/usage', {
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
Cookie: `auth=${credential.authCookie}`,
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'User-Agent': 'OpenChamber quota provider',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error('OpenCode Go authentication failed');
|
||||
}
|
||||
if (!response.ok) throw new Error(`OpenCode Go dashboard returned HTTP ${response.status}`);
|
||||
const windows = parseOpenCodeGoUsage(await response.text());
|
||||
if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`);
|
||||
const windows = parseOpenCodeGoUsage(await response.json().catch(() => null));
|
||||
if (Object.keys(windows).length === 0) throw new Error('OpenCode Go usage data could not be parsed');
|
||||
return windows;
|
||||
};
|
||||
|
||||
export const isConfigured = () => Boolean(readOpenCodeGoCredential());
|
||||
const getApiKey = () => {
|
||||
const entry = normalizeAuthEntry(getAuthEntry(readAuthFile(), aliases));
|
||||
return entry?.key ?? entry?.token ?? null;
|
||||
};
|
||||
|
||||
export const isConfigured = () => Boolean(getApiKey());
|
||||
|
||||
export const fetchQuota = async () => {
|
||||
const credential = readOpenCodeGoCredential();
|
||||
if (!credential) return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' });
|
||||
try {
|
||||
const windows = await fetchOpenCodeGoUsage(credential);
|
||||
deleteLegacyOpenCodeGoCredential();
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' });
|
||||
const windows = await fetchOpenCodeGoUsage(apiKey);
|
||||
return buildResult({ providerId, providerName, ok: true, configured: true, usage: { windows } });
|
||||
} catch (error) {
|
||||
return buildResult({ providerId, providerName, ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
|
||||
|
||||
@@ -1,17 +1,62 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { fetchOpenCodeGoUsage, parseOpenCodeGoUsage } from './opencode-go.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const previousDataDirectory = process.env.OPENCHAMBER_DATA_DIR;
|
||||
const temporaryDataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-opencode-go-'));
|
||||
process.env.OPENCHAMBER_DATA_DIR = temporaryDataDirectory;
|
||||
|
||||
vi.mock('../../opencode/auth.js', () => ({
|
||||
readAuthFile: () => ({ 'opencode-go': { key: 'test-key' } }),
|
||||
}));
|
||||
|
||||
import { fetchOpenCodeGoUsage, fetchQuota, parseOpenCodeGoUsage } from './opencode-go.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (previousDataDirectory === undefined) delete process.env.OPENCHAMBER_DATA_DIR;
|
||||
else process.env.OPENCHAMBER_DATA_DIR = previousDataDirectory;
|
||||
fs.rmSync(temporaryDataDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('OpenCode Go quota provider', () => {
|
||||
it('parses partial SSR usage windows in either field order', () => {
|
||||
const windows = parseOpenCodeGoUsage('rollingUsage:$R[1]={usagePercent:25,resetInSec:60} weeklyUsage:$R[2]={resetInSec:120,usagePercent:40}', 1_000);
|
||||
it('parses partial API usage windows', () => {
|
||||
const windows = parseOpenCodeGoUsage({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' }, weekly: { percent: 40, resetsAt: '2026-08-19T12:00:00.000Z' } } });
|
||||
expect(windows['5h'].usedPercent).toBe(25);
|
||||
expect(windows['5h'].resetAt).toBe(61_000);
|
||||
expect(windows['5h'].resetAt).toBe('2026-08-12T12:00:00.000Z');
|
||||
expect(windows.weekly.usedPercent).toBe(40);
|
||||
expect(windows.monthly).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not expose credentials in authentication errors', async () => {
|
||||
const credential = { workspaceId: 'wrk_test', authCookie: 'secret' };
|
||||
await expect(fetchOpenCodeGoUsage(credential, async () => new Response('', { status: 403 }))).rejects.toThrow('authentication failed');
|
||||
await expect(fetchOpenCodeGoUsage('secret', async () => new Response('', { status: 403 }))).rejects.toThrow('authentication failed');
|
||||
});
|
||||
|
||||
it('uses the Go usage API with bearer authentication', async () => {
|
||||
let request;
|
||||
const usage = await fetchOpenCodeGoUsage('secret', async (url, options) => {
|
||||
request = { url, options };
|
||||
return new Response(JSON.stringify({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } }));
|
||||
});
|
||||
expect(request.url).toBe('https://opencode.ai/zen/go/v1/usage');
|
||||
expect(request.options.headers).toMatchObject({ Accept: 'application/json', Authorization: 'Bearer secret' });
|
||||
expect(request.options.headers.Cookie).toBeUndefined();
|
||||
expect(usage['5h'].usedPercent).toBe(25);
|
||||
});
|
||||
|
||||
it('reads the API key from the OpenCode auth file', async () => {
|
||||
const legacyPath = path.join(temporaryDataDirectory, 'quota', 'opencode-go.json');
|
||||
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
||||
fs.writeFileSync(legacyPath, '{not valid json', { mode: 0o600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } })));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await fetchQuota();
|
||||
expect(result).toMatchObject({ providerId: 'opencode-go', ok: true, configured: true });
|
||||
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer test-key');
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user