pi-requesty-discover 2.1.1 → 3.0.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.
@@ -1,7 +1,7 @@
1
1
  import type { ProviderModelConfig } from '@earendil-works/pi-coding-agent'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
- import { getEnv, type Env } from './env'
4
+ import { type Env, getEnv } from './env'
5
5
  import { formatModelsDiffSummary, type ModelsDiff } from './models-json'
6
6
 
7
7
  const HEALTH_CHECK_CONCURRENCY = 10
@@ -23,9 +23,11 @@ export type Provider = {
23
23
  apiKey: string
24
24
  }
25
25
 
26
+ export type HealthCheckStatus = 'ok' | 'warning' | 'error'
27
+
26
28
  export type HealthCheckResult = {
27
29
  modelId: string
28
- ok: boolean
30
+ status: HealthCheckStatus
29
31
  latencyMs: number
30
32
  error?: string
31
33
  }
@@ -38,6 +40,11 @@ export type HealthCheckProgress = {
38
40
  modelId: string
39
41
  }
40
42
 
43
+ export type HealthCheckLogContext = {
44
+ providerId: string
45
+ bannedModels: string[]
46
+ }
47
+
41
48
  export type HealthCheckOptions = {
42
49
  concurrency?: number
43
50
  timeoutMs?: number
@@ -87,53 +94,73 @@ export async function checkModels(
87
94
  }
88
95
 
89
96
  export function formatHealthSummary(results: HealthCheckResult[]): string {
90
- const passed = results.filter(r => r.ok)
91
- const failed = results.filter(r => !r.ok)
92
-
93
- if (failed.length === 0) {
94
- return `Health check: all ${passed.length} OK.`
95
- }
97
+ const passed = results.filter(r => r.status === 'ok')
98
+ const warned = results.filter(r => r.status === 'warning')
99
+ const failed = results.filter(r => r.status === 'error')
96
100
 
97
- const failedModels = failed.map(r => `- ${r.modelId}`).join('\n')
101
+ const parts = [`${passed.length} OK`]
102
+ if (warned.length > 0) parts.push(`${warned.length} warning${warned.length === 1 ? '' : 's'}`)
103
+ if (failed.length > 0) parts.push(`${failed.length} failed`)
98
104
 
99
- return `Health check: ${passed.length} OK, ${failed.length} failed:\n${failedModels}\n`
105
+ return `Health check: ${parts.join(', ')}.`
100
106
  }
101
107
 
102
108
  export function writeHealthCheckLog(
103
109
  provider: Provider,
104
110
  results: HealthCheckResult[],
105
111
  diff: ModelsDiff,
112
+ context: HealthCheckLogContext,
106
113
  envConfig: Env = getEnv(),
107
114
  ): void {
108
- const passed = results.filter(r => r.ok)
109
- const failed = results.filter(r => !r.ok)
115
+ const passed = results.filter(r => r.status === 'ok')
116
+ const warned = results.filter(r => r.status === 'warning')
117
+ const failed = results.filter(r => r.status === 'error')
110
118
  const lines = [
111
119
  `Requesty health check log`,
112
120
  `Timestamp: ${new Date().toISOString()}`,
113
- `Provider: ${envConfig.provider_id}`,
121
+ `Provider: ${context.providerId}`,
114
122
  `Base URL: ${provider.baseUrl}`,
115
123
  `Total: ${results.length}`,
116
124
  `Passed: ${passed.length}`,
125
+ `Warnings: ${warned.length}`,
117
126
  `Failed: ${failed.length}`,
127
+ `Banned: ${context.bannedModels.length}`,
128
+ ...context.bannedModels.map(id => `- ${id}`),
118
129
  '',
119
130
  formatModelsDiffSummary(diff),
120
131
  '',
121
132
  ]
122
133
 
123
- if (failed.length === 0) {
134
+ if (warned.length === 0 && failed.length === 0) {
124
135
  lines.push('No failed models.')
125
136
  } else {
126
- lines.push('Failed models:', '')
127
- for (const result of failed) {
128
- lines.push(
129
- `Model: ${result.modelId}`,
130
- `Latency: ${result.latencyMs}ms`,
131
- 'Error:',
132
- result.error || 'Unknown error',
133
- '',
134
- '---',
135
- '',
136
- )
137
+ if (warned.length > 0) {
138
+ lines.push('Models with warnings:', '')
139
+ for (const result of warned) {
140
+ lines.push(
141
+ `Model: ${result.modelId}`,
142
+ `Latency: ${result.latencyMs}ms`,
143
+ 'Error:',
144
+ result.error || 'Unknown error',
145
+ '',
146
+ '---',
147
+ '',
148
+ )
149
+ }
150
+ }
151
+ if (failed.length > 0) {
152
+ lines.push('Failed models:', '')
153
+ for (const result of failed) {
154
+ lines.push(
155
+ `Model: ${result.modelId}`,
156
+ `Latency: ${result.latencyMs}ms`,
157
+ 'Error:',
158
+ result.error || 'Unknown error',
159
+ '',
160
+ '---',
161
+ '',
162
+ )
163
+ }
137
164
  }
138
165
  }
139
166
 
@@ -166,8 +193,10 @@ export async function postChatCompletion(
166
193
 
167
194
  if (!response.ok) {
168
195
  const text = await response.text().catch(() => '')
196
+ // rate limits are transient - a warning, not an error
197
+ const status: HealthCheckStatus = response.status === 429 ? 'warning' : 'error'
169
198
  return {
170
- ok: false,
199
+ status,
171
200
  latencyMs,
172
201
  error: `HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ''}`,
173
202
  }
@@ -183,7 +212,7 @@ export async function postChatCompletion(
183
212
  }
184
213
  const attempts = attempt + 1
185
214
  return {
186
- ok: false,
215
+ status: 'error',
187
216
  latencyMs: Date.now() - start,
188
217
  error: isTimeoutError(err)
189
218
  ? `Timed out after ${attempts} attempt(s); per-attempt timeout is ${healthCheckOptions.timeoutMs / 1000}s`
@@ -192,7 +221,7 @@ export async function postChatCompletion(
192
221
  }
193
222
  }
194
223
 
195
- return { ok: false, latencyMs: Date.now() - start, error: 'Unknown error' }
224
+ return { status: 'error', latencyMs: Date.now() - start, error: 'Unknown error' }
196
225
  }
197
226
 
198
227
  async function verifyFirstStreamChunk(body: ReadableStream<Uint8Array>, start: number): Promise<ModelCheckResult> {
@@ -219,14 +248,14 @@ async function verifyFirstStreamChunk(body: ReadableStream<Uint8Array>, start: n
219
248
  try {
220
249
  const parsed = JSON.parse(payload) as { choices?: unknown }
221
250
  if (Array.isArray(parsed.choices) && parsed.choices.length > 0) {
222
- return { ok: true, latencyMs: Date.now() - start }
251
+ return { status: 'ok', latencyMs: Date.now() - start }
223
252
  }
224
253
  } catch {
225
254
  // partial JSON or non-choices chunk — keep reading
226
255
  }
227
256
  }
228
257
  }
229
- return { ok: false, latencyMs: Date.now() - start, error: 'Stream ended without content' }
258
+ return { status: 'error', latencyMs: Date.now() - start, error: 'Stream ended without content' }
230
259
  } finally {
231
260
  await reader.cancel()
232
261
  }
@@ -248,7 +277,7 @@ async function checkModel(
248
277
  options,
249
278
  )
250
279
 
251
- if (!basicResult.ok || !model.reasoning || !checkReasoning) {
280
+ if (basicResult.status !== 'ok' || !model.reasoning || !checkReasoning) {
252
281
  return basicResult
253
282
  }
254
283
 
@@ -276,16 +305,16 @@ async function checkModel(
276
305
  options,
277
306
  )
278
307
 
279
- if (!reasoningResult.ok) {
308
+ if (reasoningResult.status !== 'ok') {
280
309
  return {
281
- ok: false,
310
+ status: 'error',
282
311
  latencyMs: reasoningResult.latencyMs,
283
312
  error: `Reasoning/tool check failed: ${reasoningResult.error}`,
284
313
  }
285
314
  }
286
315
 
287
316
  return {
288
- ok: true,
317
+ status: 'ok',
289
318
  latencyMs: basicResult.latencyMs + reasoningResult.latencyMs,
290
319
  }
291
320
  }
package/src/index.ts CHANGED
@@ -1,306 +1,141 @@
1
- import {
2
- ExtensionAPI,
3
- ExtensionCommandContext,
4
- ExtensionContext,
5
- ProviderModelConfig,
6
- } from '@earendil-works/pi-coding-agent'
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
7
2
  import { type Env, getEnv } from './env'
3
+ import { type GetApiKey, getRequestyConfig } from './models-json'
4
+ import { type ApiKeyInfo, fetchApiUsage } from './requesty-api'
5
+ import { RequestyStatusLoader } from './ui/requesty-status-loader'
8
6
  import {
9
- diffModels,
10
- formatModelsDiffSummary,
11
- getRequestyConfig,
12
- type ModelsDiff,
13
- updateModelsJson,
14
- } from './models-json'
15
- import { type ApiKeyInfo, discoverModels, fetchApiUsage } from './requesty-api'
16
- import { checkModels, formatHealthSummary, writeHealthCheckLog } from './health-check'
17
- import { RequestyStatusLoader } from './ui/requesty-status-loader.ts'
7
+ type DiscoveryEvaluation,
8
+ type DiscoveryUi,
9
+ type RefreshModelsRegistry,
10
+ evaluateDiscovery,
11
+ finalizeDiscovery,
12
+ formatDiscoveryFailure,
13
+ getArgumentCompletions,
14
+ } from './discovery'
15
+ import { type DiscoverySettings, readDiscoverySettings } from './settings'
16
+ import { runCatching, runCatchingAsync, type Try } from './utils'
18
17
 
19
18
  const COMMAND_NAME = 'requesty-discover'
20
- const DRY_RUN_ARG = '--dry-run'
21
19
  export const USAGE_STATUS_KEY = 'requesty-usage'
22
20
 
23
- interface AutocompleteItem {
24
- value: string
25
- label: string
26
- description: string
27
- }
28
-
29
- export type NotificationLevel = 'info' | 'warning' | 'error'
30
-
31
- export type Notifier = {
32
- notify(message: string, level: NotificationLevel): void
33
- }
34
-
35
- export type Confirmer = {
36
- confirm(title: string, message: string): Promise<boolean>
37
- }
38
-
39
- export type StatusReporter = {
40
- set(message: string): void
41
- }
42
-
43
- type ModelsJsonData = ReturnType<typeof getRequestyConfig>['data']
44
-
45
- type DiscoveryEvaluation = {
46
- dryRun: boolean
47
- modelCount: number
48
- failedCount: number
49
- passing: ProviderModelConfig[]
50
- diff: ModelsDiff
51
- healthCheckSummary: string
52
- logNote: string
53
- data: ModelsJsonData
54
- }
55
-
56
- export type Try<T> = { ok: true; value: T } | { ok: false; error: unknown }
57
-
58
21
  // token suppresses stale writes when turns overlap
59
22
  let latestToken: object = {}
60
23
 
61
24
  // noinspection JSUnusedGlobalSymbols
62
25
  export default function (pi: ExtensionAPI) {
63
26
  const env = runCatching(() => getEnv())
27
+ if (!env.ok) {
28
+ console.error('env loading failed', env.error)
29
+ return
30
+ }
31
+ const settings = runCatching(() => readDiscoverySettings(env.value))
32
+ if (!settings.ok) {
33
+ console.error('settings loading failed', settings.error)
34
+ return
35
+ }
64
36
 
65
37
  pi.registerCommand(COMMAND_NAME, {
66
38
  description: 'Dynamically discover Requesty models, run health checks, and update the local models.json.',
67
39
  getArgumentCompletions,
68
40
  handler: async (args, ctx) => {
69
- await runDiscoveryWorkflow(ctx, env, args)
41
+ await runDiscoveryWorkflow(ctx, settings.value, env.value, args)
70
42
  },
71
43
  })
72
44
 
73
45
  pi.on('turn_end', (_event, ctx) => {
74
- void updateUsageStatus(ctx, env)
46
+ void updateUsageStatus(ctx, settings.value, env.value)
75
47
  })
76
48
 
77
49
  pi.on('session_start', (_event, ctx) => {
78
- complainOnBrokenEnv(ctx, env)
79
- void updateUsageStatus(ctx, env)
50
+ void updateUsageStatus(ctx, settings.value, env.value)
80
51
  })
81
52
 
82
53
  pi.on('model_select', (_event, ctx) => {
83
- void updateUsageStatus(ctx, env)
54
+ void updateUsageStatus(ctx, settings.value, env.value)
84
55
  })
85
56
  }
86
57
 
87
- export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, env: Try<Env>, args: string) {
88
- complainOnBrokenEnv(ctx, env)
89
- if (!env.ok) return
90
- // Interactive TUI command; no print/json/rpc path.
58
+ export async function runDiscoveryWorkflow(
59
+ ctx: ExtensionCommandContext,
60
+ settings: DiscoverySettings,
61
+ env: Env,
62
+ args: string,
63
+ ) {
64
+ const ui = ctx.mode === 'tui' ? createTuiUi(ctx) : createConsoleUi()
91
65
  if (ctx.mode !== 'tui') {
66
+ return runSilentDiscoveryWorkflow(ctx, settings, env, args, ui)
67
+ } else {
68
+ return runInteractiveDiscoverWorkflow(ctx, settings, env, args, ui)
69
+ }
70
+ }
71
+
72
+ async function runSilentDiscoveryWorkflow(
73
+ ctx: ExtensionCommandContext,
74
+ settings: DiscoverySettings,
75
+ env: Env,
76
+ args: string,
77
+ ui: DiscoveryUi,
78
+ ): Promise<void> {
79
+ const evaluationResult = await runCatchingAsync(() =>
80
+ evaluateDiscovery(args, settings, env, ui, createGetApiKey(ctx)),
81
+ )
82
+ if (!evaluationResult.ok) {
83
+ ui.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
92
84
  return
93
85
  }
94
86
 
95
- const notifier = createUiNotifier(ctx)
96
- const confirmer = createUiConfirmer(ctx)
87
+ await finalizeDiscovery(evaluationResult.value, settings, env, ui)
88
+ }
97
89
 
98
- // Phase A: progress UI only. Loader must close before confirm (Phase B).
99
- // Errors are wrapped because ctx.ui.custom resolves via done() and does not reject.
90
+ async function runInteractiveDiscoverWorkflow(
91
+ ctx: ExtensionCommandContext,
92
+ settings: DiscoverySettings,
93
+ env: Env,
94
+ args: string,
95
+ ui: DiscoveryUi,
96
+ ) {
100
97
  const evaluationResult: Try<DiscoveryEvaluation> = await runWithStatusUi(
101
98
  ctx,
102
99
  'Discovering models...',
103
- async status => await runCatchingAsync(() => evaluateDiscovery(args, env.value, status)),
100
+ async statusUi =>
101
+ await runCatchingAsync(() => evaluateDiscovery(args, settings, env, statusUi, createGetApiKey(ctx))),
104
102
  )
105
103
 
106
104
  if (!evaluationResult.ok) {
107
- notifier.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
105
+ ui.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
108
106
  return
109
107
  }
110
108
 
111
- // Phases B + C: decide, optional write, final notify — outside the loader.
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}`
109
+ await finalizeDiscovery(evaluationResult.value, settings, env, ui, createRefreshRegistry(ctx))
110
+ await updateUsageStatus(ctx, settings, env)
118
111
  }
119
112
 
120
113
  async function runWithStatusUi<T>(
121
114
  ctx: ExtensionCommandContext,
122
115
  initialMessage: string,
123
- fn: (status: StatusReporter) => Promise<T>,
116
+ fn: (statusUi: DiscoveryUi) => Promise<T>,
124
117
  ): Promise<T> {
125
- return ctx.ui.custom<T>((_tui, theme, _kb, done) => {
126
- const loader = new RequestyStatusLoader(_tui, theme, initialMessage)
127
- const status = createLoaderStatusReporter(loader)
118
+ return ctx.ui.custom<T>((tui, theme, _kb, done) => {
119
+ const loader = new RequestyStatusLoader(tui, theme, initialMessage)
128
120
  void Promise.resolve()
129
- .then(() => fn(status))
121
+ .then(() => fn(createTuiUi(ctx, loader)))
130
122
  .then(done)
131
123
  .catch(done)
132
124
  return loader
133
125
  })
134
126
  }
135
127
 
136
- async function evaluateDiscovery(args: string, env: Env, status: StatusReporter): Promise<DiscoveryEvaluation> {
137
- status.set('Discovering Requesty models...')
138
- const dryRun = args.split(' ').includes(DRY_RUN_ARG)
139
-
140
- const { data, provider, existingModelIds } = getRequestyConfig(env)
141
- const models = await discoverModels(provider)
142
- const modelsMap = new Map(models.map(m => [m.id, m]))
143
-
144
- let diff: ModelsDiff
145
- let failedCount = 0
146
- let passing: ProviderModelConfig[]
147
- let logNote = ''
148
- let healthCheckSummary = ''
149
-
150
- if (env.health_check_mode !== 'off') {
151
- status.set(`Checking models 0/${models.length}...`)
152
- const healthResults = await checkModels(provider, models, env.health_check_mode === 'full', {
153
- onProgress: ({ completed, total }) => {
154
- status.set(`Checking models ${completed}/${total}...`)
155
- },
156
- })
157
- const sortedResults = healthResults.toSorted((a, b) => a.modelId.localeCompare(b.modelId))
158
- failedCount = sortedResults.filter(r => !r.ok).length
159
- passing = sortedResults.flatMap(r => {
160
- const model = modelsMap.get(r.modelId)
161
- return r.ok && model ? [model] : []
162
- })
163
- diff = diffModels(existingModelIds, passing)
164
- healthCheckSummary = formatHealthSummary(sortedResults)
165
- writeHealthCheckLog(provider, sortedResults, diff, env)
166
- logNote = `Full health check log: ${env.health_check_log_path}\n`
167
- } else {
168
- passing = models
169
- diff = diffModels(existingModelIds, passing)
170
- }
171
-
172
- return {
173
- dryRun,
174
- modelCount: models.length,
175
- failedCount,
176
- passing,
177
- diff,
178
- healthCheckSummary,
179
- logNote,
180
- data,
181
- }
182
- }
183
-
184
- async function finalizeDiscovery(
185
- evaluation: DiscoveryEvaluation,
186
- confirmer: Confirmer,
187
- notifier: Notifier,
188
- env: Env,
189
- ): Promise<void> {
190
- const level = notificationLevel(evaluation)
191
- const summary = buildDiscoverySummary(evaluation)
192
-
193
- // Always surface the discovery result first (toast styling), then decide.
194
- if (evaluation.dryRun) {
195
- notifier.notify(
196
- `${summary}
197
- Dry run: left models.json unchanged.`,
198
- level,
199
- )
200
- return
201
- }
202
-
203
- if (evaluation.passing.length === 0) {
204
- notifier.notify(
205
- `${summary}
206
- Left models.json unchanged.`,
207
- level,
208
- )
209
- return
210
- }
211
-
212
- notifier.notify(summary, level)
213
- const { title, message } = buildConfirmPrompt(evaluation)
214
- const shouldUpdate = await confirmer.confirm(title, message)
215
- if (shouldUpdate) {
216
- updateModelsJson(evaluation.data, evaluation.passing, env)
217
- notifier.notify('Updated models.json. Run /reload to use the changes.', 'info')
218
- return
219
- }
220
-
221
- notifier.notify('Left models.json unchanged.', 'info')
222
- }
223
-
224
- function buildDiscoverySummary(evaluation: DiscoveryEvaluation): string {
225
- return [
226
- `Discovered ${evaluation.modelCount} Requesty model(s).`,
227
- evaluation.healthCheckSummary.trimEnd(),
228
- formatModelsDiffSummary(evaluation.diff),
229
- evaluation.logNote.trimEnd(),
230
- ]
231
- .filter(part => part.length > 0)
232
- .join('\n')
233
- }
234
-
235
- function buildConfirmPrompt(evaluation: DiscoveryEvaluation): { title: string; message: string } {
236
- const hasIdChanges = evaluation.diff.added.length > 0 || evaluation.diff.removed.length > 0
237
- if (hasIdChanges) {
238
- return {
239
- title: `Write ${evaluation.passing.length} model(s)?`,
240
- message: 'Update models.json with the discovery result above.',
241
- }
242
- }
243
- return {
244
- title: 'Refresh models.json?',
245
- message: 'No model ID changes. Rewrite the file to refresh metadata anyway?',
246
- }
247
- }
248
-
249
- function notificationLevel(evaluation: DiscoveryEvaluation): NotificationLevel {
250
- if (evaluation.failedCount === 0) return 'info'
251
- if (evaluation.failedCount < evaluation.modelCount) return 'warning'
252
- return 'error'
253
- }
254
-
255
- function getArgumentCompletions(prefix: string): AutocompleteItem[] {
256
- const options = [
257
- {
258
- value: DRY_RUN_ARG,
259
- label: DRY_RUN_ARG,
260
- description: 'Preview discovery without offering to write models.json',
261
- },
262
- ]
263
- if (!prefix) return options
264
- return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
265
- }
266
-
267
- function createUiNotifier(ctx: ExtensionContext): Notifier {
268
- return {
269
- notify(message: string, level?: NotificationLevel) {
270
- const prefixedMessage = `${COMMAND_NAME}: ${message}`
271
- ctx.ui.notify(prefixedMessage, level)
272
- },
273
- }
274
- }
275
-
276
- function createUiConfirmer(ctx: ExtensionContext): Confirmer {
277
- return {
278
- confirm(title, message) {
279
- return ctx.ui.confirm(title, message)
280
- },
281
- }
282
- }
283
-
284
- function createLoaderStatusReporter(loader: RequestyStatusLoader): StatusReporter {
285
- return {
286
- set(message: string) {
287
- loader.setMessage(message)
288
- },
289
- }
290
- }
291
-
292
- async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<void> {
293
- if (!env.ok) return
128
+ async function updateUsageStatus(ctx: ExtensionContext, settings: DiscoverySettings, env: Env): Promise<void> {
294
129
  if (!ctx.hasUI) return // no footer to write to (print/json mode): skip the wasted fetch
295
130
  const token: object = {}
296
131
  latestToken = token
297
- const shouldClear = ctx.model?.provider !== env.value.provider_id
132
+ const shouldClear = ctx.model?.provider !== settings.providerId
298
133
  try {
299
134
  if (shouldClear) {
300
135
  ctx.ui.setStatus(USAGE_STATUS_KEY, undefined)
301
136
  return
302
137
  }
303
- const info = await fetchUsageStatus(env.value)
138
+ const info = await fetchUsageStatus(createGetApiKey(ctx), settings, env)
304
139
  if (latestToken !== token) return
305
140
  ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageStatus(info))
306
141
  } catch {
@@ -311,30 +146,26 @@ async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<
311
146
  export function formatUsageStatus(info: ApiKeyInfo): string {
312
147
  const spend = `$${info.monthlySpend.toFixed(2)}`
313
148
  if (info.monthlyLimit <= 0) {
314
- return `Requesty Usage (${info.name}): ${spend} (unlimited)`
149
+ return `${info.name}: ${spend} (unlimited)`
315
150
  }
316
151
  const limit = `$${info.monthlyLimit.toFixed(2)}`
317
152
  const percent = Math.floor((info.monthlySpend / info.monthlyLimit) * 100)
318
- return `Requesty Usage (${info.name}): ${spend}/${limit} (${percent}%)`
153
+ return `${info.name}: ${spend}/${limit} (${percent}%)`
319
154
  }
320
155
 
321
156
  let lastFetched: { value: ApiKeyInfo; time: Date } | undefined
322
157
 
323
- async function fetchUsageStatus(env: Env): Promise<ApiKeyInfo> {
158
+ async function fetchUsageStatus(getApiKey: GetApiKey, settings: DiscoverySettings, env: Env): Promise<ApiKeyInfo> {
324
159
  const now = new Date()
325
160
  if (lastFetched?.time && now.getTime() - lastFetched.time.getTime() < 2000) {
326
161
  return lastFetched.value
327
162
  }
328
- const { provider } = getRequestyConfig(env)
163
+ const { provider } = await getRequestyConfig(getApiKey, settings, env)
329
164
  const value = await fetchApiUsage(provider)
330
165
  lastFetched = { value, time: new Date() }
331
166
  return value
332
167
  }
333
168
 
334
- function formatError(error: unknown) {
335
- return error instanceof Error ? error.message : String(error)
336
- }
337
-
338
169
  /**
339
170
  * For testing purposes, reset the cache after each run
340
171
  */
@@ -342,25 +173,39 @@ export function resetUsageStatusCache(): void {
342
173
  lastFetched = undefined
343
174
  }
344
175
 
345
- function runCatching<T>(fn: () => T): Try<T> {
346
- try {
347
- return { ok: true, value: fn() }
348
- } catch (error) {
349
- return { ok: false, error }
176
+ /** TUI adapter: routes the workflow's UI needs to Pi's ctx.ui. */
177
+ export function createTuiUi(ctx: ExtensionContext, loader?: RequestyStatusLoader): DiscoveryUi {
178
+ return {
179
+ notify: (message, level) => ctx.ui.notify(`${COMMAND_NAME}: ${message}`, level),
180
+ confirm: (title, message) => ctx.ui.confirm(title, message),
181
+ setStatus: message => loader?.setMessage(message),
350
182
  }
351
183
  }
352
184
 
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 }
185
+ /**
186
+ * Console adapter for non-interactive modes. Always confirms: print mode is
187
+ * non-interactive, so the write proceeds without a confirmation dialog.
188
+ */
189
+ export function createConsoleUi(): DiscoveryUi {
190
+ return {
191
+ notify: (message, level) => console.log(`[${level}] ${message}`),
192
+ // eslint-disable-next-line @typescript-eslint/require-await -- must match the DiscoveryUi promise signature
193
+ confirm: async () => true,
194
+ setStatus: message => console.log(message),
358
195
  }
359
196
  }
360
197
 
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
- }
198
+ /** Re-reads models.json through Pi's model registry so new models are usable without /reload. */
199
+ export function createRefreshRegistry(ctx: ExtensionContext): RefreshModelsRegistry {
200
+ return () =>
201
+ ctx.modelRegistry.refresh({ allowNetwork: false }).then(result => {
202
+ if (result.aborted || result.errors.size > 0) {
203
+ const detail = [...result.errors.entries()].map(([id, error]) => `${id}: ${error.message}`).join(', ')
204
+ throw new Error(detail || 'refresh aborted')
205
+ }
206
+ })
207
+ }
208
+
209
+ function createGetApiKey(ctx: ExtensionContext): GetApiKey {
210
+ return providerId => ctx.modelRegistry.getApiKeyForProvider(providerId)
366
211
  }