pi-requesty-discover 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,22 @@ token/model state. No "extra footer" API exists.
14
14
  Decision: keep `setStatus`. Revisit when the line overflows visibly with other
15
15
  extensions' statuses, or if the usage string grows past a typical width.
16
16
 
17
+ == Hand-rolled `Try<T>` instead of an effects library
18
+
19
+ Context: env loading and discovery evaluation both need a caught-error result value
20
+ (`{ ok: true, value } | { ok: false, error }`). Libraries like Effect ship an
21
+ industrial `Either`/`Effect.try` for exactly this.
22
+
23
+ Why not: Effect is a runtime, a generator DSL, and a worldview — not a utility.
24
+ Two call sites in a Pi extension don't justify the dependency weight or the
25
+ learning curve for contributors. The hand-rolled version is ~10 lines
26
+ (`Try<T>`, `runCatching`, `runCatchingAsync`, named after Kotlin's `Result`),
27
+ lives in `src/index.ts`, and documents intent just as well.
28
+
29
+ Decision: keep the hand-rolled `Try<T>`. No `monads.ts` module either — rule of
30
+ three. Revisit if the extension ever needs typed errors, retries, or resource
31
+ scoping; that's the day Effect earns its place.
32
+
17
33
  == Not a provider — a models.json-writing extension
18
34
 
19
35
  Context: Requesty already works as a Pi provider via `models.json`. The temptation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-requesty-discover",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "Discover available Requesty models for the Pi Coding Agent",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.ts CHANGED
@@ -51,43 +51,42 @@ type DiscoveryEvaluation = {
51
51
  healthCheckSummary: string
52
52
  logNote: string
53
53
  data: ModelsJsonData
54
- env: Env
55
54
  }
56
55
 
57
- type EvaluationResult =
58
- | {
59
- ok: true
60
- value: DiscoveryEvaluation
61
- }
62
- | { ok: false; error: unknown }
56
+ export type Try<T> = { ok: true; value: T } | { ok: false; error: unknown }
63
57
 
64
- // A per-instance token suppresses stale writes when turns overlap
58
+ // token suppresses stale writes when turns overlap
65
59
  let latestToken: object = {}
66
60
 
67
61
  // noinspection JSUnusedGlobalSymbols
68
62
  export default function (pi: ExtensionAPI) {
63
+ const env = runCatching(() => getEnv())
64
+
69
65
  pi.registerCommand(COMMAND_NAME, {
70
66
  description: 'Dynamically discover Requesty models, run health checks, and update the local models.json.',
71
67
  getArgumentCompletions,
72
68
  handler: async (args, ctx) => {
73
- await runDiscoveryWorkflow(ctx, args)
69
+ await runDiscoveryWorkflow(ctx, env, args)
74
70
  },
75
71
  })
76
72
 
77
73
  pi.on('turn_end', (_event, ctx) => {
78
- void updateUsageStatus(ctx)
74
+ void updateUsageStatus(ctx, env)
79
75
  })
80
76
 
81
77
  pi.on('session_start', (_event, ctx) => {
82
- void updateUsageStatus(ctx)
78
+ complainOnBrokenEnv(ctx, env)
79
+ void updateUsageStatus(ctx, env)
83
80
  })
84
81
 
85
82
  pi.on('model_select', (_event, ctx) => {
86
- void updateUsageStatus(ctx)
83
+ void updateUsageStatus(ctx, env)
87
84
  })
88
85
  }
89
86
 
90
- export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, args: string) {
87
+ export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, env: Try<Env>, args: string) {
88
+ complainOnBrokenEnv(ctx, env)
89
+ if (!env.ok) return
91
90
  // Interactive TUI command; no print/json/rpc path.
92
91
  if (ctx.mode !== 'tui') {
93
92
  return
@@ -98,14 +97,11 @@ export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, args: s
98
97
 
99
98
  // Phase A: progress UI only. Loader must close before confirm (Phase B).
100
99
  // Errors are wrapped because ctx.ui.custom resolves via done() and does not reject.
101
- const evaluationResult: EvaluationResult = await runWithStatusUi(ctx, 'Discovering models...', async status => {
102
- try {
103
- const evaluation = await evaluateDiscovery(args, status)
104
- return { ok: true, value: evaluation }
105
- } catch (error) {
106
- return { ok: false, error }
107
- }
108
- })
100
+ const evaluationResult: Try<DiscoveryEvaluation> = await runWithStatusUi(
101
+ ctx,
102
+ 'Discovering models...',
103
+ async status => await runCatchingAsync(() => evaluateDiscovery(args, env.value, status)),
104
+ )
109
105
 
110
106
  if (!evaluationResult.ok) {
111
107
  notifier.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
@@ -113,7 +109,12 @@ export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, args: s
113
109
  }
114
110
 
115
111
  // Phases B + C: decide, optional write, final notify — outside the loader.
116
- await finalizeDiscovery(evaluationResult.value, confirmer, notifier)
112
+ await finalizeDiscovery(evaluationResult.value, confirmer, notifier, env.value)
113
+ }
114
+
115
+ function formatDiscoveryFailure(error: unknown): string {
116
+ const detail = formatError(error)
117
+ return `Discovery failed: ${detail}`
117
118
  }
118
119
 
119
120
  async function runWithStatusUi<T>(
@@ -132,16 +133,10 @@ async function runWithStatusUi<T>(
132
133
  })
133
134
  }
134
135
 
135
- function formatDiscoveryFailure(error: unknown): string {
136
- const detail = error instanceof Error ? error.message : String(error)
137
- return `Discovery failed: ${detail}`
138
- }
139
-
140
- async function evaluateDiscovery(args: string, status: StatusReporter): Promise<DiscoveryEvaluation> {
136
+ async function evaluateDiscovery(args: string, env: Env, status: StatusReporter): Promise<DiscoveryEvaluation> {
141
137
  status.set('Discovering Requesty models...')
142
138
  const dryRun = args.split(' ').includes(DRY_RUN_ARG)
143
139
 
144
- const env = getEnv()
145
140
  const { data, provider, existingModelIds } = getRequestyConfig(env)
146
141
  const models = await discoverModels(provider)
147
142
  const modelsMap = new Map(models.map(m => [m.id, m]))
@@ -183,7 +178,6 @@ async function evaluateDiscovery(args: string, status: StatusReporter): Promise<
183
178
  healthCheckSummary,
184
179
  logNote,
185
180
  data,
186
- env,
187
181
  }
188
182
  }
189
183
 
@@ -191,6 +185,7 @@ async function finalizeDiscovery(
191
185
  evaluation: DiscoveryEvaluation,
192
186
  confirmer: Confirmer,
193
187
  notifier: Notifier,
188
+ env: Env,
194
189
  ): Promise<void> {
195
190
  const level = notificationLevel(evaluation)
196
191
  const summary = buildDiscoverySummary(evaluation)
@@ -218,7 +213,7 @@ Left models.json unchanged.`,
218
213
  const { title, message } = buildConfirmPrompt(evaluation)
219
214
  const shouldUpdate = await confirmer.confirm(title, message)
220
215
  if (shouldUpdate) {
221
- updateModelsJson(evaluation.data, evaluation.passing, evaluation.env)
216
+ updateModelsJson(evaluation.data, evaluation.passing, env)
222
217
  notifier.notify('Updated models.json. Run /reload to use the changes.', 'info')
223
218
  return
224
219
  }
@@ -269,7 +264,7 @@ function getArgumentCompletions(prefix: string): AutocompleteItem[] {
269
264
  return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
270
265
  }
271
266
 
272
- function createUiNotifier(ctx: ExtensionCommandContext): Notifier {
267
+ function createUiNotifier(ctx: ExtensionContext): Notifier {
273
268
  return {
274
269
  notify(message: string, level?: NotificationLevel) {
275
270
  const prefixedMessage = `${COMMAND_NAME}: ${message}`
@@ -278,7 +273,7 @@ function createUiNotifier(ctx: ExtensionCommandContext): Notifier {
278
273
  }
279
274
  }
280
275
 
281
- function createUiConfirmer(ctx: ExtensionCommandContext): Confirmer {
276
+ function createUiConfirmer(ctx: ExtensionContext): Confirmer {
282
277
  return {
283
278
  confirm(title, message) {
284
279
  return ctx.ui.confirm(title, message)
@@ -294,19 +289,18 @@ function createLoaderStatusReporter(loader: RequestyStatusLoader): StatusReporte
294
289
  }
295
290
  }
296
291
 
297
- export async function updateUsageStatus(ctx: ExtensionContext): Promise<void> {
292
+ async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<void> {
293
+ if (!env.ok) return
298
294
  if (!ctx.hasUI) return // no footer to write to (print/json mode): skip the wasted fetch
299
295
  const token: object = {}
300
296
  latestToken = token
301
- const providerId = getRequestyProviderId()
302
- if (providerId === undefined) return // not configured for Requesty: nothing to do
303
- const shouldClear = ctx.model?.provider !== providerId
297
+ const shouldClear = ctx.model?.provider !== env.value.provider_id
304
298
  try {
305
299
  if (shouldClear) {
306
300
  ctx.ui.setStatus(USAGE_STATUS_KEY, undefined)
307
301
  return
308
302
  }
309
- const info = await fetchUsageStatus()
303
+ const info = await fetchUsageStatus(env.value)
310
304
  if (latestToken !== token) return
311
305
  ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageStatus(info))
312
306
  } catch {
@@ -314,15 +308,6 @@ export async function updateUsageStatus(ctx: ExtensionContext): Promise<void> {
314
308
  }
315
309
  }
316
310
 
317
- /** Returns the configured Requesty provider id, or undefined when Requesty is not configured. */
318
- function getRequestyProviderId(): string | undefined {
319
- try {
320
- return getEnv().provider_id
321
- } catch {
322
- return undefined
323
- }
324
- }
325
-
326
311
  export function formatUsageStatus(info: ApiKeyInfo): string {
327
312
  const spend = `$${info.monthlySpend.toFixed(2)}`
328
313
  if (info.monthlyLimit <= 0) {
@@ -333,8 +318,49 @@ export function formatUsageStatus(info: ApiKeyInfo): string {
333
318
  return `Requesty Usage (${info.name}): ${spend}/${limit} (${percent}%)`
334
319
  }
335
320
 
336
- async function fetchUsageStatus(): Promise<ApiKeyInfo> {
337
- const env = getEnv()
321
+ let lastFetched: { value: ApiKeyInfo; time: Date } | undefined
322
+
323
+ async function fetchUsageStatus(env: Env): Promise<ApiKeyInfo> {
324
+ const now = new Date()
325
+ if (lastFetched?.time && now.getTime() - lastFetched.time.getTime() < 2000) {
326
+ return lastFetched.value
327
+ }
338
328
  const { provider } = getRequestyConfig(env)
339
- return await fetchApiUsage(provider)
329
+ const value = await fetchApiUsage(provider)
330
+ lastFetched = { value, time: new Date() }
331
+ return value
332
+ }
333
+
334
+ function formatError(error: unknown) {
335
+ return error instanceof Error ? error.message : String(error)
336
+ }
337
+
338
+ /**
339
+ * For testing purposes, reset the cache after each run
340
+ */
341
+ export function resetUsageStatusCache(): void {
342
+ lastFetched = undefined
343
+ }
344
+
345
+ function runCatching<T>(fn: () => T): Try<T> {
346
+ try {
347
+ return { ok: true, value: fn() }
348
+ } catch (error) {
349
+ return { ok: false, error }
350
+ }
351
+ }
352
+
353
+ async function runCatchingAsync<T>(fn: () => Promise<T>): Promise<Try<T>> {
354
+ try {
355
+ return { ok: true, value: await fn() }
356
+ } catch (error) {
357
+ return { ok: false, error }
358
+ }
359
+ }
360
+
361
+ function complainOnBrokenEnv(ctx: ExtensionContext, env: Try<Env>) {
362
+ if (!env.ok) {
363
+ const notifier = createUiNotifier(ctx)
364
+ notifier.notify(`failed to load env: ${formatError(env.error)}`, 'error')
365
+ }
340
366
  }
@@ -25,7 +25,7 @@ const ModelsJsonSchema = z
25
25
  type ModelsJson = z.infer<typeof ModelsJsonSchema>
26
26
  type ModelsJsonProvider = z.infer<typeof ProviderSchema>
27
27
 
28
- type RequestyProvider = ModelsJsonProvider & {
28
+ export type RequestyProvider = ModelsJsonProvider & {
29
29
  name: string
30
30
  baseUrl: string
31
31
  apiKey: string
@@ -31,9 +31,14 @@ type Provider = {
31
31
  apiKey: string
32
32
  }
33
33
 
34
- export async function discoverModels(provider: Provider): Promise<ProviderModelConfig[]> {
34
+ export async function discoverModels(
35
+ provider: Provider,
36
+ options: { timeoutMs?: number } = {},
37
+ ): Promise<ProviderModelConfig[]> {
38
+ const timeoutMs = options.timeoutMs ?? 5_000
35
39
  const response = await fetch(`${provider.baseUrl}/models`, {
36
40
  headers: { Authorization: `Bearer ${provider.apiKey}` },
41
+ signal: AbortSignal.timeout(timeoutMs),
37
42
  })
38
43
 
39
44
  if (!response.ok) {
@@ -82,10 +87,12 @@ const ApiKeyInfoSchema = z
82
87
 
83
88
  export type ApiKeyInfo = z.infer<typeof ApiKeyInfoSchema>
84
89
 
85
- export async function fetchApiUsage(provider: Provider): Promise<ApiKeyInfo> {
90
+ export async function fetchApiUsage(provider: Provider, options: { timeoutMs?: number } = {}): Promise<ApiKeyInfo> {
86
91
  const url = `${REQUESTY_MANAGE_URL}/apikey/self`
92
+ const timeoutMs = options.timeoutMs ?? 2_000
87
93
  const response = await fetch(url, {
88
94
  headers: { Authorization: `Bearer ${provider.apiKey}` },
95
+ signal: AbortSignal.timeout(timeoutMs),
89
96
  })
90
97
 
91
98
  if (!response.ok) {