gsd-pi 2.17.0 → 2.19.0

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 (217) hide show
  1. package/README.md +39 -0
  2. package/dist/onboarding.js +2 -2
  3. package/dist/remote-questions-config.d.ts +10 -0
  4. package/dist/remote-questions-config.js +36 -0
  5. package/dist/resources/extensions/gsd/activity-log.ts +37 -7
  6. package/dist/resources/extensions/gsd/auto-dashboard.ts +14 -2
  7. package/dist/resources/extensions/gsd/auto-prompts.ts +65 -16
  8. package/dist/resources/extensions/gsd/auto-worktree.ts +33 -4
  9. package/dist/resources/extensions/gsd/auto.ts +399 -29
  10. package/dist/resources/extensions/gsd/captures.ts +384 -0
  11. package/dist/resources/extensions/gsd/commands.ts +382 -23
  12. package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
  13. package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  14. package/dist/resources/extensions/gsd/dispatch-guard.ts +7 -19
  15. package/dist/resources/extensions/gsd/docs/preferences-reference.md +201 -2
  16. package/dist/resources/extensions/gsd/files.ts +123 -1
  17. package/dist/resources/extensions/gsd/guided-flow.ts +237 -4
  18. package/dist/resources/extensions/gsd/index.ts +47 -3
  19. package/dist/resources/extensions/gsd/metrics.ts +48 -0
  20. package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
  21. package/dist/resources/extensions/gsd/model-router.ts +256 -0
  22. package/dist/resources/extensions/gsd/paths.ts +9 -0
  23. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  24. package/dist/resources/extensions/gsd/preferences.ts +132 -1
  25. package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
  26. package/dist/resources/extensions/gsd/prompts/execute-task.md +6 -5
  27. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  28. package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  29. package/dist/resources/extensions/gsd/prompts/system.md +2 -0
  30. package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  31. package/dist/resources/extensions/gsd/queue-order.ts +231 -0
  32. package/dist/resources/extensions/gsd/queue-reorder-ui.ts +263 -0
  33. package/dist/resources/extensions/gsd/state.ts +15 -3
  34. package/dist/resources/extensions/gsd/templates/knowledge.md +19 -0
  35. package/dist/resources/extensions/gsd/templates/preferences.md +14 -0
  36. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +20 -0
  37. package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
  38. package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  39. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +99 -0
  40. package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  41. package/dist/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +79 -0
  42. package/dist/resources/extensions/gsd/tests/knowledge.test.ts +161 -0
  43. package/dist/resources/extensions/gsd/tests/memory-leak-guards.test.ts +87 -0
  44. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  45. package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  46. package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  47. package/dist/resources/extensions/gsd/tests/preferences-wizard-fields.test.ts +168 -0
  48. package/dist/resources/extensions/gsd/tests/queue-order.test.ts +204 -0
  49. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +281 -0
  50. package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  51. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  52. package/dist/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +139 -0
  53. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  54. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  55. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  56. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  57. package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
  58. package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
  59. package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
  60. package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  61. package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
  62. package/dist/resources/extensions/gsd/worktree-manager.ts +8 -5
  63. package/dist/resources/extensions/gsd/worktree.ts +22 -0
  64. package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  65. package/dist/resources/extensions/remote-questions/format.ts +12 -6
  66. package/dist/resources/extensions/remote-questions/manager.ts +8 -0
  67. package/dist/resources/extensions/shared/next-action-ui.ts +16 -1
  68. package/package.json +1 -1
  69. package/packages/pi-coding-agent/dist/cli/args.d.ts +5 -0
  70. package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
  71. package/packages/pi-coding-agent/dist/cli/args.js +21 -0
  72. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  73. package/packages/pi-coding-agent/dist/cli/list-models.d.ts +14 -3
  74. package/packages/pi-coding-agent/dist/cli/list-models.d.ts.map +1 -1
  75. package/packages/pi-coding-agent/dist/cli/list-models.js +52 -17
  76. package/packages/pi-coding-agent/dist/cli/list-models.js.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/discovery-cache.d.ts +27 -0
  78. package/packages/pi-coding-agent/dist/core/discovery-cache.d.ts.map +1 -0
  79. package/packages/pi-coding-agent/dist/core/discovery-cache.js +79 -0
  80. package/packages/pi-coding-agent/dist/core/discovery-cache.js.map +1 -0
  81. package/packages/pi-coding-agent/dist/core/discovery-cache.test.d.ts +2 -0
  82. package/packages/pi-coding-agent/dist/core/discovery-cache.test.d.ts.map +1 -0
  83. package/packages/pi-coding-agent/dist/core/discovery-cache.test.js +140 -0
  84. package/packages/pi-coding-agent/dist/core/discovery-cache.test.js.map +1 -0
  85. package/packages/pi-coding-agent/dist/core/model-discovery.d.ts +35 -0
  86. package/packages/pi-coding-agent/dist/core/model-discovery.d.ts.map +1 -0
  87. package/packages/pi-coding-agent/dist/core/model-discovery.js +162 -0
  88. package/packages/pi-coding-agent/dist/core/model-discovery.js.map +1 -0
  89. package/packages/pi-coding-agent/dist/core/model-discovery.test.d.ts +2 -0
  90. package/packages/pi-coding-agent/dist/core/model-discovery.test.d.ts.map +1 -0
  91. package/packages/pi-coding-agent/dist/core/model-discovery.test.js +100 -0
  92. package/packages/pi-coding-agent/dist/core/model-discovery.test.js.map +1 -0
  93. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.d.ts +2 -0
  94. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.d.ts.map +1 -0
  95. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.js +113 -0
  96. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.js.map +1 -0
  97. package/packages/pi-coding-agent/dist/core/model-registry.d.ts +26 -0
  98. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  99. package/packages/pi-coding-agent/dist/core/model-registry.js +98 -0
  100. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  101. package/packages/pi-coding-agent/dist/core/models-json-writer.d.ts +62 -0
  102. package/packages/pi-coding-agent/dist/core/models-json-writer.d.ts.map +1 -0
  103. package/packages/pi-coding-agent/dist/core/models-json-writer.js +145 -0
  104. package/packages/pi-coding-agent/dist/core/models-json-writer.js.map +1 -0
  105. package/packages/pi-coding-agent/dist/core/models-json-writer.test.d.ts +2 -0
  106. package/packages/pi-coding-agent/dist/core/models-json-writer.test.d.ts.map +1 -0
  107. package/packages/pi-coding-agent/dist/core/models-json-writer.test.js +118 -0
  108. package/packages/pi-coding-agent/dist/core/models-json-writer.test.js.map +1 -0
  109. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +9 -0
  110. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  111. package/packages/pi-coding-agent/dist/core/settings-manager.js +11 -0
  112. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  113. package/packages/pi-coding-agent/dist/core/slash-commands.d.ts.map +1 -1
  114. package/packages/pi-coding-agent/dist/core/slash-commands.js +1 -0
  115. package/packages/pi-coding-agent/dist/core/slash-commands.js.map +1 -1
  116. package/packages/pi-coding-agent/dist/index.d.ts +5 -1
  117. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  118. package/packages/pi-coding-agent/dist/index.js +4 -1
  119. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  120. package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
  121. package/packages/pi-coding-agent/dist/main.js +17 -2
  122. package/packages/pi-coding-agent/dist/main.js.map +1 -1
  123. package/packages/pi-coding-agent/dist/modes/interactive/components/index.d.ts +1 -0
  124. package/packages/pi-coding-agent/dist/modes/interactive/components/index.d.ts.map +1 -1
  125. package/packages/pi-coding-agent/dist/modes/interactive/components/index.js +1 -0
  126. package/packages/pi-coding-agent/dist/modes/interactive/components/index.js.map +1 -1
  127. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js +1 -1
  128. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js.map +1 -1
  129. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.d.ts +25 -0
  130. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.d.ts.map +1 -0
  131. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.js +121 -0
  132. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.js.map +1 -0
  133. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +1 -0
  134. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  135. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +32 -0
  136. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  137. package/packages/pi-coding-agent/src/cli/args.ts +21 -0
  138. package/packages/pi-coding-agent/src/cli/list-models.ts +70 -17
  139. package/packages/pi-coding-agent/src/core/discovery-cache.test.ts +170 -0
  140. package/packages/pi-coding-agent/src/core/discovery-cache.ts +97 -0
  141. package/packages/pi-coding-agent/src/core/model-discovery.test.ts +125 -0
  142. package/packages/pi-coding-agent/src/core/model-discovery.ts +231 -0
  143. package/packages/pi-coding-agent/src/core/model-registry-discovery.test.ts +135 -0
  144. package/packages/pi-coding-agent/src/core/model-registry.ts +107 -0
  145. package/packages/pi-coding-agent/src/core/models-json-writer.test.ts +145 -0
  146. package/packages/pi-coding-agent/src/core/models-json-writer.ts +188 -0
  147. package/packages/pi-coding-agent/src/core/settings-manager.ts +21 -0
  148. package/packages/pi-coding-agent/src/core/slash-commands.ts +1 -0
  149. package/packages/pi-coding-agent/src/index.ts +5 -0
  150. package/packages/pi-coding-agent/src/main.ts +19 -2
  151. package/packages/pi-coding-agent/src/modes/interactive/components/index.ts +1 -0
  152. package/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +1 -1
  153. package/packages/pi-coding-agent/src/modes/interactive/components/provider-manager.ts +163 -0
  154. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +37 -0
  155. package/src/resources/extensions/gsd/activity-log.ts +37 -7
  156. package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
  157. package/src/resources/extensions/gsd/auto-prompts.ts +65 -16
  158. package/src/resources/extensions/gsd/auto-worktree.ts +33 -4
  159. package/src/resources/extensions/gsd/auto.ts +399 -29
  160. package/src/resources/extensions/gsd/captures.ts +384 -0
  161. package/src/resources/extensions/gsd/commands.ts +382 -23
  162. package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
  163. package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  164. package/src/resources/extensions/gsd/dispatch-guard.ts +7 -19
  165. package/src/resources/extensions/gsd/docs/preferences-reference.md +201 -2
  166. package/src/resources/extensions/gsd/files.ts +123 -1
  167. package/src/resources/extensions/gsd/guided-flow.ts +237 -4
  168. package/src/resources/extensions/gsd/index.ts +47 -3
  169. package/src/resources/extensions/gsd/metrics.ts +48 -0
  170. package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
  171. package/src/resources/extensions/gsd/model-router.ts +256 -0
  172. package/src/resources/extensions/gsd/paths.ts +9 -0
  173. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  174. package/src/resources/extensions/gsd/preferences.ts +132 -1
  175. package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
  176. package/src/resources/extensions/gsd/prompts/execute-task.md +6 -5
  177. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  178. package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  179. package/src/resources/extensions/gsd/prompts/system.md +2 -0
  180. package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  181. package/src/resources/extensions/gsd/queue-order.ts +231 -0
  182. package/src/resources/extensions/gsd/queue-reorder-ui.ts +263 -0
  183. package/src/resources/extensions/gsd/state.ts +15 -3
  184. package/src/resources/extensions/gsd/templates/knowledge.md +19 -0
  185. package/src/resources/extensions/gsd/templates/preferences.md +14 -0
  186. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +20 -0
  187. package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
  188. package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  189. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +99 -0
  190. package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  191. package/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +79 -0
  192. package/src/resources/extensions/gsd/tests/knowledge.test.ts +161 -0
  193. package/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +87 -0
  194. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  195. package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  196. package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  197. package/src/resources/extensions/gsd/tests/preferences-wizard-fields.test.ts +168 -0
  198. package/src/resources/extensions/gsd/tests/queue-order.test.ts +204 -0
  199. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +281 -0
  200. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  201. package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  202. package/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +139 -0
  203. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  204. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  205. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  206. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  207. package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
  208. package/src/resources/extensions/gsd/triage-ui.ts +175 -0
  209. package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
  210. package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  211. package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
  212. package/src/resources/extensions/gsd/worktree-manager.ts +8 -5
  213. package/src/resources/extensions/gsd/worktree.ts +22 -0
  214. package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  215. package/src/resources/extensions/remote-questions/format.ts +12 -6
  216. package/src/resources/extensions/remote-questions/manager.ts +8 -0
  217. package/src/resources/extensions/shared/next-action-ui.ts +16 -1
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Provider discovery adapters for runtime model enumeration.
3
+ * Each adapter implements ProviderDiscoveryAdapter to fetch models from provider APIs.
4
+ */
5
+
6
+ export interface DiscoveredModel {
7
+ id: string;
8
+ name?: string;
9
+ contextWindow?: number;
10
+ maxTokens?: number;
11
+ reasoning?: boolean;
12
+ input?: ("text" | "image")[];
13
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
14
+ }
15
+
16
+ export interface DiscoveryResult {
17
+ provider: string;
18
+ models: DiscoveredModel[];
19
+ fetchedAt: number;
20
+ error?: string;
21
+ }
22
+
23
+ export interface ProviderDiscoveryAdapter {
24
+ provider: string;
25
+ supportsDiscovery: boolean;
26
+ fetchModels(apiKey: string, baseUrl?: string): Promise<DiscoveredModel[]>;
27
+ }
28
+
29
+ /** Per-provider TTLs in milliseconds */
30
+ export const DISCOVERY_TTLS: Record<string, number> = {
31
+ ollama: 5 * 60 * 1000, // 5 minutes (local, models change often)
32
+ openai: 60 * 60 * 1000, // 1 hour
33
+ google: 60 * 60 * 1000, // 1 hour
34
+ openrouter: 60 * 60 * 1000, // 1 hour
35
+ default: 24 * 60 * 60 * 1000, // 24 hours
36
+ };
37
+
38
+ export function getDefaultTTL(provider: string): number {
39
+ return DISCOVERY_TTLS[provider] ?? DISCOVERY_TTLS.default;
40
+ }
41
+
42
+ async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 5000): Promise<Response> {
43
+ const controller = new AbortController();
44
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
45
+ try {
46
+ return await fetch(url, { ...options, signal: controller.signal });
47
+ } finally {
48
+ clearTimeout(timeout);
49
+ }
50
+ }
51
+
52
+ // ─── OpenAI Adapter ──────────────────────────────────────────────────────────
53
+
54
+ const OPENAI_EXCLUDED_PREFIXES = ["embedding", "tts", "dall-e", "whisper", "text-embedding", "davinci", "babbage"];
55
+
56
+ class OpenAIDiscoveryAdapter implements ProviderDiscoveryAdapter {
57
+ provider = "openai";
58
+ supportsDiscovery = true;
59
+
60
+ async fetchModels(apiKey: string, baseUrl?: string): Promise<DiscoveredModel[]> {
61
+ const url = `${baseUrl ?? "https://api.openai.com"}/v1/models`;
62
+ const response = await fetchWithTimeout(url, {
63
+ headers: { Authorization: `Bearer ${apiKey}` },
64
+ });
65
+
66
+ if (!response.ok) {
67
+ throw new Error(`OpenAI models API returned ${response.status}: ${response.statusText}`);
68
+ }
69
+
70
+ const data = (await response.json()) as { data: Array<{ id: string; owned_by?: string }> };
71
+ return data.data
72
+ .filter((m) => !OPENAI_EXCLUDED_PREFIXES.some((prefix) => m.id.startsWith(prefix)))
73
+ .map((m) => ({
74
+ id: m.id,
75
+ name: m.id,
76
+ input: ["text" as const, "image" as const],
77
+ }));
78
+ }
79
+ }
80
+
81
+ // ─── Ollama Adapter ──────────────────────────────────────────────────────────
82
+
83
+ class OllamaDiscoveryAdapter implements ProviderDiscoveryAdapter {
84
+ provider = "ollama";
85
+ supportsDiscovery = true;
86
+
87
+ async fetchModels(_apiKey: string, baseUrl?: string): Promise<DiscoveredModel[]> {
88
+ const url = `${baseUrl ?? "http://localhost:11434"}/api/tags`;
89
+ const response = await fetchWithTimeout(url);
90
+
91
+ if (!response.ok) {
92
+ throw new Error(`Ollama tags API returned ${response.status}: ${response.statusText}`);
93
+ }
94
+
95
+ const data = (await response.json()) as {
96
+ models: Array<{ name: string; size: number; details?: { parameter_size?: string } }>;
97
+ };
98
+
99
+ return (data.models ?? []).map((m) => ({
100
+ id: m.name,
101
+ name: m.name,
102
+ input: ["text" as const],
103
+ }));
104
+ }
105
+ }
106
+
107
+ // ─── OpenRouter Adapter ──────────────────────────────────────────────────────
108
+
109
+ class OpenRouterDiscoveryAdapter implements ProviderDiscoveryAdapter {
110
+ provider = "openrouter";
111
+ supportsDiscovery = true;
112
+
113
+ async fetchModels(apiKey: string, baseUrl?: string): Promise<DiscoveredModel[]> {
114
+ const url = `${baseUrl ?? "https://openrouter.ai"}/api/v1/models`;
115
+ const response = await fetchWithTimeout(url, {
116
+ headers: { Authorization: `Bearer ${apiKey}` },
117
+ });
118
+
119
+ if (!response.ok) {
120
+ throw new Error(`OpenRouter models API returned ${response.status}: ${response.statusText}`);
121
+ }
122
+
123
+ const data = (await response.json()) as {
124
+ data: Array<{
125
+ id: string;
126
+ name: string;
127
+ context_length?: number;
128
+ top_provider?: { max_completion_tokens?: number };
129
+ pricing?: { prompt: string; completion: string };
130
+ }>;
131
+ };
132
+
133
+ return (data.data ?? []).map((m) => {
134
+ const cost =
135
+ m.pricing?.prompt !== undefined && m.pricing?.completion !== undefined
136
+ ? {
137
+ input: parseFloat(m.pricing.prompt) * 1_000_000,
138
+ output: parseFloat(m.pricing.completion) * 1_000_000,
139
+ cacheRead: 0,
140
+ cacheWrite: 0,
141
+ }
142
+ : undefined;
143
+
144
+ return {
145
+ id: m.id,
146
+ name: m.name,
147
+ contextWindow: m.context_length,
148
+ maxTokens: m.top_provider?.max_completion_tokens,
149
+ cost,
150
+ input: ["text" as const, "image" as const],
151
+ };
152
+ });
153
+ }
154
+ }
155
+
156
+ // ─── Google/Gemini Adapter ───────────────────────────────────────────────────
157
+
158
+ class GoogleDiscoveryAdapter implements ProviderDiscoveryAdapter {
159
+ provider = "google";
160
+ supportsDiscovery = true;
161
+
162
+ async fetchModels(apiKey: string, baseUrl?: string): Promise<DiscoveredModel[]> {
163
+ const url = `${baseUrl ?? "https://generativelanguage.googleapis.com"}/v1beta/models?key=${apiKey}`;
164
+ const response = await fetchWithTimeout(url);
165
+
166
+ if (!response.ok) {
167
+ throw new Error(`Google models API returned ${response.status}: ${response.statusText}`);
168
+ }
169
+
170
+ const data = (await response.json()) as {
171
+ models: Array<{
172
+ name: string;
173
+ displayName: string;
174
+ supportedGenerationMethods?: string[];
175
+ inputTokenLimit?: number;
176
+ outputTokenLimit?: number;
177
+ }>;
178
+ };
179
+
180
+ return (data.models ?? [])
181
+ .filter((m) => m.supportedGenerationMethods?.includes("generateContent"))
182
+ .map((m) => ({
183
+ id: m.name.replace("models/", ""),
184
+ name: m.displayName,
185
+ contextWindow: m.inputTokenLimit,
186
+ maxTokens: m.outputTokenLimit,
187
+ input: ["text" as const, "image" as const],
188
+ }));
189
+ }
190
+ }
191
+
192
+ // ─── Static Adapter (no discovery) ───────────────────────────────────────────
193
+
194
+ class StaticDiscoveryAdapter implements ProviderDiscoveryAdapter {
195
+ provider: string;
196
+ supportsDiscovery = false;
197
+
198
+ constructor(provider: string) {
199
+ this.provider = provider;
200
+ }
201
+
202
+ async fetchModels(): Promise<DiscoveredModel[]> {
203
+ return [];
204
+ }
205
+ }
206
+
207
+ // ─── Registry ────────────────────────────────────────────────────────────────
208
+
209
+ const adapters: Record<string, ProviderDiscoveryAdapter> = {
210
+ openai: new OpenAIDiscoveryAdapter(),
211
+ ollama: new OllamaDiscoveryAdapter(),
212
+ openrouter: new OpenRouterDiscoveryAdapter(),
213
+ google: new GoogleDiscoveryAdapter(),
214
+ anthropic: new StaticDiscoveryAdapter("anthropic"),
215
+ bedrock: new StaticDiscoveryAdapter("bedrock"),
216
+ "azure-openai": new StaticDiscoveryAdapter("azure-openai"),
217
+ groq: new StaticDiscoveryAdapter("groq"),
218
+ cerebras: new StaticDiscoveryAdapter("cerebras"),
219
+ xai: new StaticDiscoveryAdapter("xai"),
220
+ mistral: new StaticDiscoveryAdapter("mistral"),
221
+ };
222
+
223
+ export function getDiscoveryAdapter(provider: string): ProviderDiscoveryAdapter {
224
+ return adapters[provider] ?? new StaticDiscoveryAdapter(provider);
225
+ }
226
+
227
+ export function getDiscoverableProviders(): string[] {
228
+ return Object.entries(adapters)
229
+ .filter(([, adapter]) => adapter.supportsDiscovery)
230
+ .map(([name]) => name);
231
+ }
@@ -0,0 +1,135 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, it } from "node:test";
6
+ import { AuthStorage } from "./auth-storage.js";
7
+ import { ModelDiscoveryCache } from "./discovery-cache.js";
8
+ import { getDefaultTTL, getDiscoverableProviders, getDiscoveryAdapter } from "./model-discovery.js";
9
+
10
+ let testDir: string;
11
+
12
+ beforeEach(() => {
13
+ testDir = join(tmpdir(), `model-registry-discovery-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
14
+ mkdirSync(testDir, { recursive: true });
15
+ });
16
+
17
+ afterEach(() => {
18
+ try {
19
+ rmSync(testDir, { recursive: true, force: true });
20
+ } catch {
21
+ // Cleanup best-effort
22
+ }
23
+ });
24
+
25
+ // ─── discovery cache integration ─────────────────────────────────────────────
26
+
27
+ describe("ModelDiscoveryCache — integration with discovery", () => {
28
+ it("cache respects provider-specific TTLs", () => {
29
+ const cachePath = join(testDir, "cache.json");
30
+ const cache = new ModelDiscoveryCache(cachePath);
31
+
32
+ cache.set("ollama", [{ id: "llama2" }]);
33
+ const entry = cache.get("ollama");
34
+ assert.ok(entry);
35
+ assert.equal(entry.ttlMs, getDefaultTTL("ollama"));
36
+ });
37
+
38
+ it("cache uses custom TTL when provided", () => {
39
+ const cachePath = join(testDir, "cache.json");
40
+ const cache = new ModelDiscoveryCache(cachePath);
41
+
42
+ cache.set("openai", [{ id: "gpt-4o" }], 999);
43
+ const entry = cache.get("openai");
44
+ assert.ok(entry);
45
+ assert.equal(entry.ttlMs, 999);
46
+ });
47
+ });
48
+
49
+ // ─── adapter resolution ─────────────────────────────────────────────────────
50
+
51
+ describe("Discovery adapter resolution", () => {
52
+ it("all discoverable providers have adapters", () => {
53
+ const providers = getDiscoverableProviders();
54
+ for (const provider of providers) {
55
+ const adapter = getDiscoveryAdapter(provider);
56
+ assert.equal(adapter.supportsDiscovery, true, `${provider} should support discovery`);
57
+ }
58
+ });
59
+
60
+ it("static adapters return empty model lists", async () => {
61
+ const staticProviders = ["anthropic", "bedrock", "azure-openai", "groq", "cerebras"];
62
+ for (const provider of staticProviders) {
63
+ const adapter = getDiscoveryAdapter(provider);
64
+ assert.equal(adapter.supportsDiscovery, false, `${provider} should not support discovery`);
65
+ const models = await adapter.fetchModels("dummy-key");
66
+ assert.deepEqual(models, [], `${provider} should return empty models`);
67
+ }
68
+ });
69
+ });
70
+
71
+ // ─── AuthStorage hasAuth for discovery ───────────────────────────────────────
72
+
73
+ describe("AuthStorage — hasAuth for discovery providers", () => {
74
+ it("returns false for providers without auth", () => {
75
+ const storage = AuthStorage.inMemory({});
76
+ assert.equal(storage.hasAuth("openai"), false);
77
+ assert.equal(storage.hasAuth("ollama"), false);
78
+ });
79
+
80
+ it("returns true for providers with stored keys", () => {
81
+ const storage = AuthStorage.inMemory({
82
+ openai: { type: "api_key" as const, key: "sk-test" },
83
+ });
84
+ assert.equal(storage.hasAuth("openai"), true);
85
+ assert.equal(storage.hasAuth("ollama"), false);
86
+ });
87
+ });
88
+
89
+ // ─── cache persistence across instances ──────────────────────────────────────
90
+
91
+ describe("ModelDiscoveryCache — persistence", () => {
92
+ it("data survives across cache instances", () => {
93
+ const cachePath = join(testDir, "persist.json");
94
+
95
+ const cache1 = new ModelDiscoveryCache(cachePath);
96
+ cache1.set("openai", [
97
+ { id: "gpt-4o", name: "GPT-4o", contextWindow: 128000 },
98
+ { id: "gpt-4o-mini", name: "GPT-4o Mini" },
99
+ ]);
100
+
101
+ const cache2 = new ModelDiscoveryCache(cachePath);
102
+ const entry = cache2.get("openai");
103
+ assert.ok(entry);
104
+ assert.equal(entry.models.length, 2);
105
+ assert.equal(entry.models[0].contextWindow, 128000);
106
+ });
107
+
108
+ it("clear persists across instances", () => {
109
+ const cachePath = join(testDir, "clear.json");
110
+
111
+ const cache1 = new ModelDiscoveryCache(cachePath);
112
+ cache1.set("openai", [{ id: "gpt-4o" }]);
113
+ cache1.clear("openai");
114
+
115
+ const cache2 = new ModelDiscoveryCache(cachePath);
116
+ assert.equal(cache2.get("openai"), undefined);
117
+ });
118
+ });
119
+
120
+ // ─── discovery TTL values ────────────────────────────────────────────────────
121
+
122
+ describe("Discovery TTL configuration", () => {
123
+ it("ollama has shortest TTL (local models change often)", () => {
124
+ const ollamaTTL = getDefaultTTL("ollama");
125
+ const openaiTTL = getDefaultTTL("openai");
126
+ assert.ok(ollamaTTL < openaiTTL, "ollama TTL should be shorter than openai");
127
+ });
128
+
129
+ it("unknown providers get default TTL", () => {
130
+ const customTTL = getDefaultTTL("my-custom-provider");
131
+ const defaultTTL = getDefaultTTL("default");
132
+ // Unknown providers should get the same TTL as the explicit "default" key
133
+ assert.equal(customTTL, defaultTTL);
134
+ });
135
+ });
@@ -24,6 +24,9 @@ import { existsSync, readFileSync } from "fs";
24
24
  import { join } from "path";
25
25
  import { getAgentDir } from "../config.js";
26
26
  import type { AuthStorage } from "./auth-storage.js";
27
+ import { ModelDiscoveryCache } from "./discovery-cache.js";
28
+ import type { DiscoveredModel, DiscoveryResult } from "./model-discovery.js";
29
+ import { getDefaultTTL, getDiscoverableProviders, getDiscoveryAdapter } from "./model-discovery.js";
27
30
  import { clearConfigValueCache, resolveConfigValue, resolveHeaders } from "./resolve-config-value.js";
28
31
 
29
32
  const Ajv = (AjvModule as any).default || AjvModule;
@@ -221,6 +224,8 @@ export const clearApiKeyCache = clearConfigValueCache;
221
224
  */
222
225
  export class ModelRegistry {
223
226
  private models: Model<Api>[] = [];
227
+ private discoveredModels: Model<Api>[] = [];
228
+ private discoveryCache: ModelDiscoveryCache;
224
229
  private customProviderApiKeys: Map<string, string> = new Map();
225
230
  private registeredProviders: Map<string, ProviderConfigInput> = new Map();
226
231
  private loadError: string | undefined = undefined;
@@ -229,6 +234,8 @@ export class ModelRegistry {
229
234
  readonly authStorage: AuthStorage,
230
235
  private modelsJsonPath: string | undefined = join(getAgentDir(), "models.json"),
231
236
  ) {
237
+ this.discoveryCache = new ModelDiscoveryCache();
238
+
232
239
  // Set up fallback resolver for custom provider API keys
233
240
  this.authStorage.setFallbackResolver((provider) => {
234
241
  const keyConfig = this.customProviderApiKeys.get(provider);
@@ -666,6 +673,106 @@ export class ModelRegistry {
666
673
  });
667
674
  }
668
675
  }
676
+
677
+ /**
678
+ * Discover models from all providers that support discovery.
679
+ * Results are cached and merged into the registry (never overrides existing models).
680
+ */
681
+ async discoverModels(providers?: string[]): Promise<DiscoveryResult[]> {
682
+ const targetProviders = providers ?? getDiscoverableProviders();
683
+ const results: DiscoveryResult[] = [];
684
+
685
+ for (const providerName of targetProviders) {
686
+ const adapter = getDiscoveryAdapter(providerName);
687
+ if (!adapter.supportsDiscovery) continue;
688
+
689
+ // Skip if cache is still fresh
690
+ if (!this.discoveryCache.isStale(providerName)) {
691
+ const cached = this.discoveryCache.get(providerName);
692
+ if (cached) {
693
+ results.push({
694
+ provider: providerName,
695
+ models: cached.models,
696
+ fetchedAt: cached.fetchedAt,
697
+ });
698
+ continue;
699
+ }
700
+ }
701
+
702
+ try {
703
+ const apiKey = await this.authStorage.getApiKey(providerName);
704
+ if (!apiKey && providerName !== "ollama") continue;
705
+
706
+ const models = await adapter.fetchModels(apiKey ?? "", undefined);
707
+ this.discoveryCache.set(providerName, models);
708
+ results.push({
709
+ provider: providerName,
710
+ models,
711
+ fetchedAt: Date.now(),
712
+ });
713
+ } catch (error) {
714
+ results.push({
715
+ provider: providerName,
716
+ models: [],
717
+ fetchedAt: Date.now(),
718
+ error: error instanceof Error ? error.message : String(error),
719
+ });
720
+ }
721
+ }
722
+
723
+ // Convert and merge discovered models
724
+ this.discoveredModels = this.convertDiscoveredModels(results);
725
+ return results;
726
+ }
727
+
728
+ /**
729
+ * Get all models including discovered ones.
730
+ * Discovered models are appended but never override existing models.
731
+ */
732
+ getAllWithDiscovered(): Model<Api>[] {
733
+ const existingIds = new Set(this.models.map((m) => `${m.provider}/${m.id}`));
734
+ const unique = this.discoveredModels.filter((m) => !existingIds.has(`${m.provider}/${m.id}`));
735
+ return [...this.models, ...unique];
736
+ }
737
+
738
+ /**
739
+ * Check if a model was added via discovery (not built-in or custom).
740
+ */
741
+ isDiscovered(model: Model<Api>): boolean {
742
+ return this.discoveredModels.some((m) => m.provider === model.provider && m.id === model.id);
743
+ }
744
+
745
+ /**
746
+ * Get the discovery cache instance.
747
+ */
748
+ getDiscoveryCache(): ModelDiscoveryCache {
749
+ return this.discoveryCache;
750
+ }
751
+
752
+ /**
753
+ * Convert DiscoveryResult[] into Model<Api>[] with default values.
754
+ */
755
+ private convertDiscoveredModels(results: DiscoveryResult[]): Model<Api>[] {
756
+ const converted: Model<Api>[] = [];
757
+ for (const result of results) {
758
+ if (result.error) continue;
759
+ for (const dm of result.models) {
760
+ converted.push({
761
+ id: dm.id,
762
+ name: dm.name ?? dm.id,
763
+ api: "openai" as Api,
764
+ provider: result.provider,
765
+ baseUrl: "",
766
+ reasoning: dm.reasoning ?? false,
767
+ input: dm.input ?? ["text"],
768
+ cost: dm.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
769
+ contextWindow: dm.contextWindow ?? 128000,
770
+ maxTokens: dm.maxTokens ?? 16384,
771
+ } as Model<Api>);
772
+ }
773
+ }
774
+ return converted;
775
+ }
669
776
  }
670
777
 
671
778
  /**
@@ -0,0 +1,145 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, it } from "node:test";
6
+ import { ModelsJsonWriter } from "./models-json-writer.js";
7
+
8
+ let testDir: string;
9
+ let modelsJsonPath: string;
10
+
11
+ beforeEach(() => {
12
+ testDir = join(tmpdir(), `models-json-writer-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
13
+ mkdirSync(testDir, { recursive: true });
14
+ modelsJsonPath = join(testDir, "models.json");
15
+ });
16
+
17
+ afterEach(() => {
18
+ try {
19
+ rmSync(testDir, { recursive: true, force: true });
20
+ } catch {
21
+ // Cleanup best-effort
22
+ }
23
+ });
24
+
25
+ function readModels(): Record<string, unknown> {
26
+ return JSON.parse(readFileSync(modelsJsonPath, "utf-8"));
27
+ }
28
+
29
+ // ─── addModel ────────────────────────────────────────────────────────────────
30
+
31
+ describe("ModelsJsonWriter — addModel", () => {
32
+ it("creates file and adds model to new provider", () => {
33
+ const writer = new ModelsJsonWriter(modelsJsonPath);
34
+ writer.addModel("openai", { id: "gpt-4o", name: "GPT-4o" }, { baseUrl: "https://api.openai.com", apiKey: "env:OPENAI_API_KEY", api: "openai" });
35
+
36
+ const config = readModels() as any;
37
+ assert.ok(config.providers.openai);
38
+ assert.equal(config.providers.openai.models.length, 1);
39
+ assert.equal(config.providers.openai.models[0].id, "gpt-4o");
40
+ });
41
+
42
+ it("appends model to existing provider", () => {
43
+ const writer = new ModelsJsonWriter(modelsJsonPath);
44
+ writer.addModel("openai", { id: "gpt-4o" }, { baseUrl: "https://api.openai.com", apiKey: "env:OPENAI_API_KEY", api: "openai" });
45
+ writer.addModel("openai", { id: "gpt-4o-mini" });
46
+
47
+ const config = readModels() as any;
48
+ assert.equal(config.providers.openai.models.length, 2);
49
+ });
50
+
51
+ it("replaces model with same id", () => {
52
+ const writer = new ModelsJsonWriter(modelsJsonPath);
53
+ writer.addModel("openai", { id: "gpt-4o", name: "Old" }, { baseUrl: "https://api.openai.com", apiKey: "env:OPENAI_API_KEY", api: "openai" });
54
+ writer.addModel("openai", { id: "gpt-4o", name: "New" });
55
+
56
+ const config = readModels() as any;
57
+ assert.equal(config.providers.openai.models.length, 1);
58
+ assert.equal(config.providers.openai.models[0].name, "New");
59
+ });
60
+ });
61
+
62
+ // ─── removeModel ─────────────────────────────────────────────────────────────
63
+
64
+ describe("ModelsJsonWriter — removeModel", () => {
65
+ it("removes a model from provider", () => {
66
+ const writer = new ModelsJsonWriter(modelsJsonPath);
67
+ writer.addModel("openai", { id: "gpt-4o" }, { baseUrl: "https://api.openai.com", apiKey: "env:OPENAI_API_KEY", api: "openai" });
68
+ writer.addModel("openai", { id: "gpt-4o-mini" });
69
+
70
+ writer.removeModel("openai", "gpt-4o");
71
+
72
+ const config = readModels() as any;
73
+ assert.equal(config.providers.openai.models.length, 1);
74
+ assert.equal(config.providers.openai.models[0].id, "gpt-4o-mini");
75
+ });
76
+
77
+ it("removes provider when last model is removed", () => {
78
+ const writer = new ModelsJsonWriter(modelsJsonPath);
79
+ writer.addModel("openai", { id: "gpt-4o" }, { baseUrl: "https://api.openai.com", apiKey: "env:OPENAI_API_KEY", api: "openai" });
80
+
81
+ writer.removeModel("openai", "gpt-4o");
82
+
83
+ const config = readModels() as any;
84
+ assert.equal(config.providers.openai, undefined);
85
+ });
86
+
87
+ it("handles removing from nonexistent provider", () => {
88
+ const writer = new ModelsJsonWriter(modelsJsonPath);
89
+ // Should not throw
90
+ writer.removeModel("nonexistent", "model-id");
91
+ });
92
+ });
93
+
94
+ // ─── setProvider / removeProvider ────────────────────────────────────────────
95
+
96
+ describe("ModelsJsonWriter — provider operations", () => {
97
+ it("sets a provider configuration", () => {
98
+ const writer = new ModelsJsonWriter(modelsJsonPath);
99
+ writer.setProvider("custom", {
100
+ baseUrl: "http://localhost:8080",
101
+ apiKey: "test-key",
102
+ api: "openai",
103
+ models: [{ id: "local-model" }],
104
+ });
105
+
106
+ const config = readModels() as any;
107
+ assert.ok(config.providers.custom);
108
+ assert.equal(config.providers.custom.baseUrl, "http://localhost:8080");
109
+ });
110
+
111
+ it("removes a provider", () => {
112
+ const writer = new ModelsJsonWriter(modelsJsonPath);
113
+ writer.setProvider("custom", { baseUrl: "http://localhost:8080" });
114
+ writer.removeProvider("custom");
115
+
116
+ const config = readModels() as any;
117
+ assert.equal(config.providers.custom, undefined);
118
+ });
119
+
120
+ it("handles removing nonexistent provider", () => {
121
+ const writer = new ModelsJsonWriter(modelsJsonPath);
122
+ writer.removeProvider("nonexistent");
123
+ // Should not throw
124
+ });
125
+ });
126
+
127
+ // ─── listProviders ───────────────────────────────────────────────────────────
128
+
129
+ describe("ModelsJsonWriter — listProviders", () => {
130
+ it("returns empty config when file does not exist", () => {
131
+ const writer = new ModelsJsonWriter(join(testDir, "nonexistent.json"));
132
+ const config = writer.listProviders();
133
+ assert.deepEqual(config, { providers: {} });
134
+ });
135
+
136
+ it("returns current provider config", () => {
137
+ const writer = new ModelsJsonWriter(modelsJsonPath);
138
+ writer.setProvider("openai", { baseUrl: "https://api.openai.com" });
139
+ writer.setProvider("ollama", { baseUrl: "http://localhost:11434" });
140
+
141
+ const config = writer.listProviders();
142
+ assert.ok(config.providers.openai);
143
+ assert.ok(config.providers.ollama);
144
+ });
145
+ });