@oh-my-pi/pi-coding-agent 15.5.6 → 15.5.8

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  3. package/dist/types/commands/auth-gateway.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +60 -12
  5. package/dist/types/edit/file-snapshot-store.d.ts +9 -6
  6. package/dist/types/edit/hashline/diff.d.ts +4 -5
  7. package/dist/types/edit/streaming.d.ts +2 -1
  8. package/dist/types/eval/py/index.d.ts +1 -0
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  10. package/dist/types/extensibility/shared-events.d.ts +1 -1
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
  13. package/dist/types/lib/xai-http.d.ts +40 -0
  14. package/dist/types/mcp/transports/http.d.ts +9 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +2 -1
  16. package/dist/types/session/agent-session.d.ts +4 -1
  17. package/dist/types/tools/fetch.d.ts +16 -0
  18. package/dist/types/tools/image-gen.d.ts +6 -2
  19. package/dist/types/tools/index.d.ts +1 -0
  20. package/dist/types/tools/match-line-format.d.ts +2 -2
  21. package/dist/types/tools/plan-mode-guard.d.ts +5 -6
  22. package/dist/types/tools/render-utils.d.ts +3 -1
  23. package/dist/types/tools/tts.d.ts +18 -0
  24. package/dist/types/tools/write.d.ts +2 -0
  25. package/dist/types/utils/file-mentions.d.ts +2 -0
  26. package/package.json +8 -8
  27. package/src/cli/args.ts +2 -0
  28. package/src/cli/auth-broker-cli.ts +2 -1
  29. package/src/cli/auth-gateway-cli.ts +210 -9
  30. package/src/commands/auth-gateway.ts +7 -1
  31. package/src/config/model-registry.ts +41 -9
  32. package/src/config/settings-schema.ts +55 -13
  33. package/src/edit/file-snapshot-store.ts +9 -6
  34. package/src/edit/hashline/diff.ts +26 -13
  35. package/src/edit/hashline/execute.ts +13 -9
  36. package/src/edit/renderer.ts +9 -9
  37. package/src/edit/streaming.ts +4 -6
  38. package/src/eval/py/index.ts +1 -1
  39. package/src/extensibility/custom-tools/types.ts +1 -1
  40. package/src/extensibility/shared-events.ts +1 -1
  41. package/src/internal-urls/docs-index.generated.ts +7 -7
  42. package/src/internal-urls/index.ts +1 -0
  43. package/src/internal-urls/router.ts +2 -0
  44. package/src/internal-urls/vault-protocol.ts +936 -0
  45. package/src/lib/xai-http.ts +124 -0
  46. package/src/main.ts +1 -2
  47. package/src/mcp/transports/http.ts +29 -2
  48. package/src/modes/components/tool-execution.ts +6 -4
  49. package/src/modes/controllers/event-controller.ts +10 -3
  50. package/src/modes/controllers/selector-controller.ts +7 -2
  51. package/src/modes/interactive-mode.ts +11 -3
  52. package/src/modes/utils/ui-helpers.ts +2 -1
  53. package/src/prompts/system/system-prompt.md +3 -0
  54. package/src/prompts/tools/ast-edit.md +1 -1
  55. package/src/prompts/tools/ast-grep.md +1 -1
  56. package/src/prompts/tools/read.md +3 -3
  57. package/src/prompts/tools/search.md +1 -1
  58. package/src/sdk.ts +41 -10
  59. package/src/session/agent-session.ts +112 -14
  60. package/src/system-prompt.ts +2 -0
  61. package/src/tools/ast-edit.ts +10 -7
  62. package/src/tools/ast-grep.ts +12 -11
  63. package/src/tools/eval.ts +28 -3
  64. package/src/tools/fetch.ts +52 -24
  65. package/src/tools/image-gen.ts +205 -7
  66. package/src/tools/index.ts +1 -0
  67. package/src/tools/match-line-format.ts +2 -2
  68. package/src/tools/path-utils.ts +2 -0
  69. package/src/tools/plan-mode-guard.ts +20 -7
  70. package/src/tools/read.ts +70 -55
  71. package/src/tools/render-utils.ts +15 -0
  72. package/src/tools/search.ts +14 -14
  73. package/src/tools/tts.ts +133 -0
  74. package/src/tools/write.ts +61 -6
  75. package/src/utils/file-mentions.ts +11 -5
  76. package/src/web/search/providers/codex.ts +2 -1
@@ -2,12 +2,11 @@ import type { ToolSession } from ".";
2
2
  /**
3
3
  * Resolve a write/edit target to its absolute filesystem path.
4
4
  *
5
- * In plan mode, transparently redirects targets whose basename matches the
6
- * plan file's basename (e.g. a bare `PLAN.md` or `./PLAN.md`) to the canonical
7
- * plan file location at `state.planFilePath`. This lets `write` and `edit`
8
- * accept the unqualified plan filename and have the change land at the
9
- * session-scoped `local://PLAN.md` artifact instead of a stray cwd-relative
10
- * file the plan-mode guard would otherwise reject.
5
+ * In plan mode, transparently redirects `PLAN.md` aliases and targets whose
6
+ * basename matches the plan file's basename to the canonical plan file
7
+ * location at `state.planFilePath`. This lets `write` and `edit` accept the
8
+ * habitual plan filename after approval even when the active artifact has a
9
+ * titled path such as `local://APPROVED.md`.
11
10
  *
12
11
  * Outside plan mode (or when the basename does not match) this is a no-op.
13
12
  */
@@ -100,7 +100,9 @@ export interface DiffStats {
100
100
  }
101
101
  export declare function getDiffStats(diffText: string): DiffStats;
102
102
  export declare function formatDiffStats(added: number, removed: number, hunks: number, theme: Theme): string;
103
- export declare function truncateDiffByHunk(diffText: string, maxHunks: number, maxLines: number): {
103
+ export declare function truncateDiffByHunk(diffText: string, maxHunks: number, maxLines: number, options?: {
104
+ fromTail?: boolean;
105
+ }): {
104
106
  text: string;
105
107
  hiddenHunks: number;
106
108
  hiddenLines: number;
@@ -0,0 +1,18 @@
1
+ import * as z from "zod/v4";
2
+ import type { CustomTool } from "../extensibility/custom-tools/types";
3
+ type TtsCodec = "mp3" | "wav";
4
+ declare const ttsSchema: z.ZodObject<{
5
+ text: z.ZodString;
6
+ voice_id: z.ZodDefault<z.ZodString>;
7
+ language: z.ZodDefault<z.ZodString>;
8
+ output_path: z.ZodString;
9
+ sample_rate: z.ZodOptional<z.ZodNumber>;
10
+ bit_rate: z.ZodOptional<z.ZodNumber>;
11
+ }, z.core.$strip>;
12
+ interface TtsToolDetails {
13
+ bytes: number;
14
+ voiceId: string;
15
+ codec: TtsCodec;
16
+ }
17
+ export declare const ttsTool: CustomTool<typeof ttsSchema, TtsToolDetails>;
18
+ export {};
@@ -15,6 +15,8 @@ export type WriteToolInput = z.infer<typeof writeSchema>;
15
15
  export interface WriteToolDetails {
16
16
  diagnostics?: FileDiagnosticsResult;
17
17
  meta?: OutputMeta;
18
+ /** Set when the file was auto-chmod'd because content begins with a `#!` shebang. */
19
+ madeExecutable?: boolean;
18
20
  }
19
21
  type WriteParams = WriteToolInput;
20
22
  /**
@@ -1,3 +1,4 @@
1
+ import { type SnapshotStore } from "@oh-my-pi/hashline";
1
2
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
3
  /** Extract all @filepath mentions from text */
3
4
  export declare function extractFileMentions(text: string): string[];
@@ -8,4 +9,5 @@ export declare function extractFileMentions(text: string): string[];
8
9
  export declare function generateFileMentionMessages(filePaths: string[], cwd: string, options?: {
9
10
  autoResizeImages?: boolean;
10
11
  useHashLines?: boolean;
12
+ snapshotStore?: SnapshotStore;
11
13
  }): Promise<AgentMessage[]>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.5.6",
4
+ "version": "15.5.8",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,13 +47,13 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "15.5.6",
51
- "@oh-my-pi/omp-stats": "15.5.6",
52
- "@oh-my-pi/pi-agent-core": "15.5.6",
53
- "@oh-my-pi/pi-ai": "15.5.6",
54
- "@oh-my-pi/pi-natives": "15.5.6",
55
- "@oh-my-pi/pi-tui": "15.5.6",
56
- "@oh-my-pi/pi-utils": "15.5.6",
50
+ "@oh-my-pi/hashline": "15.5.8",
51
+ "@oh-my-pi/omp-stats": "15.5.8",
52
+ "@oh-my-pi/pi-agent-core": "15.5.8",
53
+ "@oh-my-pi/pi-ai": "15.5.8",
54
+ "@oh-my-pi/pi-natives": "15.5.8",
55
+ "@oh-my-pi/pi-tui": "15.5.8",
56
+ "@oh-my-pi/pi-utils": "15.5.8",
57
57
  "@puppeteer/browsers": "^2.13.0",
58
58
  "@types/turndown": "5.0.6",
59
59
  "@xterm/headless": "^6.0.0",
package/src/cli/args.ts CHANGED
@@ -247,6 +247,8 @@ export function getExtraHelpText(): string {
247
247
  OPENCODE_API_KEY - OpenCode Zen/OpenCode Go models
248
248
  CURSOR_ACCESS_TOKEN - Cursor AI models
249
249
  AI_GATEWAY_API_KEY - Vercel AI Gateway
250
+ WAFER_PASS_API_KEY - Wafer Pass (flat-rate subscription; GLM-5.1, Qwen3.5)
251
+ WAFER_SERVERLESS_API_KEY - Wafer Serverless (pay-as-you-go)
250
252
 
251
253
  ${chalk.dim("# Cloud Providers")}
252
254
  AWS_PROFILE - AWS Bedrock (or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
@@ -34,6 +34,7 @@ import {
34
34
  startAuthBroker,
35
35
  } from "@oh-my-pi/pi-ai";
36
36
  import { $which, APP_NAME, getAgentDbPath, getConfigRootDir, isEnoent, logger, VERSION } from "@oh-my-pi/pi-utils";
37
+ import { setTransports as setLoggerTransports } from "@oh-my-pi/pi-utils/logger";
37
38
  import { $ } from "bun";
38
39
  import chalk from "chalk";
39
40
  import { resolveAuthBrokerConfig } from "../session/auth-broker-config";
@@ -124,7 +125,7 @@ async function runServe(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
124
125
  // The broker is a long-running headless service: route structured logs to
125
126
  // stdout so a process supervisor (pm2, journald, k8s) captures them, and
126
127
  // skip the rotating ~/.omp/logs/ file the TUI default would have used.
127
- logger.setTransports({ console: true, file: false });
128
+ setLoggerTransports({ console: true, file: false });
128
129
 
129
130
  const bind = flags.bind ?? DEFAULT_AUTH_BROKER_BIND;
130
131
  const token = await ensureToken();
@@ -19,6 +19,10 @@ import {
19
19
  type Api,
20
20
  AuthBrokerClient,
21
21
  AuthStorage,
22
+ type CompletionProbe,
23
+ type CompletionProbeInput,
24
+ type CredentialCompletionResult,
25
+ completeSimple,
22
26
  DEFAULT_AUTH_GATEWAY_BIND,
23
27
  type GeneratedProvider,
24
28
  getBundledModels,
@@ -46,6 +50,14 @@ export interface AuthGatewayCommandArgs {
46
50
  * to wire token-paste plumbing into every local client.
47
51
  */
48
52
  noAuth?: boolean;
53
+ /**
54
+ * Strict mode for `check` — additionally exercise every credential
55
+ * against its provider's chat-completion endpoint. The usage probe (run
56
+ * unconditionally) can pass while the chat endpoint still 401s the same
57
+ * bearer, so strict mode is the definitive "is this credential
58
+ * actually usable" signal. Slower and consumes a tiny amount of quota.
59
+ */
60
+ strict?: boolean;
49
61
  };
50
62
  }
51
63
 
@@ -342,12 +354,185 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis
342
354
  }
343
355
  }
344
356
 
357
+ /**
358
+ * Providers whose chat endpoint expects a JSON-serialized credential blob
359
+ * (`{ token, projectId, refreshToken, expiresAt, … }`) rather than the raw
360
+ * access token. Mirrors `getOAuthApiKey` in `packages/ai/src/utils/oauth`.
361
+ */
362
+ const STRUCTURED_API_KEY_PROVIDERS: ReadonlySet<string> = new Set([
363
+ "github-copilot",
364
+ "google-gemini-cli",
365
+ "google-antigravity",
366
+ ]);
367
+
368
+ /**
369
+ * Provider API types that strict-mode chat probes intentionally skip:
370
+ * - `bedrock-converse-stream` resolves credentials from the AWS env/profile, not the broker bearer.
371
+ * - `google-vertex` uses Application Default Credentials; the broker bearer is not the right key.
372
+ * - `cursor-agent` and `pi-native` (gateway forwarding) have transport quirks
373
+ * that make a bearer-only "ping" a poor signal.
374
+ */
375
+ const STRICT_PROBE_SKIPPED_APIS: ReadonlySet<Api> = new Set<Api>([
376
+ "bedrock-converse-stream",
377
+ "google-vertex",
378
+ "cursor-agent",
379
+ ]);
380
+
381
+ /** Max chat models to try per credential before reporting failure. */
382
+ const STRICT_PROBE_MAX_CANDIDATES = 4;
383
+
384
+ /** Per-attempt deadline. Each candidate gets its own slice instead of sharing one budget. */
385
+ const STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS = 15_000;
386
+
387
+ /**
388
+ * Overall per-credential budget passed to {@link AuthStorage.checkCredentials}.
389
+ * Big enough to walk every candidate at the per-attempt cap with a small
390
+ * margin for refresh/network overhead.
391
+ */
392
+ const STRICT_PROBE_OVERALL_TIMEOUT_MS = STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS * (STRICT_PROBE_MAX_CANDIDATES + 1);
393
+
394
+ /** Match upstream errors that mean "this model is gone, try a different one" so we walk the catalog instead of declaring the credential bad. */
395
+ const RETRYABLE_MODEL_ERROR_RE =
396
+ /not[_ -]found|invalid[_ -]model|model[_ -]is[_ -]not[_ -]valid|no longer supported|deprecated|404|decommissioned/i;
397
+
398
+ /**
399
+ * Rank bundled models for a provider in probe order: cheapest first, then by
400
+ * id for determinism. Filters out non-bearer-auth APIs (Vertex/Bedrock),
401
+ * pi-native transport (would loop through the gateway), and placeholder /
402
+ * router entries with negative/missing cost.
403
+ */
404
+ function pickProbeCandidates(provider: string): Model<Api>[] {
405
+ const bundled = getBundledModels(provider as GeneratedProvider);
406
+ if (bundled.length === 0) return [];
407
+ const candidates = bundled.filter(model => {
408
+ if (model.transport === "pi-native") return false;
409
+ if (STRICT_PROBE_SKIPPED_APIS.has(model.api)) return false;
410
+ if (!model.input.includes("text")) return false;
411
+ const totalCost = (model.cost?.input ?? 0) + (model.cost?.output ?? 0);
412
+ if (!Number.isFinite(totalCost) || totalCost < 0) return false;
413
+ if (model.maxTokens <= 0) return false;
414
+ return true;
415
+ });
416
+ candidates.sort((a, b) => a.cost.input + a.cost.output - (b.cost.input + b.cost.output) || a.id.localeCompare(b.id));
417
+ return candidates;
418
+ }
419
+
420
+ /**
421
+ * Compose the apiKey bytes a provider's chat endpoint expects, given a
422
+ * post-refresh probe credential. Mirrors `getOAuthApiKey` for the providers
423
+ * that require a structured blob; otherwise returns the raw access token /
424
+ * API key.
425
+ */
426
+ function composeProbeApiKey(provider: string, credential: CompletionProbeInput["credential"]): string {
427
+ if (credential.type === "api_key") return credential.apiKey;
428
+ if (!STRUCTURED_API_KEY_PROVIDERS.has(provider)) return credential.accessToken;
429
+ return JSON.stringify({
430
+ token: credential.accessToken,
431
+ enterpriseUrl: credential.enterpriseUrl,
432
+ projectId: credential.projectId,
433
+ refreshToken: credential.refreshToken,
434
+ expiresAt: credential.expiresAt,
435
+ email: credential.email,
436
+ accountId: credential.accountId,
437
+ });
438
+ }
439
+
440
+ async function probeOneModel(
441
+ model: Model<Api>,
442
+ apiKey: string,
443
+ outerSignal: AbortSignal,
444
+ ): Promise<CredentialCompletionResult> {
445
+ const start = Date.now();
446
+ const attemptTimeoutSignal = AbortSignal.timeout(STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS);
447
+ const attemptSignal = AbortSignal.any([outerSignal, attemptTimeoutSignal]);
448
+ // `systemPrompt` is mandatory for some providers (Codex 400s "Instructions
449
+ // are required" without it). `disableReasoning` is intentionally NOT set:
450
+ // providers like Fireworks reject the "none" effort it maps to, and we'd
451
+ // rather burn 16 reasoning tokens than misdiagnose a healthy credential.
452
+ const response = await completeSimple(
453
+ model,
454
+ {
455
+ systemPrompt: ["Connectivity check. Reply with the single word 'pong'."],
456
+ messages: [{ role: "user", content: "ping", timestamp: start }],
457
+ },
458
+ {
459
+ apiKey,
460
+ maxTokens: 32,
461
+ signal: attemptSignal,
462
+ },
463
+ );
464
+ const latencyMs = Date.now() - start;
465
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
466
+ return {
467
+ ok: false,
468
+ reason: response.errorMessage ?? `chat probe ended with stopReason=${response.stopReason}`,
469
+ modelId: model.id,
470
+ latencyMs,
471
+ };
472
+ }
473
+ return { ok: true, modelId: model.id, latencyMs };
474
+ }
475
+
476
+ /**
477
+ * Build the {@link CompletionProbe} consumed by
478
+ * {@link AuthStorage.checkCredentials} in `--strict` mode. Walks the cheapest
479
+ * candidates per provider, retrying on "model not found / invalid model"
480
+ * errors so a stale catalog entry doesn't masquerade as a bad credential.
481
+ * Stops as soon as one model returns a successful response (the credential
482
+ * authenticated against at least one model in the catalog).
483
+ */
484
+ function createStrictCompletionProbe(): CompletionProbe {
485
+ return async (input: CompletionProbeInput): Promise<CredentialCompletionResult> => {
486
+ const candidates = pickProbeCandidates(input.provider).slice(0, STRICT_PROBE_MAX_CANDIDATES);
487
+ if (candidates.length === 0) {
488
+ return { ok: null, reason: `no bearer-compatible probe model bundled for provider ${input.provider}` };
489
+ }
490
+ const apiKey = composeProbeApiKey(input.provider, input.credential);
491
+ let lastFailure: CredentialCompletionResult | undefined;
492
+ for (const model of candidates) {
493
+ if (input.signal.aborted) {
494
+ return {
495
+ ok: false,
496
+ reason: "aborted",
497
+ modelId: model.id,
498
+ };
499
+ }
500
+ const result = await probeOneModel(model, apiKey, input.signal);
501
+ if (result.ok === true) return result;
502
+ lastFailure = result;
503
+ if (!RETRYABLE_MODEL_ERROR_RE.test(result.reason ?? "")) {
504
+ // Non-model error (401, 403, 5xx, network) — the credential is the
505
+ // issue, not the catalog. Stop walking.
506
+ return result;
507
+ }
508
+ }
509
+ return (
510
+ lastFailure ?? {
511
+ ok: false,
512
+ reason: `all ${candidates.length} probe models failed for provider ${input.provider}`,
513
+ }
514
+ );
515
+ };
516
+ }
517
+
518
+ function formatCompletionStatus(completion: CredentialCompletionResult | undefined): string {
519
+ if (!completion) return "";
520
+ if (completion.ok === true) return chalk.green(" [chat: ok]");
521
+ if (completion.ok === false) return chalk.red(" [chat: FAIL]");
522
+ return chalk.yellow(" [chat: skip]");
523
+ }
524
+
345
525
  /**
346
526
  * `omp auth-gateway check` — probe each broker-supplied credential and print
347
527
  * per-credential auth health. Use this when the gateway is returning 401s and
348
528
  * you need to find which row in a multi-account pool is the bad one. The
349
529
  * aggregate `/v1/usage` endpoint silently drops failed credentials, so a
350
530
  * dedicated diagnostic is the only way to see which credentials failed.
531
+ *
532
+ * Strict mode (`--strict`) additionally exercises each credential against a
533
+ * cheap chat model from its provider's bundled catalog. This catches the case
534
+ * where the usage endpoint reports 200 but the chat endpoint 401s the same
535
+ * bearer (revoked OAuth scope, mislabeled provider row, etc).
351
536
  */
352
537
  async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
353
538
  const brokerConfig = await resolveAuthBrokerConfig();
@@ -363,10 +548,16 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
363
548
  const storage = new AuthStorage(store, { sourceLabel: `broker ${brokerConfig.url}` });
364
549
  try {
365
550
  await storage.reload();
366
- const results = await storage.checkCredentials();
551
+ const results = await storage.checkCredentials(
552
+ flags.strict
553
+ ? { completionProbe: createStrictCompletionProbe(), completionTimeoutMs: STRICT_PROBE_OVERALL_TIMEOUT_MS }
554
+ : undefined,
555
+ );
367
556
 
368
557
  if (flags.json) {
369
- process.stdout.write(`${JSON.stringify({ broker: brokerConfig.url, credentials: results }, null, 2)}\n`);
558
+ process.stdout.write(
559
+ `${JSON.stringify({ broker: brokerConfig.url, strict: flags.strict === true, credentials: results }, null, 2)}\n`,
560
+ );
370
561
  } else {
371
562
  const grouped = new Map<string, typeof results>();
372
563
  for (const row of results) {
@@ -375,7 +566,7 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
375
566
  grouped.set(row.provider, list);
376
567
  }
377
568
  const providers = [...grouped.keys()].sort();
378
- process.stdout.write(`broker: ${brokerConfig.url}\n`);
569
+ process.stdout.write(`broker: ${brokerConfig.url}${flags.strict ? chalk.dim(" [strict]") : ""}\n`);
379
570
  for (const provider of providers) {
380
571
  const rows = grouped.get(provider) ?? [];
381
572
  process.stdout.write(`\n${chalk.bold(provider)} (${rows.length})\n`);
@@ -389,19 +580,29 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
389
580
  const identity =
390
581
  row.email ?? row.accountId ?? (row.type === "api_key" ? "(api key)" : "(no identity on credential)");
391
582
  const remote = row.remoteRefresh ? chalk.dim(" [remote-refresh]") : "";
392
- const reason = row.reason ? chalk.dim(` — ${row.reason}`) : "";
583
+ const reasonParts: string[] = [];
584
+ if (row.reason) reasonParts.push(row.reason);
585
+ if (row.completion?.reason) reasonParts.push(`chat: ${row.completion.reason}`);
586
+ const reason = reasonParts.length > 0 ? chalk.dim(` — ${reasonParts.join("; ")}`) : "";
587
+ const chat = formatCompletionStatus(row.completion);
393
588
  process.stdout.write(
394
- ` ${status} id=${row.id.toString().padStart(3)} ${row.type.padEnd(7)} ${identity}${remote}${reason}\n`,
589
+ ` ${status}${chat} id=${row.id.toString().padStart(3)} ${row.type.padEnd(7)} ${identity}${remote}${reason}\n`,
395
590
  );
396
591
  }
397
592
  }
398
593
  const failed = results.filter(row => row.ok === false).length;
399
594
  const unverifiable = results.filter(row => row.ok === null).length;
400
595
  const passing = results.filter(row => row.ok === true).length;
401
- process.stdout.write(
402
- `\n${chalk.green(`${passing} ok`)}, ${chalk.red(`${failed} failed`)}, ${chalk.yellow(`${unverifiable} unverifiable`)}, ${results.length} total\n`,
403
- );
404
- if (failed > 0) process.exitCode = 1;
596
+ const chatFailed = flags.strict ? results.filter(row => row.completion?.ok === false).length : 0;
597
+ const summaryParts = [
598
+ chalk.green(`${passing} ok`),
599
+ chalk.red(`${failed} failed`),
600
+ chalk.yellow(`${unverifiable} unverifiable`),
601
+ ];
602
+ if (flags.strict) summaryParts.push(chalk.red(`${chatFailed} chat-failed`));
603
+ summaryParts.push(`${results.length} total`);
604
+ process.stdout.write(`\n${summaryParts.join(", ")}\n`);
605
+ if (failed > 0 || chatFailed > 0) process.exitCode = 1;
405
606
  }
406
607
  } finally {
407
608
  storage.close();
@@ -22,13 +22,17 @@ export default class AuthGateway extends Command {
22
22
  };
23
23
 
24
24
  static flags = {
25
- json: Flags.boolean({ description: "Output JSON (token/status)" }),
25
+ json: Flags.boolean({ description: "Output JSON (token/status/check)" }),
26
26
  bind: Flags.string({ description: "Bind address for `serve` (host:port)", char: "b" }),
27
27
  regenerate: Flags.boolean({ description: "Regenerate the gateway bearer token (token)" }),
28
28
  "no-auth": Flags.boolean({
29
29
  description:
30
30
  "Disable inbound bearer-token auth (serve). Useful when bound to loopback — any caller is allowed.",
31
31
  }),
32
+ strict: Flags.boolean({
33
+ description:
34
+ "For `check`: additionally probe each credential against its provider's chat-completion endpoint. Slower; consumes a tiny amount of quota per credential.",
35
+ }),
32
36
  };
33
37
 
34
38
  static examples = [
@@ -40,6 +44,7 @@ export default class AuthGateway extends Command {
40
44
  "# Show local gateway + broker config status\n omp auth-gateway status",
41
45
  "# Probe each broker credential to see which one is producing 401s\n omp auth-gateway check",
42
46
  "# Same, machine-readable for scripts\n omp auth-gateway check --json",
47
+ "# Strict check — also exercises each credential with a real chat-completion ping\n omp auth-gateway check --strict",
43
48
  ];
44
49
 
45
50
  async run(): Promise<void> {
@@ -55,6 +60,7 @@ export default class AuthGateway extends Command {
55
60
  bind: flags.bind,
56
61
  regenerate: flags.regenerate,
57
62
  noAuth: flags["no-auth"],
63
+ strict: flags.strict,
58
64
  },
59
65
  };
60
66
  await initTheme();
@@ -291,6 +291,12 @@ export function mergeDiscoveredModel<TApi extends Api>(
291
291
  return model;
292
292
  }
293
293
 
294
+ const AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS = new Set<string>(
295
+ PROVIDER_DESCRIPTORS.filter(descriptor => descriptor.dynamicModelsAuthoritative).map(
296
+ descriptor => descriptor.providerId,
297
+ ),
298
+ );
299
+
294
300
  function isAuthoritativeProjectCatalogModel(model: Model<Api>): boolean {
295
301
  return (
296
302
  model.provider === "google-vertex" &&
@@ -323,6 +329,11 @@ interface DiscoveryProviderConfig {
323
329
  optional?: boolean;
324
330
  }
325
331
 
332
+ interface BuiltInDiscoveryResult {
333
+ models: Model<Api>[];
334
+ authoritativeProviders: Set<string>;
335
+ }
336
+
326
337
  export type ProviderDiscoveryStatus = "idle" | "ok" | "empty" | "cached" | "unavailable" | "unauthenticated";
327
338
 
328
339
  export interface ProviderDiscoveryState {
@@ -914,6 +925,11 @@ export class ModelRegistry {
914
925
  cachedAuthoritativeProviders.add(provider);
915
926
  }
916
927
  }
928
+ for (const provider of cachedStandardResult.authoritativeFreshProviders) {
929
+ if (AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS.has(provider)) {
930
+ cachedAuthoritativeProviders.add(provider);
931
+ }
932
+ }
917
933
  if (cachedAuthoritativeProviders.size > 0) {
918
934
  builtInModels = dropProviderModels(builtInModels, cachedAuthoritativeProviders);
919
935
  }
@@ -1253,12 +1269,12 @@ export class ModelRegistry {
1253
1269
  : Promise.all(
1254
1270
  selectedDiscoverableProviders.map(provider => this.#discoverProviderModels(provider, strategy)),
1255
1271
  ).then(results => results.flat());
1256
- const [configuredDiscovered, builtInDiscovered] = await Promise.all([
1272
+ const [configuredDiscovered, builtInDiscovery] = await Promise.all([
1257
1273
  configuredDiscoveriesPromise,
1258
1274
  this.#discoverBuiltInProviderModels(strategy, providerFilter),
1259
1275
  ]);
1260
- const discovered = [...configuredDiscovered, ...builtInDiscovered];
1261
- if (discovered.length === 0) {
1276
+ const discovered = [...configuredDiscovered, ...builtInDiscovery.models];
1277
+ if (discovered.length === 0 && builtInDiscovery.authoritativeProviders.size === 0) {
1262
1278
  return;
1263
1279
  }
1264
1280
  const discoveredModels = this.#applyHardcodedModelPolicies(
@@ -1271,6 +1287,9 @@ export class ModelRegistry {
1271
1287
  ),
1272
1288
  );
1273
1289
  const authoritativeProviders = providersWithAuthoritativeProjectCatalog(discoveredModels);
1290
+ for (const provider of builtInDiscovery.authoritativeProviders) {
1291
+ authoritativeProviders.add(provider);
1292
+ }
1274
1293
  const baseModels =
1275
1294
  authoritativeProviders.size > 0 ? dropProviderModels(this.#models, authoritativeProviders) : this.#models;
1276
1295
  const resolved = this.#mergeResolvedModels(baseModels, discoveredModels);
@@ -1385,7 +1404,7 @@ export class ModelRegistry {
1385
1404
  async #discoverBuiltInProviderModels(
1386
1405
  strategy: ModelRefreshStrategy,
1387
1406
  providerFilter?: ReadonlySet<string>,
1388
- ): Promise<Model<Api>[]> {
1407
+ ): Promise<BuiltInDiscoveryResult> {
1389
1408
  // Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type)
1390
1409
  const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(p => p.provider));
1391
1410
  const managerOptions = (await this.#collectBuiltInModelManagerOptions()).filter(opts => {
@@ -1395,12 +1414,20 @@ export class ModelRegistry {
1395
1414
  return providerFilter ? providerFilter.has(opts.providerId) : true;
1396
1415
  });
1397
1416
  if (managerOptions.length === 0) {
1398
- return [];
1417
+ return { models: [], authoritativeProviders: new Set() };
1399
1418
  }
1400
1419
  const discoveries = await Promise.all(
1401
1420
  managerOptions.map(options => this.#discoverWithModelManager(options, strategy)),
1402
1421
  );
1403
- return discoveries.flat();
1422
+ const authoritativeProviders = new Set<string>();
1423
+ const models: Model<Api>[] = [];
1424
+ for (const discovery of discoveries) {
1425
+ models.push(...discovery.models);
1426
+ for (const provider of discovery.authoritativeProviders) {
1427
+ authoritativeProviders.add(provider);
1428
+ }
1429
+ }
1430
+ return { models, authoritativeProviders };
1404
1431
  }
1405
1432
 
1406
1433
  async #collectBuiltInModelManagerOptions(): Promise<ModelManagerOptions<Api>[]> {
@@ -1482,19 +1509,24 @@ export class ModelRegistry {
1482
1509
  async #discoverWithModelManager(
1483
1510
  options: ModelManagerOptions<Api>,
1484
1511
  strategy: ModelRefreshStrategy,
1485
- ): Promise<Model<Api>[]> {
1512
+ ): Promise<BuiltInDiscoveryResult> {
1486
1513
  try {
1487
1514
  const manager = createModelManager({ ...options, cacheDbPath: this.#cacheDbPath });
1488
1515
  const result = await manager.refresh(strategy);
1489
- return result.models.map(model =>
1516
+ const models = result.models.map(model =>
1490
1517
  model.provider === options.providerId ? model : { ...model, provider: options.providerId },
1491
1518
  );
1519
+ const authoritativeProviders = new Set<string>();
1520
+ if (options.dynamicModelsAuthoritative && !result.stale) {
1521
+ authoritativeProviders.add(options.providerId);
1522
+ }
1523
+ return { models, authoritativeProviders };
1492
1524
  } catch (error) {
1493
1525
  logger.warn("model discovery failed for provider", {
1494
1526
  provider: options.providerId,
1495
1527
  error: error instanceof Error ? error.message : String(error),
1496
1528
  });
1497
- return [];
1529
+ return { models: [], authoritativeProviders: new Set() };
1498
1530
  }
1499
1531
  }
1500
1532