mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
fix usage source discovery and session report parity
This commit is contained in:
@@ -1 +1 @@
|
||||
{ "id": "codex-usage-source", "requirements": { "paseo": ">=0.9.2" } }
|
||||
{ "id": "codex-usage-source" }
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="#1a1a1a"/><path d="M7 12h10M12 7v10" stroke="white" stroke-width="2"/></svg>
|
||||
<svg width="54" height="30" viewBox="0 0 54 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z" fill="#211E1E"/>
|
||||
<path d="M12 18H18V24H6V12H12V18Z" fill="#CFCECD"/>
|
||||
<path d="M48 12V24H36V12H48Z" fill="#CFCECD"/>
|
||||
<path d="M54 30H30V0H54V30ZM36 24H48V6H36V24Z" fill="#211E1E"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 173 B After Width: | Height: | Size: 333 B |
@@ -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 () => {};
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<Array<{}>> {
|
||||
return (await readDefaultKey(path)) ? [{}] : [];
|
||||
}
|
||||
|
||||
export async function fetchUsage(
|
||||
input: Input,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
path = authPath(),
|
||||
): Promise<UsageReport> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<typeof inputSchema>;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+28
-2
@@ -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<string, string | undefined>();
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user