From 3de968392668b5b66ec3f94b65f85edafaa5d1fc Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 1 Aug 2026 15:55:22 -0500 Subject: [PATCH 1/5] test: add failing tests for AI Gateway telemetry cost (#304) --- tests/unit/airs/aigateway.spec.ts | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/unit/airs/aigateway.spec.ts b/tests/unit/airs/aigateway.spec.ts index 8ce6737..f673eca 100644 --- a/tests/unit/airs/aigateway.spec.ts +++ b/tests/unit/airs/aigateway.spec.ts @@ -6,6 +6,7 @@ const mockWorkspacesGet = vi.fn(); const mockWorkspacesCreate = vi.fn(); const mockWorkspacesUpdate = vi.fn(); const mockWorkspacesDelete = vi.fn(); +const mockTelemetryCost = vi.fn(); function makeMockClient() { return { @@ -16,6 +17,9 @@ function makeMockClient() { update: mockWorkspacesUpdate, delete: mockWorkspacesDelete, }, + telemetry: { + cost: mockTelemetryCost, + }, }; } @@ -240,6 +244,53 @@ describe('workspace writes', () => { }); }); +describe('getTelemetryCost', () => { + let service: SdkAiGatewayService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new SdkAiGatewayService(); + }); + + it('normalizes the cost chart keeping raw cents', async () => { + mockTelemetryCost.mockResolvedValue({ + success: true, + data: { + isQuotaExceeded: false, + records: [ + { x: '2026-07-30', y: 123456.78 }, + { x: '2026-07-31', y: 100 }, + ], + total: 123556.78, + avg: 61778.39, + }, + }); + const report = await service.getTelemetryCost({ workspaceSlug: 'ws-main-a-349e0e', days: 7 }); + expect(mockTelemetryCost).toHaveBeenCalledWith({ workspaceSlug: 'ws-main-a-349e0e', days: 7 }); + expect(report).toEqual({ + workspaceSlug: 'ws-main-a-349e0e', + days: 7, + totalCents: 123556.78, + avgCents: 61778.39, + quotaExceeded: false, + records: [ + { date: '2026-07-30', costCents: 123456.78 }, + { date: '2026-07-31', costCents: 100 }, + ], + }); + }); + + it('defaults days to 7', async () => { + mockTelemetryCost.mockResolvedValue({ + success: true, + data: { isQuotaExceeded: false, records: [], total: 0, avg: 0 }, + }); + const report = await service.getTelemetryCost({ workspaceSlug: 'ws-x' }); + expect(mockTelemetryCost).toHaveBeenCalledWith({ workspaceSlug: 'ws-x', days: 7 }); + expect(report.days).toBe(7); + }); +}); + describe('aiGatewayGrantHint', () => { it('returns undefined for non-403 errors', () => { expect( From cfd96093e13a15019d6c0c247fb3fe05dba97991 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 1 Aug 2026 15:56:51 -0500 Subject: [PATCH 2/5] feat: add aigateway telemetry cost command (#304) --- .changeset/0004-aigateway-telemetry-cost.md | 5 +++ docs-site/docs/cli/aigateway/telemetry.md | 42 +++++++++++++++++++++ docs-site/docs/cli/index.md | 1 + src/airs/aigateway.ts | 27 +++++++++++++ src/airs/types.ts | 20 ++++++++++ src/cli/commands/aigateway.ts | 36 ++++++++++++++++++ src/cli/renderer/aigateway.ts | 34 +++++++++++++++++ 7 files changed, 165 insertions(+) create mode 100644 .changeset/0004-aigateway-telemetry-cost.md create mode 100644 docs-site/docs/cli/aigateway/telemetry.md diff --git a/.changeset/0004-aigateway-telemetry-cost.md b/.changeset/0004-aigateway-telemetry-cost.md new file mode 100644 index 0000000..9bf6f47 --- /dev/null +++ b/.changeset/0004-aigateway-telemetry-cost.md @@ -0,0 +1,5 @@ +--- +"@cdot65/prisma-airs-cli": minor +--- + +Add `airs aigateway telemetry cost --workspace [--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. diff --git a/docs-site/docs/cli/aigateway/telemetry.md b/docs-site/docs/cli/aigateway/telemetry.md new file mode 100644 index 0000000..d93dc45 --- /dev/null +++ b/docs-site/docs/cli/aigateway/telemetry.md @@ -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 [--days ] [--output ] +``` + +#### Options + +| Flag | Required | Default | Description | +|------|:--------:|---------|-------------| +| `--workspace ` | Yes | — | Workspace slug (e.g. `ws-main-a-349e0e`) | +| `--days ` | No | `7` | Rolling window in days, counted back from now | +| `--output ` | 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. diff --git a/docs-site/docs/cli/index.md b/docs-site/docs/cli/index.md index fb394c4..727b2ae 100644 --- a/docs-site/docs/cli/index.md +++ b/docs-site/docs/cli/index.md @@ -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) diff --git a/src/airs/aigateway.ts b/src/airs/aigateway.ts index 6551e50..3f8c5d5 100644 --- a/src/airs/aigateway.ts +++ b/src/airs/aigateway.ts @@ -5,6 +5,8 @@ import { type GatewayWorkspaceUpdateRequest, } from '@cdot65/prisma-airs-sdk'; import type { + AiGatewayCostOptions, + AiGatewayCostReport, AiGatewayService, AiGatewayWorkspace, AiGatewayWorkspaceCreateRequest, @@ -159,6 +161,31 @@ export class SdkAiGatewayService implements AiGatewayService { await this.client.workspaces.delete(workspaceRef); } + async getTelemetryCost(opts: AiGatewayCostOptions): Promise { + const days = opts.days ?? 7; + const raw = (await this.client.telemetry.cost({ + workspaceSlug: opts.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: opts.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. */ private async refetchAfterWrite( workspaceRef: string, diff --git a/src/airs/types.ts b/src/airs/types.ts index 4cb6a0a..0d066e0 100644 --- a/src/airs/types.ts +++ b/src/airs/types.ts @@ -1346,6 +1346,8 @@ export interface AiGatewayService { ): Promise; /** Soft delete — archives the workspace; there is no hard delete. */ deleteWorkspace(workspaceRef: string): Promise; + /** Total and per-day spend for a workspace. Values are CENTS. */ + getTelemetryCost(opts: AiGatewayCostOptions): Promise; } /** Request to create an AI Gateway workspace. */ @@ -1374,3 +1376,21 @@ export interface AiGatewayWorkspaceUpdateRequest { usageLimits?: Array>; rateLimits?: Array>; } + +/** 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 }>; +} diff --git a/src/cli/commands/aigateway.ts b/src/cli/commands/aigateway.ts index 65b0113..ff710cd 100644 --- a/src/cli/commands/aigateway.ts +++ b/src/cli/commands/aigateway.ts @@ -9,6 +9,7 @@ import { fail, type OutputFormat, renderAiGatewayHeader, + renderCostReport, renderWorkspaceDetail, renderWorkspaceList, ui, @@ -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 ', 'Workspace slug (not UUID), e.g. ws-main-a-349e0e') + .option('--days ', 'Rolling window in days, counted back from now', '7') + .option('--output ', '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); + } + }); } diff --git a/src/cli/renderer/aigateway.ts b/src/cli/renderer/aigateway.ts index 8e2b81d..0dc20ed 100644 --- a/src/cli/renderer/aigateway.ts +++ b/src/cli/renderer/aigateway.ts @@ -115,3 +115,37 @@ export function renderWorkspaceDetail( } console.log(); } + +/** + * Render a telemetry cost report. Every `*Cents` value is CENTS — the API + * never converts. Pretty output shows dollars; structured output keeps the + * raw cents fields so consumers are never handed a silently-scaled number. + */ +export function renderCostReport( + report: { + workspaceSlug: string; + days: number; + totalCents: number; + avgCents: number; + quotaExceeded: boolean; + records: Array<{ date: string; costCents: number }>; + }, + format: OutputFormat = 'pretty', +): void { + if (format !== 'pretty') { + console.log(format === 'json' ? JSON.stringify(report, null, 2) : yamlDump(report)); + return; + } + const dollars = (cents: number): string => `$${(cents / 100).toFixed(2)}`; + ui.section(`Cost — ${report.workspaceSlug} (last ${report.days}d):`); + ui.keyValue([ + ['Total', dollars(report.totalCents)], + ['Daily average', dollars(report.avgCents)], + ]); + if (report.quotaExceeded) ui.warn('Telemetry quota exceeded — data may be truncated'); + if (report.records.length > 0) { + ui.section('Per day:'); + ui.keyValue(report.records.map((r) => [r.date, dollars(r.costCents)])); + } + console.log(); +} From a012a9832a86a14bbdef91f6888e42f3ff1e6e3a Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 1 Aug 2026 16:12:22 -0500 Subject: [PATCH 3/5] docs: add AI Gateway sections to full CLI sweep (B.6 reads, D.9 workspace CRUD) (#304) --- docs-site/docs/development/full-cli-sweep.md | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/docs-site/docs/development/full-cli-sweep.md b/docs-site/docs/development/full-cli-sweep.md index 2d8cae6..7b94375 100644 --- a/docs-site/docs/development/full-cli-sweep.md +++ b/docs-site/docs/development/full-cli-sweep.md @@ -244,6 +244,40 @@ Produces `temp/clean//`, `temp/dirty//__.`, 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 --plane admin --output json + +# Telemetry (data plane; workspace SLUG, not UUID; costs are cents — pretty output shows dollars) +airs aigateway telemetry cost --workspace +airs aigateway telemetry cost --workspace --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. @@ -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 --plane admin + +# Update is a partial patch; the API answers {} and the CLI re-reads for you +airs aigateway workspace update --description "updated by sweep" + +# Delete = archive (confirm prompt; --force for non-TTY) +airs aigateway workspace delete --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 --plane admin +``` + ## Section E — Long-running workflows These tie multiple commands together. Each subsection is one end-to-end flow. @@ -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 + +# 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 --force ``` ## Section H — Interpretation guide From 6e214bdfe99fdb999ec893b8442cae140f91d479 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 1 Aug 2026 18:04:58 -0500 Subject: [PATCH 4/5] test: add failing tests for workspace name-ref resolution (#304) --- tests/unit/airs/aigateway.spec.ts | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/unit/airs/aigateway.spec.ts b/tests/unit/airs/aigateway.spec.ts index f673eca..3db4174 100644 --- a/tests/unit/airs/aigateway.spec.ts +++ b/tests/unit/airs/aigateway.spec.ts @@ -244,6 +244,96 @@ describe('workspace writes', () => { }); }); +describe('workspace ref resolution (name | slug | uuid)', () => { + let service: SdkAiGatewayService; + + const rows = [ + { ...{ + id: 'ws-uuid-dev', slug: 'ws-develo-71f8d8', name: 'Development', icon: null, + description: null, created_at: '', last_updated_at: '', is_default: 0, + status: 'active', scope_name: 's', object: 'workspace', + } }, + { id: 'ws-uuid-prod', slug: 'ws-produc-985697', name: 'Production', icon: null, + description: null, created_at: '', last_updated_at: '', is_default: 0, + status: 'active', scope_name: 's2', object: 'workspace' }, + ]; + + const detail = { + id: 'ws-uuid-dev', name: 'Development', description: 'd', created_at: '', + last_updated_at: '', is_default: 0, slug: 'ws-develo-71f8d8', icon: null, + defaults: null, usage_limits: null, rate_limits: null, status: 'active', + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new SdkAiGatewayService(); + }); + + it('update resolves a display name to the slug via an admin-plane list', async () => { + mockWorkspacesList.mockResolvedValue({ data: rows }); + mockWorkspacesUpdate.mockResolvedValue({}); + mockWorkspacesGet.mockResolvedValue(detail); + await service.updateWorkspace('Development', { description: 'x' }); + expect(mockWorkspacesList).toHaveBeenCalledWith({ plane: 'admin' }); + expect(mockWorkspacesUpdate).toHaveBeenCalledWith('ws-develo-71f8d8', { description: 'x' }); + }); + + it('update passes a slug straight through', async () => { + mockWorkspacesList.mockResolvedValue({ data: rows }); + mockWorkspacesUpdate.mockResolvedValue({}); + mockWorkspacesGet.mockResolvedValue(detail); + await service.updateWorkspace('ws-develo-71f8d8', { description: 'x' }); + expect(mockWorkspacesUpdate).toHaveBeenCalledWith('ws-develo-71f8d8', { description: 'x' }); + }); + + it('update passes an unmatched ref through unchanged (API produces the error)', async () => { + mockWorkspacesList.mockResolvedValue({ data: rows }); + mockWorkspacesUpdate.mockResolvedValue({}); + mockWorkspacesGet.mockResolvedValue(detail); + await service.updateWorkspace('nope', { description: 'x' }); + expect(mockWorkspacesUpdate).toHaveBeenCalledWith('nope', { description: 'x' }); + }); + + it('throws a clear error when a display name matches multiple workspaces', async () => { + mockWorkspacesList.mockResolvedValue({ + data: [rows[0], { ...rows[1], name: 'Development' }], + }); + await expect(service.updateWorkspace('Development', { description: 'x' })).rejects.toThrow( + /ambiguous/, + ); + expect(mockWorkspacesUpdate).not.toHaveBeenCalled(); + }); + + it('delete resolves a display name too', async () => { + mockWorkspacesList.mockResolvedValue({ data: rows }); + mockWorkspacesDelete.mockResolvedValue(undefined); + await service.deleteWorkspace('Production'); + expect(mockWorkspacesDelete).toHaveBeenCalledWith('ws-produc-985697'); + }); + + it('telemetry cost resolves a display name to the slug, falling back to the admin plane', async () => { + mockWorkspacesList + .mockRejectedValueOnce(Object.assign(new Error('AB03'), { statusCode: 403 })) + .mockResolvedValueOnce({ data: rows }); + mockTelemetryCost.mockResolvedValue({ + success: true, + data: { isQuotaExceeded: false, records: [], total: 0, avg: 0 }, + }); + await service.getTelemetryCost({ workspaceSlug: 'Development' }); + expect(mockTelemetryCost).toHaveBeenCalledWith({ workspaceSlug: 'ws-develo-71f8d8', days: 7 }); + }); + + it('get retries once with a resolved ref after a 404', async () => { + mockWorkspacesGet + .mockRejectedValueOnce(Object.assign(new Error('not found'), { statusCode: 404 })) + .mockResolvedValueOnce(detail); + mockWorkspacesList.mockResolvedValue({ data: rows }); + const ws = await service.getWorkspace('Development'); + expect(mockWorkspacesGet).toHaveBeenLastCalledWith('ws-develo-71f8d8', undefined); + expect(ws.id).toBe('ws-uuid-dev'); + }); +}); + describe('getTelemetryCost', () => { let service: SdkAiGatewayService; From f4d46a0223f166cb5f826384f1d5b419f4badf43 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Sat, 1 Aug 2026 18:07:17 -0500 Subject: [PATCH 5/5] feat: accept workspace display names as refs (resolve to slug) (#304) Raw names sent to the API yield a misleading 400 AB01 on writes and 404 on reads. Resolve name|slug|uuid against the workspace list for get/update/ delete/telemetry; ambiguous names error with the candidate slugs. --- docs-site/docs/cli/aigateway/telemetry.md | 2 +- docs-site/docs/cli/aigateway/workspaces.md | 10 ++-- src/airs/aigateway.ts | 63 ++++++++++++++++++---- tests/unit/airs/aigateway.spec.ts | 51 ++++++++++++++---- 4 files changed, 101 insertions(+), 25 deletions(-) diff --git a/docs-site/docs/cli/aigateway/telemetry.md b/docs-site/docs/cli/aigateway/telemetry.md index d93dc45..71495e9 100644 --- a/docs-site/docs/cli/aigateway/telemetry.md +++ b/docs-site/docs/cli/aigateway/telemetry.md @@ -19,7 +19,7 @@ airs aigateway telemetry cost --workspace [--days ] [--output | Flag | Required | Default | Description | |------|:--------:|---------|-------------| -| `--workspace ` | Yes | — | Workspace slug (e.g. `ws-main-a-349e0e`) | +| `--workspace ` | Yes | — | Workspace slug (e.g. `ws-main-a-349e0e`); UUID or display name also accepted (CLI resolves to the slug) | | `--days ` | No | `7` | Rolling window in days, counted back from now | | `--output ` | No | `pretty` | Output format: pretty, json, yaml | diff --git a/docs-site/docs/cli/aigateway/workspaces.md b/docs-site/docs/cli/aigateway/workspaces.md index 8b05bd5..a734271 100644 --- a/docs-site/docs/cli/aigateway/workspaces.md +++ b/docs-site/docs/cli/aigateway/workspaces.md @@ -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 [options] @@ -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.** `` 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 [options] diff --git a/src/airs/aigateway.ts b/src/airs/aigateway.ts index 3f8c5d5..d0dc909 100644 --- a/src/airs/aigateway.ts +++ b/src/airs/aigateway.ts @@ -7,6 +7,7 @@ import { import type { AiGatewayCostOptions, AiGatewayCostReport, + AiGatewayPlane, AiGatewayService, AiGatewayWorkspace, AiGatewayWorkspaceCreateRequest, @@ -109,11 +110,24 @@ export class SdkAiGatewayService implements AiGatewayService { workspaceRef: string, options?: AiGatewayWorkspaceGetOptions, ): Promise { - 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; + return normalizeWorkspaceDetail(raw); + } } async createWorkspace( @@ -141,6 +155,7 @@ export class SdkAiGatewayService implements AiGatewayService { workspaceRef: string, request: AiGatewayWorkspaceUpdateRequest, ): Promise { + 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; @@ -149,22 +164,50 @@ 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 { + 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 { + 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 { const days = opts.days ?? 7; + const workspaceSlug = await this.resolveWorkspaceRef(opts.workspaceSlug, ['data', 'admin']); const raw = (await this.client.telemetry.cost({ - workspaceSlug: opts.workspaceSlug, + workspaceSlug, days, })) as { data: { @@ -177,7 +220,7 @@ export class SdkAiGatewayService implements AiGatewayService { // Every cost value is CENTS — the SDK never converts; conversion is a // display concern (renderer divides by 100). return { - workspaceSlug: opts.workspaceSlug, + workspaceSlug, days, totalCents: raw.data.total, avgCents: raw.data.avg, diff --git a/tests/unit/airs/aigateway.spec.ts b/tests/unit/airs/aigateway.spec.ts index 3db4174..ec3d82a 100644 --- a/tests/unit/airs/aigateway.spec.ts +++ b/tests/unit/airs/aigateway.spec.ts @@ -248,20 +248,49 @@ describe('workspace ref resolution (name | slug | uuid)', () => { let service: SdkAiGatewayService; const rows = [ - { ...{ - id: 'ws-uuid-dev', slug: 'ws-develo-71f8d8', name: 'Development', icon: null, - description: null, created_at: '', last_updated_at: '', is_default: 0, - status: 'active', scope_name: 's', object: 'workspace', - } }, - { id: 'ws-uuid-prod', slug: 'ws-produc-985697', name: 'Production', icon: null, - description: null, created_at: '', last_updated_at: '', is_default: 0, - status: 'active', scope_name: 's2', object: 'workspace' }, + { + ...{ + id: 'ws-uuid-dev', + slug: 'ws-develo-71f8d8', + name: 'Development', + icon: null, + description: null, + created_at: '', + last_updated_at: '', + is_default: 0, + status: 'active', + scope_name: 's', + object: 'workspace', + }, + }, + { + id: 'ws-uuid-prod', + slug: 'ws-produc-985697', + name: 'Production', + icon: null, + description: null, + created_at: '', + last_updated_at: '', + is_default: 0, + status: 'active', + scope_name: 's2', + object: 'workspace', + }, ]; const detail = { - id: 'ws-uuid-dev', name: 'Development', description: 'd', created_at: '', - last_updated_at: '', is_default: 0, slug: 'ws-develo-71f8d8', icon: null, - defaults: null, usage_limits: null, rate_limits: null, status: 'active', + id: 'ws-uuid-dev', + name: 'Development', + description: 'd', + created_at: '', + last_updated_at: '', + is_default: 0, + slug: 'ws-develo-71f8d8', + icon: null, + defaults: null, + usage_limits: null, + rate_limits: null, + status: 'active', }; beforeEach(() => {