Skip to content

Commit 8b91fe3

Browse files
ost006claude
andauthored
feat(usage): expose per-account vicoop-codex usage via the bridge (#360)
Add an admin/owner-only API to read a connected vicoop-codex agent's per-account Codex usage through the bridge server. The usage data lives on the client (vicoop-codex `serve`'s GET /usage), so a minimal request/response RPC is layered over the WS protocol: - protocol: `usage.request` (Down) + `usage.response` (Up), correlated by requestId; `usage` payload is opaque (z.unknown), forwarded verbatim. - client: `Backend` gains an optional `usage()`; the vicoop-codex backend implements it by GETting its serve `/usage` (read-only, no quota cost). The client answers `usage.request` with `usage.response` (ok | unsupported | usage_failed). - server: `usage-rpc.ts` correlates request↔response (offline/timeout/error, with a responding-agent id-binding guard); `ws.ts` routes `usage.response`; new `GET /admin-api/agents/:id/usage` is owner-session authed and allowed only for the bridge admin (`isAdmin`) or the agent's owner (`conn.ownerPrincipal`), and only for the `vicoop-codex` backend. Errors map to 404 (not found/unauthorized), 400 (wrong backend), 503 (offline), 504 (timeout). Tests: usage-rpc correlation (offline/success/error/timeout/id-binding/stale); client dispatch (ok/unsupported/usage_failed); vicoop-codex usage() GET + error. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9627f0e commit 8b91fe3

11 files changed

Lines changed: 450 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@vicoop-bridge/client": minor
3+
---
4+
5+
vicoop-codex backend: report per-account Codex usage to the bridge on request. The client answers the new `usage.request` frame by querying its local `vicoop-codex serve` `/usage` endpoint, which backs the server's admin/owner-only `GET /admin-api/agents/:id/usage` API.

packages/client/src/backend.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,10 @@ export interface Backend {
4242
// implementations should send a kill signal / close a socket and return
4343
// immediately rather than wait for graceful shutdown.
4444
stop?(): void;
45+
// Optional: return the backend's current usage / rate-limit snapshot, served
46+
// on demand in response to a server-initiated `usage.request` frame. Only
47+
// backends that have such a concept implement this (today: vicoop-codex,
48+
// which queries its local `serve`'s GET /usage). The returned value is opaque
49+
// and forwarded verbatim to the caller, so the shape can evolve freely.
50+
usage?(): Promise<unknown>;
4551
}

packages/client/src/backends/vicoop-codex.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,3 +1235,53 @@ test('envelope.model is dropped when the probed list does NOT advertise it (#302
12351235
);
12361236
assert.equal(body.model, undefined);
12371237
});
1238+
1239+
// ─────────────────────────────────────────────────────────────────────────────
1240+
// usage(): GET <serve baseUrl>/usage (uses the global fetch, not the injected
1241+
// streaming fetchFn). The fake spawn provides the serve baseUrl; we stub
1242+
// globalThis.fetch to script the /usage response.
1243+
// ─────────────────────────────────────────────────────────────────────────────
1244+
1245+
// Drive the lazy `ensureServe()` (which spawns synchronously and awaits the
1246+
// listening line) by emitting that line on the next microtask, then await the
1247+
// in-flight usage() promise.
1248+
async function runUsage(stubFetch: typeof globalThis.fetch): Promise<unknown> {
1249+
const fake = makeFakeSpawn();
1250+
const backend = createVicoopCodexBackend({ spawn: fake.spawn });
1251+
const orig = globalThis.fetch;
1252+
globalThis.fetch = stubFetch;
1253+
try {
1254+
const p = backend.usage!();
1255+
await new Promise<void>((resolve) => queueMicrotask(() => {
1256+
driveServeListening(fake.lastChild());
1257+
resolve();
1258+
}));
1259+
return await p;
1260+
} finally {
1261+
globalThis.fetch = orig;
1262+
}
1263+
}
1264+
1265+
test('usage(): GETs the serve /usage endpoint and returns the parsed JSON', async () => {
1266+
const calls: Array<{ url: string; method: string }> = [];
1267+
const payload = { accounts: [{ key: 'k1', email: 'a@x.com', primary: { remaining_percent: 80 } }] };
1268+
const usage = await runUsage((async (url, init) => {
1269+
calls.push({ url: String(url), method: (init?.method as string) ?? 'GET' });
1270+
return new Response(JSON.stringify(payload), {
1271+
status: 200,
1272+
headers: { 'content-type': 'application/json' },
1273+
});
1274+
}) as typeof globalThis.fetch);
1275+
assert.deepEqual(usage, payload);
1276+
assert.equal(calls.length, 1);
1277+
assert.equal(calls[0].method, 'GET');
1278+
assert.match(calls[0].url, /^http:\/\/127\.0\.0\.1:8787\/usage$/);
1279+
});
1280+
1281+
test('usage(): a non-OK serve response throws (with status in the message)', async () => {
1282+
await assert.rejects(
1283+
runUsage((async () =>
1284+
new Response('nope', { status: 503 })) as typeof globalThis.fetch),
1285+
/HTTP 503/,
1286+
);
1287+
});

packages/client/src/backends/vicoop-codex.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,6 +1038,25 @@ export function createVicoopCodexBackend(
10381038
}
10391039
},
10401040

1041+
// Per-account Codex usage, served on demand for the bridge's usage API.
1042+
// Reuses the shared `serve` singleton and hits its read-only GET /usage
1043+
// (which does not consume quota). The payload is forwarded verbatim.
1044+
async usage() {
1045+
const handle = await ensureServe();
1046+
const res = await fetch(`${handle.baseUrl}/usage`, {
1047+
method: 'GET',
1048+
headers: { accept: 'application/json' },
1049+
});
1050+
if (!res.ok) {
1051+
const detail = await res.text().catch(() => '');
1052+
throw new Error(
1053+
`vicoop-codex serve /usage returned HTTP ${res.status}` +
1054+
(detail ? `: ${detail.slice(0, 300)}` : ''),
1055+
);
1056+
}
1057+
return res.json();
1058+
},
1059+
10411060
async resolveCapabilities() {
10421061
const ids = await probeVicoopCodexModels({
10431062
command,

packages/client/src/client.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { AddressInfo } from 'node:net';
77
import { fileURLToPath } from 'node:url';
88
import { WebSocketServer, type WebSocket } from 'ws';
99
import {
10+
encodeFrame,
1011
parseUpFrame,
1112
type Part,
1213
type TaskAssignFrame,
@@ -1157,3 +1158,88 @@ test('processTask: forwards non-terminal frames (e.g. task.artifact, task.status
11571158
['task.status', 'task.artifact', 'task.complete'],
11581159
);
11591160
});
1161+
1162+
// ── usage.request → usage.response dispatch ──────────────────────────────────
1163+
// Stand up a fake bridge server that, on hello, sends a `usage.request` down
1164+
// the wire, and capture the client's `usage.response`.
1165+
async function runUsageDispatch(backend: Backend): Promise<UpFrame[]> {
1166+
const server = createServer();
1167+
const wss = new WebSocketServer({ server, path: '/connect' });
1168+
const serverUrl = await listen(server);
1169+
const upFrames: UpFrame[] = [];
1170+
wss.on('connection', (ws) => {
1171+
ws.on('message', (raw) => {
1172+
const f = parseUpFrame(typeof raw === 'string' ? raw : raw.toString('utf8'));
1173+
if (f.type === 'hello') {
1174+
ws.send(encodeFrame({ type: 'usage.request', requestId: 'req-1' }));
1175+
} else {
1176+
upFrames.push(f);
1177+
}
1178+
});
1179+
});
1180+
const c = makeSink();
1181+
const client = new Client({
1182+
serverUrl,
1183+
token: 'client-token',
1184+
agentId: 'agent-1',
1185+
backendKind: backend.name,
1186+
backend,
1187+
logSink: c.sink,
1188+
logLevel: 'info',
1189+
reconnectDelayMs: 10,
1190+
reconnectMaxDelayMs: 10,
1191+
reconnectJitterRatio: 0,
1192+
reconnectStableMs: 0,
1193+
heartbeatIntervalMs: 0,
1194+
});
1195+
try {
1196+
client.start();
1197+
await waitFor(
1198+
() => upFrames.some((f) => f.type === 'usage.response'),
1199+
'expected a usage.response frame',
1200+
);
1201+
return upFrames;
1202+
} finally {
1203+
client.stop();
1204+
await closeServer(server, wss);
1205+
}
1206+
}
1207+
1208+
test('usage.request: client queries backend.usage() and replies usage.response', async () => {
1209+
const payload = { accounts: [{ key: 'k1', email: 'a@x.com' }] };
1210+
const backend: Backend = {
1211+
name: 'vicoop-codex',
1212+
handle: async () => {},
1213+
usage: async () => payload,
1214+
};
1215+
const frames = await runUsageDispatch(backend);
1216+
const resp = frames.find((f) => f.type === 'usage.response');
1217+
assert.ok(resp && resp.type === 'usage.response');
1218+
assert.equal(resp.requestId, 'req-1');
1219+
assert.equal(resp.ok, true);
1220+
assert.deepEqual(resp.usage, payload);
1221+
});
1222+
1223+
test('usage.request: backend without usage() replies ok:false / unsupported', async () => {
1224+
const frames = await runUsageDispatch(backendOf('echo', async () => {}));
1225+
const resp = frames.find((f) => f.type === 'usage.response');
1226+
assert.ok(resp && resp.type === 'usage.response');
1227+
assert.equal(resp.ok, false);
1228+
assert.equal(resp.error?.code, 'unsupported');
1229+
});
1230+
1231+
test('usage.request: a throwing backend.usage() replies ok:false / usage_failed', async () => {
1232+
const backend: Backend = {
1233+
name: 'vicoop-codex',
1234+
handle: async () => {},
1235+
usage: async () => {
1236+
throw new Error('serve down');
1237+
},
1238+
};
1239+
const frames = await runUsageDispatch(backend);
1240+
const resp = frames.find((f) => f.type === 'usage.response');
1241+
assert.ok(resp && resp.type === 'usage.response');
1242+
assert.equal(resp.ok, false);
1243+
assert.equal(resp.error?.code, 'usage_failed');
1244+
assert.match(resp.error?.message ?? '', /serve down/);
1245+
});

packages/client/src/client.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,9 @@ export class Client {
358358
case 'ping':
359359
this.send({ type: 'pong' });
360360
break;
361+
case 'usage.request':
362+
this.handleUsageRequest(frame);
363+
break;
361364
}
362365
});
363366

@@ -554,6 +557,45 @@ export class Client {
554557
this.ws.send(encodeFrame(frame));
555558
}
556559

560+
// Answer a server-initiated usage.request by querying the backend's optional
561+
// usage() capability. Fire-and-forget (errors surface as usage.response with
562+
// ok:false rather than throwing) so it never stalls the message loop.
563+
private handleUsageRequest(
564+
frame: import('@vicoop-bridge/protocol').UsageRequestFrame,
565+
): void {
566+
const backend = this.opts.backend;
567+
if (!backend.usage) {
568+
this.send({
569+
type: 'usage.response',
570+
requestId: frame.requestId,
571+
ok: false,
572+
error: {
573+
code: 'unsupported',
574+
message: `backend '${backend.name}' does not support usage queries`,
575+
},
576+
});
577+
return;
578+
}
579+
this.logger.info(`usage.request requestId=${safeToken(frame.requestId)}`);
580+
backend.usage().then(
581+
(usage) => {
582+
this.send({ type: 'usage.response', requestId: frame.requestId, ok: true, usage });
583+
},
584+
(err: unknown) => {
585+
const message = err instanceof Error ? err.message : String(err);
586+
this.logger.error(
587+
`usage.request failed requestId=${safeToken(frame.requestId)}: ${safeToken(message)}`,
588+
);
589+
this.send({
590+
type: 'usage.response',
591+
requestId: frame.requestId,
592+
ok: false,
593+
error: { code: 'usage_failed', message },
594+
});
595+
},
596+
);
597+
}
598+
557599
private async runTask(frame: import('@vicoop-bridge/protocol').DownFrame): Promise<void> {
558600
if (frame.type !== 'task.assign') return;
559601
const controller = new AbortController();

packages/protocol/src/index.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,11 +227,29 @@ export const TaskFailFrame = z.object({
227227

228228
export const PongFrame = z.object({ type: z.literal('pong') });
229229

230+
// Response to a server-initiated `usage.request` (see DownFrame). Correlated by
231+
// `requestId`. `usage` is the backend's opaque usage payload (e.g. the
232+
// vicoop-codex `{ accounts: [...] }` shape) — kept `unknown` so the payload can
233+
// evolve without a protocol bump; the server passes it through verbatim.
234+
export const UsageResponseFrame = z.object({
235+
type: z.literal('usage.response'),
236+
requestId: z.string(),
237+
ok: z.boolean(),
238+
usage: z.unknown().optional(),
239+
error: z
240+
.object({
241+
code: z.string(),
242+
message: z.string(),
243+
})
244+
.optional(),
245+
});
246+
230247
export type HelloFrame = z.infer<typeof HelloFrame>;
231248
export type TaskStatusFrame = z.infer<typeof TaskStatusFrame>;
232249
export type TaskArtifactFrame = z.infer<typeof TaskArtifactFrame>;
233250
export type TaskCompleteFrame = z.infer<typeof TaskCompleteFrame>;
234251
export type TaskFailFrame = z.infer<typeof TaskFailFrame>;
252+
export type UsageResponseFrame = z.infer<typeof UsageResponseFrame>;
235253

236254
export const UpFrame = z.discriminatedUnion('type', [
237255
HelloFrame,
@@ -240,6 +258,7 @@ export const UpFrame = z.discriminatedUnion('type', [
240258
TaskCompleteFrame,
241259
TaskFailFrame,
242260
PongFrame,
261+
UsageResponseFrame,
243262
]);
244263
export type UpFrame = z.infer<typeof UpFrame>;
245264

@@ -258,13 +277,23 @@ export const TaskCancelFrame = z.object({
258277

259278
export const PingFrame = z.object({ type: z.literal('ping') });
260279

280+
// Server→client request for the backend's current usage snapshot. The client
281+
// replies with a `usage.response` UpFrame carrying the same `requestId`. Only
282+
// backends that implement `usage()` can satisfy it; others reply with an error.
283+
export const UsageRequestFrame = z.object({
284+
type: z.literal('usage.request'),
285+
requestId: z.string(),
286+
});
287+
261288
export type TaskAssignFrame = z.infer<typeof TaskAssignFrame>;
262289
export type TaskCancelFrame = z.infer<typeof TaskCancelFrame>;
290+
export type UsageRequestFrame = z.infer<typeof UsageRequestFrame>;
263291

264292
export const DownFrame = z.discriminatedUnion('type', [
265293
TaskAssignFrame,
266294
TaskCancelFrame,
267295
PingFrame,
296+
UsageRequestFrame,
268297
]);
269298
export type DownFrame = z.infer<typeof DownFrame>;
270299

packages/server/src/http.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414
} from '@a2x/sdk';
1515
import type { ClientConnection, Registry } from './registry.js';
1616
import { createAdminA2XAgent } from './admin.js';
17-
import { getAdminWallets } from './admin-scope.js';
17+
import { getAdminWallets, isAdmin } from './admin-scope.js';
18+
import { requestUsage, UsageRpcError } from './usage-rpc.js';
1819
import {
1920
AdminApiError,
2021
addCaller,
@@ -424,6 +425,41 @@ export function createHttpApp(opts: ServerHttpOptions): Hono {
424425
}
425426
});
426427

428+
// Per-account backend usage for a connected agent. Restricted to the bridge
429+
// admin or the agent's owning user. Only the `vicoop-codex` backend supports
430+
// it; the data is pulled from the client over the WS (usage-rpc) and the
431+
// client's serve `/usage` payload is returned verbatim.
432+
app.get('/admin-api/agents/:id/usage', async (c) => {
433+
const auth = await authOwnerSession(c);
434+
if (!auth.ok) return adminApiUnauthorized(c, auth);
435+
const agentId = c.req.param('id');
436+
const conn = opts.registry.getAgent(agentId);
437+
// Collapse "not connected" and "not authorized" into one 404 so a
438+
// non-owner can't probe which agent ids exist / are online.
439+
if (!conn || !(isAdmin(auth.principalId) || conn.ownerPrincipal === auth.principalId)) {
440+
return c.json({ error: 'Agent not found, not connected, or not authorized.' }, 404);
441+
}
442+
if (conn.backendKind !== 'vicoop-codex') {
443+
return c.json(
444+
{
445+
error: `Usage is not available for backend '${conn.backendKind ?? 'unknown'}' (supported: vicoop-codex).`,
446+
},
447+
400,
448+
);
449+
}
450+
try {
451+
const usage = await requestUsage(opts.registry, agentId);
452+
return c.json(usage as Record<string, unknown>);
453+
} catch (err) {
454+
if (err instanceof UsageRpcError) {
455+
const status = err.code === 'offline' ? 503 : err.code === 'timeout' ? 504 : 502;
456+
return c.json({ error: err.message, code: err.code }, status);
457+
}
458+
logEvent('admin_api_error', { error: String(err) });
459+
return c.json({ error: 'Internal error' }, 500);
460+
}
461+
});
462+
427463
app.post('/admin-api/agents/:id/callers', async (c) => {
428464
const auth = await authOwnerSession(c);
429465
if (!auth.ok) return adminApiUnauthorized(c, auth);

0 commit comments

Comments
 (0)