@odla-ai/cli 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -57,9 +57,11 @@ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
57
57
  var import_security2 = require("@odla-ai/security");
58
58
 
59
59
  // src/admin-ai.ts
60
- var import_node_process3 = __toESM(require("process"), 1);
61
- var import_node_fs2 = require("fs");
62
- var import_db2 = require("@odla-ai/db");
60
+ var import_node_process4 = __toESM(require("process"), 1);
61
+
62
+ // src/token.ts
63
+ var import_db = require("@odla-ai/db");
64
+ var import_node_process2 = __toESM(require("process"), 1);
63
65
 
64
66
  // src/open.ts
65
67
  var import_node_child_process = require("child_process");
@@ -85,10 +87,6 @@ function openerFor(platform) {
85
87
  return { cmd: "xdg-open", args: [] };
86
88
  }
87
89
 
88
- // src/token.ts
89
- var import_db = require("@odla-ai/db");
90
- var import_node_process2 = __toESM(require("process"), 1);
91
-
92
90
  // src/local.ts
93
91
  var import_node_fs = require("fs");
94
92
  var import_node_path = require("path");
@@ -218,9 +216,18 @@ function displayPath(path, rootDir = process.cwd()) {
218
216
  // src/token.ts
219
217
  async function getDeveloperToken(cfg, options, doFetch, out) {
220
218
  if (options.token) return options.token;
221
- if (import_node_process2.default.env.ODLA_DEV_TOKEN) return import_node_process2.default.env.ODLA_DEV_TOKEN;
219
+ const audience = platformAudience(cfg.platformUrl);
220
+ if (import_node_process2.default.env.ODLA_DEV_TOKEN) {
221
+ const declared = import_node_process2.default.env.ODLA_DEV_TOKEN_AUDIENCE;
222
+ if (declared) {
223
+ if (platformAudience(declared) !== audience) throw new Error("ODLA_DEV_TOKEN_AUDIENCE does not match the configured platform");
224
+ } else if (audience !== "https://odla.ai") {
225
+ throw new Error("ODLA_DEV_TOKEN_AUDIENCE is required for a non-default platform");
226
+ }
227
+ return import_node_process2.default.env.ODLA_DEV_TOKEN;
228
+ }
222
229
  const cached = readJsonFile(cfg.local.tokenFile);
223
- if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
230
+ if (cached?.token && cached.platform === audience && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
224
231
  out.log(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
225
232
  return cached.token;
226
233
  }
@@ -246,7 +253,7 @@ async function getDeveloperToken(cfg, options, doFetch, out) {
246
253
  out.log("");
247
254
  }
248
255
  });
249
- writePrivateJson(cfg.local.tokenFile, { token, expiresAt });
256
+ writePrivateJson(cfg.local.tokenFile, { platform: audience, token, expiresAt });
250
257
  out.log(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
251
258
  return token;
252
259
  }
@@ -263,35 +270,271 @@ function handshakeUrl(platformUrl, userCode) {
263
270
  url.searchParams.set("code", userCode);
264
271
  return url.toString();
265
272
  }
273
+ function platformAudience(value) {
274
+ let url;
275
+ try {
276
+ url = new URL(value);
277
+ } catch {
278
+ throw new Error("platform must be an absolute URL");
279
+ }
280
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
281
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
282
+ throw new Error("platform credentials require HTTPS except for loopback development");
283
+ }
284
+ if (url.username || url.password || url.search || url.hash) {
285
+ throw new Error("platform URL must not contain credentials, query, or fragment");
286
+ }
287
+ return url.origin;
288
+ }
266
289
 
267
- // src/admin-ai.ts
268
- var SYSTEM_AI_PURPOSES = ["o11y.triage", "security.discovery", "security.validation"];
290
+ // src/admin-ai-auth.ts
291
+ var import_node_fs2 = require("fs");
292
+ var import_node_process3 = __toESM(require("process"), 1);
293
+ var import_db2 = require("@odla-ai/db");
269
294
  async function getScopedPlatformToken(options) {
295
+ return resolveAdminPlatformToken(options);
296
+ }
297
+ async function resolveAdminPlatformToken(options) {
298
+ const audience = platformAudience(options.platform);
299
+ if (options.token) return options.token;
270
300
  const fromEnv = import_node_process3.default.env.ODLA_ADMIN_TOKEN;
271
- if (fromEnv) return fromEnv;
301
+ if (fromEnv) return audienceBoundEnvToken(fromEnv, audience);
272
302
  return scopedToken(
273
- options.platform.replace(/\/$/, ""),
303
+ audience,
274
304
  options.scope,
275
- { action: "show", ...options },
305
+ options,
276
306
  options.fetch ?? fetch,
277
307
  options.stdout ?? console
278
308
  );
279
309
  }
310
+ function audienceBoundEnvToken(token, platform) {
311
+ const audience = platformAudience(platform);
312
+ const declared = import_node_process3.default.env.ODLA_ADMIN_TOKEN_AUDIENCE;
313
+ if (declared) {
314
+ if (platformAudience(declared) !== audience) throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE does not match the configured platform");
315
+ } else if (audience !== "https://odla.ai") {
316
+ throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE is required for a non-default platform");
317
+ }
318
+ return token;
319
+ }
320
+ async function scopedToken(platform, scope, options, doFetch, out) {
321
+ const audience = platformAudience(platform);
322
+ const tokenFile = options.tokenFile ?? `${import_node_process3.default.cwd()}/.odla/admin-token.local.json`;
323
+ const cache = readJsonFile(tokenFile);
324
+ const cached = cache?.platform === audience ? cache.tokens?.[scope] : void 0;
325
+ if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
326
+ out.log(`auth: using cached ${scope} grant (${tokenFile})`);
327
+ return cached.token;
328
+ }
329
+ const { token, expiresAt } = await (0, import_db2.requestToken)({
330
+ endpoint: audience,
331
+ label: `odla CLI admin AI (${scope})`,
332
+ scopes: [scope],
333
+ fetch: doFetch,
334
+ onCode: async ({ userCode, expiresIn }) => {
335
+ const approvalUrl = handshakeUrl(audience, userCode);
336
+ out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
337
+ const shouldOpen = options.open === true || options.open !== false && !import_node_process3.default.env.CI && Boolean(import_node_process3.default.stdin.isTTY && import_node_process3.default.stdout.isTTY);
338
+ if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
339
+ }
340
+ });
341
+ const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
342
+ tokens[scope] = { token, expiresAt };
343
+ if ((0, import_node_fs2.existsSync)(`${import_node_process3.default.cwd()}/.git`)) ensureGitignore(import_node_process3.default.cwd(), [tokenFile]);
344
+ writePrivateJson(tokenFile, { platform: audience, tokens });
345
+ out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
346
+ return token;
347
+ }
348
+
349
+ // src/admin-ai-audit.ts
350
+ function adminAiAuditQuery(filters) {
351
+ if (filters.limit === void 0) return "";
352
+ if (!Number.isSafeInteger(filters.limit) || filters.limit < 1 || filters.limit > 200) {
353
+ throw new Error("audit limit must be an integer from 1 to 200");
354
+ }
355
+ return `?limit=${filters.limit}`;
356
+ }
357
+ async function readAdminAiAudit(request) {
358
+ const response = await request.fetch(`${request.platform}/registry/platform/ai-audit${request.query}`, {
359
+ headers: request.headers
360
+ });
361
+ const body = await responseBody(response);
362
+ if (!response.ok) throw new Error(apiError(response.status, body));
363
+ if (request.json) {
364
+ request.stdout.log(JSON.stringify(body, null, 2));
365
+ return;
366
+ }
367
+ const events = isRecord(body) && Array.isArray(body.events) ? body.events.filter(isRecord) : [];
368
+ request.stdout.log("when change target before -> after actor");
369
+ for (const event of events) {
370
+ const before = isRecord(event.oldPolicy) ? event.oldPolicy : void 0;
371
+ const after = isRecord(event.newPolicy) ? event.newPolicy : void 0;
372
+ const route = before && after ? `${String(before.provider)}/${String(before.model)}@v${String(before.version)} -> ${String(after.provider)}/${String(after.model)}@v${String(after.version)}` : "value not retained";
373
+ request.stdout.log([
374
+ timestamp(event.createdAt),
375
+ String(event.changeKind ?? ""),
376
+ String(event.purpose ?? event.provider ?? ""),
377
+ route,
378
+ `${String(event.actorType ?? "")}:${String(event.actorId ?? "")}`
379
+ ].join(" "));
380
+ }
381
+ }
382
+ async function responseBody(response) {
383
+ const text = await response.text();
384
+ if (!text) return {};
385
+ try {
386
+ return JSON.parse(text);
387
+ } catch {
388
+ return { message: text.slice(0, 300) };
389
+ }
390
+ }
391
+ function apiError(status, body) {
392
+ const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
393
+ const message = error && typeof error.message === "string" ? error.message : isRecord(body) && typeof body.message === "string" ? body.message : "request failed";
394
+ return `read System AI admin changes failed (${status}): ${message}`;
395
+ }
396
+ function timestamp(value) {
397
+ if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
398
+ const date = new Date(value);
399
+ return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
400
+ }
401
+ function isRecord(value) {
402
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
403
+ }
404
+
405
+ // src/admin-ai-policy.ts
406
+ var SYSTEM_AI_PURPOSES = ["o11y.triage", "security.discovery", "security.validation"];
407
+ function requireSystemAiPurpose(value) {
408
+ if (!SYSTEM_AI_PURPOSES.includes(value)) {
409
+ throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
410
+ }
411
+ return value;
412
+ }
413
+
414
+ // src/admin-ai-usage.ts
415
+ function adminAiUsageQuery(filters) {
416
+ const params = new URLSearchParams();
417
+ if (filters.appId?.trim()) params.set("appId", filters.appId.trim());
418
+ if (filters.env?.trim()) params.set("env", filters.env.trim());
419
+ if (filters.runId?.trim()) params.set("runId", filters.runId.trim());
420
+ if (filters.limit !== void 0) params.set("limit", String(usageLimit(filters.limit)));
421
+ const query = params.toString();
422
+ return query ? `?${query}` : "";
423
+ }
424
+ async function readAdminAiUsage(request) {
425
+ const res = await request.fetch(`${request.platform}/registry/platform/ai-usage${request.query}`, {
426
+ headers: request.headers
427
+ });
428
+ const body = await responseBody2(res);
429
+ if (!res.ok) throw new Error(apiError2("read platform AI usage", res.status, body));
430
+ if (request.json) request.stdout.log(JSON.stringify(body, null, 2));
431
+ else printUsage(body, request.stdout);
432
+ }
433
+ function usageLimit(value) {
434
+ if (!Number.isSafeInteger(value) || value < 1 || value > 500) {
435
+ throw new Error("usage limit must be an integer from 1 to 500");
436
+ }
437
+ return value;
438
+ }
439
+ function printUsage(body, out) {
440
+ const aggregates = isRecord2(body) && isRecord2(body.aggregates) && Array.isArray(body.aggregates.byStatus) ? body.aggregates.byStatus.filter(isRecord2) : [];
441
+ out.log(`All retained hosted-security status totals (up to ${String(isRecord2(body) ? body.retentionDays ?? 90 : 90)} days; o11y triage excluded)
442
+ status calls input tokens output tokens cost unpriced calls`);
443
+ for (const row of aggregates) {
444
+ out.log([
445
+ String(row.status ?? ""),
446
+ String(row.calls ?? ""),
447
+ String(row.input_tokens ?? ""),
448
+ String(row.output_tokens ?? ""),
449
+ formatMicrousd(row.cost_microusd),
450
+ String(row.unpriced_calls ?? "")
451
+ ].join(" "));
452
+ }
453
+ const events = isRecord2(body) && Array.isArray(body.events) ? body.events.filter(isRecord2) : [];
454
+ out.log(`Latest ${String(isRecord2(body) ? body.eventLimit ?? events.length : events.length)} hosted-security events
455
+ when app/env run actor purpose/role route / policy tokens cost status`);
456
+ for (const event of events) {
457
+ const input = numeric(event.input_tokens);
458
+ const output = numeric(event.output_tokens);
459
+ const priced = event.priced === true || event.priced === 1;
460
+ out.log([
461
+ timestamp2(event.created_at),
462
+ `${String(event.app_id ?? "")}/${String(event.env ?? "")}`,
463
+ String(event.run_id ?? ""),
464
+ `${String(event.actor_type ?? "")}:${String(event.actor_id ?? "")}`,
465
+ `${String(event.purpose ?? "")}/${String(event.role ?? "")}`,
466
+ `${String(event.provider ?? "")}/${String(event.model ?? "")}@v${String(event.policy_version ?? "unknown")}`,
467
+ String(input + output),
468
+ priced ? formatMicrousd(event.cost_microusd) : "unpriced",
469
+ `${String(event.status ?? "")}${event.error_code ? `:${String(event.error_code)}` : ""}`
470
+ ].join(" "));
471
+ }
472
+ }
473
+ function numeric(value) {
474
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
475
+ }
476
+ function formatMicrousd(value) {
477
+ return typeof value === "number" && Number.isFinite(value) ? `$${(value / 1e6).toFixed(6)}` : "unpriced";
478
+ }
479
+ function timestamp2(value) {
480
+ if (typeof value !== "number" || !Number.isFinite(value)) return String(value ?? "");
481
+ const date = new Date(value);
482
+ return Number.isFinite(date.valueOf()) ? date.toISOString() : "";
483
+ }
484
+ async function responseBody2(res) {
485
+ const text = await res.text();
486
+ if (!text) return {};
487
+ try {
488
+ return JSON.parse(text);
489
+ } catch {
490
+ return { message: text.slice(0, 300) };
491
+ }
492
+ }
493
+ function apiError2(action, status, body) {
494
+ const error = isRecord2(body) && isRecord2(body.error) ? body.error : void 0;
495
+ const message = error && typeof error.message === "string" ? error.message : isRecord2(body) && typeof body.message === "string" ? body.message : "request failed";
496
+ return `${action} failed (${status}): ${message}`;
497
+ }
498
+ function isRecord2(value) {
499
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
500
+ }
501
+
502
+ // src/admin-ai.ts
280
503
  async function adminAi(options) {
281
- const platform = (options.platform ?? import_node_process3.default.env.ODLA_PLATFORM ?? "https://odla.ai").replace(/\/$/, "");
504
+ const platform = platformAudience(options.platform ?? import_node_process4.default.env.ODLA_PLATFORM ?? "https://odla.ai");
282
505
  const doFetch = options.fetch ?? fetch;
283
506
  const out = options.stdout ?? console;
284
- const scope = options.action === "set" || options.action === "credential-set" ? "platform:ai:write" : "platform:ai:read";
285
- const token = options.token ?? import_node_process3.default.env.ODLA_ADMIN_TOKEN ?? await scopedToken(platform, scope, options, doFetch, out);
507
+ const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
508
+ const auditQuery = options.action === "audit" ? adminAiAuditQuery(options) : void 0;
509
+ const scope = options.action === "set" ? "platform:ai:policy:write" : options.action === "credential-set" ? "platform:ai:credential:write" : options.action === "credentials" ? "platform:ai:credential:read" : options.action === "usage" ? "platform:ai:usage:read" : options.action === "audit" ? "platform:ai:audit:read" : "platform:ai:policy:read";
510
+ const token = await resolveAdminPlatformToken({
511
+ platform,
512
+ scope,
513
+ token: options.token,
514
+ open: options.open,
515
+ fetch: doFetch,
516
+ stdout: out,
517
+ openApprovalUrl: options.openApprovalUrl,
518
+ tokenFile: options.tokenFile
519
+ });
286
520
  const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
521
+ if (options.action === "usage") {
522
+ await readAdminAiUsage({ platform, query: usageQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
523
+ return;
524
+ }
525
+ if (options.action === "audit") {
526
+ await readAdminAiAudit({ platform, query: auditQuery ?? "", headers, json: options.json, fetch: doFetch, stdout: out });
527
+ return;
528
+ }
287
529
  if (options.action === "credentials") {
288
530
  const res2 = await doFetch(`${platform}/registry/platform/ai-credentials`, { headers });
289
- const body2 = await responseBody(res2);
290
- if (!res2.ok) throw new Error(apiError("read platform AI credential status", res2.status, body2));
291
- const credentials = isRecord(body2) && isRecord(body2.credentials) ? body2.credentials : body2;
531
+ const body2 = await responseBody3(res2);
532
+ if (!res2.ok) throw new Error(apiError3("read platform AI credential status", res2.status, body2));
533
+ const credentials = isRecord3(body2) && isRecord3(body2.credentials) ? body2.credentials : body2;
292
534
  if (options.json) out.log(JSON.stringify({ credentials }, null, 2));
293
- else for (const [provider, status] of Object.entries(isRecord(credentials) ? credentials : {})) {
294
- out.log(`${provider} ${isRecord(status) && status.set === true ? "set" : "not set"}`);
535
+ else for (const [provider, status] of Object.entries(isRecord3(credentials) ? credentials : {})) {
536
+ const label = isRecord3(status) && status.set === true ? status.readable === true ? "vault readable" : "stored; vault unreadable" : "not set";
537
+ out.log(`${provider} ${label}`);
295
538
  }
296
539
  return;
297
540
  }
@@ -303,18 +546,25 @@ async function adminAi(options) {
303
546
  headers,
304
547
  body: JSON.stringify({ value })
305
548
  });
306
- const body2 = await responseBody(res2);
307
- if (!res2.ok) throw new Error(apiError(`store ${provider} platform credential`, res2.status, body2));
549
+ const body2 = await responseBody3(res2);
550
+ if (!res2.ok) throw new Error(apiError3(`store ${provider} platform credential`, res2.status, body2));
308
551
  out.log(`stored ${provider} platform credential (write-only; value was not returned)`);
309
552
  return;
310
553
  }
311
- if (options.action === "show") {
554
+ if (options.action === "show" || options.action === "models") {
312
555
  const res2 = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
313
- const body2 = await responseBody(res2);
314
- if (!res2.ok) throw new Error(apiError("read platform AI policies", res2.status, body2));
556
+ const body2 = await responseBody3(res2);
557
+ if (!res2.ok) throw new Error(apiError3("read platform AI policies", res2.status, body2));
558
+ if (options.action === "models") {
559
+ const models = catalogModels(body2).filter((model) => !options.provider || model.provider === options.provider);
560
+ if (!models.length) throw new Error(options.provider ? `no System AI models found for provider ${options.provider}` : "platform returned no System AI models");
561
+ if (options.json) out.log(JSON.stringify({ models }, null, 2));
562
+ else for (const model of models) out.log(`${model.provider} ${model.id}`);
563
+ return;
564
+ }
315
565
  const policies = policyArray(body2);
316
566
  if (options.json) {
317
- out.log(JSON.stringify({ policies }, null, 2));
567
+ out.log(JSON.stringify({ policies, models: catalogModels(body2) }, null, 2));
318
568
  return;
319
569
  }
320
570
  out.log("purpose enabled provider model max calls max input bytes max output tokens");
@@ -331,59 +581,68 @@ async function adminAi(options) {
331
581
  }
332
582
  return;
333
583
  }
334
- const purpose = requirePurpose(options.purpose);
335
- if (!options.provider?.trim()) throw new Error("--provider is required");
336
- if (!options.model?.trim()) throw new Error("--model is required");
584
+ if (options.purpose === "security") {
585
+ const currentResponse = await doFetch(`${platform}/registry/platform/ai-policies`, { headers });
586
+ const currentBody = await responseBody3(currentResponse);
587
+ if (!currentResponse.ok) throw new Error(apiError3("read current security AI policies", currentResponse.status, currentBody));
588
+ const policies = policyArray(currentBody);
589
+ const discovery = policies.find((policy2) => policy2.purpose === "security.discovery");
590
+ const validation = policies.find((policy2) => policy2.purpose === "security.validation");
591
+ if (!discovery || !validation || !Number.isSafeInteger(discovery.version) || !Number.isSafeInteger(validation.version)) {
592
+ throw new Error("platform returned invalid security AI policy revisions");
593
+ }
594
+ const shared = {
595
+ ...options.enabled === void 0 ? {} : { enabled: options.enabled },
596
+ ...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
597
+ ...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
598
+ ...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
599
+ };
600
+ const discoveryUpdate = {
601
+ ...shared,
602
+ ...options.discoveryProvider?.trim() ? { provider: options.discoveryProvider.trim() } : {},
603
+ ...options.discoveryModel?.trim() ? { model: options.discoveryModel.trim() } : {}
604
+ };
605
+ const validationUpdate = {
606
+ ...shared,
607
+ ...options.validationProvider?.trim() ? { provider: options.validationProvider.trim() } : {},
608
+ ...options.validationModel?.trim() ? { model: options.validationModel.trim() } : {}
609
+ };
610
+ if (!Object.keys(discoveryUpdate).length && !Object.keys(validationUpdate).length) {
611
+ throw new Error("security set requires a route, enabled state, or budget change");
612
+ }
613
+ const res2 = await doFetch(`${platform}/registry/platform/ai-policies/security`, {
614
+ method: "PUT",
615
+ headers,
616
+ body: JSON.stringify({
617
+ expectedVersions: { discovery: discovery.version, validation: validation.version },
618
+ discovery: discoveryUpdate,
619
+ validation: validationUpdate
620
+ })
621
+ });
622
+ const response2 = await responseBody3(res2);
623
+ if (!res2.ok) throw new Error(apiError3("update security review routes", res2.status, response2));
624
+ out.log(options.json ? JSON.stringify(response2, null, 2) : "updated security review discovery and independent validation routes atomically");
625
+ return;
626
+ }
627
+ const purpose = requireSystemAiPurpose(options.purpose);
337
628
  const body = {
338
- provider: options.provider.trim(),
339
- model: options.model.trim(),
629
+ ...options.provider?.trim() ? { provider: options.provider.trim() } : {},
630
+ ...options.model?.trim() ? { model: options.model.trim() } : {},
340
631
  ...options.enabled === void 0 ? {} : { enabled: options.enabled },
341
632
  ...options.maxInputBytes === void 0 ? {} : { maxInputBytes: options.maxInputBytes },
342
633
  ...options.maxOutputTokens === void 0 ? {} : { maxOutputTokens: options.maxOutputTokens },
343
634
  ...options.maxCallsPerRun === void 0 ? {} : { maxCallsPerRun: options.maxCallsPerRun }
344
635
  };
636
+ if (!Object.keys(body).length) throw new Error("set requires a provider, model, enabled state, or budget change");
345
637
  const res = await doFetch(`${platform}/registry/platform/ai-policies/${encodeURIComponent(purpose)}`, {
346
638
  method: "PUT",
347
639
  headers,
348
640
  body: JSON.stringify(body)
349
641
  });
350
- const response = await responseBody(res);
351
- if (!res.ok) throw new Error(apiError(`update ${purpose}`, res.status, response));
352
- const policy = isRecord(response) && isRecord(response.policy) ? response.policy : isRecord(response) ? response : {};
353
- out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ?? body.provider)}/${String(policy.model ?? body.model)}`);
354
- }
355
- async function scopedToken(platform, scope, options, doFetch, out) {
356
- const tokenFile = options.tokenFile ?? `${import_node_process3.default.cwd()}/.odla/admin-token.local.json`;
357
- const cache = readJsonFile(tokenFile);
358
- const cached = cache?.platform === platform ? cache.tokens?.[scope] : void 0;
359
- if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
360
- out.log(`auth: using cached ${scope} grant (${tokenFile})`);
361
- return cached.token;
362
- }
363
- const { token, expiresAt } = await (0, import_db2.requestToken)({
364
- endpoint: platform,
365
- label: `odla CLI admin AI (${scope})`,
366
- scopes: [scope],
367
- fetch: doFetch,
368
- onCode: async ({ userCode, expiresIn }) => {
369
- const approvalUrl = handshakeUrl(platform, userCode);
370
- out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
371
- const shouldOpen = options.open === true || options.open !== false && !import_node_process3.default.env.CI && Boolean(import_node_process3.default.stdin.isTTY && import_node_process3.default.stdout.isTTY);
372
- if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
373
- }
374
- });
375
- const tokens = cache?.platform === platform ? { ...cache.tokens ?? {} } : {};
376
- tokens[scope] = { token, expiresAt };
377
- if ((0, import_node_fs2.existsSync)(`${import_node_process3.default.cwd()}/.git`)) ensureGitignore(import_node_process3.default.cwd(), [tokenFile]);
378
- writePrivateJson(tokenFile, { platform, tokens });
379
- out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
380
- return token;
381
- }
382
- function requirePurpose(value) {
383
- if (!SYSTEM_AI_PURPOSES.includes(value)) {
384
- throw new Error(`purpose must be one of: ${SYSTEM_AI_PURPOSES.join(", ")}`);
385
- }
386
- return value;
642
+ const response = await responseBody3(res);
643
+ if (!res.ok) throw new Error(apiError3(`update ${purpose}`, res.status, response));
644
+ const policy = isRecord3(response) && isRecord3(response.policy) ? response.policy : isRecord3(response) ? response : {};
645
+ out.log(options.json ? JSON.stringify({ policy }, null, 2) : `updated ${purpose}: ${String(policy.provider ?? "unchanged")}/${String(policy.model ?? "unchanged")}`);
387
646
  }
388
647
  function requireProvider(value) {
389
648
  if (value !== "anthropic" && value !== "openai" && value !== "google") {
@@ -394,7 +653,7 @@ function requireProvider(value) {
394
653
  async function credentialValue(options) {
395
654
  if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
396
655
  let value;
397
- if (options.fromEnv) value = import_node_process3.default.env[options.fromEnv];
656
+ if (options.fromEnv) value = import_node_process4.default.env[options.fromEnv];
398
657
  else if (options.stdin) value = await (options.readStdin ?? readStdin)();
399
658
  else throw new Error("credential input required: use --from-env <NAME> or --stdin; values are never accepted as arguments");
400
659
  value = value?.replace(/[\r\n]+$/, "");
@@ -404,18 +663,24 @@ async function credentialValue(options) {
404
663
  }
405
664
  async function readStdin() {
406
665
  let value = "";
407
- for await (const chunk of import_node_process3.default.stdin) {
666
+ for await (const chunk of import_node_process4.default.stdin) {
408
667
  value += String(chunk);
409
668
  if (value.length > 64 * 1024) throw new Error("credential exceeds 64 KiB");
410
669
  }
411
670
  return value;
412
671
  }
413
672
  function policyArray(body) {
414
- if (isRecord(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord);
415
- if (isRecord(body) && isRecord(body.policies)) return Object.values(body.policies).filter(isRecord);
673
+ if (isRecord3(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord3);
674
+ if (isRecord3(body) && isRecord3(body.policies)) return Object.values(body.policies).filter(isRecord3);
416
675
  throw new Error("platform returned an invalid AI policy response");
417
676
  }
418
- async function responseBody(res) {
677
+ function catalogModels(body) {
678
+ if (!isRecord3(body) || !isRecord3(body.catalog) || !Array.isArray(body.catalog.models)) {
679
+ throw new Error("platform returned an invalid System AI model catalog");
680
+ }
681
+ return body.catalog.models.filter((value) => isRecord3(value) && typeof value.id === "string" && typeof value.provider === "string");
682
+ }
683
+ async function responseBody3(res) {
419
684
  const text = await res.text();
420
685
  if (!text) return {};
421
686
  try {
@@ -424,12 +689,12 @@ async function responseBody(res) {
424
689
  return { message: text.slice(0, 300) };
425
690
  }
426
691
  }
427
- function apiError(action, status, body) {
428
- const error = isRecord(body) && isRecord(body.error) ? body.error : void 0;
429
- const message = error && typeof error.message === "string" ? error.message : isRecord(body) && typeof body.message === "string" ? body.message : "request failed";
692
+ function apiError3(action, status, body) {
693
+ const error = isRecord3(body) && isRecord3(body.error) ? body.error : void 0;
694
+ const message = error && typeof error.message === "string" ? error.message : isRecord3(body) && typeof body.message === "string" ? body.message : "request failed";
430
695
  return `${action} failed (${status}): ${message}`;
431
696
  }
432
- function isRecord(value) {
697
+ function isRecord3(value) {
433
698
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
434
699
  }
435
700
 
@@ -517,7 +782,7 @@ var CAPABILITIES = {
517
782
  "push db schema/rules and configure platform AI, auth, and deployment links",
518
783
  "validate config offline and smoke-test a provisioned db environment",
519
784
  "run app-attributed hosted security discovery and independent validation without provider keys",
520
- "let admins manage system AI routes/credentials through short-lived exact-scope approval"
785
+ "let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
521
786
  ],
522
787
  agent: [
523
788
  "install and import the selected odla SDKs",
@@ -1049,7 +1314,7 @@ function relativeDisplay(path, rootDir) {
1049
1314
  // src/provision.ts
1050
1315
  var import_apps2 = require("@odla-ai/apps");
1051
1316
  var import_ai = require("@odla-ai/ai");
1052
- var import_node_process4 = __toESM(require("process"), 1);
1317
+ var import_node_process5 = __toESM(require("process"), 1);
1053
1318
 
1054
1319
  // src/provision-credentials.ts
1055
1320
  var import_apps = require("@odla-ai/apps");
@@ -1353,7 +1618,7 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
1353
1618
  out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
1354
1619
  }
1355
1620
  if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
1356
- const key = import_node_process4.default.env[cfg.ai.keyEnv];
1621
+ const key = import_node_process5.default.env[cfg.ai.keyEnv];
1357
1622
  if (key) {
1358
1623
  const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
1359
1624
  await (0, import_ai.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
@@ -1508,10 +1773,14 @@ function printSummary(out, appId, env, run, report, output) {
1508
1773
  out.log(`security: ${appId}/${env} run=${run.runId} profile=${run.profileVersion}`);
1509
1774
  out.log(` discovery: ${run.discovery.identity.provider}/${run.discovery.identity.model}`);
1510
1775
  out.log(` validation: ${run.validation.identity.provider}/${run.validation.identity.model}`);
1511
- out.log(` coverage: ${report.coverageStatus} ${complete}/${report.coverage.length} blocked=${report.metrics.blockedCells} shallow=${report.metrics.shallowCells} unscheduled=${report.metrics.unscheduledCells}`);
1776
+ out.log(` coverage: ${report.coverageStatus} ${complete}/${report.coverage.length} blocked=${report.metrics.blockedCells} shallow=${report.metrics.shallowCells} unscheduled=${report.metrics.unscheduledCells} budget_exhausted=${report.metrics.budgetExhaustedCells}`);
1777
+ if (report.callBudget) out.log(` calls: discovery=${formatBudget(report.callBudget.discovery)} validation=${formatBudget(report.callBudget.validation)}`);
1512
1778
  out.log(` findings: confirmed=${report.metrics.confirmed} needs_reproduction=${report.metrics.needsReproduction} candidates=${report.metrics.candidates}`);
1513
1779
  out.log(` report: ${(0, import_node_path6.resolve)(output, "REPORT.md")}`);
1514
1780
  }
1781
+ function formatBudget(usage) {
1782
+ return usage ? `${usage.usedCalls}/${usage.maxCalls} skipped=${usage.skippedCalls}` : "caller-managed";
1783
+ }
1515
1784
 
1516
1785
  // src/help.ts
1517
1786
  var import_node_fs7 = require("fs");
@@ -1528,9 +1797,14 @@ Usage:
1528
1797
  odla-ai doctor [--config odla.config.mjs]
1529
1798
  odla-ai capabilities [--json]
1530
1799
  odla-ai admin ai show [--platform https://odla.ai] [--json]
1531
- odla-ai admin ai set <purpose> --provider <id> --model <id> [--enabled|--no-enabled]
1800
+ odla-ai admin ai models [--provider <id>] [--json]
1801
+ odla-ai admin ai set <purpose> [--provider <id>] [--model <id>] [--enabled|--no-enabled]
1802
+ odla-ai admin ai set security --discovery-provider <id> --discovery-model <id>
1803
+ --validation-provider <id> --validation-model <id> [--enabled|--no-enabled]
1532
1804
  odla-ai admin ai credentials [--json]
1533
1805
  odla-ai admin ai credential set <provider> (--from-env <NAME>|--stdin)
1806
+ odla-ai admin ai usage [--app-id <id>] [--env <env>] [--run-id <id>] [--limit <1-500>] [--json]
1807
+ odla-ai admin ai audit [--limit <1-200>] [--json]
1534
1808
  odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
1535
1809
  odla-ai security run [target] --self --ack-redacted-source
1536
1810
  odla-ai provision [--config odla.config.mjs] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
@@ -1544,7 +1818,7 @@ Commands:
1544
1818
  init Create a generic odla.config.mjs plus starter schema/rules files.
1545
1819
  doctor Validate and summarize the project config without network calls.
1546
1820
  capabilities Show what the CLI automates vs agent edits and human checkpoints.
1547
- admin Manage platform-funded AI routing with a narrow admin-approved device grant.
1821
+ admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
1548
1822
  security Run hosted discovery + independent validation with app/run attribution.
1549
1823
  provision Register services, configure them, persist credentials, optionally push secrets.
1550
1824
  smoke Verify local credentials, public-config, live schema, and db aggregate.
@@ -1940,12 +2214,14 @@ async function runCli(argv = process.argv.slice(2)) {
1940
2214
  const action = parsed.positionals[2];
1941
2215
  const credentialSet = action === "credential" && parsed.positionals[3] === "set";
1942
2216
  const credentials = action === "credentials";
1943
- if (area !== "ai" || action !== "show" && action !== "set" && !credentialSet && !credentials) {
2217
+ const models = action === "models";
2218
+ const usage = action === "usage";
2219
+ const audit = action === "audit";
2220
+ if (area !== "ai" || action !== "show" && action !== "set" && !credentialSet && !credentials && !models && !usage && !audit) {
1944
2221
  throw new Error('unknown admin command. Try "odla-ai admin ai show".');
1945
2222
  }
1946
2223
  assertArgs(parsed, [
1947
2224
  "platform",
1948
- "token",
1949
2225
  "json",
1950
2226
  "open",
1951
2227
  "provider",
@@ -1955,10 +2231,18 @@ async function runCli(argv = process.argv.slice(2)) {
1955
2231
  "max-output-tokens",
1956
2232
  "max-calls-per-run",
1957
2233
  "from-env",
1958
- "stdin"
2234
+ "stdin",
2235
+ "discovery-provider",
2236
+ "discovery-model",
2237
+ "validation-provider",
2238
+ "validation-model",
2239
+ "app-id",
2240
+ "env",
2241
+ "run-id",
2242
+ "limit"
1959
2243
  ], credentialSet ? 5 : action === "set" ? 4 : 3);
1960
2244
  await adminAi({
1961
- action: credentialSet ? "credential-set" : credentials ? "credentials" : action,
2245
+ action: credentialSet ? "credential-set" : credentials ? "credentials" : models ? "models" : usage ? "usage" : audit ? "audit" : action,
1962
2246
  purpose: action === "set" ? parsed.positionals[3] : void 0,
1963
2247
  provider: stringOpt(parsed.options.provider),
1964
2248
  model: stringOpt(parsed.options.model),
@@ -1967,12 +2251,19 @@ async function runCli(argv = process.argv.slice(2)) {
1967
2251
  maxOutputTokens: numberOpt(parsed.options["max-output-tokens"], "--max-output-tokens"),
1968
2252
  maxCallsPerRun: numberOpt(parsed.options["max-calls-per-run"], "--max-calls-per-run"),
1969
2253
  platform: stringOpt(parsed.options.platform),
1970
- token: stringOpt(parsed.options.token),
1971
2254
  json: parsed.options.json === true,
1972
2255
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
1973
2256
  credentialProvider: credentialSet ? parsed.positionals[4] : void 0,
1974
2257
  fromEnv: stringOpt(parsed.options["from-env"]),
1975
- stdin: parsed.options.stdin === true
2258
+ stdin: parsed.options.stdin === true,
2259
+ discoveryProvider: stringOpt(parsed.options["discovery-provider"]),
2260
+ discoveryModel: stringOpt(parsed.options["discovery-model"]),
2261
+ validationProvider: stringOpt(parsed.options["validation-provider"]),
2262
+ validationModel: stringOpt(parsed.options["validation-model"]),
2263
+ appId: stringOpt(parsed.options["app-id"]),
2264
+ env: stringOpt(parsed.options.env),
2265
+ runId: stringOpt(parsed.options["run-id"]),
2266
+ limit: numberOpt(parsed.options.limit, "--limit")
1976
2267
  });
1977
2268
  return;
1978
2269
  }
@@ -2014,6 +2305,9 @@ async function runCli(argv = process.argv.slice(2)) {
2014
2305
  return getScopedPlatformToken({ platform: request.platform, scope: request.scope, open });
2015
2306
  }
2016
2307
  const cfg = await loadProjectConfig(configPath);
2308
+ if (platformAudience(cfg.platformUrl) !== platformAudience(request.platform)) {
2309
+ throw new Error("--platform cannot reuse a project developer token from another platform; update odla.config.mjs and authenticate there");
2310
+ }
2017
2311
  return getDeveloperToken(cfg, { configPath, open }, fetch, console);
2018
2312
  }
2019
2313
  });