From 4ea4c1cf8a150ce29c7d35b55ebba3707287a3fa Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 08:50:59 +0200 Subject: [PATCH 1/8] feat(usage): polymorfa usage summary, records and gates Reads /platform/usage, /platform/usage/records and /platform/gates through the raw client until the typed SDK resource is published. Usage is measured, not charged. Co-Authored-By: Claude --- README.md | 22 +++ src/commands/usage/gates.ts | 35 +++++ src/commands/usage/records.ts | 51 +++++++ src/commands/usage/summary.ts | 37 +++++ src/services/usage.ts | 261 ++++++++++++++++++++++++++++++++++ test/commands/usage.test.ts | 172 ++++++++++++++++++++++ 6 files changed, 578 insertions(+) create mode 100644 src/commands/usage/gates.ts create mode 100644 src/commands/usage/records.ts create mode 100644 src/commands/usage/summary.ts create mode 100644 src/services/usage.ts create mode 100644 test/commands/usage.test.ts diff --git a/README.md b/README.md index 3d206bde..5a3f48ce 100644 --- a/README.md +++ b/README.md @@ -569,6 +569,28 @@ passwords never appear in the command line. `create` refuses a body that names exits with code 4 when the trunk changed; `delete` exits with code 4 while a session routes calls to the trunk. +### Call usage and limits + +Polymorfa measures the calls your numbers host. Usage is measured, not charged. +Commands require a server API key with `sessions:read`; a project API key reads +its own project. + +```shell +polymorfa usage summary +polymorfa usage summary --period 2026-09 --session support +polymorfa usage records --call 3EB0C431C26A1916E07A +polymorfa usage records --session support --meter call.duration --limit 50 +polymorfa usage records --session support --cursor +polymorfa usage gates --session support +``` + +`usage summary` totals one calendar month (UTC) by meter and by number, and +defaults to the current month. `usage records` lists records newest first and +prints the cursor for the next page. `usage gates` shows each gate's mode, +limit, usage this month and how often an action went over the limit in the last +30 days: in `record` mode nothing is refused, and in `enforce` mode an action +over the limit exits with code 4 (`gate_limit_reached`). + ## Session call rejection and stable identities `call reject` is a separate command for the Linked Device session route. It diff --git a/src/commands/usage/gates.ts b/src/commands/usage/gates.ts new file mode 100644 index 00000000..acee8fda --- /dev/null +++ b/src/commands/usage/gates.ts @@ -0,0 +1,35 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createRawClient} from '../../sdk/client.js' +import {listUsageGates, renderUsageGates} from '../../services/usage.js' + +export default class UsageGates extends BaseCommand { + static override description = + 'List your usage gates with their mode, limit, usage this month and how often an action went over the limit in the last 30 days. In record mode a gate never refuses an action; in enforce mode an action over the limit fails with gate_limit_reached. Pass --session to include limits that apply per number.' + + static override examples = [ + '<%= config.bin %> usage gates', + '<%= config.bin %> usage gates --session support --json', + ] + + static override flags = { + session: Flags.string({ + description: 'Include limits that apply to one number (session name).', + }), + } + + static override summary = 'List usage gates and their limits.' + + async run(): Promise { + const {flags} = await this.parse(UsageGates) + const result = await listUsageGates( + createRawClient(await this.platformEnvironment(flags)), + { + ...(flags.project === undefined ? {} : {project: flags.project}), + ...(flags.session === undefined ? {} : {session: flags.session}), + }, + ) + this.writeValue(result, renderUsageGates(result.data), flags) + } +} diff --git a/src/commands/usage/records.ts b/src/commands/usage/records.ts new file mode 100644 index 00000000..9b4036c2 --- /dev/null +++ b/src/commands/usage/records.ts @@ -0,0 +1,51 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createRawClient} from '../../sdk/client.js' +import {listUsageRecords, renderUsageRecords} from '../../services/usage.js' + +export default class UsageRecords extends BaseCommand { + static override description = + 'List usage records, newest first. Filter by call, number, meter or calendar month. A call has one call.duration record, and an outgoing Cloud API call also a call.cloud_pulses record. Pass the printed cursor to read the next page.' + + static override examples = [ + '<%= config.bin %> usage records --call 3EB0C431C26A1916E07A', + '<%= config.bin %> usage records --session support --meter call.duration --limit 50 --json', + ] + + static override flags = { + call: Flags.string({description: 'One call, by call ID.'}), + cursor: Flags.string({description: 'Continue from a previous page.'}), + limit: Flags.integer({ + description: 'Records per page, 1 to 100.', + max: 100, + min: 1, + }), + meter: Flags.string({ + description: 'One meter, for example call.duration.', + }), + period: Flags.string({ + description: 'Calendar month in UTC as YYYY-MM.', + }), + session: Flags.string({description: 'Limit to one number (session name).'}), + } + + static override summary = 'List usage records.' + + async run(): Promise { + const {flags} = await this.parse(UsageRecords) + const result = await listUsageRecords( + createRawClient(await this.platformEnvironment(flags)), + { + ...(flags.call === undefined ? {} : {callId: flags.call}), + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.meter === undefined ? {} : {meter: flags.meter}), + ...(flags.period === undefined ? {} : {period: flags.period}), + ...(flags.project === undefined ? {} : {project: flags.project}), + ...(flags.session === undefined ? {} : {session: flags.session}), + }, + ) + this.writeValue(result, renderUsageRecords(result.data), flags) + } +} diff --git a/src/commands/usage/summary.ts b/src/commands/usage/summary.ts new file mode 100644 index 00000000..8fe400e2 --- /dev/null +++ b/src/commands/usage/summary.ts @@ -0,0 +1,37 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createRawClient} from '../../sdk/client.js' +import {getUsageSummary, renderUsageSummary} from '../../services/usage.js' + +export default class UsageSummary extends BaseCommand { + static override description = + 'Show metered usage for one calendar month (UTC), by meter and by number. Usage is measured, not charged: no credits are deducted for it. Requires a server API key with sessions:read. A project API key reads its own project.' + + static override examples = [ + '<%= config.bin %> usage summary', + '<%= config.bin %> usage summary --period 2026-09 --session support --json', + ] + + static override flags = { + period: Flags.string({ + description: 'Calendar month in UTC as YYYY-MM. Defaults to this month.', + }), + session: Flags.string({description: 'Limit to one number (session name).'}), + } + + static override summary = 'Show metered usage for a month.' + + async run(): Promise { + const {flags} = await this.parse(UsageSummary) + const result = await getUsageSummary( + createRawClient(await this.platformEnvironment(flags)), + { + ...(flags.period === undefined ? {} : {period: flags.period}), + ...(flags.project === undefined ? {} : {project: flags.project}), + ...(flags.session === undefined ? {} : {session: flags.session}), + }, + ) + this.writeValue(result, renderUsageSummary(result.data), flags) + } +} diff --git a/src/services/usage.ts b/src/services/usage.ts new file mode 100644 index 00000000..85fae7f3 --- /dev/null +++ b/src/services/usage.ts @@ -0,0 +1,261 @@ +import { + PolymorfaValidationError, + type RawClient, + type ResponseMetadata, +} from '@polymorfa/sdk' + +// Metered usage and usage gates (Platform API `/platform/usage`, +// `/platform/usage/records` and `/platform/gates`). The typed SDK resource +// lands with the next published SDK; these read through the raw client so the +// commands work with the pinned SDK today. + +export interface UsageResult { + readonly data: T + readonly metadata: { + readonly apiVersion?: string + readonly attempts: number + readonly requestId?: string + readonly status: number + } +} + +export interface UsageMeterTotal { + readonly keySource: string + readonly meter: string + readonly quantity: number + readonly records: number + readonly unit: string +} + +export interface UsageSummary { + readonly billingEnabled: boolean + readonly end: string + readonly meters: readonly UsageMeterTotal[] + readonly numbers: readonly { + readonly meters: readonly UsageMeterTotal[] + readonly projectId: null | string + readonly session: string + }[] + readonly numbersTruncated: boolean + readonly period: string + readonly projectId: null | string + readonly session: null | string + readonly start: string +} + +export interface UsageRecord { + readonly dimensions: Readonly> + readonly id: string + readonly keySource: string + readonly meter: string + readonly occurredAt: string + readonly pricedCredits: null | number + readonly pricingState: string + readonly projectId: null | string + readonly quantity: number + readonly revision: number + readonly session: null | string + readonly sourceId: string + readonly sourceKind: string + readonly unit: string +} + +export interface UsageRecordPage { + readonly nextCursor: null | string + readonly records: readonly UsageRecord[] +} + +export interface UsageGate { + readonly active: boolean + readonly decisions: { + readonly blocked: number + readonly evaluationError: number + readonly wouldBlock: number + } + readonly key: string + readonly kind: string + readonly limit: null | number + readonly mode: string + readonly overLimit: boolean | null + readonly subject: string + readonly unit: null | string + readonly used: null | number +} + +export interface UsageGateList { + readonly gates: readonly UsageGate[] + readonly session: null | string +} + +export interface UsageFilters { + readonly callId?: string + readonly cursor?: string + readonly limit?: number + readonly meter?: string + readonly period?: string + readonly project?: string + readonly session?: string +} + +const PERIOD = /^\d{4}-(0[1-9]|1[0-2])$/ + +export function validatePeriod(period?: string): string | undefined { + if (period === undefined) return undefined + if (!PERIOD.test(period)) { + throw new PolymorfaValidationError( + 'Use a calendar month in the form YYYY-MM, for example 2026-09.', + {code: 'invalid_usage_period'}, + ) + } + return period +} + +export async function getUsageSummary( + client: RawClient, + filters: UsageFilters, +): Promise> { + return request(client, '/platform/usage', { + period: validatePeriod(filters.period), + projectId: filters.project, + session: filters.session, + }) +} + +export async function listUsageRecords( + client: RawClient, + filters: UsageFilters, +): Promise> { + return request(client, '/platform/usage/records', { + callId: filters.callId, + cursor: filters.cursor, + limit: filters.limit, + meter: filters.meter, + period: validatePeriod(filters.period), + projectId: filters.project, + session: filters.session, + }) +} + +export async function listUsageGates( + client: RawClient, + filters: UsageFilters, +): Promise> { + return request(client, '/platform/gates', { + projectId: filters.project, + session: filters.session, + }) +} + +export function renderUsageSummary(summary: UsageSummary): string { + const lines = [ + `Period\t${summary.period}`, + `Project\t${summary.projectId ?? 'every project'}`, + `Number\t${summary.session ?? 'every number'}`, + `Charged\t${summary.billingEnabled ? 'yes' : 'no (measured only)'}`, + ] + lines.push( + summary.meters.length === 0 + ? 'No usage recorded for this period.' + : summary.meters + .map( + (total) => + `${total.meter}\t${total.quantity} ${total.unit}\t${total.records} records\t${total.keySource}`, + ) + .join('\n'), + ) + if (summary.numbers.length > 0) { + lines.push( + 'Numbers', + ...summary.numbers.map( + (number) => + `${number.session}\t${number.meters + .map((total) => `${total.meter} ${total.quantity} ${total.unit}`) + .join(', ')}`, + ), + ) + } + if (summary.numbersTruncated) lines.push('More than 100 numbers had usage.') + return lines.join('\n') +} + +export function renderUsageRecords(page: UsageRecordPage): string { + if (page.records.length === 0) return 'No usage records found.' + const rows = page.records.map((record) => + [ + record.occurredAt, + record.meter, + `${record.quantity} ${record.unit}`, + record.session ?? '-', + record.sourceId, + record.pricingState, + `rev ${record.revision}`, + ].join('\t'), + ) + if (page.nextCursor !== null) { + rows.push(`Next page\t--cursor ${page.nextCursor}`) + } + return rows.join('\n') +} + +export function renderUsageGates(list: UsageGateList): string { + if (list.gates.length === 0) return 'No usage gates found.' + return list.gates + .map((gate) => + [ + gate.key, + gate.mode, + gate.active ? 'checked' : 'not checked yet', + `limit ${limitLabel(gate.limit)}`, + `used ${gate.used ?? '-'}`, + `over limit last 30 days ${gate.decisions.wouldBlock + gate.decisions.blocked}`, + ].join('\t'), + ) + .join('\n') +} + +function limitLabel(limit: null | number): string { + if (limit === null) return 'per number (pass --session)' + return limit < 0 ? 'none' : String(limit) +} + +async function request( + client: RawClient, + path: string, + query: Readonly>, +): Promise> { + const response = await client.request({ + method: 'GET', + path, + query: Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== undefined), + ), + }) + return result(unwrap(response.data), response.metadata) +} + +function unwrap(body: unknown): T { + if ( + typeof body === 'object' && + body !== null && + 'data' in (body as Record) + ) { + return (body as {data: T}).data + } + return body as T +} + +function result(data: T, metadata: ResponseMetadata): UsageResult { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + }, + } +} diff --git a/test/commands/usage.test.ts b/test/commands/usage.test.ts new file mode 100644 index 00000000..581e93fa --- /dev/null +++ b/test/commands/usage.test.ts @@ -0,0 +1,172 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +const meterTotal = { + keySource: 'none', + meter: 'call.duration', + quantity: 5400, + records: 42, + unit: 'second', +} +const summary = { + billingEnabled: false, + end: '2026-10-01T00:00:00.000Z', + meters: [meterTotal], + numbers: [{meters: [meterTotal], projectId: null, session: 'support'}], + numbersTruncated: false, + period: '2026-09', + projectId: null, + session: null, + start: '2026-09-01T00:00:00.000Z', +} +const record = { + dimensions: { + connections: 1, + direction: 'outbound', + origin: 'direct', + participants: 1, + sipLegs: 0, + upstream: 'linked_device', + video: false, + }, + id: '11111111-1111-4111-8111-111111111111', + keySource: 'none', + meter: 'call.duration', + occurredAt: '2026-09-19T10:00:00.000Z', + pricedCredits: null, + pricingState: 'unpriced', + projectId: null, + quantity: 42, + revision: 1, + session: 'support', + sourceId: 'CALL-1', + sourceKind: 'call', + unit: 'second', +} +const gates = { + gates: [ + { + active: true, + decisions: {blocked: 0, evaluationError: 0, wouldBlock: 3}, + key: 'calls.outbound_monthly', + kind: 'quota', + limit: 50, + mode: 'record', + overLimit: false, + subject: 'number', + unit: 'call', + used: 4, + }, + { + active: false, + decisions: {blocked: 0, evaluationError: 0, wouldBlock: 0}, + key: 'voice.flows', + kind: 'capability', + limit: 1, + mode: 'record', + overLimit: false, + subject: 'team', + unit: null, + used: null, + }, + ], + session: 'support', +} + +describe('usage commands', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('summarizes a month and lists a call record page', async () => { + server = await startHttpSequenceServer([ + {body: {data: summary, success: true}}, + { + body: { + data: {nextCursor: 'cursor-2', records: [record]}, + success: true, + }, + }, + ]) + + const summarized = await apiKey(() => + runCommand('usage summary --period 2026-09'), + ) + const listed = await apiKey(() => + runCommand('usage records --call CALL-1 --limit 10'), + ) + + expect(summarized.error).toBeUndefined() + expect(summarized.stdout).toContain('Period\t2026-09') + expect(summarized.stdout).toContain('Charged\tno (measured only)') + expect(summarized.stdout).toContain( + 'call.duration\t5400 second\t42 records', + ) + expect(listed.stdout).toContain('call.duration\t42 second\tsupport\tCALL-1') + expect(listed.stdout).toContain('Next page\t--cursor cursor-2') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/usage?period=2026-09', + 'GET /platform/usage/records?callId=CALL-1&limit=10', + ]) + }) + + test('lists gates with their mode and limit', async () => { + server = await startHttpSequenceServer([ + {body: {data: gates, success: true}}, + ]) + + const listed = await apiKey(() => + runCommand('usage gates --session support'), + ) + + expect(listed.error).toBeUndefined() + expect(listed.stdout).toContain( + 'calls.outbound_monthly\trecord\tchecked\tlimit 50\tused 4\tover limit last 30 days 3', + ) + expect(listed.stdout).toContain('voice.flows\trecord\tnot checked yet') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/gates?session=support', + ]) + }) + + test('refuses a malformed period before any request', async () => { + server = await startHttpSequenceServer([ + {body: {data: summary, success: true}}, + ]) + + const failed = await apiKey(() => + runCommand('usage summary --period 2026-9'), + ) + + expect(exitCode(failed.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + async function apiKey(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } +}) + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} From 8023bae7d00fb2bad920acd1978698d0bd32c4c7 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 12:17:36 +0200 Subject: [PATCH 2/8] docs(usage): usage gates needs a team API key Co-Authored-By: Claude --- README.md | 3 ++- src/commands/usage/gates.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5a3f48ce..f5886dd1 100644 --- a/README.md +++ b/README.md @@ -573,7 +573,8 @@ session routes calls to the trunk. Polymorfa measures the calls your numbers host. Usage is measured, not charged. Commands require a server API key with `sessions:read`; a project API key reads -its own project. +its own project's usage. `usage gates` needs a team API key, because gate state +covers the whole team. ```shell polymorfa usage summary diff --git a/src/commands/usage/gates.ts b/src/commands/usage/gates.ts index acee8fda..e2fba7cc 100644 --- a/src/commands/usage/gates.ts +++ b/src/commands/usage/gates.ts @@ -6,7 +6,7 @@ import {listUsageGates, renderUsageGates} from '../../services/usage.js' export default class UsageGates extends BaseCommand { static override description = - 'List your usage gates with their mode, limit, usage this month and how often an action went over the limit in the last 30 days. In record mode a gate never refuses an action; in enforce mode an action over the limit fails with gate_limit_reached. Pass --session to include limits that apply per number.' + 'List your usage gates with their mode, limit, usage this month and how often an action went over the limit in the last 30 days. In record mode a gate never refuses an action; in enforce mode an action over the limit fails with gate_limit_reached. Pass --session to include limits that apply per number. Gate state covers the whole team, so this needs a team API key; project API keys are refused. A limit that cannot be checked fails the action with 503 in either mode.' static override examples = [ '<%= config.bin %> usage gates', From 83dce18a40f4c3b0d674e1ad1b05e655ca3ef3f4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 14:25:44 +0200 Subject: [PATCH 3/8] fix(usage): show each row's project in cross-project usage output usage records prints each record's project, and usage summary without a project prints each number's project, so numbers that share a session name across projects stay distinguishable. Co-Authored-By: Claude --- README.md | 5 ++-- src/services/usage.ts | 15 +++++++++--- test/commands/usage.test.ts | 49 ++++++++++++++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f5886dd1..a81a8635 100644 --- a/README.md +++ b/README.md @@ -586,8 +586,9 @@ polymorfa usage gates --session support ``` `usage summary` totals one calendar month (UTC) by meter and by number, and -defaults to the current month. `usage records` lists records newest first and -prints the cursor for the next page. `usage gates` shows each gate's mode, +defaults to the current month; without `--project`, each number row also shows +its project. `usage records` lists records newest first with each record's +project, and prints the cursor for the next page. `usage gates` shows each gate's mode, limit, usage this month and how often an action went over the limit in the last 30 days: in `record` mode nothing is refused, and in `enforce` mode an action over the limit exits with code 4 (`gate_limit_reached`). diff --git a/src/services/usage.ts b/src/services/usage.ts index 85fae7f3..4ce0acad 100644 --- a/src/services/usage.ts +++ b/src/services/usage.ts @@ -164,13 +164,19 @@ export function renderUsageSummary(summary: UsageSummary): string { .join('\n'), ) if (summary.numbers.length > 0) { + // Without a project, numbers from several projects can share a session + // name, so each row names its project. + const crossProject = summary.projectId === null lines.push( 'Numbers', - ...summary.numbers.map( - (number) => - `${number.session}\t${number.meters + ...summary.numbers.map((number) => + [ + number.session, + ...(crossProject ? [number.projectId ?? '-'] : []), + number.meters .map((total) => `${total.meter} ${total.quantity} ${total.unit}`) - .join(', ')}`, + .join(', '), + ].join('\t'), ), ) } @@ -186,6 +192,7 @@ export function renderUsageRecords(page: UsageRecordPage): string { record.meter, `${record.quantity} ${record.unit}`, record.session ?? '-', + record.projectId ?? '-', record.sourceId, record.pricingState, `rev ${record.revision}`, diff --git a/test/commands/usage.test.ts b/test/commands/usage.test.ts index 581e93fa..c6fda939 100644 --- a/test/commands/usage.test.ts +++ b/test/commands/usage.test.ts @@ -10,6 +10,9 @@ import { const API_KEY = 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const PROJECT_A = '22222222-2222-4222-8222-222222222222' +const PROJECT_B = '33333333-3333-4333-8333-333333333333' + const meterTotal = { keySource: 'none', meter: 'call.duration', @@ -21,7 +24,10 @@ const summary = { billingEnabled: false, end: '2026-10-01T00:00:00.000Z', meters: [meterTotal], - numbers: [{meters: [meterTotal], projectId: null, session: 'support'}], + numbers: [ + {meters: [meterTotal], projectId: PROJECT_A, session: 'support'}, + {meters: [meterTotal], projectId: PROJECT_B, session: 'support'}, + ], numbersTruncated: false, period: '2026-09', projectId: null, @@ -44,7 +50,7 @@ const record = { occurredAt: '2026-09-19T10:00:00.000Z', pricedCredits: null, pricingState: 'unpriced', - projectId: null, + projectId: PROJECT_A, quantity: 42, revision: 1, session: 'support', @@ -114,7 +120,15 @@ describe('usage commands', () => { expect(summarized.stdout).toContain( 'call.duration\t5400 second\t42 records', ) - expect(listed.stdout).toContain('call.duration\t42 second\tsupport\tCALL-1') + expect(summarized.stdout).toContain( + `support\t${PROJECT_A}\tcall.duration 5400 second`, + ) + expect(summarized.stdout).toContain( + `support\t${PROJECT_B}\tcall.duration 5400 second`, + ) + expect(listed.stdout).toContain( + `call.duration\t42 second\tsupport\t${PROJECT_A}\tCALL-1`, + ) expect(listed.stdout).toContain('Next page\t--cursor cursor-2') expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ 'GET /platform/usage?period=2026-09', @@ -122,6 +136,35 @@ describe('usage commands', () => { ]) }) + test('omits the per-number project when the summary covers one project', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: { + ...summary, + numbers: [ + {meters: [meterTotal], projectId: PROJECT_A, session: 'support'}, + ], + projectId: PROJECT_A, + }, + success: true, + }, + }, + ]) + + const summarized = await apiKey(() => + runCommand(`usage summary --project ${PROJECT_A}`), + ) + + expect(summarized.error).toBeUndefined() + expect(summarized.stdout).toContain(`Project\t${PROJECT_A}`) + expect(summarized.stdout).toContain('support\tcall.duration 5400 second') + expect(summarized.stdout).not.toContain(`support\t${PROJECT_A}`) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `GET /platform/usage?projectId=${PROJECT_A}`, + ]) + }) + test('lists gates with their mode and limit', async () => { server = await startHttpSequenceServer([ {body: {data: gates, success: true}}, From 775e2c1c3dbe8e64238a542462ee763d48a66049 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 12:51:42 +0200 Subject: [PATCH 4/8] Use typed SDK usage methods in CLI commands --- src/commands/usage/gates.ts | 4 +- src/commands/usage/records.ts | 4 +- src/commands/usage/summary.ts | 4 +- src/services/usage.ts | 150 ++++++++-------------------------- test/commands/usage.test.ts | 47 +++++++++++ 5 files changed, 85 insertions(+), 124 deletions(-) diff --git a/src/commands/usage/gates.ts b/src/commands/usage/gates.ts index e2fba7cc..4b4b9d76 100644 --- a/src/commands/usage/gates.ts +++ b/src/commands/usage/gates.ts @@ -1,7 +1,7 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../base-command.js' -import {createRawClient} from '../../sdk/client.js' +import {createPlatformClient} from '../../sdk/client.js' import {listUsageGates, renderUsageGates} from '../../services/usage.js' export default class UsageGates extends BaseCommand { @@ -24,7 +24,7 @@ export default class UsageGates extends BaseCommand { async run(): Promise { const {flags} = await this.parse(UsageGates) const result = await listUsageGates( - createRawClient(await this.platformEnvironment(flags)), + createPlatformClient(await this.platformEnvironment(flags)), { ...(flags.project === undefined ? {} : {project: flags.project}), ...(flags.session === undefined ? {} : {session: flags.session}), diff --git a/src/commands/usage/records.ts b/src/commands/usage/records.ts index 9b4036c2..ef8326a7 100644 --- a/src/commands/usage/records.ts +++ b/src/commands/usage/records.ts @@ -1,7 +1,7 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../base-command.js' -import {createRawClient} from '../../sdk/client.js' +import {createPlatformClient} from '../../sdk/client.js' import {listUsageRecords, renderUsageRecords} from '../../services/usage.js' export default class UsageRecords extends BaseCommand { @@ -35,7 +35,7 @@ export default class UsageRecords extends BaseCommand { async run(): Promise { const {flags} = await this.parse(UsageRecords) const result = await listUsageRecords( - createRawClient(await this.platformEnvironment(flags)), + createPlatformClient(await this.platformEnvironment(flags)), { ...(flags.call === undefined ? {} : {callId: flags.call}), ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), diff --git a/src/commands/usage/summary.ts b/src/commands/usage/summary.ts index 8fe400e2..46934db7 100644 --- a/src/commands/usage/summary.ts +++ b/src/commands/usage/summary.ts @@ -1,7 +1,7 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../base-command.js' -import {createRawClient} from '../../sdk/client.js' +import {createPlatformClient} from '../../sdk/client.js' import {getUsageSummary, renderUsageSummary} from '../../services/usage.js' export default class UsageSummary extends BaseCommand { @@ -25,7 +25,7 @@ export default class UsageSummary extends BaseCommand { async run(): Promise { const {flags} = await this.parse(UsageSummary) const result = await getUsageSummary( - createRawClient(await this.platformEnvironment(flags)), + createPlatformClient(await this.platformEnvironment(flags)), { ...(flags.period === undefined ? {} : {period: flags.period}), ...(flags.project === undefined ? {} : {project: flags.project}), diff --git a/src/services/usage.ts b/src/services/usage.ts index 4ce0acad..f5725bbe 100644 --- a/src/services/usage.ts +++ b/src/services/usage.ts @@ -1,14 +1,13 @@ import { PolymorfaValidationError, - type RawClient, + type ClientInstance, type ResponseMetadata, + type UsageGateList, + type UsageMeter, + type UsageRecordPage, + type UsageSummary, } from '@polymorfa/sdk' -// Metered usage and usage gates (Platform API `/platform/usage`, -// `/platform/usage/records` and `/platform/gates`). The typed SDK resource -// lands with the next published SDK; these read through the raw client so the -// commands work with the pinned SDK today. - export interface UsageResult { readonly data: T readonly metadata: { @@ -19,74 +18,6 @@ export interface UsageResult { } } -export interface UsageMeterTotal { - readonly keySource: string - readonly meter: string - readonly quantity: number - readonly records: number - readonly unit: string -} - -export interface UsageSummary { - readonly billingEnabled: boolean - readonly end: string - readonly meters: readonly UsageMeterTotal[] - readonly numbers: readonly { - readonly meters: readonly UsageMeterTotal[] - readonly projectId: null | string - readonly session: string - }[] - readonly numbersTruncated: boolean - readonly period: string - readonly projectId: null | string - readonly session: null | string - readonly start: string -} - -export interface UsageRecord { - readonly dimensions: Readonly> - readonly id: string - readonly keySource: string - readonly meter: string - readonly occurredAt: string - readonly pricedCredits: null | number - readonly pricingState: string - readonly projectId: null | string - readonly quantity: number - readonly revision: number - readonly session: null | string - readonly sourceId: string - readonly sourceKind: string - readonly unit: string -} - -export interface UsageRecordPage { - readonly nextCursor: null | string - readonly records: readonly UsageRecord[] -} - -export interface UsageGate { - readonly active: boolean - readonly decisions: { - readonly blocked: number - readonly evaluationError: number - readonly wouldBlock: number - } - readonly key: string - readonly kind: string - readonly limit: null | number - readonly mode: string - readonly overLimit: boolean | null - readonly subject: string - readonly unit: null | string - readonly used: null | number -} - -export interface UsageGateList { - readonly gates: readonly UsageGate[] - readonly session: null | string -} - export interface UsageFilters { readonly callId?: string readonly cursor?: string @@ -111,39 +42,48 @@ export function validatePeriod(period?: string): string | undefined { } export async function getUsageSummary( - client: RawClient, + client: ClientInstance<'organization'>, filters: UsageFilters, ): Promise> { - return request(client, '/platform/usage', { - period: validatePeriod(filters.period), - projectId: filters.project, - session: filters.session, + const period = validatePeriod(filters.period) + const response = await client.usage.summary({ + ...(period === undefined ? {} : {period}), + ...(filters.project === undefined ? {} : {projectId: filters.project}), + ...(filters.session === undefined ? {} : {session: filters.session}), }) + return result(response.data, response.metadata) } export async function listUsageRecords( - client: RawClient, + client: ClientInstance<'organization'>, filters: UsageFilters, ): Promise> { - return request(client, '/platform/usage/records', { - callId: filters.callId, - cursor: filters.cursor, - limit: filters.limit, - meter: filters.meter, - period: validatePeriod(filters.period), - projectId: filters.project, - session: filters.session, + const period = validatePeriod(filters.period) + const response = await client.usage.listRecords({ + ...(filters.callId === undefined ? {} : {callId: filters.callId}), + ...(filters.cursor === undefined ? {} : {cursor: filters.cursor}), + ...(filters.limit === undefined ? {} : {limit: filters.limit}), + // Keep accepting future meter names at the CLI boundary. The API validates + // names the installed SDK has not typed yet. + ...(filters.meter === undefined + ? {} + : {meter: filters.meter as UsageMeter}), + ...(period === undefined ? {} : {period}), + ...(filters.project === undefined ? {} : {projectId: filters.project}), + ...(filters.session === undefined ? {} : {session: filters.session}), }) + return result(response.data, response.metadata) } export async function listUsageGates( - client: RawClient, + client: ClientInstance<'organization'>, filters: UsageFilters, ): Promise> { - return request(client, '/platform/gates', { - projectId: filters.project, - session: filters.session, + const response = await client.usage.listGates({ + ...(filters.project === undefined ? {} : {projectId: filters.project}), + ...(filters.session === undefined ? {} : {session: filters.session}), }) + return result(response.data, response.metadata) } export function renderUsageSummary(summary: UsageSummary): string { @@ -225,32 +165,6 @@ function limitLabel(limit: null | number): string { return limit < 0 ? 'none' : String(limit) } -async function request( - client: RawClient, - path: string, - query: Readonly>, -): Promise> { - const response = await client.request({ - method: 'GET', - path, - query: Object.fromEntries( - Object.entries(query).filter(([, value]) => value !== undefined), - ), - }) - return result(unwrap(response.data), response.metadata) -} - -function unwrap(body: unknown): T { - if ( - typeof body === 'object' && - body !== null && - 'data' in (body as Record) - ) { - return (body as {data: T}).data - } - return body as T -} - function result(data: T, metadata: ResponseMetadata): UsageResult { return { data, diff --git a/test/commands/usage.test.ts b/test/commands/usage.test.ts index c6fda939..a026a96c 100644 --- a/test/commands/usage.test.ts +++ b/test/commands/usage.test.ts @@ -184,6 +184,53 @@ describe('usage commands', () => { ]) }) + test('keeps typed SDK data and response metadata in JSON output', async () => { + server = await startHttpSequenceServer([ + { + body: {data: {nextCursor: null, records: [record]}, success: true}, + headers: {'x-request-id': 'usage-request-1'}, + }, + ]) + + const listed = await apiKey(() => + runCommand('usage records --meter call.duration --json'), + ) + + expect(listed.error).toBeUndefined() + expect(JSON.parse(listed.stdout)).toMatchObject({ + data: {nextCursor: null, records: [record]}, + metadata: {attempts: 1, requestId: 'usage-request-1', status: 200}, + }) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/usage/records?meter=call.duration', + ]) + }) + + test('reports gate credential refusal without presenting an empty list', async () => { + server = await startHttpSequenceServer([ + { + body: { + error: { + code: 'permission_denied', + message: 'Gate state needs a team API key.', + type: 'permission_error', + }, + success: false, + }, + status: 403, + }, + ]) + + const denied = await apiKey(() => runCommand('usage gates')) + + expect(exitCode(denied.error)).toBe(3) + expect(denied.stderr).toContain('Gate state needs a team API key.') + expect(denied.stdout).toBe('') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/gates', + ]) + }) + test('refuses a malformed period before any request', async () => { server = await startHttpSequenceServer([ {body: {data: summary, success: true}}, From 9c9fe2c3c0e333be3b14da4a5bbef169cf48b096 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 14:10:11 +0200 Subject: [PATCH 5/8] Handle campaign stop without a delivery operation --- src/services/campaigns.ts | 8 ++++++-- test/commands/campaigns.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index d97876b2..270c0581 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -2,6 +2,7 @@ import { type Campaign, type CampaignAnalytics, type CampaignOperation, + type CampaignStopOperation, type CampaignRequeueResult, type CreateCampaignRequest, type MessagingClient, @@ -109,7 +110,7 @@ export async function stopCampaign( projectSlug: string, campaignId: string, options: RequestOptions = {}, -): Promise> { +): Promise> { const response = await client.campaigns.stop(projectSlug, campaignId, options) return result(response.data.data, response.metadata) } @@ -192,8 +193,11 @@ export function renderCampaignAnalytics(analytics: CampaignAnalytics): string { export function renderCampaignOperation( action: string, - campaign: CampaignOperation, + campaign: CampaignOperation | CampaignStopOperation, ): string { + if (campaign.operationId === null) { + return `Campaign ${campaign.id} is ${campaign.status}; no delivery operation was needed.` + } return [ `Accepted campaign ${action} as operation ${campaign.operationId}.`, `Campaign ${campaign.id} is ${campaign.status}.`, diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index 36fb1636..cf17212c 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -223,6 +223,25 @@ describe('Messaging campaign commands', () => { expect(server.requests).toHaveLength(0) }) + test('reports an immediate stop without inventing an operation ID', async () => { + server = await startHttpServer({ + body: { + data: {...campaign(), operationId: null, status: 'cancelled'}, + success: true, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand('campaign stop campaign_1 --project-slug project_1 --yes'), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain( + 'Campaign campaign/1 is cancelled; no delivery operation was needed.', + ) + expect(result.stdout).not.toContain('operation null') + }) + test('requeues failed and optionally skipped-error recipients directly', async () => { server = await startHttpServer({body: {data: {requeued: 3}, success: true}}) From 4d3553d766b0a1be978f34f05c6e05af8a0d7f3d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 14:12:11 +0200 Subject: [PATCH 6/8] Document immediate campaign stop outcome --- README.md | 17 +++++++++-------- docs/command-research/campaigns.md | 8 +++++--- test/commands/campaigns.test.ts | 10 ++++++++++ 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8a61e937..28d14a7d 100644 --- a/README.md +++ b/README.md @@ -1087,10 +1087,12 @@ polymorfa campaign requeue campaign_123 \ --include-skipped-error ``` -Launch, pause, resume, and stop return accepted durable work; they do not wait -for the state transition. Check the campaign with `polymorfa campaign view`. -Follow the returned operation with `polymorfa operation wait` or -`polymorfa operation watch`; see [Operations](#operations). +Launch, pause, and resume return accepted durable work; they do not wait for +the state transition. Stop also returns an operation ID when it has an active +delivery run to stop. If no delivery run is active, stop cancels the campaign +immediately and returns `operationId: null`. Check the campaign with +`polymorfa campaign view`. Messaging campaign operation IDs are not available +to the CLI's Platform operation commands. Launch times accept epoch milliseconds or timezone-qualified ISO 8601 instants. List returns one complete newest-first array with no cursor. Requeue @@ -1108,10 +1110,9 @@ again. Save the first campaign or operation response and inspect campaign state before using a new key. Pause, resume, stop, and requeue do not persist the header, so it is not server-side deduplication for them. -The source exposes no campaign update, delete, validate, archive, duplicate, -recipient, event, history, or replay route. The CLI does not substitute -similarly named Platform campaign methods for these gaps. To follow or stop a -campaign's operation, use the `operation` commands in [Operations](#operations). +The CLI does not substitute similarly named Platform campaign methods for +Messaging campaign workflows. The Messaging API does not expose these campaign +operation IDs through the CLI's Platform operation commands. ## Developer events and webhooks diff --git a/docs/command-research/campaigns.md b/docs/command-research/campaigns.md index 5085d0ba..5cef7395 100644 --- a/docs/command-research/campaigns.md +++ b/docs/command-research/campaigns.md @@ -59,9 +59,11 @@ rejects timezone-ambiguous local timestamps. Pause, resume, and stop remain distinct commands because the server enforces different source states. Stop is a terminal transition and requires `--yes`. -Launch, pause, resume, and stop append durable commands and return an operation -ID plus the campaign's present state. Human output says “Accepted” and does not -say the transition completed. The returned IDs belong to the Messaging campaign +Launch, pause, and resume append durable commands and return an operation ID +plus the campaign's present state. Stop returns an operation ID when it has an +active delivery run; without one, it cancels immediately and returns +`operationId: null`. Human output reports the immediate cancellation without +inventing an operation. The returned IDs belong to the Messaging campaign API. Operation inspection is console-only, so the CLI cannot poll them. The unpublished `--surface messaging` selector was removed rather than retained as a compatibility alias. diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index cf17212c..9b837bbf 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -240,6 +240,16 @@ describe('Messaging campaign commands', () => { 'Campaign campaign/1 is cancelled; no delivery operation was needed.', ) expect(result.stdout).not.toContain('operation null') + + const json = await apiKeyEnvironment(() => + runCommand( + 'campaign stop campaign_1 --project-slug project_1 --yes --json', + ), + ) + expect(json.error).toBeUndefined() + expect(JSON.parse(json.stdout)).toMatchObject({ + data: {operationId: null}, + }) }) test('requeues failed and optionally skipped-error recipients directly', async () => { From e527412bf4805f390f67c285093fa52291fdd10d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:23:07 +0200 Subject: [PATCH 7/8] docs(usage): restore Calls usage CLI guidance after dev merge --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 92baf218..e125369a 100644 --- a/README.md +++ b/README.md @@ -862,6 +862,30 @@ optional address lookup stops after five seconds without retrying and respects a shorter `--timeout`. If it fails or times out, the command keeps the password on screen and exits successfully. +### Call usage and limits + +When Calls access is enabled for a team, Polymorfa measures the calls its numbers host. Usage is measured, not charged. +Commands require a server API key with `sessions:read`; a project API key reads +its own project's usage. `usage gates` needs a team API key, because gate state +covers the whole team. + +```shell +polymorfa usage summary +polymorfa usage summary --period 2026-09 --session support +polymorfa usage records --call 3EB0C431C26A1916E07A +polymorfa usage records --session support --meter call.duration --limit 50 +polymorfa usage records --session support --cursor +polymorfa usage gates --session support +``` + +`usage summary` totals one calendar month (UTC) by meter and by number, and +defaults to the current month; without `--project`, each number row also shows +its project. `usage records` lists records newest first with each record's +project, and prints the cursor for the next page. `usage gates` shows each gate's mode, +limit, usage this month and how often an action went over the limit in the last +30 days: in `record` mode nothing is refused, and in `enforce` mode an action +over the limit exits with code 4 (`gate_limit_reached`). + ## Session call rejection and stable identities `call reject` is a separate command for the Linked Device session route. It From 281e3b89605a605ddfba6397d4cabfa22f716d96 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:28:51 +0200 Subject: [PATCH 8/8] feat(cli): pin typed usage SDK and honor project tokens --- README.md | 6 ++-- package-lock.json | 9 +++--- package.json | 2 +- src/commands/usage/gates.ts | 11 ++++++-- src/commands/usage/records.ts | 11 ++++++-- src/commands/usage/summary.ts | 13 ++++++--- src/services/usage.ts | 53 ++++++++++++++++++++++++++++++----- test/commands/usage.test.ts | 43 ++++++++++++++++++++++++++++ 8 files changed, 122 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index e125369a..0c239b0e 100644 --- a/README.md +++ b/README.md @@ -865,9 +865,9 @@ or times out, the command keeps the password on screen and exits successfully. ### Call usage and limits When Calls access is enabled for a team, Polymorfa measures the calls its numbers host. Usage is measured, not charged. -Commands require a server API key with `sessions:read`; a project API key reads -its own project's usage. `usage gates` needs a team API key, because gate state -covers the whole team. +Commands require `sessions:read`; a project token reads only its own project's +usage and needs `--project` or a project-bound profile. `usage gates` needs a +team API key, because gate state covers the whole team. ```shell polymorfa usage summary diff --git a/package-lock.json b/package-lock.json index 4bd54b5c..43bccdd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923124417", + "@polymorfa/sdk": "0.1.0-dev.20260923132258", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,10 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20260923124417", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923124417.tgz", - "integrity": "sha512-mwcZVuYgFmLJeTSMT12I4YhFFUVKrdXzO+N4LEPAVNIstIxXiC8K8AB4eEmMIzOsShVndcINyj/BZfoTahdPlw==", - "license": "MIT", + "version": "0.1.0-dev.20260923132258", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923132258.tgz", + "integrity": "sha512-ol2nSNl0tfRJQOAk1kywPwMu3+7KJxO1pVVlZj5S6Akj2+mnyKzjSgSU1EVHIObDLY9LLgg/kkTSeVbBmf0FGg==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index fefad1fb..4034f73b 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923124417", + "@polymorfa/sdk": "0.1.0-dev.20260923132258", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/src/commands/usage/gates.ts b/src/commands/usage/gates.ts index 4b4b9d76..cb7f5687 100644 --- a/src/commands/usage/gates.ts +++ b/src/commands/usage/gates.ts @@ -1,8 +1,11 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../base-command.js' -import {createPlatformClient} from '../../sdk/client.js' -import {listUsageGates, renderUsageGates} from '../../services/usage.js' +import { + createUsageResource, + listUsageGates, + renderUsageGates, +} from '../../services/usage.js' export default class UsageGates extends BaseCommand { static override description = @@ -24,7 +27,9 @@ export default class UsageGates extends BaseCommand { async run(): Promise { const {flags} = await this.parse(UsageGates) const result = await listUsageGates( - createPlatformClient(await this.platformEnvironment(flags)), + createUsageResource( + await this.developerEnvironment(flags, {inferOwner: true}), + ), { ...(flags.project === undefined ? {} : {project: flags.project}), ...(flags.session === undefined ? {} : {session: flags.session}), diff --git a/src/commands/usage/records.ts b/src/commands/usage/records.ts index ef8326a7..b60d3914 100644 --- a/src/commands/usage/records.ts +++ b/src/commands/usage/records.ts @@ -1,8 +1,11 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../base-command.js' -import {createPlatformClient} from '../../sdk/client.js' -import {listUsageRecords, renderUsageRecords} from '../../services/usage.js' +import { + createUsageResource, + listUsageRecords, + renderUsageRecords, +} from '../../services/usage.js' export default class UsageRecords extends BaseCommand { static override description = @@ -35,7 +38,9 @@ export default class UsageRecords extends BaseCommand { async run(): Promise { const {flags} = await this.parse(UsageRecords) const result = await listUsageRecords( - createPlatformClient(await this.platformEnvironment(flags)), + createUsageResource( + await this.developerEnvironment(flags, {inferOwner: true}), + ), { ...(flags.call === undefined ? {} : {callId: flags.call}), ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), diff --git a/src/commands/usage/summary.ts b/src/commands/usage/summary.ts index 46934db7..a4c003a0 100644 --- a/src/commands/usage/summary.ts +++ b/src/commands/usage/summary.ts @@ -1,12 +1,15 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../base-command.js' -import {createPlatformClient} from '../../sdk/client.js' -import {getUsageSummary, renderUsageSummary} from '../../services/usage.js' +import { + createUsageResource, + getUsageSummary, + renderUsageSummary, +} from '../../services/usage.js' export default class UsageSummary extends BaseCommand { static override description = - 'Show metered usage for one calendar month (UTC), by meter and by number. Usage is measured, not charged: no credits are deducted for it. Requires a server API key with sessions:read. A project API key reads its own project.' + 'Show metered usage for one calendar month (UTC), by meter and by number. Usage is measured, not charged: no credits are deducted for it. Requires sessions:read. A project token reads only its own project.' static override examples = [ '<%= config.bin %> usage summary', @@ -25,7 +28,9 @@ export default class UsageSummary extends BaseCommand { async run(): Promise { const {flags} = await this.parse(UsageSummary) const result = await getUsageSummary( - createPlatformClient(await this.platformEnvironment(flags)), + createUsageResource( + await this.developerEnvironment(flags, {inferOwner: true}), + ), { ...(flags.period === undefined ? {} : {period: flags.period}), ...(flags.project === undefined ? {} : {project: flags.project}), diff --git a/src/services/usage.ts b/src/services/usage.ts index f5725bbe..da0f3fa8 100644 --- a/src/services/usage.ts +++ b/src/services/usage.ts @@ -1,13 +1,17 @@ import { + Client, + PolymorfaConfigurationError, PolymorfaValidationError, - type ClientInstance, type ResponseMetadata, type UsageGateList, type UsageMeter, type UsageRecordPage, + type UsageResource, type UsageSummary, } from '@polymorfa/sdk' +import type {DeveloperEnvironment} from '../context/environment.js' + export interface UsageResult { readonly data: T readonly metadata: { @@ -30,6 +34,41 @@ export interface UsageFilters { const PERIOD = /^\d{4}-(0[1-9]|1[0-2])$/ +export function createUsageResource( + environment: DeveloperEnvironment, +): UsageResource { + const options = { + baseUrl: environment.baseUrl, + ...(environment.apiVersion === undefined + ? {} + : {apiVersion: environment.apiVersion}), + ...(environment.timeoutMs === undefined + ? {} + : {timeoutMs: environment.timeoutMs}), + } + const projectId = environment.context.projectId + if (environment.context.principalType === 'project_token') { + if (!projectId) { + throw new PolymorfaConfigurationError( + 'A project token requires --project or a project-bound profile.', + 'project', + ) + } + return new Client({ + ...options, + credential: {type: 'projectToken', value: environment.credential}, + projectId, + }).usage + } + const client = new Client({ + ...options, + credential: {type: 'organizationApiKey', value: environment.credential}, + }) + return environment.context.owner === 'project' && projectId + ? client.project(projectId).usage + : client.usage +} + export function validatePeriod(period?: string): string | undefined { if (period === undefined) return undefined if (!PERIOD.test(period)) { @@ -42,11 +81,11 @@ export function validatePeriod(period?: string): string | undefined { } export async function getUsageSummary( - client: ClientInstance<'organization'>, + usage: UsageResource, filters: UsageFilters, ): Promise> { const period = validatePeriod(filters.period) - const response = await client.usage.summary({ + const response = await usage.summary({ ...(period === undefined ? {} : {period}), ...(filters.project === undefined ? {} : {projectId: filters.project}), ...(filters.session === undefined ? {} : {session: filters.session}), @@ -55,11 +94,11 @@ export async function getUsageSummary( } export async function listUsageRecords( - client: ClientInstance<'organization'>, + usage: UsageResource, filters: UsageFilters, ): Promise> { const period = validatePeriod(filters.period) - const response = await client.usage.listRecords({ + const response = await usage.listRecords({ ...(filters.callId === undefined ? {} : {callId: filters.callId}), ...(filters.cursor === undefined ? {} : {cursor: filters.cursor}), ...(filters.limit === undefined ? {} : {limit: filters.limit}), @@ -76,10 +115,10 @@ export async function listUsageRecords( } export async function listUsageGates( - client: ClientInstance<'organization'>, + usage: UsageResource, filters: UsageFilters, ): Promise> { - const response = await client.usage.listGates({ + const response = await usage.listGates({ ...(filters.project === undefined ? {} : {projectId: filters.project}), ...(filters.session === undefined ? {} : {session: filters.session}), }) diff --git a/test/commands/usage.test.ts b/test/commands/usage.test.ts index a026a96c..1aa474c1 100644 --- a/test/commands/usage.test.ts +++ b/test/commands/usage.test.ts @@ -9,6 +9,8 @@ import { const API_KEY = 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const PROJECT_TOKEN = + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' const PROJECT_A = '22222222-2222-4222-8222-222222222222' const PROJECT_B = '33333333-3333-4333-8333-333333333333' @@ -165,6 +167,34 @@ describe('usage commands', () => { ]) }) + test('uses a project token only for its explicit project', async () => { + server = await startHttpSequenceServer([ + {body: {data: {...summary, projectId: PROJECT_A}, success: true}}, + {body: {data: {nextCursor: null, records: [record]}, success: true}}, + ]) + + const missing = await projectToken(() => runCommand('usage summary')) + const summarized = await projectToken(() => + runCommand(`usage summary --project ${PROJECT_A}`), + ) + const listed = await projectToken(() => + runCommand(`usage records --project ${PROJECT_A}`), + ) + + expect(exitCode(missing.error)).toBe(2) + expect(missing.stderr).toContain('--project') + expect(summarized.error).toBeUndefined() + expect(listed.error).toBeUndefined() + expect(server.requests.map(({url}) => url)).toEqual([ + `/platform/usage?projectId=${PROJECT_A}`, + `/platform/usage/records?projectId=${PROJECT_A}`, + ]) + expect(server.requests.map(({headers}) => headers.authorization)).toEqual([ + `Bearer ${PROJECT_TOKEN}`, + `Bearer ${PROJECT_TOKEN}`, + ]) + }) + test('lists gates with their mode and limit', async () => { server = await startHttpSequenceServer([ {body: {data: gates, success: true}}, @@ -255,6 +285,19 @@ describe('usage commands', () => { run, ) } + + async function projectToken(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: PROJECT_TOKEN, + }, + run, + ) + } }) function exitCode(error: unknown): number | undefined {