Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/0004-aigateway-telemetry-cost.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cdot65/prisma-airs-cli": minor
---

Add `airs aigateway telemetry cost --workspace <slug> [--days 7]` — total and per-day workspace spend. The API reports cents; pretty output converts to dollars while structured output keeps raw values in explicit `*Cents` fields.
42 changes: 42 additions & 0 deletions docs-site/docs/cli/aigateway/telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
sidebar_label: telemetry
---

# aigateway telemetry

Runtime telemetry for AI Gateway workspaces — the data behind the SCM
Observability tabs. Data plane; keyed by workspace **slug**, not UUID.

### aigateway telemetry cost

Total and per-day spend for a workspace.

```text
airs aigateway telemetry cost --workspace <slug> [--days <n>] [--output <format>]
```

#### Options

| Flag | Required | Default | Description |
|------|:--------:|---------|-------------|
| `--workspace <ref>` | Yes | — | Workspace slug (e.g. `ws-main-a-349e0e`); UUID or display name also accepted (CLI resolves to the slug) |
| `--days <n>` | No | `7` | Rolling window in days, counted back from now |
| `--output <format>` | No | `pretty` | Output format: pretty, json, yaml |

:::warning Costs are in cents

The API reports every cost value in **cents** and never converts. Pretty
output shows dollars; `--output json|yaml` keeps the raw values in explicitly
named `totalCents` / `avgCents` / `costCents` fields.

:::

#### Examples

```bash
airs aigateway telemetry cost --workspace ws-main-a-349e0e
airs aigateway telemetry cost --workspace ws-main-a-349e0e --days 30 --output json
```

The other telemetry surfaces (requests, tokens, latency, group-bys, raw logs)
are not yet exposed by the CLI — scoped for a future release.
10 changes: 7 additions & 3 deletions docs-site/docs/cli/aigateway/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ airs aigateway workspace list --all --output json

### aigateway workspace get

Get one workspace by UUID **or** slug, including the settings blocks list rows
do not carry.
Get one workspace by UUID, slug, **or display name**, including the settings
blocks list rows do not carry. (The API itself accepts only UUID/slug; the CLI
resolves display names against the workspace list — an ambiguous name errors
with the matching slugs.)

```text
airs aigateway workspace get <ref> [options]
Expand Down Expand Up @@ -143,7 +145,9 @@ airs aigateway workspace create --name Production --scope-name ws_production_bx7

### aigateway workspace update

Partial update — send only what changes. **Admin plane.**
Partial update — send only what changes. **Admin plane.** `<ref>` accepts
UUID, slug, or display name (a raw name sent to the API yields a misleading
`400 AB01 "No update fields provided"` — the CLI resolves it for you).

```text
airs aigateway workspace update <ref> [options]
Expand Down
1 change: 1 addition & 0 deletions docs-site/docs/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ slug: /cli/

Auto-generated from the `airs` command tree. Every command below lists its synopsis, options, and at least one input/output example.

- [`airs aigateway telemetry`](aigateway/telemetry.md)
- [`airs aigateway workspace`](aigateway/workspaces.md)
- [`airs model-security groups`](model-security/groups.md)
- [`airs model-security install`](model-security/install.md)
Expand Down
66 changes: 66 additions & 0 deletions docs-site/docs/development/full-cli-sweep.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,40 @@ Produces `temp/clean/<type>/`, `temp/dirty/<type>/<base>__<technique>.<ext>`, an
`temp/manifest.json` (each dirty file → technique + embedded synthetic values). All values are
synthetic / reserved-for-testing.

### B.6 — AI Gateway

Two planes, two grants (see [aigateway workspace](../cli/aigateway/workspaces.md)): the data
plane needs a **workspace-scope** grant, the admin plane a **tenant-root admin** grant. A `403`
with `errorCode: AB03` on the data-plane commands means the workspace-scope grant is missing —
the CLI prints the exact fix. Reads verified live 2026-08-01 (admin plane; the reference
service account holds only the tenant-root grant, so the bare data-plane `list` 403s with the
documented AB03 hint).

```bash
# Workspaces — bare list is data-plane and shows only ACTIVE workspaces you are SCOPED to
airs aigateway workspace list
airs aigateway workspace list --plane admin # whole tenant
airs aigateway workspace list --plane admin --status archived # archived rows only
airs aigateway workspace list --all # admin active + archived merged
airs aigateway workspace get <slugOrUuid> --plane admin --output json

# Telemetry (data plane; workspace SLUG, not UUID; costs are cents — pretty output shows dollars)
airs aigateway telemetry cost --workspace <workspaceSlug>
airs aigateway telemetry cost --workspace <workspaceSlug> --days 30 --output json
```

Expected `list --plane admin` output (pretty):

```
16f7e90d-382a-4e78-b577-1b01eb5f8297
talos_k8s_cluster ws-main-a-349e0e active
scope: main_airs_workspace_1852583913

ff9a513e-2625-4677-9c41-eecdab839f7c
Production ws-produc-985697 active
scope: ws_production_bx7qw0
```

## Section C — Synchronous scan

Smallest possible write — single sync scan returns immediately, no state to clean up.
Expand Down Expand Up @@ -540,6 +574,34 @@ Once the upstream is fixed, the full CRUD shape is documented in the per-resourc
- [Data Dictionaries](../runtime/dlp/dictionaries.md) — multipart `create` / `replace`
- [Data Filtering Profiles](../runtime/dlp/filtering-profiles.md) — `replace` body shape

### D.9 — AI Gateway workspace CRUD

Admin plane throughout — needs the tenant-root admin grant. **`delete` archives; there is no
hard delete**, so unlike every other section this one cannot be fully torn down: the archived
row remains under `--status archived` forever. Use a throwaway name.

```bash
# Create — scope_name is the SCM role scope, NOT derived from the name.
# A scope nobody holds makes the workspace invisible to data-plane lists.
airs aigateway workspace create --name sweep-test --scope-name ws_sweeptest_000000 \
--description "full-cli-sweep test workspace" \
--rate-limits '[{"type":"requests","unit":"rpm","value":10}]'

# The CLI renders from a follow-up get (create's response omits half the record)
airs aigateway workspace get <newSlug> --plane admin

# Update is a partial patch; the API answers {} and the CLI re-reads for you
airs aigateway workspace update <newSlug> --description "updated by sweep"

# Delete = archive (confirm prompt; --force for non-TTY)
airs aigateway workspace delete <newSlug> --force

# Verify: gone from the default list, present under archived…
airs aigateway workspace list --plane admin --status archived
# …and get now answers 404 AB08 on both planes — EXPECTED, not a bug
airs aigateway workspace get <newSlug> --plane admin
```

## Section E — Long-running workflows

These tie multiple commands together. Each subsection is one end-to-end flow.
Expand Down Expand Up @@ -636,6 +698,10 @@ airs runtime api-keys delete "smoke-test-key"

# 5. DLP — soft-archive any patterns created in D.8
airs runtime dlp patterns delete <patternId>

# 6. AI Gateway — workspaces can only be ARCHIVED, never destroyed (D.9's row
# stays under --status archived; nothing further to clean up)
airs aigateway workspace delete <workspaceSlug> --force
```

## Section H — Interpretation guide
Expand Down
86 changes: 78 additions & 8 deletions src/airs/aigateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
type GatewayWorkspaceUpdateRequest,
} from '@cdot65/prisma-airs-sdk';
import type {
AiGatewayCostOptions,
AiGatewayCostReport,
AiGatewayPlane,
AiGatewayService,
AiGatewayWorkspace,
AiGatewayWorkspaceCreateRequest,
Expand Down Expand Up @@ -107,11 +110,24 @@ export class SdkAiGatewayService implements AiGatewayService {
workspaceRef: string,
options?: AiGatewayWorkspaceGetOptions,
): Promise<AiGatewayWorkspaceDetail> {
const raw = (await this.client.workspaces.get(workspaceRef, options)) as Record<
string,
unknown
>;
return normalizeWorkspaceDetail(raw);
try {
const raw = (await this.client.workspaces.get(workspaceRef, options)) as Record<
string,
unknown
>;
return normalizeWorkspaceDetail(raw);
} catch (err) {
// A display name 404s — resolve it against the list and retry once.
const status = (err as { statusCode?: number }).statusCode;
if (status !== 404) throw err;
const resolved = await this.resolveWorkspaceRef(workspaceRef, [
options?.plane ?? 'data',
'admin',
]);
if (resolved === workspaceRef) throw err;
const raw = (await this.client.workspaces.get(resolved, options)) as Record<string, unknown>;
return normalizeWorkspaceDetail(raw);
}
}

async createWorkspace(
Expand Down Expand Up @@ -139,6 +155,7 @@ export class SdkAiGatewayService implements AiGatewayService {
workspaceRef: string,
request: AiGatewayWorkspaceUpdateRequest,
): Promise<AiGatewayWorkspaceDetail> {
const ref = await this.resolveWorkspaceRef(workspaceRef, ['admin']);
const body: GatewayWorkspaceUpdateRequest = {};
if (request.name !== undefined) body.name = request.name;
if (request.description !== undefined) body.description = request.description;
Expand All @@ -147,16 +164,69 @@ export class SdkAiGatewayService implements AiGatewayService {
if (request.usageLimits !== undefined) body.usage_limits = request.usageLimits;
if (request.rateLimits !== undefined) body.rate_limits = request.rateLimits;

await this.client.workspaces.update(workspaceRef, body);
await this.client.workspaces.update(ref, body);
// update returns a literal `{}` — the write lands; re-read to display anything.
return this.getWorkspace(workspaceRef, { plane: 'admin' });
return this.getWorkspace(ref, { plane: 'admin' });
}

async deleteWorkspace(workspaceRef: string): Promise<void> {
const ref = await this.resolveWorkspaceRef(workspaceRef, ['admin']);
// Soft delete. Deliberately no verify-by-get: an archived workspace
// answers 404 AB08 on both planes even though list --status archived
// still shows it.
await this.client.workspaces.delete(workspaceRef);
await this.client.workspaces.delete(ref);
}

/**
* The API accepts only a UUID or slug as a workspace ref — a display name
* gets a misleading 400 AB01 ("No update fields provided") on writes.
* Match a user-supplied ref against the workspace list so name | slug |
* uuid all work. Unmatched refs pass through so the API's own error stands.
*/
private async resolveWorkspaceRef(ref: string, planes: AiGatewayPlane[]): Promise<string> {
for (const plane of planes) {
let rows: AiGatewayWorkspace[];
try {
rows = await this.listWorkspaces({ plane });
} catch {
continue; // e.g. missing grant on this plane — try the next one
}
if (rows.some((w) => w.id === ref || w.slug === ref)) return ref;
const byName = rows.filter((w) => w.name === ref);
if (byName.length > 1) {
throw new Error(
`workspace name '${ref}' is ambiguous (${byName.map((w) => w.slug).join(', ')}) — use a slug or UUID`,
);
}
if (byName.length === 1) return byName[0].slug;
}
return ref;
}

async getTelemetryCost(opts: AiGatewayCostOptions): Promise<AiGatewayCostReport> {
const days = opts.days ?? 7;
const workspaceSlug = await this.resolveWorkspaceRef(opts.workspaceSlug, ['data', 'admin']);
const raw = (await this.client.telemetry.cost({
workspaceSlug,
days,
})) as {
data: {
isQuotaExceeded: boolean;
records: Array<{ x: string; y: number }>;
total: number;
avg: number;
};
};
// Every cost value is CENTS — the SDK never converts; conversion is a
// display concern (renderer divides by 100).
return {
workspaceSlug,
days,
totalCents: raw.data.total,
avgCents: raw.data.avg,
quotaExceeded: raw.data.isQuotaExceeded,
records: raw.data.records.map((r) => ({ date: r.x, costCents: r.y })),
};
}

/** Re-read after a write, falling back to the (partial) write response if the get fails. */
Expand Down
20 changes: 20 additions & 0 deletions src/airs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1346,6 +1346,8 @@ export interface AiGatewayService {
): Promise<AiGatewayWorkspaceDetail>;
/** Soft delete — archives the workspace; there is no hard delete. */
deleteWorkspace(workspaceRef: string): Promise<void>;
/** Total and per-day spend for a workspace. Values are CENTS. */
getTelemetryCost(opts: AiGatewayCostOptions): Promise<AiGatewayCostReport>;
}

/** Request to create an AI Gateway workspace. */
Expand Down Expand Up @@ -1374,3 +1376,21 @@ export interface AiGatewayWorkspaceUpdateRequest {
usageLimits?: Array<Record<string, unknown>>;
rateLimits?: Array<Record<string, unknown>>;
}

/** Options for the AI Gateway telemetry cost query. */
export interface AiGatewayCostOptions {
/** Workspace slug (not UUID) — required by every telemetry endpoint. */
workspaceSlug: string;
/** Rolling window in days, counted back from now. Defaults to 7. */
days?: number;
}

/** Normalized AI Gateway cost report. All monetary values are CENTS — the API never converts. */
export interface AiGatewayCostReport {
workspaceSlug: string;
days: number;
totalCents: number;
avgCents: number;
quotaExceeded: boolean;
records: Array<{ date: string; costCents: number }>;
}
36 changes: 36 additions & 0 deletions src/cli/commands/aigateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
fail,
type OutputFormat,
renderAiGatewayHeader,
renderCostReport,
renderWorkspaceDetail,
renderWorkspaceList,
ui,
Expand Down Expand Up @@ -293,4 +294,39 @@ export function registerAiGatewayCommand(program: Command): void {
failWithGrantHint(err);
}
});

const telemetry = aigateway
.command('telemetry')
.description('AI Gateway runtime telemetry (data plane)');

telemetry
.command('cost')
.description(
'Total and per-day spend for a workspace (API reports cents; pretty output shows dollars)',
)
.requiredOption('--workspace <slug>', 'Workspace slug (not UUID), e.g. ws-main-a-349e0e')
.option('--days <n>', 'Rolling window in days, counted back from now', '7')
.option('--output <format>', 'Output format: pretty, json, yaml', 'pretty')
.addHelpText(
'after',
examples(
'airs aigateway telemetry cost --workspace ws-main-a-349e0e',
'airs aigateway telemetry cost --workspace ws-main-a-349e0e --days 30 --output json',
),
)
.action(async (opts) => {
try {
const fmt = opts.output as OutputFormat;
if (fmt === 'pretty') renderAiGatewayHeader();
const days = Number.parseInt(opts.days, 10);
if (!Number.isFinite(days) || days <= 0) {
usageError(`Invalid --days '${opts.days}'. Expected a positive integer`);
}
const service = await createService();
const report = await service.getTelemetryCost({ workspaceSlug: opts.workspace, days });
renderCostReport(report, fmt);
} catch (err) {
failWithGrantHint(err);
}
});
}
Loading
Loading