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 });