pi-requesty-discover 1.1.0 → 2.0.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.
package/README.adoc CHANGED
@@ -16,7 +16,8 @@ This is a fork of https://github.com/requestyai/pi-requesty[pi-requesty] with ad
16
16
 
17
17
  * Use `REQUESTY_API_KEY` from the environment
18
18
  * Health-check available models and write a log about them
19
- * Provide a dry-run mode that does not change anything
19
+ * Brief information about what was found, including added/removed models, health summary
20
+ * Confirm before writing `models.json`, with a dry-run mode that only previews
20
21
 
21
22
  == When and why use it
22
23
 
@@ -25,7 +26,7 @@ Use this extension if
25
26
  - you are on Requesty.ai
26
27
  - you are limited to certain models by a company policy (for instance, for data residency/protection reasons)
27
28
  - you don't want to handcraft your own curated list of models constantly
28
- - you want to health check the list to complain about non-working models via a dry-run
29
+ - you want to health-check models and preview the diff before writing
29
30
 
30
31
  == Installation
31
32
 
@@ -124,12 +125,20 @@ Inside Pi:
124
125
  /requesty-discover
125
126
  ----
126
127
 
127
- The command fetches Requesty models using `~/.pi/agent/models.json` and writes the discovered model IDs back to the same file.
128
- Run `/reload` after syncing.
128
+ The command:
129
+
130
+ - discovers Requesty models (and optional health checks)
131
+ - shows a summary notification (added/removed IDs, health summary)
132
+ - asks for confirmation before writing `~/.pi/agent/models.json`
133
+ - reports whether the file was updated
134
+
135
+ If there are no model ID changes, confirmation still offers a metadata refresh write, which might have changed as well.
136
+
137
+ After a successful write, run `/reload`.
129
138
 
130
139
  [source,text]
131
140
  ----
132
141
  /requesty-discover --dry-run
133
142
  ----
134
143
 
135
- This will not overwrite the `models.json` file, but will do everything else, including the health checks.
144
+ Same discovery and health checks, but never prompts to write and leaves `models.json` unchanged.
package/README.md CHANGED
@@ -8,7 +8,8 @@ This is a fork of [pi-requesty](https://github.com/requestyai/pi-requesty) with
8
8
 
9
9
  * Use `REQUESTY_API_KEY` from the environment
10
10
  * Health-check available models and write a log about them
11
- * Provide a dry-run mode that does not change anything
11
+ * Brief information about what was found, including added/removed models, health summary
12
+ * Confirm before writing `models.json`, with a dry-run mode that only previews
12
13
 
13
14
  ## When and why use it
14
15
 
@@ -17,7 +18,7 @@ Use this extension if
17
18
  * you are on Requesty.ai
18
19
  * you are limited to certain models by a company policy (for instance, for data residency/protection reasons)
19
20
  * you don’t want to handcraft your own curated list of models constantly
20
- * you want to health check the list to complain about non-working models via a dry-run
21
+ * you want to health-check models and preview the diff before writing
21
22
 
22
23
  ## Installation
23
24
 
@@ -91,11 +92,19 @@ Inside Pi:
91
92
  /requesty-discover
92
93
  ```
93
94
 
94
- The command fetches Requesty models using `~/.pi/agent/models.json` and writes the discovered model IDs back to the same file.
95
- Run `/reload` after syncing.
95
+ The command:
96
+
97
+ * discovers Requesty models (and optional health checks)
98
+ * shows a summary notification (added/removed IDs, health summary)
99
+ * asks for confirmation before writing `~/.pi/agent/models.json`
100
+ * reports whether the file was updated
101
+
102
+ If there are no model ID changes, confirmation still offers a metadata refresh write, which might have changed as well.
103
+
104
+ After a successful write, run `/reload`.
96
105
 
97
106
  ```text
98
107
  /requesty-discover --dry-run
99
108
  ```
100
109
 
101
- This will not overwrite the `models.json` file, but will do everything else, including the health checks.
110
+ Same discovery and health checks, but never prompts to write and leaves `models.json` unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-requesty-discover",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Discover available Requesty models for the Pi Coding Agent",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,6 +2,7 @@ import type { ProviderModelConfig } from '@earendil-works/pi-coding-agent'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { getEnv, type Env } from './env'
5
+ import { formatModelsDiffSummary, type ModelsDiff } from './models-json'
5
6
 
6
7
  const HEALTH_CHECK_CONCURRENCY = 10
7
8
  const HEALTH_CHECK_TIMEOUT_MS = 15_000
@@ -98,7 +99,12 @@ export function formatHealthSummary(results: HealthCheckResult[]): string {
98
99
  return `Health check: ${passed.length} OK, ${failed.length} failed:\n${failedModels}\n`
99
100
  }
100
101
 
101
- export function writeHealthCheckLog(provider: Provider, results: HealthCheckResult[], envConfig: Env = getEnv()): void {
102
+ export function writeHealthCheckLog(
103
+ provider: Provider,
104
+ results: HealthCheckResult[],
105
+ diff: ModelsDiff,
106
+ envConfig: Env = getEnv(),
107
+ ): void {
102
108
  const passed = results.filter(r => r.ok)
103
109
  const failed = results.filter(r => !r.ok)
104
110
  const lines = [
@@ -110,6 +116,8 @@ export function writeHealthCheckLog(provider: Provider, results: HealthCheckResu
110
116
  `Passed: ${passed.length}`,
111
117
  `Failed: ${failed.length}`,
112
118
  '',
119
+ formatModelsDiffSummary(diff),
120
+ '',
113
121
  ]
114
122
 
115
123
  if (failed.length === 0) {
package/src/index.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { ExtensionAPI, ExtensionCommandContext, ProviderModelConfig } from '@earendil-works/pi-coding-agent'
2
- import { getEnv } from './env'
3
- import { getRequestyConfig, updateModelsJson } from './models-json'
2
+ import { type Env, getEnv } from './env'
3
+ import {
4
+ diffModels,
5
+ formatModelsDiffSummary,
6
+ getRequestyConfig,
7
+ type ModelsDiff,
8
+ updateModelsJson,
9
+ } from './models-json'
4
10
  import { discoverModels } from './requesty-api'
5
11
  import { checkModels, formatHealthSummary, writeHealthCheckLog } from './health-check'
6
12
  import { RequestyStatusLoader } from './ui/requesty-status-loader.ts'
@@ -20,102 +26,214 @@ export type Notifier = {
20
26
  notify(message: string, level: NotificationLevel): void
21
27
  }
22
28
 
29
+ export type Confirmer = {
30
+ confirm(title: string, message: string): Promise<boolean>
31
+ }
32
+
23
33
  export type StatusReporter = {
24
34
  set(message: string): void
25
- clear(): void
26
35
  }
27
36
 
37
+ type ModelsJsonData = ReturnType<typeof getRequestyConfig>['data']
38
+
39
+ type DiscoveryEvaluation = {
40
+ dryRun: boolean
41
+ modelCount: number
42
+ failedCount: number
43
+ passing: ProviderModelConfig[]
44
+ diff: ModelsDiff
45
+ healthCheckSummary: string
46
+ logNote: string
47
+ data: ModelsJsonData
48
+ env: Env
49
+ }
50
+
51
+ type EvaluationResult =
52
+ | {
53
+ ok: true
54
+ value: DiscoveryEvaluation
55
+ }
56
+ | { ok: false; error: unknown }
57
+
28
58
  // noinspection JSUnusedGlobalSymbols
29
59
  export default function (pi: ExtensionAPI) {
30
60
  pi.registerCommand(COMMAND_NAME, {
31
61
  description: 'Dynamically discover Requesty models, run health checks, and update the local models.json.',
32
62
  getArgumentCompletions,
33
63
  handler: async (args, ctx) => {
34
- await runWithStatusUi(ctx, 'Discovering models...', (status, notifier) => runCommand(args, status, notifier))
64
+ await runDiscoveryWorkflow(ctx, args)
35
65
  },
36
66
  })
37
67
  }
38
68
 
69
+ export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, args: string) {
70
+ // Interactive TUI command; no print/json/rpc path.
71
+ if (ctx.mode !== 'tui') {
72
+ return
73
+ }
74
+
75
+ const notifier = createUiNotifier(ctx)
76
+ const confirmer = createUiConfirmer(ctx)
77
+
78
+ // Phase A: progress UI only. Loader must close before confirm (Phase B).
79
+ // Errors are wrapped because ctx.ui.custom resolves via done() and does not reject.
80
+ const evaluationResult: EvaluationResult = await runWithStatusUi(ctx, 'Discovering models...', async status => {
81
+ try {
82
+ const evaluation = await evaluateDiscovery(args, status)
83
+ return { ok: true, value: evaluation }
84
+ } catch (error) {
85
+ return { ok: false, error }
86
+ }
87
+ })
88
+
89
+ if (!evaluationResult.ok) {
90
+ notifier.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
91
+ return
92
+ }
93
+
94
+ // Phases B + C: decide, optional write, final notify — outside the loader.
95
+ await finalizeDiscovery(evaluationResult.value, confirmer, notifier)
96
+ }
97
+
39
98
  async function runWithStatusUi<T>(
40
99
  ctx: ExtensionCommandContext,
41
100
  initialMessage: string,
42
- fn: (status: StatusReporter, notifier: Notifier) => Promise<T>,
101
+ fn: (status: StatusReporter) => Promise<T>,
43
102
  ): Promise<T> {
44
- if (ctx.mode !== 'tui') {
45
- return fn(createNoopStatusReporter(), createNoopNotifier())
46
- }
47
-
48
103
  return ctx.ui.custom<T>((_tui, theme, _kb, done) => {
49
104
  const loader = new RequestyStatusLoader(_tui, theme, initialMessage)
50
- const notifier = createUiNotifier(ctx)
51
105
  const status = createLoaderStatusReporter(loader)
52
106
  void Promise.resolve()
53
- .then(() => fn(status, notifier))
107
+ .then(() => fn(status))
54
108
  .then(done)
55
109
  .catch(done)
56
110
  return loader
57
111
  })
58
112
  }
59
113
 
60
- export async function runCommand(args: string, status: StatusReporter, notifier: Notifier): Promise<void> {
114
+ function formatDiscoveryFailure(error: unknown): string {
115
+ const detail = error instanceof Error ? error.message : String(error)
116
+ return `Discovery failed: ${detail}`
117
+ }
118
+
119
+ async function evaluateDiscovery(args: string, status: StatusReporter): Promise<DiscoveryEvaluation> {
61
120
  status.set('Discovering Requesty models...')
62
- const parts = args.split(' ')
63
- const dryRun = parts.includes(DRY_RUN_ARG)
64
- if (dryRun) {
65
- notifier.notify('running in dry mode, no changes will be done', 'info')
121
+ const dryRun = args.split(' ').includes(DRY_RUN_ARG)
122
+
123
+ const env = getEnv()
124
+ const { data, provider, existingModelIds } = getRequestyConfig(env)
125
+ const models = await discoverModels(provider)
126
+ const modelsMap = new Map(models.map(m => [m.id, m]))
127
+
128
+ let diff: ModelsDiff
129
+ let failedCount = 0
130
+ let passing: ProviderModelConfig[]
131
+ let logNote = ''
132
+ let healthCheckSummary = ''
133
+
134
+ if (env.health_check_mode !== 'off') {
135
+ status.set(`Checking models 0/${models.length}...`)
136
+ const healthResults = await checkModels(provider, models, env.health_check_mode === 'full', {
137
+ onProgress: ({ completed, total }) => {
138
+ status.set(`Checking models ${completed}/${total}...`)
139
+ },
140
+ })
141
+ const sortedResults = healthResults.toSorted((a, b) => a.modelId.localeCompare(b.modelId))
142
+ failedCount = sortedResults.filter(r => !r.ok).length
143
+ passing = sortedResults.flatMap(r => {
144
+ const model = modelsMap.get(r.modelId)
145
+ return r.ok && model ? [model] : []
146
+ })
147
+ diff = diffModels(existingModelIds, passing)
148
+ healthCheckSummary = formatHealthSummary(sortedResults)
149
+ writeHealthCheckLog(provider, sortedResults, diff, env)
150
+ logNote = `Full health check log: ${env.health_check_log_path}\n`
151
+ } else {
152
+ passing = models
153
+ diff = diffModels(existingModelIds, passing)
66
154
  }
67
155
 
68
- try {
69
- const env = getEnv()
70
- const { data, provider } = getRequestyConfig(env)
71
- const models = await discoverModels(provider)
72
- const modelsMap = new Map(models.map(m => [m.id, m]))
73
-
74
- let failed = []
75
- let passing: ProviderModelConfig[]
76
- let logNote = ''
77
- let healthCheckSummary = ''
78
-
79
- if (env.health_check_mode !== 'off') {
80
- status.set(`Checking models 0/${models.length}...`)
81
- const healthResults = await checkModels(provider, models, env.health_check_mode === 'full', {
82
- onProgress: ({ completed, total }) => {
83
- status.set(`Checking models ${completed}/${total}...`)
84
- },
85
- })
86
- const sortedResults = healthResults.toSorted((a, b) => a.modelId.localeCompare(b.modelId))
87
- failed = sortedResults.filter(r => !r.ok)
88
- passing = sortedResults.flatMap(r => {
89
- const model = modelsMap.get(r.modelId)
90
- return r.ok && model ? [model] : []
91
- })
92
- healthCheckSummary = formatHealthSummary(sortedResults)
93
- writeHealthCheckLog(provider, sortedResults, env)
94
- logNote = `Full health check log: ${env.health_check_log_path}\n`
95
- } else {
96
- passing = models
97
- }
156
+ return {
157
+ dryRun,
158
+ modelCount: models.length,
159
+ failedCount,
160
+ passing,
161
+ diff,
162
+ healthCheckSummary,
163
+ logNote,
164
+ data,
165
+ env,
166
+ }
167
+ }
98
168
 
99
- const shouldUpdate = passing.length > 0 && !dryRun
100
- if (shouldUpdate) {
101
- updateModelsJson(data, passing, env)
102
- }
169
+ async function finalizeDiscovery(
170
+ evaluation: DiscoveryEvaluation,
171
+ confirmer: Confirmer,
172
+ notifier: Notifier,
173
+ ): Promise<void> {
174
+ const level = notificationLevel(evaluation)
175
+ const summary = buildDiscoverySummary(evaluation)
176
+
177
+ // Always surface the discovery result first (toast styling), then decide.
178
+ if (evaluation.dryRun) {
179
+ notifier.notify(
180
+ `${summary}
181
+ Dry run: left models.json unchanged.`,
182
+ level,
183
+ )
184
+ return
185
+ }
186
+
187
+ if (evaluation.passing.length === 0) {
188
+ notifier.notify(
189
+ `${summary}
190
+ Left models.json unchanged.`,
191
+ level,
192
+ )
193
+ return
194
+ }
195
+
196
+ notifier.notify(summary, level)
197
+ const { title, message } = buildConfirmPrompt(evaluation)
198
+ const shouldUpdate = await confirmer.confirm(title, message)
199
+ if (shouldUpdate) {
200
+ updateModelsJson(evaluation.data, evaluation.passing, evaluation.env)
201
+ notifier.notify('Updated models.json. Run /reload to use the changes.', 'info')
202
+ return
203
+ }
103
204
 
104
- const writeNote = shouldUpdate ? 'Run /reload to use models.json changes.' : 'models.json was not updated.'
105
- const message = `Discovered ${models.length} Requesty model(s).\n${healthCheckSummary}${logNote}${writeNote}`
205
+ notifier.notify('Left models.json unchanged.', 'info')
206
+ }
106
207
 
107
- if (failed.length === 0) {
108
- notifier.notify(message, 'info')
109
- } else if (failed.length < models.length) {
110
- notifier.notify(message, 'warning')
111
- } else {
112
- notifier.notify(message, 'error')
208
+ function buildDiscoverySummary(evaluation: DiscoveryEvaluation): string {
209
+ return [
210
+ `Discovered ${evaluation.modelCount} Requesty model(s).`,
211
+ evaluation.healthCheckSummary.trimEnd(),
212
+ formatModelsDiffSummary(evaluation.diff),
213
+ evaluation.logNote.trimEnd(),
214
+ ]
215
+ .filter(part => part.length > 0)
216
+ .join('\n')
217
+ }
218
+
219
+ function buildConfirmPrompt(evaluation: DiscoveryEvaluation): { title: string; message: string } {
220
+ const hasIdChanges = evaluation.diff.added.length > 0 || evaluation.diff.removed.length > 0
221
+ if (hasIdChanges) {
222
+ return {
223
+ title: `Write ${evaluation.passing.length} model(s)?`,
224
+ message: 'Update models.json with the discovery result above.',
113
225
  }
114
- } catch (error) {
115
- notifier.notify(`Discovery failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
116
- } finally {
117
- status.clear()
118
226
  }
227
+ return {
228
+ title: 'Refresh models.json?',
229
+ message: 'No model ID changes. Rewrite the file to refresh metadata anyway?',
230
+ }
231
+ }
232
+
233
+ function notificationLevel(evaluation: DiscoveryEvaluation): NotificationLevel {
234
+ if (evaluation.failedCount === 0) return 'info'
235
+ if (evaluation.failedCount < evaluation.modelCount) return 'warning'
236
+ return 'error'
119
237
  }
120
238
 
121
239
  function getArgumentCompletions(prefix: string): AutocompleteItem[] {
@@ -123,37 +241,26 @@ function getArgumentCompletions(prefix: string): AutocompleteItem[] {
123
241
  {
124
242
  value: DRY_RUN_ARG,
125
243
  label: DRY_RUN_ARG,
126
- description: 'Preview without writing into the new model.json file',
244
+ description: 'Preview discovery without offering to write models.json',
127
245
  },
128
246
  ]
129
247
  if (!prefix) return options
130
248
  return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
131
249
  }
132
250
 
133
- function createNoopStatusReporter(): StatusReporter {
134
- return {
135
- set() {
136
- // Status output is only rendered in TUI mode.
137
- },
138
- clear() {
139
- // Status output is only rendered in TUI mode.
140
- },
141
- }
142
- }
143
-
144
- function createNoopNotifier(): Notifier {
251
+ function createUiNotifier(ctx: ExtensionCommandContext): Notifier {
145
252
  return {
146
- notify() {
147
- // Notifications are only rendered in TUI mode.
253
+ notify(message: string, level?: NotificationLevel) {
254
+ const prefixedMessage = `${COMMAND_NAME}: ${message}`
255
+ ctx.ui.notify(prefixedMessage, level)
148
256
  },
149
257
  }
150
258
  }
151
259
 
152
- function createUiNotifier(ctx: ExtensionCommandContext): Notifier {
260
+ function createUiConfirmer(ctx: ExtensionCommandContext): Confirmer {
153
261
  return {
154
- notify(message: string, level?: NotificationLevel) {
155
- const prefixedMessage = `${COMMAND_NAME}: ${message}`
156
- ctx.ui.notify(prefixedMessage, level)
262
+ confirm(title, message) {
263
+ return ctx.ui.confirm(title, message)
157
264
  },
158
265
  }
159
266
  }
@@ -163,8 +270,5 @@ function createLoaderStatusReporter(loader: RequestyStatusLoader): StatusReporte
163
270
  set(message: string) {
164
271
  loader.setMessage(message)
165
272
  },
166
- clear() {
167
- // Nothing to clear: the loader closes when ctx.ui.custom() resolves.
168
- },
169
273
  }
170
274
  }
@@ -12,6 +12,7 @@ const ProviderSchema = z
12
12
  name: z.string().optional(),
13
13
  baseUrl: z.string().optional(),
14
14
  apiKey: z.string().optional(),
15
+ models: z.array(z.object({ id: z.string() }).catchall(z.unknown())).optional(),
15
16
  })
16
17
  .catchall(z.unknown())
17
18
 
@@ -33,6 +34,12 @@ type RequestyProvider = ModelsJsonProvider & {
33
34
  type RequestyConfig = {
34
35
  data: ModelsJson
35
36
  provider: RequestyProvider
37
+ existingModelIds: string[]
38
+ }
39
+
40
+ export type ModelsDiff = {
41
+ added: string[]
42
+ removed: string[]
36
43
  }
37
44
 
38
45
  export function getRequestyConfig(envConfig: Env = getEnv()): RequestyConfig {
@@ -46,6 +53,7 @@ export function getRequestyConfig(envConfig: Env = getEnv()): RequestyConfig {
46
53
  const apiKey = envConfig.requesty_api_key
47
54
  return {
48
55
  data,
56
+ existingModelIds: (provider.models ?? []).map(m => m.id),
49
57
  provider: {
50
58
  ...provider,
51
59
  name: nonEmptyString(provider.name) ?? DEFAULT_NAME,
@@ -55,6 +63,24 @@ export function getRequestyConfig(envConfig: Env = getEnv()): RequestyConfig {
55
63
  }
56
64
  }
57
65
 
66
+ export function diffModels(previousIds: string[], nextModels: ProviderModelConfig[]): ModelsDiff {
67
+ const previous = new Set(previousIds)
68
+ const next = new Set(nextModels.map(m => m.id))
69
+ return {
70
+ added: [...next].filter(id => !previous.has(id)).toSorted(),
71
+ removed: [...previous].filter(id => !next.has(id)).toSorted(),
72
+ }
73
+ }
74
+
75
+ export function formatModelsDiffSummary(diff: ModelsDiff): string {
76
+ return [
77
+ diff.added.length === 0 ? 'No added models.' : 'Added models:',
78
+ ...diff.added.map(id => `- ${id}`),
79
+ diff.removed.length === 0 ? 'No removed models.' : 'Removed models:',
80
+ ...diff.removed.map(id => `- ${id}`),
81
+ ].join('\n')
82
+ }
83
+
58
84
  export function updateModelsJson(data: ModelsJson, models: ProviderModelConfig[], envConfig: Env = getEnv()): void {
59
85
  const provider = data.providers[envConfig.provider_id]
60
86
  data.providers[envConfig.provider_id] = {