@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.6

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 (135) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/dist/cli.js +692 -607
  3. package/dist/types/cli/usage-cli.d.ts +10 -1
  4. package/dist/types/commands/usage.d.ts +9 -0
  5. package/dist/types/config/api-key-resolver.d.ts +9 -3
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/model-discovery.d.ts +6 -4
  8. package/dist/types/config/model-registry.d.ts +7 -4
  9. package/dist/types/config/settings-schema.d.ts +508 -155
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/mnemopi/config.d.ts +3 -1
  12. package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/components/settings-defs.d.ts +9 -2
  15. package/dist/types/modes/components/settings-selector.d.ts +9 -4
  16. package/dist/types/modes/components/tool-execution.d.ts +26 -1
  17. package/dist/types/modes/components/transcript-container.d.ts +12 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +9 -1
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +10 -0
  21. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  22. package/dist/types/modes/theme/theme.d.ts +23 -3
  23. package/dist/types/modes/types.d.ts +2 -0
  24. package/dist/types/modes/utils/context-usage.d.ts +6 -1
  25. package/dist/types/session/agent-session.d.ts +28 -8
  26. package/dist/types/session/auth-storage.d.ts +1 -1
  27. package/dist/types/session/codex-auto-reset.d.ts +107 -0
  28. package/dist/types/session/snapcompact-inline.d.ts +129 -0
  29. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
  30. package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
  31. package/dist/types/system-prompt.d.ts +3 -1
  32. package/dist/types/task/render.d.ts +17 -6
  33. package/dist/types/tools/gh.d.ts +3 -0
  34. package/dist/types/tools/render-utils.d.ts +8 -16
  35. package/dist/types/tools/todo.d.ts +0 -11
  36. package/dist/types/utils/session-color.d.ts +15 -3
  37. package/dist/types/web/kagi.d.ts +1 -2
  38. package/dist/types/web/search/providers/codex.d.ts +1 -1
  39. package/dist/types/web/search/providers/gemini.d.ts +9 -6
  40. package/package.json +11 -11
  41. package/src/auto-thinking/classifier.ts +1 -5
  42. package/src/cli/usage-cli.ts +187 -16
  43. package/src/commands/usage.ts +8 -0
  44. package/src/commit/model-selection.ts +3 -6
  45. package/src/config/api-key-resolver.ts +10 -3
  46. package/src/config/keybindings.ts +1 -1
  47. package/src/config/model-discovery.ts +60 -46
  48. package/src/config/model-registry.ts +21 -8
  49. package/src/config/model-resolver.ts +57 -3
  50. package/src/config/settings-schema.ts +654 -153
  51. package/src/config/settings.ts +9 -0
  52. package/src/eval/completion-bridge.ts +1 -5
  53. package/src/export/html/template.generated.ts +1 -1
  54. package/src/export/html/template.js +13 -6
  55. package/src/internal-urls/docs-index.generated.ts +6 -6
  56. package/src/internal-urls/issue-pr-protocol.ts +10 -4
  57. package/src/memories/index.ts +2 -10
  58. package/src/mnemopi/backend.ts +30 -8
  59. package/src/mnemopi/config.ts +6 -1
  60. package/src/mnemopi/state.ts +6 -0
  61. package/src/modes/components/extensions/inspector-panel.ts +6 -2
  62. package/src/modes/components/plan-review-overlay.ts +15 -17
  63. package/src/modes/components/plugin-settings.ts +22 -5
  64. package/src/modes/components/reset-usage-selector.ts +161 -0
  65. package/src/modes/components/session-selector.ts +8 -2
  66. package/src/modes/components/settings-defs.ts +19 -4
  67. package/src/modes/components/settings-selector.ts +510 -95
  68. package/src/modes/components/status-line/component.ts +3 -1
  69. package/src/modes/components/status-line/segments.ts +3 -1
  70. package/src/modes/components/tool-execution.ts +87 -12
  71. package/src/modes/components/transcript-container.ts +49 -1
  72. package/src/modes/components/tree-selector.ts +16 -6
  73. package/src/modes/controllers/command-controller.ts +61 -8
  74. package/src/modes/controllers/event-controller.ts +1 -0
  75. package/src/modes/controllers/input-controller.ts +68 -6
  76. package/src/modes/controllers/selector-controller.ts +149 -61
  77. package/src/modes/interactive-mode.ts +63 -2
  78. package/src/modes/rpc/rpc-mode.ts +2 -1
  79. package/src/modes/session-observer-registry.ts +61 -3
  80. package/src/modes/shared.ts +2 -0
  81. package/src/modes/theme/theme.ts +102 -9
  82. package/src/modes/types.ts +2 -0
  83. package/src/modes/utils/context-usage.ts +78 -2
  84. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  85. package/src/modes/utils/ui-helpers.ts +9 -5
  86. package/src/prompts/system/personalities/default.md +26 -0
  87. package/src/prompts/system/personalities/friendly.md +17 -0
  88. package/src/prompts/system/personalities/pragmatic.md +15 -0
  89. package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
  90. package/src/prompts/system/snapcompact-context-stub.md +1 -0
  91. package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
  92. package/src/prompts/system/snapcompact-system-stub.md +1 -0
  93. package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
  94. package/src/prompts/system/system-prompt.md +5 -22
  95. package/src/prompts/tools/browser.md +33 -43
  96. package/src/prompts/tools/eval.md +27 -50
  97. package/src/prompts/tools/irc.md +29 -31
  98. package/src/prompts/tools/read.md +31 -37
  99. package/src/prompts/tools/task.md +3 -3
  100. package/src/prompts/tools/todo.md +1 -2
  101. package/src/sdk.ts +23 -1
  102. package/src/session/agent-session.ts +221 -29
  103. package/src/session/auth-storage.ts +4 -0
  104. package/src/session/codex-auto-reset.ts +190 -0
  105. package/src/session/session-dump-format.ts +8 -1
  106. package/src/session/session-manager.ts +5 -5
  107. package/src/session/snapcompact-inline.ts +524 -0
  108. package/src/slash-commands/builtin-registry.ts +145 -8
  109. package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
  110. package/src/slash-commands/helpers/context-report.ts +28 -1
  111. package/src/slash-commands/helpers/reset-usage.ts +66 -0
  112. package/src/slash-commands/helpers/usage-report.ts +36 -3
  113. package/src/system-prompt.ts +15 -1
  114. package/src/task/index.ts +30 -7
  115. package/src/task/render.ts +57 -32
  116. package/src/tool-discovery/tool-index.ts +2 -0
  117. package/src/tools/bash.ts +10 -3
  118. package/src/tools/eval-render.ts +13 -8
  119. package/src/tools/gh.ts +39 -1
  120. package/src/tools/image-gen.ts +114 -78
  121. package/src/tools/inspect-image.ts +1 -5
  122. package/src/tools/job.ts +25 -5
  123. package/src/tools/read.ts +1 -57
  124. package/src/tools/render-utils.ts +29 -31
  125. package/src/tools/ssh.ts +3 -3
  126. package/src/tools/todo.ts +8 -128
  127. package/src/tools/tts.ts +40 -20
  128. package/src/utils/clipboard.ts +56 -4
  129. package/src/utils/commit-message-generator.ts +1 -5
  130. package/src/utils/session-color.ts +83 -9
  131. package/src/utils/title-generator.ts +1 -1
  132. package/src/web/kagi.ts +26 -27
  133. package/src/web/search/providers/codex.ts +42 -40
  134. package/src/web/search/providers/gemini.ts +42 -22
  135. package/src/web/search/providers/perplexity.ts +22 -10
@@ -7,7 +7,14 @@
7
7
  * credentials produced no usage report are listed too, so the output
8
8
  * always covers the full credential pool.
9
9
  */
10
- import type { AuthStorage, UsageLimit, UsageReport, UsageUnit } from "@oh-my-pi/pi-ai";
10
+ import {
11
+ type AuthStorage,
12
+ resolveUsedFraction,
13
+ type UsageHistoryEntry,
14
+ type UsageLimit,
15
+ type UsageReport,
16
+ type UsageUnit,
17
+ } from "@oh-my-pi/pi-ai";
11
18
  import { formatDuration, formatNumber } from "@oh-my-pi/pi-utils";
12
19
  import chalk from "chalk";
13
20
  import { ModelRegistry } from "../config/model-registry";
@@ -19,6 +26,10 @@ export interface UsageCommandArgs {
19
26
  json?: boolean;
20
27
  provider?: string;
21
28
  redact?: boolean;
29
+ /** Show recorded usage-limit history instead of a live snapshot. */
30
+ history?: boolean;
31
+ /** History window in days (with `history`). */
32
+ days?: number;
22
33
  }
23
34
 
24
35
  /** Identity slice of a stored credential, for "every account" coverage. */
@@ -139,20 +150,9 @@ function collectIdentityStrings(reports: UsageReport[], accounts: UsageAccountId
139
150
 
140
151
  type LimitStatus = NonNullable<UsageLimit["status"]>;
141
152
 
142
- function resolveFraction(limit: UsageLimit): number | undefined {
143
- const amount = limit.amount;
144
- if (amount.usedFraction !== undefined) return amount.usedFraction;
145
- if (amount.used !== undefined && amount.limit !== undefined && amount.limit > 0) {
146
- return amount.used / amount.limit;
147
- }
148
- if (amount.unit === "percent" && amount.used !== undefined) return amount.used / 100;
149
- if (amount.remainingFraction !== undefined) return Math.max(0, 1 - amount.remainingFraction);
150
- return undefined;
151
- }
152
-
153
153
  function resolveStatus(limit: UsageLimit): LimitStatus {
154
154
  if (limit.status && limit.status !== "unknown") return limit.status;
155
- const fraction = resolveFraction(limit);
155
+ const fraction = resolveUsedFraction(limit);
156
156
  if (fraction === undefined) return "unknown";
157
157
  if (fraction >= 1) return "exhausted";
158
158
  if (fraction >= 0.8) return "warning";
@@ -208,7 +208,7 @@ function describeAmount(limit: UsageLimit): string {
208
208
  } else if (absoluteUnit && amount.remaining !== undefined) {
209
209
  parts.push(`${formatUnitValue(amount.remaining, amount.unit)}${UNIT_SUFFIX[amount.unit]} left`);
210
210
  }
211
- const fraction = resolveFraction(limit);
211
+ const fraction = resolveUsedFraction(limit);
212
212
  if (fraction !== undefined) {
213
213
  parts.push(`${(fraction * 100).toFixed(1)}% used`);
214
214
  } else if (amount.remainingFraction !== undefined) {
@@ -219,7 +219,7 @@ function describeAmount(limit: UsageLimit): string {
219
219
  }
220
220
 
221
221
  function renderBar(limit: UsageLimit): string {
222
- const fraction = resolveFraction(limit);
222
+ const fraction = resolveUsedFraction(limit);
223
223
  if (fraction === undefined) return chalk.dim("·".repeat(BAR_WIDTH));
224
224
  const clamped = Math.min(Math.max(fraction, 0), 1);
225
225
  const filled = Math.round(clamped * BAR_WIDTH);
@@ -325,6 +325,8 @@ function formatAccountHeader(
325
325
  let header = `${icon} ${chalk.bold(redaction?.get(label) ?? label)}`;
326
326
  const planType = report.metadata?.planType;
327
327
  if (typeof planType === "string" && planType) header += chalk.dim(` · plan: ${planType}`);
328
+ const savedResets = report.resetCredits?.availableCount ?? 0;
329
+ if (savedResets > 0) header += chalk.cyan(` · ✦ ${savedResets} saved reset${savedResets === 1 ? "" : "s"}`);
328
330
  if (report.fetchedAt && nowMs - report.fetchedAt > 90_000) {
329
331
  header += chalk.dim(` · fetched ${formatDuration(nowMs - report.fetchedAt)} ago`);
330
332
  }
@@ -375,7 +377,7 @@ export function computeProviderWindowStats(reports: UsageReport[]): ProviderWind
375
377
  for (const report of reports) {
376
378
  const accountMax = new Map<string, number>();
377
379
  for (const limit of report.limits) {
378
- const fraction = resolveFraction(limit);
380
+ const fraction = resolveUsedFraction(limit);
379
381
  if (fraction === undefined) continue;
380
382
  const durationMs = limit.window?.durationMs;
381
383
  const key =
@@ -482,6 +484,144 @@ export function formatUsageBreakdown(
482
484
  return lines.join("\n");
483
485
  }
484
486
 
487
+ const HISTORY_SPARK_WIDTH = 48;
488
+ const SPARK_LEVELS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
489
+
490
+ interface HistorySeries {
491
+ title: string;
492
+ /** Snapshots ascending by recordedAt (listUsageHistory order). */
493
+ entries: UsageHistoryEntry[];
494
+ }
495
+
496
+ interface HistoryAccount {
497
+ label: string;
498
+ series: Map<string, HistorySeries>;
499
+ }
500
+
501
+ /** Mirror of {@link limitTitle} for history rows (no scope/tier available). */
502
+ function historySeriesTitle(entry: UsageHistoryEntry): string {
503
+ const label = entry.label;
504
+ const windowLabel = entry.windowLabel;
505
+ if (!windowLabel) return label;
506
+ if (windowLabel.toLowerCase() === "quota window") return label;
507
+ if (label.toLowerCase().includes(windowLabel.toLowerCase())) return label;
508
+ return `${label} (${windowLabel})`;
509
+ }
510
+
511
+ function historyAccountLabel(entry: UsageHistoryEntry): string {
512
+ return entry.email ?? entry.accountId ?? entry.accountKey;
513
+ }
514
+
515
+ function historyStatus(fraction: number | undefined, status: UsageHistoryEntry["status"]): LimitStatus {
516
+ if (status && status !== "unknown") return status;
517
+ if (fraction === undefined) return "unknown";
518
+ if (fraction >= 1) return "exhausted";
519
+ if (fraction >= 0.8) return "warning";
520
+ return "ok";
521
+ }
522
+
523
+ /** Peak-per-bucket sparkline over [sinceMs, nowMs]; empty buckets render dim dots. */
524
+ function renderHistorySparkline(entries: UsageHistoryEntry[], sinceMs: number, nowMs: number): string {
525
+ const span = Math.max(1, nowMs - sinceMs);
526
+ const buckets: Array<number | undefined> = new Array(HISTORY_SPARK_WIDTH).fill(undefined);
527
+ for (const entry of entries) {
528
+ if (entry.usedFraction === undefined) continue;
529
+ const offset = Math.floor(((entry.recordedAt - sinceMs) / span) * HISTORY_SPARK_WIDTH);
530
+ const index = Math.min(HISTORY_SPARK_WIDTH - 1, Math.max(0, offset));
531
+ const prev = buckets[index];
532
+ buckets[index] = prev === undefined ? entry.usedFraction : Math.max(prev, entry.usedFraction);
533
+ }
534
+ return buckets
535
+ .map(fraction => {
536
+ if (fraction === undefined) return chalk.dim("·");
537
+ const clamped = Math.min(Math.max(fraction, 0), 1);
538
+ const level = SPARK_LEVELS[Math.min(SPARK_LEVELS.length - 1, Math.floor(clamped * SPARK_LEVELS.length))];
539
+ return STATUS_COLOR[historyStatus(clamped, undefined)](level);
540
+ })
541
+ .join("");
542
+ }
543
+
544
+ /** Identity strings a history rendering could surface — input for {@link buildRedactionMap}. */
545
+ function collectHistoryIdentityStrings(entries: UsageHistoryEntry[]): string[] {
546
+ const values: string[] = [];
547
+ for (const entry of entries) {
548
+ if (entry.email) values.push(entry.email);
549
+ if (entry.accountId) values.push(entry.accountId);
550
+ values.push(entry.accountKey);
551
+ }
552
+ return values;
553
+ }
554
+
555
+ /**
556
+ * Render recorded usage-limit history: per provider, per account, one
557
+ * peak-per-bucket sparkline per limit window plus latest/peak percentages.
558
+ */
559
+ export function formatUsageHistory(
560
+ entries: UsageHistoryEntry[],
561
+ sinceMs: number,
562
+ nowMs: number,
563
+ redaction?: Map<string, string>,
564
+ ): string {
565
+ const providers = new Map<string, Map<string, HistoryAccount>>();
566
+ for (const entry of entries) {
567
+ let accounts = providers.get(entry.provider);
568
+ if (!accounts) {
569
+ accounts = new Map();
570
+ providers.set(entry.provider, accounts);
571
+ }
572
+ let account = accounts.get(entry.accountKey);
573
+ if (!account) {
574
+ account = { label: historyAccountLabel(entry), series: new Map() };
575
+ accounts.set(entry.accountKey, account);
576
+ }
577
+ let series = account.series.get(entry.limitId);
578
+ if (!series) {
579
+ series = { title: historySeriesTitle(entry), entries: [] };
580
+ account.series.set(entry.limitId, series);
581
+ }
582
+ // Labels can change across snapshots (provider renames); latest wins.
583
+ series.title = historySeriesTitle(entry);
584
+ series.entries.push(entry);
585
+ }
586
+
587
+ const lines: string[] = [];
588
+ lines.push(
589
+ `${chalk.bold("Usage history")}${chalk.dim(` · last ${formatDuration(nowMs - sinceMs)} · peak per bucket`)}`,
590
+ );
591
+
592
+ for (const provider of [...providers.keys()].sort((a, b) => a.localeCompare(b))) {
593
+ const accounts = providers.get(provider) ?? new Map<string, HistoryAccount>();
594
+ lines.push("");
595
+ lines.push(
596
+ `${chalk.bold.cyan(formatProviderName(provider))} ${chalk.dim(`— ${accounts.size} ${accounts.size === 1 ? "account" : "accounts"}`)}`,
597
+ );
598
+ const sortedAccounts = [...accounts.values()].sort((a, b) => a.label.localeCompare(b.label));
599
+ for (const account of sortedAccounts) {
600
+ lines.push(` ${chalk.bold(redaction?.get(account.label) ?? account.label)}`);
601
+ const labelWidth = [...account.series.values()].reduce((max, series) => Math.max(max, series.title.length), 0);
602
+ const sortedSeries = [...account.series.values()].sort((a, b) => a.title.localeCompare(b.title));
603
+ for (const series of sortedSeries) {
604
+ const fractions = series.entries
605
+ .map(entry => entry.usedFraction)
606
+ .filter((fraction): fraction is number => fraction !== undefined);
607
+ const latestEntry = series.entries[series.entries.length - 1];
608
+ const latestFraction = fractions.length > 0 ? fractions[fractions.length - 1] : undefined;
609
+ const peakFraction = fractions.length > 0 ? Math.max(...fractions) : undefined;
610
+ const status = historyStatus(latestFraction, latestEntry?.status);
611
+ const details: string[] = [];
612
+ if (latestFraction !== undefined) details.push(`latest ${(latestFraction * 100).toFixed(1)}%`);
613
+ if (peakFraction !== undefined) details.push(`peak ${(peakFraction * 100).toFixed(1)}%`);
614
+ details.push(`${series.entries.length} snapshot${series.entries.length === 1 ? "" : "s"}`);
615
+ lines.push(
616
+ ` ${STATUS_COLOR[status]("●")} ${series.title.padEnd(labelWidth)} ${renderHistorySparkline(series.entries, sinceMs, nowMs)} ${chalk.dim(details.join(" · "))}`,
617
+ );
618
+ }
619
+ }
620
+ }
621
+
622
+ return lines.join("\n");
623
+ }
624
+
485
625
  function collectStoredAccounts(authStorage: AuthStorage): UsageAccountIdentity[] {
486
626
  const accounts: UsageAccountIdentity[] = [];
487
627
  const all = authStorage.getAll();
@@ -541,6 +681,37 @@ function redactReportForJson(
541
681
  export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
542
682
  const authStorage = await discoverAuthStorage();
543
683
  try {
684
+ if (cmd.history) {
685
+ const days = cmd.days !== undefined && Number.isFinite(cmd.days) && cmd.days > 0 ? cmd.days : 7;
686
+ const nowMs = Date.now();
687
+ const sinceMs = nowMs - days * 86_400_000;
688
+ const entries = authStorage.listUsageHistory({ sinceMs, provider: cmd.provider?.toLowerCase() });
689
+ const redaction = cmd.redact ? buildRedactionMap(collectHistoryIdentityStrings(entries)) : undefined;
690
+ if (cmd.json) {
691
+ const masked = redaction
692
+ ? entries.map(entry => ({
693
+ ...entry,
694
+ accountKey: redaction.get(entry.accountKey) ?? entry.accountKey,
695
+ email: maskIdentity(redaction, entry.email),
696
+ accountId: maskIdentity(redaction, entry.accountId),
697
+ }))
698
+ : entries;
699
+ process.stdout.write(`${JSON.stringify({ generatedAt: nowMs, sinceMs, entries: masked }, null, 2)}\n`);
700
+ return;
701
+ }
702
+ if (entries.length === 0) {
703
+ const scope = cmd.provider ? ` for provider "${cmd.provider}"` : "";
704
+ process.stderr.write(
705
+ chalk.yellow(
706
+ `No usage history recorded${scope} yet. Snapshots accumulate whenever usage is fetched (TUI footer, /usage, omp usage).\n`,
707
+ ),
708
+ );
709
+ process.exitCode = 1;
710
+ return;
711
+ }
712
+ process.stdout.write(`${formatUsageHistory(entries, sinceMs, nowMs, redaction)}\n`);
713
+ return;
714
+ }
544
715
  const modelRegistry = new ModelRegistry(authStorage);
545
716
  const reports =
546
717
  (await authStorage.fetchUsageReports({
@@ -15,6 +15,11 @@ export default class Usage extends Command {
15
15
  description: "Redact account emails/ids (shortest unique prefix) for sharing screenshots",
16
16
  default: false,
17
17
  }),
18
+ history: Flags.boolean({
19
+ description: "Show recorded usage-limit history (hourly snapshots) instead of a live snapshot",
20
+ default: false,
21
+ }),
22
+ days: Flags.integer({ char: "d", description: "History window in days (with --history)", default: 7 }),
18
23
  };
19
24
 
20
25
  static examples = [
@@ -22,6 +27,7 @@ export default class Usage extends Command {
22
27
  "# Only Anthropic accounts\n omp usage --provider anthropic",
23
28
  "# Redact account identifiers for screenshots\n omp usage --redact",
24
29
  "# Machine-readable output\n omp usage --json",
30
+ "# Usage-limit trend over the last 30 days\n omp usage --history --days 30",
25
31
  ];
26
32
 
27
33
  async run(): Promise<void> {
@@ -30,6 +36,8 @@ export default class Usage extends Command {
30
36
  json: flags.json,
31
37
  provider: flags.provider,
32
38
  redact: flags.redact,
39
+ history: flags.history,
40
+ days: flags.days,
33
41
  });
34
42
  }
35
43
  }
@@ -48,7 +48,7 @@ export async function resolvePrimaryModel(
48
48
  }
49
49
  return {
50
50
  model,
51
- apiKey: modelRegistry.resolver(model.provider, { baseUrl: model.baseUrl, modelId: model.id }),
51
+ apiKey: modelRegistry.resolver(model),
52
52
  thinkingLevel: resolved?.thinkingLevel,
53
53
  };
54
54
  }
@@ -66,10 +66,7 @@ export async function resolveSmolModel(
66
66
  if (apiKey) {
67
67
  return {
68
68
  model: resolvedSmol.model,
69
- apiKey: modelRegistry.resolver(resolvedSmol.model.provider, {
70
- baseUrl: resolvedSmol.model.baseUrl,
71
- modelId: resolvedSmol.model.id,
72
- }),
69
+ apiKey: modelRegistry.resolver(resolvedSmol.model),
73
70
  thinkingLevel: resolvedSmol.thinkingLevel,
74
71
  };
75
72
  }
@@ -83,7 +80,7 @@ export async function resolveSmolModel(
83
80
  if (apiKey) {
84
81
  return {
85
82
  model: candidate,
86
- apiKey: modelRegistry.resolver(candidate.provider, { baseUrl: candidate.baseUrl, modelId: candidate.id }),
83
+ apiKey: modelRegistry.resolver(candidate),
87
84
  };
88
85
  }
89
86
  }
@@ -1,4 +1,7 @@
1
- import type { ApiKeyResolver, AuthStorage } from "@oh-my-pi/pi-ai";
1
+ import type { Api, ApiKeyResolver, AuthStorage, Model } from "@oh-my-pi/pi-ai";
2
+
3
+ /** Model slice accepted by the model-form `resolver(model, sessionId)` overload. */
4
+ export type ApiKeyResolverModel = Pick<Model<Api>, "provider" | "baseUrl" | "id">;
2
5
 
3
6
  export interface ApiKeyResolverOptions {
4
7
  /** Session id for credential stickiness; read at resolve time by the caller. */
@@ -26,10 +29,14 @@ export interface ApiKeyResolverRegistry {
26
29
  * policy: initial → resolve; step (b) → force-refresh same account; step (c)
27
30
  * → rotate to a sibling credential, then re-resolve.
28
31
  *
29
- * The resolver is stateless (safe to reuse across requests). Callers that
30
- * need the initial key for a guard can call `resolveApiKeyOnce(resolver)`.
32
+ * Two call forms: `resolver(provider, options?)` for provider-scoped keys,
33
+ * and `resolver(model, sessionId?)` which derives `baseUrl`/`modelId` from
34
+ * the model. The resolver is stateless (safe to reuse across requests).
35
+ * Callers that need the initial key for a guard can call
36
+ * `resolveApiKeyOnce(resolver)`.
31
37
  */
32
38
  resolver(provider: string, options?: ApiKeyResolverOptions): ApiKeyResolver;
39
+ resolver(model: ApiKeyResolverModel, sessionId?: string): ApiKeyResolver;
33
40
  }
34
41
 
35
42
  /**
@@ -137,7 +137,7 @@ export const KEYBINDINGS = {
137
137
  },
138
138
  "app.clipboard.pasteImage": {
139
139
  defaultKeys: getDefaultPasteImageKeys(),
140
- description: "Paste image from clipboard",
140
+ description: "Paste image or text from clipboard",
141
141
  },
142
142
  "app.clipboard.pasteTextRaw": {
143
143
  defaultKeys: ["ctrl+shift+v", "alt+shift+v"],
@@ -5,7 +5,7 @@
5
5
  * `discoverModelsByProviderType` with a `DiscoveryContext`; built-in provider
6
6
  * discovery lives in pi-catalog's provider-models.
7
7
  */
8
- import type { FetchImpl } from "@oh-my-pi/pi-ai";
8
+ import { type ApiKey, type FetchImpl, withAuth } from "@oh-my-pi/pi-ai";
9
9
  import type { Api, Model } from "@oh-my-pi/pi-ai/types";
10
10
  import { buildModel } from "@oh-my-pi/pi-catalog/build";
11
11
  import {
@@ -97,10 +97,12 @@ export interface DiscoveryContext {
97
97
  /** Injected fetch implementation (tests stub this). */
98
98
  fetch: FetchImpl;
99
99
  /**
100
- * Resolve a provider's API key for `Authorization: Bearer …`. Returns
101
- * undefined when no key is stored or it is a local/no-auth sentinel.
100
+ * Resolve a provider's bearer credential for `Authorization: Bearer …`.
101
+ * Returns undefined when no key is stored or it is a local/no-auth
102
+ * sentinel; otherwise an {@link ApiKey} whose resolver participates in the
103
+ * central force-refresh/rotate auth-retry policy on 401/usage-limit.
102
104
  */
103
- getBearerApiKey(provider: string): Promise<string | undefined>;
105
+ getBearerApiKeyResolver(provider: string): Promise<ApiKey | undefined>;
104
106
  }
105
107
 
106
108
  type OllamaDiscoveredModelMetadata = {
@@ -314,22 +316,26 @@ export async function discoverLlamaCppModels(
314
316
  const baseUrl = normalizeLlamaCppBaseUrl(providerConfig.baseUrl);
315
317
  const modelsUrl = `${baseUrl}/models`;
316
318
 
317
- const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
318
- const apiKey = await ctx.getBearerApiKey(providerConfig.provider);
319
- if (apiKey) {
320
- headers.Authorization = `Bearer ${apiKey}`;
321
- }
322
-
323
- const [response, serverMetadata] = await Promise.all([
324
- ctx.fetch(modelsUrl, {
325
- headers,
326
- signal: AbortSignal.timeout(250),
327
- }),
328
- discoverLlamaCppServerMetadata(ctx, baseUrl, headers),
329
- ]);
330
- if (!response.ok) {
331
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
332
- }
319
+ const baseHeaders: Record<string, string> = { ...(providerConfig.headers ?? {}) };
320
+ let headers = baseHeaders;
321
+ const attempt = async (h: Record<string, string>) => {
322
+ const [response, metadata] = await Promise.all([
323
+ ctx.fetch(modelsUrl, {
324
+ headers: h,
325
+ signal: AbortSignal.timeout(250),
326
+ }),
327
+ discoverLlamaCppServerMetadata(ctx, baseUrl, h),
328
+ ]);
329
+ if (!response.ok) {
330
+ throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
331
+ }
332
+ headers = h;
333
+ return [response, metadata] as const;
334
+ };
335
+ const apiKey = await ctx.getBearerApiKeyResolver(providerConfig.provider);
336
+ const [response, serverMetadata] = apiKey
337
+ ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
338
+ : await attempt(baseHeaders);
333
339
  const payload = (await response.json()) as { data?: Array<{ id: string }> };
334
340
  const models = payload.data ?? [];
335
341
  const discovered: Model<Api>[] = [];
@@ -370,19 +376,23 @@ export async function discoverOpenAIModelsList(
370
376
  const baseUrl = normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl);
371
377
  const modelsUrl = `${baseUrl}/models`;
372
378
 
373
- const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
374
- const apiKey = await ctx.getBearerApiKey(providerConfig.provider);
375
- if (apiKey) {
376
- headers.Authorization = `Bearer ${apiKey}`;
377
- }
378
-
379
- const response = await ctx.fetch(modelsUrl, {
380
- headers,
381
- signal: AbortSignal.timeout(10_000),
382
- });
383
- if (!response.ok) {
384
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
385
- }
379
+ const baseHeaders: Record<string, string> = { ...(providerConfig.headers ?? {}) };
380
+ let headers = baseHeaders;
381
+ const attempt = async (h: Record<string, string>) => {
382
+ const res = await ctx.fetch(modelsUrl, {
383
+ headers: h,
384
+ signal: AbortSignal.timeout(10_000),
385
+ });
386
+ if (!res.ok) {
387
+ throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
388
+ }
389
+ headers = h;
390
+ return res;
391
+ };
392
+ const apiKey = await ctx.getBearerApiKeyResolver(providerConfig.provider);
393
+ const response = apiKey
394
+ ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
395
+ : await attempt(baseHeaders);
386
396
  const payload = (await response.json()) as { data?: Array<{ id: string }> };
387
397
  const models = payload.data ?? [];
388
398
  const discovered: Model<Api>[] = [];
@@ -435,19 +445,23 @@ export async function discoverProxyModels(
435
445
  const baseUrl = normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl);
436
446
  const modelsUrl = `${baseUrl}/models`;
437
447
 
438
- const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
439
- const apiKey = await ctx.getBearerApiKey(providerConfig.provider);
440
- if (apiKey) {
441
- headers.Authorization = `Bearer ${apiKey}`;
442
- }
443
-
444
- const response = await ctx.fetch(modelsUrl, {
445
- headers,
446
- signal: AbortSignal.timeout(10_000),
447
- });
448
- if (!response.ok) {
449
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
450
- }
448
+ const baseHeaders: Record<string, string> = { ...(providerConfig.headers ?? {}) };
449
+ let headers = baseHeaders;
450
+ const attempt = async (h: Record<string, string>) => {
451
+ const res = await ctx.fetch(modelsUrl, {
452
+ headers: h,
453
+ signal: AbortSignal.timeout(10_000),
454
+ });
455
+ if (!res.ok) {
456
+ throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
457
+ }
458
+ headers = h;
459
+ return res;
460
+ };
461
+ const apiKey = await ctx.getBearerApiKeyResolver(providerConfig.provider);
462
+ const response = apiKey
463
+ ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
464
+ : await attempt(baseHeaders);
451
465
  const payload = (await response.json()) as {
452
466
  data?: Array<{ id?: string; name?: string; supported_endpoint_types?: string[] }>;
453
467
  };
@@ -57,7 +57,7 @@ import {
57
57
  import { isRecord, logger } from "@oh-my-pi/pi-utils";
58
58
  import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
59
59
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
60
- import { type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver";
60
+ import { type ApiKeyResolverModel, type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver";
61
61
  import type { ConfigError, ConfigFile } from "./config-file";
62
62
  import {
63
63
  DISCOVERY_DEFAULT_MAX_TOKENS,
@@ -1238,9 +1238,10 @@ export class ModelRegistry {
1238
1238
  #discoveryContext(): DiscoveryContext {
1239
1239
  return {
1240
1240
  fetch: this.#fetch,
1241
- getBearerApiKey: async provider => {
1241
+ getBearerApiKeyResolver: async provider => {
1242
1242
  const apiKey = await this.getApiKeyForProvider(provider);
1243
- return apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth ? apiKey : undefined;
1243
+ if (!apiKey || apiKey === DEFAULT_LOCAL_TOKEN || apiKey === kNoAuth) return undefined;
1244
+ return this.resolver(provider);
1244
1245
  },
1245
1246
  };
1246
1247
  }
@@ -1754,12 +1755,24 @@ export class ModelRegistry {
1754
1755
  }
1755
1756
 
1756
1757
  /**
1757
- * Build an {@link ApiKeyResolver} for this provider, implementing the
1758
- * central a/b/c auth-retry policy. Callers that need the initial key for
1759
- * a guard can call `resolveApiKeyOnce(resolver)`.
1758
+ * Build an {@link ApiKeyResolver} implementing the central a/b/c auth-retry
1759
+ * policy. Accepts a provider id with options, or a model with an optional
1760
+ * session id (`resolver(model, sessionId)`) which derives `baseUrl`/`modelId`
1761
+ * from the model. Callers that need the initial key for a guard can call
1762
+ * `resolveApiKeyOnce(resolver)`.
1760
1763
  */
1761
- resolver(provider: string, options?: ApiKeyResolverOptions): ApiKeyResolver {
1762
- return createApiKeyResolver(this, provider, options);
1764
+ resolver(provider: string, options?: ApiKeyResolverOptions): ApiKeyResolver;
1765
+ resolver(model: ApiKeyResolverModel, sessionId?: string): ApiKeyResolver;
1766
+ resolver(target: string | ApiKeyResolverModel, optionsOrSessionId?: ApiKeyResolverOptions | string): ApiKeyResolver {
1767
+ const options = typeof optionsOrSessionId === "string" ? { sessionId: optionsOrSessionId } : optionsOrSessionId;
1768
+ if (typeof target === "string") {
1769
+ return createApiKeyResolver(this, target, options);
1770
+ }
1771
+ return createApiKeyResolver(this, target.provider, {
1772
+ ...options,
1773
+ baseUrl: target.baseUrl,
1774
+ modelId: target.id,
1775
+ });
1763
1776
  }
1764
1777
 
1765
1778
  async #peekApiKeyForProvider(provider: string): Promise<string | undefined> {
@@ -633,18 +633,72 @@ function isSessionInheritedAgentPattern(value: string): boolean {
633
633
  return value === DEFAULT_MODEL_ROLE || value === `${PREFIX_MODEL_ROLE}${DEFAULT_MODEL_ROLE}` || value === "pi/task";
634
634
  }
635
635
 
636
- function resolveConfiguredRolePattern(value: string, settings?: Settings): string[] | undefined {
636
+ function shouldInheritDefaultBeforePriority(role: ModelRole): boolean {
637
+ return role === "smol" || role === "slow" || role === "designer";
638
+ }
639
+
640
+ function resolveDefaultInheritedPatterns(
641
+ role: ModelRole,
642
+ configuredDefault: string | undefined,
643
+ roleDefaults: string[],
644
+ settings: Settings | undefined,
645
+ visited: Set<ModelRole>,
646
+ ): string[] {
647
+ if (!shouldInheritDefaultBeforePriority(role) || !configuredDefault) return [];
648
+
649
+ const resolved: string[] = [];
650
+ for (const pattern of normalizeModelPatternList(configuredDefault)) {
651
+ const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix(pattern, PREFIX_MODEL_ROLE.length);
652
+ const aliasRole = getModelRoleAlias(aliasCandidate);
653
+ if (aliasRole === role) {
654
+ // Self-alias (e.g. modelRoles.default = "pi/smol") would loop back to the
655
+ // same unset role; collapse straight to the built-in priority chain.
656
+ resolved.push(
657
+ ...(thinkingLevel
658
+ ? roleDefaults.map(defaultPattern => `${defaultPattern}:${thinkingLevel}`)
659
+ : roleDefaults),
660
+ );
661
+ continue;
662
+ }
663
+ if (aliasRole && !visited.has(aliasRole)) {
664
+ // Cross-role alias (e.g. modelRoles.default = "pi/slow"): resolve the
665
+ // target role's patterns now so downstream one-layer expanders see
666
+ // concrete model patterns instead of another role alias.
667
+ const recursed = resolveConfiguredRolePattern(pattern, settings, new Set(visited));
668
+ if (recursed && recursed.length > 0) {
669
+ resolved.push(...recursed);
670
+ continue;
671
+ }
672
+ }
673
+ resolved.push(pattern);
674
+ }
675
+ return resolved;
676
+ }
677
+
678
+ function resolveConfiguredRolePattern(
679
+ value: string,
680
+ settings?: Settings,
681
+ visited: Set<ModelRole> = new Set(),
682
+ ): string[] | undefined {
637
683
  const normalized = value.trim();
638
684
  if (!normalized) return undefined;
639
685
 
640
686
  const { base: aliasCandidate, level: thinkingLevel } = splitThinkingSuffix(normalized, PREFIX_MODEL_ROLE.length);
641
687
  const role = getModelRoleAlias(aliasCandidate);
642
688
  if (!role) return [normalized];
689
+ if (visited.has(role)) return undefined;
690
+ visited.add(role);
643
691
 
644
692
  const configured = settings?.getModelRole(role)?.trim();
693
+ const configuredDefault = settings?.getModelRole(DEFAULT_MODEL_ROLE)?.trim();
645
694
  const roleDefaults = normalizeModelPatternList(MODEL_PRIO[role as keyof typeof MODEL_PRIO]);
646
- const resolved = configured ? normalizeModelPatternList(configured) : roleDefaults;
647
- if (!resolved || resolved.length === 0) {
695
+ const resolved = configured
696
+ ? normalizeModelPatternList(configured)
697
+ : resolveDefaultInheritedPatterns(role, configuredDefault, roleDefaults, settings, visited);
698
+ if (resolved.length === 0) {
699
+ resolved.push(...roleDefaults);
700
+ }
701
+ if (resolved.length === 0) {
648
702
  return undefined;
649
703
  }
650
704