diff --git a/packages/server/src/plugins/codex-usage-source/paseo-plugin.json b/packages/server/src/plugins/codex-usage-source/paseo-plugin.json
index 64470099fb..6fe9779596 100644
--- a/packages/server/src/plugins/codex-usage-source/paseo-plugin.json
+++ b/packages/server/src/plugins/codex-usage-source/paseo-plugin.json
@@ -1 +1 @@
-{ "id": "codex-usage-source", "requirements": { "paseo": ">=0.9.2" } }
+{ "id": "codex-usage-source" }
diff --git a/packages/server/src/plugins/opencode-go-usage-source/icon.svg b/packages/server/src/plugins/opencode-go-usage-source/icon.svg
index 2099d47ec4..79991973d6 100644
--- a/packages/server/src/plugins/opencode-go-usage-source/icon.svg
+++ b/packages/server/src/plugins/opencode-go-usage-source/icon.svg
@@ -1 +1,6 @@
-
+
diff --git a/packages/server/src/plugins/opencode-go-usage-source/index.server.ts b/packages/server/src/plugins/opencode-go-usage-source/index.server.ts
index c048c9dcb5..b011eaf2f4 100644
--- a/packages/server/src/plugins/opencode-go-usage-source/index.server.ts
+++ b/packages/server/src/plugins/opencode-go-usage-source/index.server.ts
@@ -1,6 +1,6 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inputSchema } from "./shared/input.js";
-import { fetchUsage } from "./server/usage.js";
+import { discover, fetchUsage } from "./server/usage.js";
export default function contribute(server: PluginServerContext) {
server.registerUsageSource({
@@ -8,6 +8,7 @@ export default function contribute(server: PluginServerContext) {
label: "OpenCode Go",
icon: "icon.svg",
input: inputSchema,
+ discover,
fetch: fetchUsage,
});
return () => {};
diff --git a/packages/server/src/plugins/opencode-go-usage-source/server/usage.test.ts b/packages/server/src/plugins/opencode-go-usage-source/server/usage.test.ts
index 57453396f2..bb33576b49 100644
--- a/packages/server/src/plugins/opencode-go-usage-source/server/usage.test.ts
+++ b/packages/server/src/plugins/opencode-go-usage-source/server/usage.test.ts
@@ -1,33 +1,75 @@
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import { expect, test } from "vitest";
-import { fetchUsage } from "./usage.js";
+import { discover, fetchUsage } from "./usage.js";
-test("reads the OpenCode Go usage windows", async () => {
- const requests: Array<{ url: string; authorization: string | null }> = [];
- const fetchApi = async (url: string | URL | Request, init?: RequestInit) => {
- const headers = new Headers(init?.headers);
- requests.push({ url: String(url), authorization: headers.get("Authorization") });
- return Response.json({
- rollingUsage: { usagePercent: 21, resetInSec: 1800 },
- weeklyUsage: { usagePercent: 42 },
- monthlyUsage: { usagePercent: 63 },
- });
- };
- const report = await fetchUsage({ apiKey: "fixture-key" }, fetchApi as typeof fetch);
- expect(requests).toEqual([
- { url: "https://opencode.ai/zen/go/v1/usage", authorization: "Bearer fixture-key" },
- ]);
- expect(report.status).toBe("available");
- expect(report.windows.map((window) => [window.id, window.usedPct, window.headline])).toEqual([
- ["rolling", 21, true],
- ["weekly", 42, undefined],
- ["monthly", 63, undefined],
- ]);
+const upstreamResponse = {
+ usage: {
+ rolling: { status: "ok", percent: 21, resetsAt: "2026-09-26T20:00:00.000Z" },
+ weekly: { status: "ok", percent: 42, resetsAt: "2026-09-28T00:00:00.000Z" },
+ monthly: { status: "rate-limited", percent: 100, resetsAt: "2026-10-01T00:00:00.000Z" },
+ },
+};
+
+test("discovers and fetches the default key from read-only auth.json", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "paseo-go-usage-"));
+ const path = join(directory, "auth.json");
+ const content = JSON.stringify({ "opencode-go": { type: "api", key: "fixture-key" } });
+ try {
+ await writeFile(path, content);
+ expect(await discover(path)).toEqual([{}]);
+ const requests: Array<{ url: string; authorization: string | null }> = [];
+ const fetchApi = async (url: string | URL | Request, init?: RequestInit) => {
+ requests.push({
+ url: String(url),
+ authorization: new Headers(init?.headers).get("Authorization"),
+ });
+ return Response.json(upstreamResponse);
+ };
+ const report = await fetchUsage({}, fetchApi as typeof fetch, path);
+ expect(requests).toEqual([
+ { url: "https://opencode.ai/zen/go/v1/usage", authorization: "Bearer fixture-key" },
+ ]);
+ expect(report.status).toBe("available");
+ expect(
+ report.windows.map((window) => [window.id, window.usedPct, window.resetsAt, window.headline]),
+ ).toEqual([
+ ["rolling", 21, "2026-09-26T20:00:00.000Z", true],
+ ["weekly", 42, "2026-09-28T00:00:00.000Z", undefined],
+ ["monthly", 100, "2026-10-01T00:00:00.000Z", undefined],
+ ]);
+ // The current upstream endpoint returns windows only; do not invent balances.
+ expect(report.balances).toBeUndefined();
+ expect(await readFile(path, "utf8")).toBe(content);
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
});
-test("treats rejected credentials as unavailable", async () => {
+test("omits default discovery when auth.json lacks an OpenCode Go API key", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "paseo-go-usage-"));
+ const path = join(directory, "auth.json");
+ try {
+ await writeFile(path, JSON.stringify({ openai: { type: "oauth", access: "other-token" } }));
+ expect(await discover(path)).toEqual([]);
+ const report = await fetchUsage(
+ {},
+ async () => {
+ throw new Error("must not fetch");
+ },
+ path,
+ );
+ expect(report).toEqual({ account: { key: "default" }, status: "unavailable", windows: [] });
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
+});
+
+test.each([401, 403])("maps HTTP %i to unavailable", async (status) => {
const report = await fetchUsage(
{ apiKey: "fixture-key" },
- async () => new Response(null, { status: 401 }),
+ async () => new Response(null, { status }),
);
expect(report.status).toBe("unavailable");
expect(report.windows).toEqual([]);
diff --git a/packages/server/src/plugins/opencode-go-usage-source/server/usage.ts b/packages/server/src/plugins/opencode-go-usage-source/server/usage.ts
index 7dff014137..348f9c2d9e 100644
--- a/packages/server/src/plugins/opencode-go-usage-source/server/usage.ts
+++ b/packages/server/src/plugins/opencode-go-usage-source/server/usage.ts
@@ -1,4 +1,7 @@
import { createHash } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { join } from "node:path";
import { z } from "zod";
import {
toneFromUsedPct,
@@ -7,51 +10,75 @@ import {
} from "@getpaseo/plugin/server/usage";
import type { Input } from "../shared/input.js";
-const windowSchema = z
- .object({ usagePercent: z.number().finite(), resetInSec: z.number().finite().optional() })
+const authSchema = z
+ .object({
+ "opencode-go": z
+ .object({ type: z.literal("api"), key: z.string().min(1) })
+ .passthrough()
+ .optional(),
+ })
.passthrough();
-const responseSchema = z.object({
- rollingUsage: windowSchema.optional(),
- weeklyUsage: windowSchema.optional(),
- monthlyUsage: windowSchema.optional(),
+const windowSchema = z.object({
+ status: z.enum(["ok", "rate-limited"]),
+ percent: z.number().finite(),
+ resetsAt: z.iso.datetime(),
});
+const responseSchema = z.object({
+ usage: z.object({ rolling: windowSchema, weekly: windowSchema, monthly: windowSchema }),
+});
+
+function authPath(env: NodeJS.ProcessEnv = process.env): string {
+ return join(
+ env.XDG_DATA_HOME || join(env.HOME || homedir(), ".local", "share"),
+ "opencode",
+ "auth.json",
+ );
+}
+
+async function readDefaultKey(path = authPath()): Promise {
+ try {
+ const auth = authSchema.parse(JSON.parse(await readFile(path, "utf8")));
+ return auth["opencode-go"]?.key ?? null;
+ } catch {
+ return null;
+ }
+}
+
+export async function discover(path = authPath()): Promise> {
+ return (await readDefaultKey(path)) ? [{}] : [];
+}
export async function fetchUsage(
input: Input,
fetchApi: typeof fetch = fetch,
+ path = authPath(),
): Promise {
- const key = createHash("sha256").update(input.apiKey).digest("hex");
+ const apiKey = "apiKey" in input ? input.apiKey : await readDefaultKey(path);
+ if (!apiKey) return { account: { key: "default" }, status: "unavailable", windows: [] };
+ const key = createHash("sha256").update(apiKey).digest("hex");
const response = await fetchApi("https://opencode.ai/zen/go/v1/usage", {
- headers: { Authorization: `Bearer ${input.apiKey}`, Accept: "application/json" },
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
signal: AbortSignal.timeout(15_000),
});
if (response.status === 401 || response.status === 403)
return { account: { key }, status: "unavailable", windows: [] };
if (!response.ok) throw new Error(`OpenCode Go usage API returned ${response.status}`);
const data = responseSchema.parse(await response.json());
- const now = Date.now();
const windows = (
[
- ["rolling", "Rolling", data.rollingUsage],
- ["weekly", "Weekly", data.weeklyUsage],
- ["monthly", "Monthly", data.monthlyUsage],
+ ["rolling", "Rolling", data.usage.rolling],
+ ["weekly", "Weekly", data.usage.weekly],
+ ["monthly", "Monthly", data.usage.monthly],
] as const
- ).flatMap(([id, label, value]) =>
- value
- ? [
- windowFromUsedPct({
- id,
- label,
- utilizationPct: value.usagePercent,
- resetsAt:
- value.resetInSec === undefined
- ? null
- : new Date(now + value.resetInSec * 1000).toISOString(),
- tone: toneFromUsedPct(value.usagePercent),
- headline: id === "rolling",
- }),
- ]
- : [],
+ ).map(([id, label, value]) =>
+ windowFromUsedPct({
+ id,
+ label,
+ utilizationPct: value.percent,
+ resetsAt: value.resetsAt,
+ tone: toneFromUsedPct(value.percent),
+ headline: id === "rolling",
+ }),
);
return { account: { key }, status: "available", planLabel: "Go", windows };
}
diff --git a/packages/server/src/plugins/opencode-go-usage-source/shared/input.ts b/packages/server/src/plugins/opencode-go-usage-source/shared/input.ts
index 3f8774be5b..64fb3bd25b 100644
--- a/packages/server/src/plugins/opencode-go-usage-source/shared/input.ts
+++ b/packages/server/src/plugins/opencode-go-usage-source/shared/input.ts
@@ -1,4 +1,7 @@
import { z } from "zod";
-export const inputSchema = z.object({ apiKey: z.string().min(1) }).strict();
+export const inputSchema = z.union([
+ z.object({ apiKey: z.string().min(1) }).strict(),
+ z.object({}).strict(),
+]);
export type Input = z.infer;
diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts
index 89302bc68e..7a1d2a7292 100644
--- a/packages/server/src/server/agent/provider-registry.ts
+++ b/packages/server/src/server/agent/provider-registry.ts
@@ -458,6 +458,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
}
},
getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()),
+ getUsageReference: inner.getUsageReference?.bind(inner),
getAvailableModes: () => inner.getAvailableModes(),
getCurrentMode: () => inner.getCurrentMode(),
setMode: (modeId) => inner.setMode(modeId),
diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts
index c034fde9b9..131c52c5f7 100644
--- a/packages/server/src/server/agent/providers/claude/agent.test.ts
+++ b/packages/server/src/server/agent/providers/claude/agent.test.ts
@@ -25,6 +25,7 @@ import type {
} from "../../agent-sdk-types.js";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
+import { buildProviderRegistry } from "../../provider-registry.js";
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
@@ -538,12 +539,43 @@ describe("ClaudeAgentClient binary resolution", () => {
input: { configDir: "/accounts/second" },
});
await session.close();
- const custom = await client.createSession(
- { provider: "claude", cwd: process.cwd() },
- { env: { ANTHROPIC_AUTH_TOKEN: "token" } },
- );
- expect(await custom.getUsageReference?.()).toBeNull();
- await custom.close();
+ });
+
+ test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])(
+ "Claude usage reference is null with %s override",
+ async (name) => {
+ const client = new ClaudeAgentClient({
+ logger,
+ resolveBinary: async () => "/test/claude/bin",
+ });
+ const session = await client.createSession(
+ { provider: "claude", cwd: process.cwd() },
+ { env: { [name]: "override" } },
+ );
+ expect(await session.getUsageReference?.()).toBeNull();
+ await session.close();
+ },
+ );
+
+ test("Claude custom alias keeps its CLAUDE_CONFIG_DIR usage reference", async () => {
+ const registry = buildProviderRegistry(logger, {
+ providerOverrides: {
+ "work-claude": {
+ extends: "claude",
+ label: "Work Claude",
+ env: { CLAUDE_CONFIG_DIR: "/accounts/work" },
+ },
+ },
+ });
+ const session = await registry["work-claude"].createClient(logger).createSession({
+ provider: "work-claude",
+ cwd: process.cwd(),
+ });
+ expect(await session.getUsageReference?.()).toEqual({
+ source: "claude",
+ input: { configDir: "/accounts/work" },
+ });
+ await session.close();
});
test("resolves the installed Claude Code version", async () => {
diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts
index 5163ef37f3..a2fecf7092 100644
--- a/packages/server/src/server/agent/providers/pi/agent.test.ts
+++ b/packages/server/src/server/agent/providers/pi/agent.test.ts
@@ -211,6 +211,7 @@ test("Pi usage reference follows the current OAuth model and agent directory", a
"openai-codex": {
type: "oauth",
access: "codex-token",
+ accountId: "account-1",
refresh: "refresh",
expires: Date.now() + 60_000,
},
@@ -233,7 +234,7 @@ test("Pi usage reference follows the current OAuth model and agent directory", a
});
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
- input: { accessToken: "codex-token" },
+ input: { accessToken: "codex-token", accountId: "account-1" },
});
runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" };
expect(await session.getUsageReference?.()).toEqual({
diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts
index fdd1fc1480..5ee0e6a997 100644
--- a/packages/server/src/server/agent/providers/pi/agent.ts
+++ b/packages/server/src/server/agent/providers/pi/agent.ts
@@ -1143,7 +1143,15 @@ export class PiRpcAgentSession implements AgentSession {
!credential.access
)
return null;
- return { source, input: { accessToken: credential.access } };
+ return {
+ source,
+ input: {
+ accessToken: credential.access,
+ ...(source === "codex" && typeof credential.accountId === "string" && credential.accountId
+ ? { accountId: credential.accountId }
+ : {}),
+ },
+ };
} catch {
return null;
}
diff --git a/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts b/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts
index 7eceecd767..fdf9f1c111 100644
--- a/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts
+++ b/packages/server/src/server/plugins/session-usage-reference.e2e.test.ts
@@ -1,6 +1,7 @@
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
+import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import contribute from "./test-fixtures/session-usage-reference/index.server.js";
@@ -26,6 +27,60 @@ test("resolves a provider plugin session reference through its usage source", as
});
const reports = await client.listUsageReports();
expect(reports.reports.map((entry) => entry.report.account.key)).toContain("from-session");
+ const missing = await client.createAgent({
+ provider: "fixture-session-provider",
+ cwd: directory,
+ model: "missing",
+ });
+ expect((await client.getAgentUsageReport({ agentId: missing.id })).entry).toBeNull();
+ } finally {
+ await client.close();
+ await daemon.close();
+ }
+}, 60_000);
+
+test("agent.get_usage_report resolves source IDs from default built-in and ACP sessions", async () => {
+ const directory = fileURLToPath(
+ new URL("./test-fixtures/session-usage-reference/", import.meta.url),
+ );
+ const sources = [
+ ["claude", "claude"],
+ ["codex", "codex"],
+ ["copilot", "copilot"],
+ ["cursor", "cursor"],
+ ["kimi", "kimi"],
+ ["generic-acp", "generic-match"],
+ ] as const;
+ const agentClients = Object.fromEntries(
+ sources.map(([provider, source]) => {
+ const client = createTestAgentClient(provider);
+ const createSession = client.createSession.bind(client);
+ client.createSession = async (...args) => {
+ const session = await createSession(...args);
+ session.getUsageReference = async () => ({ source, input: {} });
+ return session;
+ };
+ return [provider, client];
+ }),
+ );
+ const daemon = await createTestPaseoDaemon({
+ pluginsEnabled: false,
+ internalPlugins: [{ id: "fixture-session-usage-reference", directory, contribute }],
+ agentClients,
+ providerOverrides: {
+ cursor: { extends: "acp", label: "Cursor", command: ["cursor-agent", "acp"] },
+ kimi: { extends: "acp", label: "Kimi", command: ["kimi", "acp"] },
+ "generic-acp": { extends: "acp", label: "Generic ACP", command: ["generic-acp", "acp"] },
+ },
+ });
+ const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
+ try {
+ await client.connect();
+ for (const [provider, source] of sources) {
+ const agent = await client.createAgent({ provider, cwd: directory });
+ const result = await client.getAgentUsageReport({ agentId: agent.id });
+ expect(result.entry?.sourceId, provider).toBe(source);
+ }
} finally {
await client.close();
await daemon.close();
diff --git a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts b/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts
index 2ba78a4785..00ccf7c16d 100644
--- a/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts
+++ b/packages/server/src/server/plugins/test-fixtures/session-usage-reference/index.server.ts
@@ -6,6 +6,18 @@ import {
import { z } from "zod";
export default function contribute(server: PluginServerContext) {
+ for (const id of ["claude", "codex", "copilot", "cursor", "kimi", "generic-match"]) {
+ server.registerUsageSource({
+ id,
+ label: id,
+ input: z.object({}).strict(),
+ fetch: async () => ({
+ account: { key: "default" },
+ status: "available",
+ windows: [{ id: "hour", label: "Hour", usedPct: 31, headline: true }],
+ }),
+ });
+ }
server.registerUsageSource({
id: "fixture-session-usage",
label: "Fixture session usage",
@@ -25,6 +37,7 @@ export default function contribute(server: PluginServerContext) {
"session.usage_reference",
]);
const listeners = new Set<(event: ProviderEvent) => void>();
+ const models = new Map();
const emit = (event: ProviderEvent) => {
for (const listener of listeners) listener(event);
};
@@ -36,9 +49,16 @@ export default function contribute(server: PluginServerContext) {
emit({
type: "catalog",
requestId: input.requestId,
- catalog: { models: [{ id: "fixture", label: "Fixture" }], modes: [] },
+ catalog: {
+ models: [
+ { id: "fixture", label: "Fixture" },
+ { id: "missing", label: "Missing" },
+ ],
+ modes: [],
+ },
});
if (input.type === "session.open") {
+ models.set(input.sessionId, input.config.model);
emit({
type: "session.opened",
requestId: input.requestId,
@@ -53,7 +73,13 @@ export default function contribute(server: PluginServerContext) {
emit({
type: "usage_reference",
requestId: input.requestId,
- reference: { source: "fixture-session-usage", input: { account: "from-session" } },
+ reference: {
+ source:
+ models.get(input.sessionId) === "missing"
+ ? "missing-source"
+ : "fixture-session-usage",
+ input: { account: "from-session" },
+ },
});
if (input.type === "session.close")
emit({ type: "request.completed", requestId: input.requestId });