/** * OpenCode Go usage provider (unofficial API) */ import * as path from "node:path"; import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js"; import { BaseProvider } from "../../provider.js"; import { noCredentials, fetchFailed, httpError, apiError } from "../../errors.js"; import { isRecord, normalizeCredentialString } from "../../credentials.js"; import { formatReset, createTimeoutController } from "../../utils.js"; import { API_TIMEOUT_MS, OPENCODE_USAGE_URL } from "../../config.js"; interface OpenCodeUsageWindow { percent?: number; status?: string; resetsAt?: string; } interface OpenCodeUsageResponse { usage?: { rolling?: OpenCodeUsageWindow; weekly?: OpenCodeUsageWindow; monthly?: OpenCodeUsageWindow; }; } const normalizeApiKey = normalizeCredentialString; function clampPercent(value: number): number { return Math.max(0, Math.min(100, value)); } function authFilePath(deps: Dependencies): string { const dataHome = deps.env.XDG_DATA_HOME || path.join(deps.homedir(), ".local", "share"); return path.join(dataHome, "opencode", "auth.json"); } function piAuthFilePath(deps: Dependencies): string { return path.join(deps.homedir(), ".pi", "agent", "auth.json"); } function credentialKey(credential: unknown): string | undefined { if (typeof credential === "string") return normalizeApiKey(credential); if (!isRecord(credential)) return undefined; if (credential.type === "api" || credential.type === "api_key") { const key = normalizeApiKey(credential.key); if (key) return key; } return normalizeApiKey(credential.key) ?? normalizeApiKey(credential.access); } function keyFromAuthContent(contents: string | undefined): string | undefined { try { if (!contents) return undefined; const auth = JSON.parse(contents) as Record; return credentialKey(auth["opencode-go"]); } catch { // Ignore parse errors } return undefined; } function keyFromAuthFile(deps: Dependencies, filePath: string): string | undefined { return keyFromAuthContent(deps.fileExists(filePath) ? deps.readFile(filePath) : undefined); } /** * Collect OpenCode Go API key candidates in priority order: env, * OPENCODE_AUTH_CONTENT, opencode auth.json, then pi's agent auth.json * (fallback for pi's own opencode-go provider credential). * * Duplicates are removed and stale keys are not trusted blindly: fetchUsage * tries each candidate in order and only accepts one that authenticates. */ function loadOpenCodeApiKeys(deps: Dependencies): string[] { const candidates: string[] = []; const push = (key: string | undefined) => { const normalized = normalizeApiKey(key); if (normalized && !candidates.includes(normalized)) { candidates.push(normalized); } }; push(normalizeApiKey(deps.env.OPENCODE_API_KEY ?? deps.env.OPENCODE_GO_API_KEY)); push(keyFromAuthContent(deps.env.OPENCODE_AUTH_CONTENT)); push(keyFromAuthFile(deps, authFilePath(deps))); push(keyFromAuthFile(deps, piAuthFilePath(deps))); return candidates; } function pushWindow( windows: RateWindow[], label: string, window: OpenCodeUsageWindow | undefined ): void { if (!window || typeof window.percent !== "number" || !Number.isFinite(window.percent)) return; const resetDate = typeof window.resetsAt === "string" && window.resetsAt.trim() ? new Date(window.resetsAt) : undefined; const validReset = resetDate && !Number.isNaN(resetDate.getTime()) ? resetDate : undefined; windows.push({ label, usedPercent: clampPercent(window.percent), resetDescription: validReset ? formatReset(validReset) : undefined, resetAt: validReset?.toISOString(), }); } export class OpenCodeProvider extends BaseProvider { readonly name = "opencode" as const; readonly displayName = "OpenCode"; hasCredentials(deps: Dependencies): boolean { return loadOpenCodeApiKeys(deps).length > 0; } private async fetchUsageWithKey(deps: Dependencies, apiKey: string): Promise { const { controller, clear } = createTimeoutController(API_TIMEOUT_MS); try { const res = await deps.fetch(OPENCODE_USAGE_URL, { method: "GET", headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", }, signal: controller.signal, }); clear(); if (!res.ok) { return this.emptySnapshot(httpError(res.status)); } let data: OpenCodeUsageResponse; try { data = (await res.json()) as OpenCodeUsageResponse; } catch { return this.emptySnapshot(apiError("Invalid OpenCode usage response")); } const windows: RateWindow[] = []; // Primary quota: rolling 5h + weekly + monthly (billing cycle). pushWindow(windows, "5h", data.usage?.rolling); pushWindow(windows, "Week", data.usage?.weekly); pushWindow(windows, "Month", data.usage?.monthly); if (windows.length === 0) { return this.emptySnapshot(apiError("Invalid OpenCode usage response")); } return this.snapshot({ windows }); } catch { clear(); return this.emptySnapshot(fetchFailed()); } } async fetchUsage(deps: Dependencies): Promise { const candidates = loadOpenCodeApiKeys(deps); if (candidates.length === 0) { return this.emptySnapshot(noCredentials()); } // Try each candidate until one authenticates. A stale env key must not // shadow valid stored credentials: only auth failures (401/403) fall // through; network errors and malformed responses are final. let lastResult: UsageSnapshot | undefined; for (let i = 0; i < candidates.length; i += 1) { const result = await this.fetchUsageWithKey(deps, candidates[i]); lastResult = result; const error = result.error; const isAuthError = error?.code === "HTTP_ERROR" && (error.httpStatus === 401 || error.httpStatus === 403); if (!isAuthError || i === candidates.length - 1) { return result; } } return lastResult ?? this.emptySnapshot(noCredentials()); } }