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
@@ -33,7 +33,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` / `providers/minimax-shared.js` | `minimax-cn-coding-plan` |
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Manual cookie stored under `~/.config/openchamber/quota/` |
| `wafer` | Wafer.ai | `providers/wafer.js` | `wafer`, `wafer-ai`, `wafer_ai`, `wafer.ai` |
| `opencode-go` | OpenCode Go | `providers/opencode-go.js` | Manual workspace ID and auth cookie stored under `~/.config/openchamber/quota/` |
| `opencode-go` | OpenCode Go | `providers/opencode-go.js` | `opencode-go` API key from OpenCode `auth.json` |
| `neuralwatt` | NeuralWatt | `providers/neuralwatt.js` | `neuralwatt` (API key under `key` or `token`) |
| `xai` | xAI | `providers/xai.js` | `xai` OAuth entry in OpenCode `auth.json` |
@@ -49,7 +49,9 @@ All providers should return results via shared helpers to preserve API shape:
Provider modules must export `providerId`, `providerName`, `aliases`, `isConfigured(auth?)`, and `fetchQuota()`.
`fetchQuota()` should return a quota result with `usage.windows` keyed by window name (for example `5h`, `7d`, `daily`) and optional provider-specific `usage.models` data.
OpenCode Go, Ollama Cloud, and Cursor credentials are explicitly managed through Settings. The server validates credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database.
Ollama Cloud and Cursor credentials are explicitly managed through Settings. OpenCode Go usage uses `GET https://opencode.ai/zen/go/v1/usage` with the `opencode-go` API key from OpenCode `auth.json` as a bearer token. The server validates managed credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database.
On the first OpenCode Go usage refresh after upgrading, OpenChamber deletes the obsolete `quota/opencode-go.json` credential file without reading its cookie value.
## Add a new provider (quick steps)
1. Choose module shape based on complexity:
@@ -3,12 +3,6 @@ import { deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from
const clean = (value) => typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : '';
export const normalizers = {
'opencode-go': (value) => {
const workspaceId = clean(value?.workspaceId);
let authCookie = clean(value?.authCookie);
if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim();
return workspaceId && authCookie ? { workspaceId, authCookie } : null;
},
'ollama-cloud': (value) => {
const cookie = clean(value?.cookie);
return cookie ? { cookie } : null;
@@ -35,7 +29,6 @@ export const writeManagedCredential = (providerId, value) => {
export const getManagedCredentialStatus = (providerId) => {
const credential = readManagedCredential(providerId);
if (!credential) return { configured: false };
if (providerId === 'opencode-go') return { configured: true, workspaceId: credential.workspaceId, secretMasked: '••••••••' };
if (providerId === 'cursor') return { configured: true, hasRefreshToken: Boolean(credential.refreshToken), secretMasked: '••••••••' };
return { configured: true, secretMasked: '••••••••' };
};
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const MANAGED_QUOTA_PROVIDERS = new Set(['opencode-go', 'ollama-cloud', 'cursor']);
const MANAGED_QUOTA_PROVIDERS = new Set(['ollama-cloud', 'cursor']);
const credentialsDirectory = () => path.join(
process.env.OPENCHAMBER_DATA_DIR
@@ -46,3 +46,13 @@ export const deleteQuotaCredential = (providerId) => {
if (error?.code !== 'ENOENT') throw error;
}
};
// OpenCode Go used to store a browser auth cookie here. Its usage API now uses
// OpenCode's auth.json API key, so remove the obsolete secret without reading it.
export const deleteLegacyOpenCodeGoCredential = () => {
try {
fs.unlinkSync(path.join(credentialsDirectory(), 'opencode-go.json'));
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
};
@@ -2,7 +2,7 @@ import { afterAll, describe, expect, it } from 'bun:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from './store.js';
import { deleteLegacyOpenCodeGoCredential, deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from './store.js';
const previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-quota-store-'));
@@ -17,6 +17,14 @@ describe('quota credential store', () => {
expect(() => writeQuotaCredential('../escape', {})).toThrow('Unsupported credential provider');
deleteQuotaCredential('ollama-cloud');
});
it('removes the obsolete OpenCode Go credential without parsing it', () => {
const legacyPath = path.join(temporaryDirectory, 'quota', 'opencode-go.json');
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, '{not valid json', { mode: 0o600 });
deleteLegacyOpenCodeGoCredential();
expect(fs.existsSync(legacyPath)).toBe(false);
});
});
afterAll(() => {
@@ -1,11 +0,0 @@
import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js';
export const normalizeOpenCodeGoCredential = normalizers['opencode-go'];
export const readOpenCodeGoCredential = () => readManagedCredential('opencode-go');
export const getOpenCodeGoCredentialStatus = () => getManagedCredentialStatus('opencode-go');
export const writeOpenCodeGoCredential = (value) => writeManagedCredential('opencode-go', value);
export const deleteOpenCodeGoCredential = () => deleteManagedCredential('opencode-go');
@@ -1,32 +0,0 @@
import { afterAll, afterEach, describe, expect, it } from 'bun:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { deleteOpenCodeGoCredential, getOpenCodeGoCredentialStatus, readOpenCodeGoCredential, writeOpenCodeGoCredential } from './opencode-go-credentials.js';
const previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-go-'));
process.env.OPENCHAMBER_DATA_DIR = temporaryDirectory;
afterEach(() => deleteOpenCodeGoCredential());
describe('OpenCode Go credential store', () => {
it('normalizes, masks, and stores credentials with owner-only permissions', () => {
const status = writeOpenCodeGoCredential({ workspaceId: ' wrk_test ', authCookie: ' auth=secret ' });
expect(status).toEqual({ configured: true, workspaceId: 'wrk_test', secretMasked: '••••••••' });
expect(readOpenCodeGoCredential()).toEqual({ workspaceId: 'wrk_test', authCookie: 'secret' });
expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'opencode-go.json')).mode & 0o777).toBe(0o600);
});
it('removes credentials without exposing prior values', () => {
writeOpenCodeGoCredential({ workspaceId: 'wrk_test', authCookie: 'secret' });
deleteOpenCodeGoCredential();
expect(getOpenCodeGoCredentialStatus()).toEqual({ configured: false });
});
});
afterAll(() => {
if (previousDataDir === undefined) delete process.env.OPENCHAMBER_DATA_DIR;
else process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
});
@@ -1,68 +1,68 @@
import { readOpenCodeGoCredential } from '../opencode-go-credentials.js';
import { buildResult, toUsageWindow } from '../utils/index.js';
import { readAuthFile } from '../../opencode/auth.js';
import { deleteLegacyOpenCodeGoCredential } from '../credentials/store.js';
import { buildResult, getAuthEntry, normalizeAuthEntry, toUsageWindow } from '../utils/index.js';
export const providerId = 'opencode-go';
export const providerName = 'OpenCode Go';
export const aliases = ['opencode-go'];
const patterns = {
'5h': 'rollingUsage',
weekly: 'weeklyUsage',
monthly: 'monthlyUsage',
const windowsByApiKey = {
'5h': 'rolling',
weekly: 'weekly',
monthly: 'monthly',
};
const captureNumber = (name, body) => {
const match = body.match(new RegExp(`["']?${name}["']?\\s*:\\s*["']?(-?\\d+(?:\\.\\d+)?)`));
const value = match ? Number(match[1]) : null;
return Number.isFinite(value) ? value : null;
};
export const parseOpenCodeGoUsage = (html, now = Date.now()) => {
if (typeof html !== 'string') return {};
const normalized = html.replaceAll('"', '"').replaceAll('"', '"').replaceAll('\\u0022', '"').replaceAll('\\"', '"');
export const parseOpenCodeGoUsage = (payload) => {
const usage = payload && typeof payload === 'object' ? payload.usage : null;
if (!usage || typeof usage !== 'object') return {};
const windows = {};
for (const [key, field] of Object.entries(patterns)) {
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = normalized.match(new RegExp(`["']?${escaped}["']?\\s*:\\s*(?:\\$R\\[\\d+\\]\\s*=\\s*)?\\{([^{}]*)\\}`, 's'));
if (!match) continue;
const usedPercent = captureNumber('usagePercent', match[1]);
const resetInSec = captureNumber('resetInSec', match[1]);
if (usedPercent === null || resetInSec === null) continue;
for (const [key, apiKey] of Object.entries(windowsByApiKey)) {
const entry = usage[apiKey];
if (!entry || typeof entry !== 'object') continue;
const usedPercent = entry.percent;
const resetAt = entry.resetsAt;
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) continue;
if (typeof resetAt !== 'string' || !Number.isFinite(new Date(resetAt).getTime())) continue;
windows[key] = toUsageWindow({
usedPercent: Math.min(100, Math.max(0, usedPercent)),
resetAt: now + Math.max(0, resetInSec) * 1000,
resetAt,
windowSeconds: null,
});
}
return windows;
};
export const fetchOpenCodeGoUsage = async (credential, fetchImpl = fetch) => {
const response = await fetchImpl(`https://opencode.ai/workspace/${encodeURIComponent(credential.workspaceId)}/go`, {
export const fetchOpenCodeGoUsage = async (apiKey, fetchImpl = fetch) => {
const response = await fetchImpl('https://opencode.ai/zen/go/v1/usage', {
headers: {
Accept: 'text/html,application/xhtml+xml',
Cookie: `auth=${credential.authCookie}`,
Accept: 'application/json',
Authorization: `Bearer ${apiKey}`,
'User-Agent': 'OpenChamber quota provider',
},
redirect: 'manual',
signal: AbortSignal.timeout(15_000),
});
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) {
if (response.status === 401 || response.status === 403) {
throw new Error('OpenCode Go authentication failed');
}
if (!response.ok) throw new Error(`OpenCode Go dashboard returned HTTP ${response.status}`);
const windows = parseOpenCodeGoUsage(await response.text());
if (!response.ok) throw new Error(`OpenCode Go usage API returned HTTP ${response.status}`);
const windows = parseOpenCodeGoUsage(await response.json().catch(() => null));
if (Object.keys(windows).length === 0) throw new Error('OpenCode Go usage data could not be parsed');
return windows;
};
export const isConfigured = () => Boolean(readOpenCodeGoCredential());
const getApiKey = () => {
const entry = normalizeAuthEntry(getAuthEntry(readAuthFile(), aliases));
return entry?.key ?? entry?.token ?? null;
};
export const isConfigured = () => Boolean(getApiKey());
export const fetchQuota = async () => {
const credential = readOpenCodeGoCredential();
if (!credential) return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' });
try {
const windows = await fetchOpenCodeGoUsage(credential);
deleteLegacyOpenCodeGoCredential();
const apiKey = getApiKey();
if (!apiKey) return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' });
const windows = await fetchOpenCodeGoUsage(apiKey);
return buildResult({ providerId, providerName, ok: true, configured: true, usage: { windows } });
} catch (error) {
return buildResult({ providerId, providerName, ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
@@ -1,17 +1,62 @@
import { describe, expect, it } from 'bun:test';
import { fetchOpenCodeGoUsage, parseOpenCodeGoUsage } from './opencode-go.js';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
const previousDataDirectory = process.env.OPENCHAMBER_DATA_DIR;
const temporaryDataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-opencode-go-'));
process.env.OPENCHAMBER_DATA_DIR = temporaryDataDirectory;
vi.mock('../../opencode/auth.js', () => ({
readAuthFile: () => ({ 'opencode-go': { key: 'test-key' } }),
}));
import { fetchOpenCodeGoUsage, fetchQuota, parseOpenCodeGoUsage } from './opencode-go.js';
afterEach(() => {
vi.unstubAllGlobals();
});
afterAll(() => {
if (previousDataDirectory === undefined) delete process.env.OPENCHAMBER_DATA_DIR;
else process.env.OPENCHAMBER_DATA_DIR = previousDataDirectory;
fs.rmSync(temporaryDataDirectory, { recursive: true, force: true });
});
describe('OpenCode Go quota provider', () => {
it('parses partial SSR usage windows in either field order', () => {
const windows = parseOpenCodeGoUsage('rollingUsage:$R[1]={usagePercent:25,resetInSec:60} weeklyUsage:$R[2]={resetInSec:120,usagePercent:40}', 1_000);
it('parses partial API usage windows', () => {
const windows = parseOpenCodeGoUsage({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' }, weekly: { percent: 40, resetsAt: '2026-08-19T12:00:00.000Z' } } });
expect(windows['5h'].usedPercent).toBe(25);
expect(windows['5h'].resetAt).toBe(61_000);
expect(windows['5h'].resetAt).toBe('2026-08-12T12:00:00.000Z');
expect(windows.weekly.usedPercent).toBe(40);
expect(windows.monthly).toBeUndefined();
});
it('does not expose credentials in authentication errors', async () => {
const credential = { workspaceId: 'wrk_test', authCookie: 'secret' };
await expect(fetchOpenCodeGoUsage(credential, async () => new Response('', { status: 403 }))).rejects.toThrow('authentication failed');
await expect(fetchOpenCodeGoUsage('secret', async () => new Response('', { status: 403 }))).rejects.toThrow('authentication failed');
});
it('uses the Go usage API with bearer authentication', async () => {
let request;
const usage = await fetchOpenCodeGoUsage('secret', async (url, options) => {
request = { url, options };
return new Response(JSON.stringify({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } }));
});
expect(request.url).toBe('https://opencode.ai/zen/go/v1/usage');
expect(request.options.headers).toMatchObject({ Accept: 'application/json', Authorization: 'Bearer secret' });
expect(request.options.headers.Cookie).toBeUndefined();
expect(usage['5h'].usedPercent).toBe(25);
});
it('reads the API key from the OpenCode auth file', async () => {
const legacyPath = path.join(temporaryDataDirectory, 'quota', 'opencode-go.json');
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, '{not valid json', { mode: 0o600 });
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ usage: { rolling: { percent: 25, resetsAt: '2026-08-12T12:00:00.000Z' } } })));
vi.stubGlobal('fetch', fetchMock);
const result = await fetchQuota();
expect(result).toMatchObject({ providerId: 'opencode-go', ok: true, configured: true });
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer test-key');
expect(fs.existsSync(legacyPath)).toBe(false);
});
});
-2
View File
@@ -1,11 +1,9 @@
import express from 'express';
import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js';
import { fetchOpenCodeGoUsage } from './providers/opencode-go.js';
import { fetchOllamaCloudUsage } from './providers/ollama-cloud.js';
import { importCursorCredential, validateCursorCredential } from './providers/cursor.js';
const validators = {
'opencode-go': fetchOpenCodeGoUsage,
'ollama-cloud': fetchOllamaCloudUsage,
cursor: validateCursorCredential,
};
@@ -1,40 +0,0 @@
import { afterAll, describe, expect, it, mock } from 'bun:test';
import express from 'express';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { registerQuotaRoutes } from './routes.js';
const previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-go-routes-'));
process.env.OPENCHAMBER_DATA_DIR = temporaryDirectory;
afterAll(() => {
if (previousDataDir === undefined) delete process.env.OPENCHAMBER_DATA_DIR;
else process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
});
describe('OpenCode Go credential routes', () => {
it('parses a JSON credential payload before validation', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = mock(async () => new Response('rollingUsage:$R[1]={usagePercent:25,resetInSec:60}'));
const app = express();
registerQuotaRoutes(app, { getQuotaProviders: async () => ({}) });
const server = app.listen(0);
try {
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Test server did not start');
const response = await originalFetch(`http://127.0.0.1:${address.port}/api/quota/credentials/opencode-go`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId: 'wrk_test', authCookie: 'auth=secret' }),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ configured: true, workspaceId: 'wrk_test', secretMasked: '••••••••' });
} finally {
globalThis.fetch = originalFetch;
server.close();
}
});
});