pi-requesty-discover 2.1.0 → 2.2.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
@@ -8,13 +8,14 @@ endif::[]
8
8
 
9
9
  = pi-requesty-discover
10
10
 
11
- image:https://img.shields.io/npm/v/pi-requesty-discover.svg[npm version, link=https://www.npmjs.com/package/pi-requesty-discover]
11
+ image:https://img.shields.io/npm/v/pi-requesty-discover.svg[npm version,link=https://www.npmjs.com/package/pi-requesty-discover]
12
12
 
13
13
  This extension helps you discover currently available models in requesty.ai for your Pi Coding Agent.
14
14
 
15
15
  This is a fork of https://github.com/requestyai/pi-requesty[pi-requesty] with additional features:
16
16
 
17
- * Use `REQUESTY_API_KEY` from the environment
17
+ * Use the `apiKey` from the model registry allowing you to use
18
+ https://pi.dev/docs/latest/models#value-resolution[pi's full value resolution] capabilities
18
19
  * Health-check available models and write a log about them
19
20
  * Brief information about what was found, including added/removed models, health summary
20
21
  * Confirm before writing `models.json`, with a dry-run mode that only previews
@@ -70,35 +71,24 @@ Example:
70
71
  "requesty-export": {
71
72
  "name": "Requesty",
72
73
  "baseUrl": "https://router.requesty.ai/v1",
73
- "apiKey": "$REQUESTY_API_KEY",
74
74
  "api": "openai-completions",
75
+ "apiKey": "$REQUESTY_API_KEY",
75
76
  "models": []
76
77
  }
77
78
  }
78
79
  }
79
80
  ----
80
81
 
81
- IMPORTANT: The `apiKey` from `models.json` is not used by this extension.
82
- It may contain Pi-specific environment-variable substitution that the extension cannot reliably resolve.
83
- Set `REQUESTY_API_KEY` instead. This variable is required.
84
- You can configure the real `apiKey` as you like, but it would be recommended to use the same variable as for the model discovery.
85
-
86
- IMPORTANT: Health checks are designed to use only a few tokens, but they still make real Requesty API calls and may consume credits.
82
+ IMPORTANT: Health checks are designed to use only a few tokens, but they still make real Requesty API calls and **will** consume credits.
87
83
 
88
- On startup, the extension fetches `<baseUrl>/models` using `REQUESTY_API_KEY` as the bearer token and registers discovered and healthy models with Pi.
84
+ On startup, the extension fetches `<baseUrl>/models` using the configured `apiKey` as the bearer token and registers discovered and healthy models with Pi.
89
85
 
90
86
  === Environment variables
91
87
 
92
- [cols="1,1,1,1,2", options="header"]
88
+ [cols="1,1,1,1,2",options="header"]
93
89
  |===
94
90
  | Name | Required | Type | Default | Meaning
95
91
 
96
- | `REQUESTY_API_KEY`
97
- | yes
98
- | `string`
99
- |
100
- | Requesty API key used for model discovery and health checks.
101
-
102
92
  | `REQUESTY_PROVIDER_ID`
103
93
  | no
104
94
  | `string`
@@ -132,12 +122,11 @@ The command:
132
122
  - discovers Requesty models (and optional health checks)
133
123
  - shows a summary notification (added/removed IDs, health summary)
134
124
  - asks for confirmation before writing `~/.pi/agent/models.json`
125
+ - refreshes Pi's model registry so the new models are immediately available in `/model`
135
126
  - reports whether the file was updated
136
127
 
137
128
  If there are no model ID changes, confirmation still offers a metadata refresh write, which might have changed as well.
138
129
 
139
- After a successful write, run `/reload`.
140
-
141
130
  [source,text]
142
131
  ----
143
132
  /requesty-discover --dry-run
@@ -145,22 +134,31 @@ After a successful write, run `/reload`.
145
134
 
146
135
  Same discovery and health checks, but never prompts to write and leaves `models.json` unchanged.
147
136
 
137
+ [source,bash]
138
+ ----
139
+ pi -p "/requesty-discover"
140
+ pi -p "/requesty-discover --dry-run"
141
+ ----
142
+
143
+ You can also run the command from the CLI in a non-interactive mode.
144
+
148
145
  == Usage status
149
146
 
150
147
  The extension shows your current Requesty monthly spend as a persistent line in Pi's footer, e.g.:
151
148
 
152
149
  [source,text]
153
150
  ----
154
- Requesty Usage (<API Key Name>): $63.55/$150.00 (42%)
151
+ <API Key Name>: $63.55/$150.00 (42%)
155
152
  ----
156
153
 
157
154
  When your API key has no monthly limit (`monthly_limit` is `0`), the limit is treated as unlimited:
158
155
 
159
156
  [source,text]
160
157
  ----
161
- Requesty Usage (<API Key Name>): $63.55 (unlimited)
158
+ <API Key Name>: $63.55 (unlimited)
162
159
  ----
163
160
 
164
- When you select a model from a provider other than `REQUESTY_PROVIDER_ID`, the status is cleared — there is no Requesty usage to show for it. If `REQUESTY_API_KEY` is not configured, the status is left untouched.
161
+ When you select a model from a provider other than `REQUESTY_PROVIDER_ID`, the status is cleared — there is no Requesty usage to show for it.
165
162
 
166
- NOTE: The footer update is best-effort and never blocks a turn. If it fails (network error, stale context after `/reload`), it fails silently without spamming notifications.
163
+ NOTE: The footer update is best-effort and never blocks a turn.
164
+ If it fails (network error, stale context after `/reload`), it fails silently without spamming notifications.
package/README.md CHANGED
@@ -6,7 +6,8 @@ This extension helps you discover currently available models in requesty.ai for
6
6
 
7
7
  This is a fork of [pi-requesty](https://github.com/requestyai/pi-requesty) with additional features:
8
8
 
9
- * Use `REQUESTY_API_KEY` from the environment
9
+ * Use the `apiKey` from the model registry allowing you to use
10
+ [pi’s full value resolution](https://pi.dev/docs/latest/models#value-resolution) capabilities
10
11
  * Health-check available models and write a log about them
11
12
  * Brief information about what was found, including added/removed models, health summary
12
13
  * Confirm before writing `models.json`, with a dry-run mode that only previews
@@ -58,8 +59,8 @@ Example:
58
59
  "requesty-export": {
59
60
  "name": "Requesty",
60
61
  "baseUrl": "https://router.requesty.ai/v1",
61
- "apiKey": "$REQUESTY_API_KEY",
62
62
  "api": "openai-completions",
63
+ "apiKey": "$REQUESTY_API_KEY",
63
64
  "models": []
64
65
  }
65
66
  }
@@ -67,21 +68,14 @@ Example:
67
68
  ```
68
69
 
69
70
  **❗ IMPORTANT**\
70
- The `apiKey` from `models.json` is not used by this extension.
71
- It may contain Pi-specific environment-variable substitution that the extension cannot reliably resolve.
72
- Set `REQUESTY_API_KEY` instead. This variable is required.
73
- You can configure the real `apiKey` as you like, but it would be recommended to use the same variable as for the model discovery.
71
+ Health checks are designed to use only a few tokens, but they still make real Requesty API calls and ***will*** consume credits.
74
72
 
75
- **❗ IMPORTANT**\
76
- Health checks are designed to use only a few tokens, but they still make real Requesty API calls and may consume credits.
77
-
78
- On startup, the extension fetches `<baseUrl>/models` using `REQUESTY_API_KEY` as the bearer token and registers discovered and healthy models with Pi.
73
+ On startup, the extension fetches `<baseUrl>/models` using the configured `apiKey` as the bearer token and registers discovered and healthy models with Pi.
79
74
 
80
75
  ### Environment variables
81
76
 
82
77
  | Name | Required | Type | Default | Meaning |
83
78
  | --- | --- | --- | --- | --- |
84
- | `REQUESTY_API_KEY` | yes | `string` | | Requesty API key used for model discovery and health checks. |
85
79
  | `REQUESTY_PROVIDER_ID` | no | `string` | `requesty-export` | The id of the provider. |
86
80
  | `REQUESTY_HEALTH_CHECK_MODE` | no | `off`, `basic`, `full` | `full` | The health-check mode. |
87
81
  | `PI_CODING_AGENT_DIR` | no | `string` | `~/.pi/agent` | pi configuration directory |
@@ -99,33 +93,40 @@ The command:
99
93
  * discovers Requesty models (and optional health checks)
100
94
  * shows a summary notification (added/removed IDs, health summary)
101
95
  * asks for confirmation before writing `~/.pi/agent/models.json`
96
+ * refreshes Pi’s model registry so the new models are immediately available in `/model`
102
97
  * reports whether the file was updated
103
98
 
104
99
  If there are no model ID changes, confirmation still offers a metadata refresh write, which might have changed as well.
105
100
 
106
- After a successful write, run `/reload`.
107
-
108
101
  ```text
109
102
  /requesty-discover --dry-run
110
103
  ```
111
104
 
112
105
  Same discovery and health checks, but never prompts to write and leaves `models.json` unchanged.
113
106
 
107
+ ```bash
108
+ pi -p "/requesty-discover"
109
+ pi -p "/requesty-discover --dry-run"
110
+ ```
111
+
112
+ You can also run the command from the CLI in a non-interactive mode.
113
+
114
114
  ## Usage status
115
115
 
116
116
  The extension shows your current Requesty monthly spend as a persistent line in Pi’s footer, e.g.:
117
117
 
118
118
  ```text
119
- Requesty Usage (<API Key Name>): $63.55/$150.00 (42%)
119
+ <API Key Name>: $63.55/$150.00 (42%)
120
120
  ```
121
121
 
122
122
  When your API key has no monthly limit (`monthly_limit` is `0`), the limit is treated as unlimited:
123
123
 
124
124
  ```text
125
- Requesty Usage (<API Key Name>): $63.55 (unlimited)
125
+ <API Key Name>: $63.55 (unlimited)
126
126
  ```
127
127
 
128
- When you select a model from a provider other than `REQUESTY_PROVIDER_ID`, the status is cleared — there is no Requesty usage to show for it. If `REQUESTY_API_KEY` is not configured, the status is left untouched.
128
+ When you select a model from a provider other than `REQUESTY_PROVIDER_ID`, the status is cleared — there is no Requesty usage to show for it.
129
129
 
130
130
  **📌 NOTE**\
131
- The footer update is best-effort and never blocks a turn. If it fails (network error, stale context after `/reload`), it fails silently without spamming notifications.
131
+ The footer update is best-effort and never blocks a turn.
132
+ If it fails (network error, stale context after `/reload`), it fails silently without spamming notifications.
@@ -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/discovery.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
@@ -34,3 +50,4 @@ The provider-like parts (API model fetch, usage footer, wanting Pi-managed auth)
34
50
  real, but the health check and the snapshot model break the provider fit.
35
51
 
36
52
  Decision: stay a `/requesty-discover` command that writes `models.json`.
53
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-requesty-discover",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Discover available Requesty models for the Pi Coding Agent",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,7 +47,8 @@
47
47
  ]
48
48
  },
49
49
  "devDependencies": {
50
- "@earendil-works/pi-tui": "^0.80.3",
50
+ "@earendil-works/pi-tui": "^0.85.0",
51
+ "@earendil-works/pi-coding-agent": "^0.85.0",
51
52
  "@eslint/js": "^10.0.1",
52
53
  "@types/node": "^26.1.0",
53
54
  "@vitest/coverage-v8": "^4.1.9",
@@ -0,0 +1,224 @@
1
+ import { ProviderModelConfig } from '@earendil-works/pi-coding-agent'
2
+ import { type Env } from './env'
3
+ import {
4
+ diffModels,
5
+ formatModelsDiffSummary,
6
+ GetApiKey,
7
+ getRequestyConfig,
8
+ type ModelsDiff,
9
+ ModelsJson,
10
+ updateModelsJson,
11
+ } from './models-json'
12
+ import { checkModels, formatHealthSummary, writeHealthCheckLog } from './health-check'
13
+ import { discoverModels } from './requesty-api'
14
+
15
+ const DRY_RUN_ARG = '--dry-run'
16
+
17
+ interface AutocompleteItem {
18
+ value: string
19
+ label: string
20
+ description: string
21
+ }
22
+
23
+ export type NotificationLevel = 'info' | 'warning' | 'error'
24
+
25
+ /** Everything the discovery workflow needs from the UI. One port, two adapters (tui/console) in index.ts. */
26
+ export type DiscoveryUi = {
27
+ notify(message: string, level: NotificationLevel): void
28
+ confirm(title: string, message: string): Promise<boolean>
29
+ setStatus(message: string): void
30
+ }
31
+
32
+ export type RefreshModelsRegistry = () => Promise<void>
33
+
34
+ export type DiscoveryEvaluation = {
35
+ dryRun: boolean
36
+ modelCount: number
37
+ failedCount: number
38
+ passing: ProviderModelConfig[]
39
+ diff: ModelsDiff
40
+ healthCheckSummary: string
41
+ logNote: string
42
+ data: ModelsJson
43
+ }
44
+
45
+ export type Try<T> = { ok: true; value: T } | { ok: false; error: unknown }
46
+
47
+ export function formatDiscoveryFailure(error: unknown): string {
48
+ const detail = formatError(error)
49
+ return `Discovery failed: ${detail}`
50
+ }
51
+
52
+ export async function evaluateDiscovery(
53
+ args: string,
54
+ env: Env,
55
+ ui: DiscoveryUi,
56
+ getApiKey: GetApiKey,
57
+ ): Promise<DiscoveryEvaluation> {
58
+ ui.setStatus('Discovering Requesty models...')
59
+ const dryRun = args.split(' ').includes(DRY_RUN_ARG)
60
+
61
+ const { data, provider, existingModelIds } = await getRequestyConfig(getApiKey, env)
62
+ const models = await discoverModels(provider)
63
+ const modelsMap = new Map(models.map(m => [m.id, m]))
64
+
65
+ let diff: ModelsDiff
66
+ let failedCount = 0
67
+ let passing: ProviderModelConfig[]
68
+ let logNote = ''
69
+ let healthCheckSummary = ''
70
+
71
+ if (env.health_check_mode !== 'off') {
72
+ ui.setStatus(`Checking models 0/${models.length}...`)
73
+ const healthResults = await checkModels(provider, models, env.health_check_mode === 'full', {
74
+ onProgress: ({ completed, total }) => {
75
+ ui.setStatus(`Checking models ${completed}/${total}...`)
76
+ },
77
+ })
78
+ const sortedResults = healthResults.toSorted((a, b) => a.modelId.localeCompare(b.modelId))
79
+ failedCount = sortedResults.filter(r => !r.ok).length
80
+ passing = sortedResults.flatMap(r => {
81
+ const model = modelsMap.get(r.modelId)
82
+ return r.ok && model ? [model] : []
83
+ })
84
+ diff = diffModels(existingModelIds, passing)
85
+ healthCheckSummary = formatHealthSummary(sortedResults)
86
+ writeHealthCheckLog(provider, sortedResults, diff, env)
87
+ logNote = `Full health check log: ${env.health_check_log_path}\n`
88
+ } else {
89
+ passing = models
90
+ diff = diffModels(existingModelIds, passing)
91
+ }
92
+
93
+ return {
94
+ dryRun,
95
+ modelCount: models.length,
96
+ failedCount,
97
+ passing,
98
+ diff,
99
+ healthCheckSummary,
100
+ logNote,
101
+ data,
102
+ }
103
+ }
104
+
105
+ export async function finalizeDiscovery(
106
+ evaluation: DiscoveryEvaluation,
107
+ env: Env,
108
+ ui: DiscoveryUi,
109
+ refresh?: RefreshModelsRegistry,
110
+ ): Promise<void> {
111
+ const level = notificationLevel(evaluation)
112
+ const summary = buildDiscoverySummary(evaluation)
113
+
114
+ // Always surface the discovery result first (toast styling), then decide.
115
+ if (evaluation.dryRun) {
116
+ ui.notify(
117
+ `${summary}
118
+ Dry run: left models.json unchanged.`,
119
+ level,
120
+ )
121
+ return
122
+ }
123
+
124
+ if (evaluation.passing.length === 0) {
125
+ ui.notify(
126
+ `${summary}
127
+ Left models.json unchanged.`,
128
+ level,
129
+ )
130
+ return
131
+ }
132
+
133
+ ui.notify(summary, level)
134
+ const { title, message } = buildConfirmPrompt(evaluation)
135
+ const shouldUpdate = await ui.confirm(title, message)
136
+ if (shouldUpdate) {
137
+ updateModelsJson(evaluation.data, evaluation.passing, env)
138
+ if (!refresh) {
139
+ ui.notify('Updated models.json.', 'info')
140
+ return
141
+ }
142
+ const refreshResult = await runCatchingAsync(() => refresh())
143
+ if (refreshResult.ok) {
144
+ ui.notify('Updated models.json. New models are available in /model.', 'info')
145
+ } else {
146
+ ui.notify(
147
+ `Updated models.json, but the model registry could not be refreshed: ${formatError(refreshResult.error)}. Run /reload or restart Pi to use the changes.`,
148
+ 'warning',
149
+ )
150
+ }
151
+ return
152
+ }
153
+
154
+ ui.notify('Left models.json unchanged.', 'info')
155
+ }
156
+
157
+ function buildDiscoverySummary(evaluation: DiscoveryEvaluation): string {
158
+ return [
159
+ `Discovered ${evaluation.modelCount} Requesty model(s).`,
160
+ evaluation.healthCheckSummary.trimEnd(),
161
+ formatModelsDiffSummary(evaluation.diff),
162
+ evaluation.logNote.trimEnd(),
163
+ ]
164
+ .filter(part => part.length > 0)
165
+ .join('\n')
166
+ }
167
+
168
+ function buildConfirmPrompt(evaluation: DiscoveryEvaluation): { title: string; message: string } {
169
+ const hasIdChanges = evaluation.diff.added.length > 0 || evaluation.diff.removed.length > 0
170
+ if (hasIdChanges) {
171
+ return {
172
+ title: `Write ${evaluation.passing.length} model(s)?`,
173
+ message: 'Update models.json with the discovery result above.',
174
+ }
175
+ }
176
+ return {
177
+ title: 'Refresh models.json?',
178
+ message: 'No model ID changes. Rewrite the file to refresh metadata anyway?',
179
+ }
180
+ }
181
+
182
+ function notificationLevel(evaluation: DiscoveryEvaluation): NotificationLevel {
183
+ if (evaluation.failedCount === 0) return 'info'
184
+ if (evaluation.failedCount < evaluation.modelCount) return 'warning'
185
+ return 'error'
186
+ }
187
+
188
+ export function getArgumentCompletions(prefix: string): AutocompleteItem[] {
189
+ const options = [
190
+ {
191
+ value: DRY_RUN_ARG,
192
+ label: DRY_RUN_ARG,
193
+ description: 'Preview discovery without offering to write models.json',
194
+ },
195
+ ]
196
+ if (!prefix) return options
197
+ return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
198
+ }
199
+
200
+ export function formatError(error: unknown) {
201
+ return error instanceof Error ? error.message : String(error)
202
+ }
203
+
204
+ export function runCatching<T>(fn: () => T): Try<T> {
205
+ try {
206
+ return { ok: true, value: fn() }
207
+ } catch (error) {
208
+ return { ok: false, error }
209
+ }
210
+ }
211
+
212
+ export async function runCatchingAsync<T>(fn: () => Promise<T>): Promise<Try<T>> {
213
+ try {
214
+ return { ok: true, value: await fn() }
215
+ } catch (error) {
216
+ return { ok: false, error }
217
+ }
218
+ }
219
+
220
+ export function complainOnBrokenEnv(ui: DiscoveryUi, env: Try<Env>) {
221
+ if (!env.ok) {
222
+ ui.notify(`failed to load env: ${formatError(env.error)}`, 'error')
223
+ }
224
+ }
package/src/env.ts CHANGED
@@ -11,7 +11,6 @@ export type Env = {
11
11
  models_json_path: string
12
12
  health_check_log_path: string
13
13
  provider_id: string
14
- requesty_api_key: string
15
14
  health_check_mode: z.infer<typeof HealthCheckModeSchema>
16
15
  }
17
16
 
@@ -22,16 +21,11 @@ export function getEnv(): Env {
22
21
  throw new Error(prettifyError(result.error))
23
22
  }
24
23
 
25
- const apiKey = envVars.REQUESTY_API_KEY
26
- if (!apiKey) {
27
- throw new Error(`apiKey must be set via REQUESTY_API_KEY env var`)
28
- }
29
24
  const agentPath = getAgentDir()
30
25
  return {
31
26
  models_json_path: path.join(agentPath, 'models.json'),
32
27
  health_check_log_path: path.join(agentPath, 'requesty-health-check.log'),
33
28
  provider_id: envVars.REQUESTY_PROVIDER_ID ?? DEFAULT_PROVIDER_ID,
34
- requesty_api_key: apiKey,
35
29
  health_check_mode: result.data,
36
30
  }
37
31
  }
package/src/index.ts CHANGED
@@ -1,312 +1,128 @@
1
- import {
2
- ExtensionAPI,
3
- ExtensionCommandContext,
4
- ExtensionContext,
5
- ProviderModelConfig,
6
- } from '@earendil-works/pi-coding-agent'
1
+ import { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
7
2
  import { type Env, getEnv } from './env'
8
- 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'
3
+ import { GetApiKey, getRequestyConfig } from './models-json'
4
+ import { type ApiKeyInfo, fetchApiUsage } from './requesty-api'
17
5
  import { RequestyStatusLoader } from './ui/requesty-status-loader.ts'
6
+ import {
7
+ complainOnBrokenEnv,
8
+ type DiscoveryEvaluation,
9
+ type DiscoveryUi,
10
+ evaluateDiscovery,
11
+ finalizeDiscovery,
12
+ formatDiscoveryFailure,
13
+ getArgumentCompletions,
14
+ RefreshModelsRegistry,
15
+ runCatching,
16
+ runCatchingAsync,
17
+ type Try,
18
+ } from './discovery'
19
+
20
+ export { type Try }
18
21
 
19
22
  const COMMAND_NAME = 'requesty-discover'
20
- const DRY_RUN_ARG = '--dry-run'
21
23
  export const USAGE_STATUS_KEY = 'requesty-usage'
22
24
 
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
- env: Env
55
- }
56
-
57
- type EvaluationResult =
58
- | {
59
- ok: true
60
- value: DiscoveryEvaluation
61
- }
62
- | { ok: false; error: unknown }
63
-
64
- // A per-instance token suppresses stale writes when turns overlap
25
+ // token suppresses stale writes when turns overlap
65
26
  let latestToken: object = {}
66
27
 
67
28
  // noinspection JSUnusedGlobalSymbols
68
29
  export default function (pi: ExtensionAPI) {
30
+ const env = runCatching(() => getEnv())
31
+
69
32
  pi.registerCommand(COMMAND_NAME, {
70
33
  description: 'Dynamically discover Requesty models, run health checks, and update the local models.json.',
71
34
  getArgumentCompletions,
72
35
  handler: async (args, ctx) => {
73
- await runDiscoveryWorkflow(ctx, args)
36
+ await runDiscoveryWorkflow(ctx, env, args)
74
37
  },
75
38
  })
76
39
 
77
40
  pi.on('turn_end', (_event, ctx) => {
78
- void updateUsageStatus(ctx)
41
+ void updateUsageStatus(ctx, env)
79
42
  })
80
43
 
81
44
  pi.on('session_start', (_event, ctx) => {
82
- void updateUsageStatus(ctx)
45
+ const ui = createTuiUi(ctx)
46
+ complainOnBrokenEnv(ui, env)
47
+ void updateUsageStatus(ctx, env)
83
48
  })
84
49
 
85
50
  pi.on('model_select', (_event, ctx) => {
86
- void updateUsageStatus(ctx)
51
+ void updateUsageStatus(ctx, env)
87
52
  })
88
53
  }
89
54
 
90
- export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, args: string) {
91
- // Interactive TUI command; no print/json/rpc path.
55
+ export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, env: Try<Env>, args: string) {
56
+ const ui = ctx.mode === 'tui' ? createTuiUi(ctx) : createConsoleUi()
57
+ complainOnBrokenEnv(ui, env)
58
+ if (!env.ok) {
59
+ return
60
+ }
61
+
92
62
  if (ctx.mode !== 'tui') {
63
+ return runSilentDiscoveryWorkflow(ctx, env.value, args, ui)
64
+ } else {
65
+ return runInteractiveDiscoverWorkflow(ctx, env.value, args, ui)
66
+ }
67
+ }
68
+
69
+ async function runSilentDiscoveryWorkflow(
70
+ ctx: ExtensionCommandContext,
71
+ env: Env,
72
+ args: string,
73
+ ui: DiscoveryUi,
74
+ ): Promise<void> {
75
+ const evaluationResult = await runCatchingAsync(() => evaluateDiscovery(args, env, ui, createGetApiKey(ctx)))
76
+ if (!evaluationResult.ok) {
77
+ ui.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
93
78
  return
94
79
  }
95
80
 
96
- const notifier = createUiNotifier(ctx)
97
- const confirmer = createUiConfirmer(ctx)
81
+ await finalizeDiscovery(evaluationResult.value, env, ui)
82
+ }
98
83
 
99
- // Phase A: progress UI only. Loader must close before confirm (Phase B).
100
- // 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
- })
84
+ async function runInteractiveDiscoverWorkflow(ctx: ExtensionCommandContext, env: Env, args: string, ui: DiscoveryUi) {
85
+ const evaluationResult: Try<DiscoveryEvaluation> = await runWithStatusUi(
86
+ ctx,
87
+ 'Discovering models...',
88
+ async statusUi => await runCatchingAsync(() => evaluateDiscovery(args, env, statusUi, createGetApiKey(ctx))),
89
+ )
109
90
 
110
91
  if (!evaluationResult.ok) {
111
- notifier.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
92
+ ui.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
112
93
  return
113
94
  }
114
95
 
115
- // Phases B + C: decide, optional write, final notify — outside the loader.
116
- await finalizeDiscovery(evaluationResult.value, confirmer, notifier)
96
+ await finalizeDiscovery(evaluationResult.value, env, ui, createRefreshRegistry(ctx))
117
97
  }
118
98
 
119
99
  async function runWithStatusUi<T>(
120
100
  ctx: ExtensionCommandContext,
121
101
  initialMessage: string,
122
- fn: (status: StatusReporter) => Promise<T>,
102
+ fn: (statusUi: DiscoveryUi) => Promise<T>,
123
103
  ): Promise<T> {
124
- return ctx.ui.custom<T>((_tui, theme, _kb, done) => {
125
- const loader = new RequestyStatusLoader(_tui, theme, initialMessage)
126
- const status = createLoaderStatusReporter(loader)
104
+ return ctx.ui.custom<T>((tui, theme, _kb, done) => {
105
+ const loader = new RequestyStatusLoader(tui, theme, initialMessage)
127
106
  void Promise.resolve()
128
- .then(() => fn(status))
107
+ .then(() => fn(createTuiUi(ctx, loader)))
129
108
  .then(done)
130
109
  .catch(done)
131
110
  return loader
132
111
  })
133
112
  }
134
113
 
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> {
141
- status.set('Discovering Requesty models...')
142
- const dryRun = args.split(' ').includes(DRY_RUN_ARG)
143
-
144
- const env = getEnv()
145
- const { data, provider, existingModelIds } = getRequestyConfig(env)
146
- const models = await discoverModels(provider)
147
- const modelsMap = new Map(models.map(m => [m.id, m]))
148
-
149
- let diff: ModelsDiff
150
- let failedCount = 0
151
- let passing: ProviderModelConfig[]
152
- let logNote = ''
153
- let healthCheckSummary = ''
154
-
155
- if (env.health_check_mode !== 'off') {
156
- status.set(`Checking models 0/${models.length}...`)
157
- const healthResults = await checkModels(provider, models, env.health_check_mode === 'full', {
158
- onProgress: ({ completed, total }) => {
159
- status.set(`Checking models ${completed}/${total}...`)
160
- },
161
- })
162
- const sortedResults = healthResults.toSorted((a, b) => a.modelId.localeCompare(b.modelId))
163
- failedCount = sortedResults.filter(r => !r.ok).length
164
- passing = sortedResults.flatMap(r => {
165
- const model = modelsMap.get(r.modelId)
166
- return r.ok && model ? [model] : []
167
- })
168
- diff = diffModels(existingModelIds, passing)
169
- healthCheckSummary = formatHealthSummary(sortedResults)
170
- writeHealthCheckLog(provider, sortedResults, diff, env)
171
- logNote = `Full health check log: ${env.health_check_log_path}\n`
172
- } else {
173
- passing = models
174
- diff = diffModels(existingModelIds, passing)
175
- }
176
-
177
- return {
178
- dryRun,
179
- modelCount: models.length,
180
- failedCount,
181
- passing,
182
- diff,
183
- healthCheckSummary,
184
- logNote,
185
- data,
186
- env,
187
- }
188
- }
189
-
190
- async function finalizeDiscovery(
191
- evaluation: DiscoveryEvaluation,
192
- confirmer: Confirmer,
193
- notifier: Notifier,
194
- ): Promise<void> {
195
- const level = notificationLevel(evaluation)
196
- const summary = buildDiscoverySummary(evaluation)
197
-
198
- // Always surface the discovery result first (toast styling), then decide.
199
- if (evaluation.dryRun) {
200
- notifier.notify(
201
- `${summary}
202
- Dry run: left models.json unchanged.`,
203
- level,
204
- )
205
- return
206
- }
207
-
208
- if (evaluation.passing.length === 0) {
209
- notifier.notify(
210
- `${summary}
211
- Left models.json unchanged.`,
212
- level,
213
- )
214
- return
215
- }
216
-
217
- notifier.notify(summary, level)
218
- const { title, message } = buildConfirmPrompt(evaluation)
219
- const shouldUpdate = await confirmer.confirm(title, message)
220
- if (shouldUpdate) {
221
- updateModelsJson(evaluation.data, evaluation.passing, evaluation.env)
222
- notifier.notify('Updated models.json. Run /reload to use the changes.', 'info')
223
- return
224
- }
225
-
226
- notifier.notify('Left models.json unchanged.', 'info')
227
- }
228
-
229
- function buildDiscoverySummary(evaluation: DiscoveryEvaluation): string {
230
- return [
231
- `Discovered ${evaluation.modelCount} Requesty model(s).`,
232
- evaluation.healthCheckSummary.trimEnd(),
233
- formatModelsDiffSummary(evaluation.diff),
234
- evaluation.logNote.trimEnd(),
235
- ]
236
- .filter(part => part.length > 0)
237
- .join('\n')
238
- }
239
-
240
- function buildConfirmPrompt(evaluation: DiscoveryEvaluation): { title: string; message: string } {
241
- const hasIdChanges = evaluation.diff.added.length > 0 || evaluation.diff.removed.length > 0
242
- if (hasIdChanges) {
243
- return {
244
- title: `Write ${evaluation.passing.length} model(s)?`,
245
- message: 'Update models.json with the discovery result above.',
246
- }
247
- }
248
- return {
249
- title: 'Refresh models.json?',
250
- message: 'No model ID changes. Rewrite the file to refresh metadata anyway?',
251
- }
252
- }
253
-
254
- function notificationLevel(evaluation: DiscoveryEvaluation): NotificationLevel {
255
- if (evaluation.failedCount === 0) return 'info'
256
- if (evaluation.failedCount < evaluation.modelCount) return 'warning'
257
- return 'error'
258
- }
259
-
260
- function getArgumentCompletions(prefix: string): AutocompleteItem[] {
261
- const options = [
262
- {
263
- value: DRY_RUN_ARG,
264
- label: DRY_RUN_ARG,
265
- description: 'Preview discovery without offering to write models.json',
266
- },
267
- ]
268
- if (!prefix) return options
269
- return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
270
- }
271
-
272
- function createUiNotifier(ctx: ExtensionCommandContext): Notifier {
273
- return {
274
- notify(message: string, level?: NotificationLevel) {
275
- const prefixedMessage = `${COMMAND_NAME}: ${message}`
276
- ctx.ui.notify(prefixedMessage, level)
277
- },
278
- }
279
- }
280
-
281
- function createUiConfirmer(ctx: ExtensionCommandContext): Confirmer {
282
- return {
283
- confirm(title, message) {
284
- return ctx.ui.confirm(title, message)
285
- },
286
- }
287
- }
288
-
289
- function createLoaderStatusReporter(loader: RequestyStatusLoader): StatusReporter {
290
- return {
291
- set(message: string) {
292
- loader.setMessage(message)
293
- },
294
- }
295
- }
296
-
297
- export async function updateUsageStatus(ctx: ExtensionContext): Promise<void> {
114
+ async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<void> {
115
+ if (!env.ok) return
298
116
  if (!ctx.hasUI) return // no footer to write to (print/json mode): skip the wasted fetch
299
117
  const token: object = {}
300
118
  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
119
+ const shouldClear = ctx.model?.provider !== env.value.provider_id
304
120
  try {
305
121
  if (shouldClear) {
306
122
  ctx.ui.setStatus(USAGE_STATUS_KEY, undefined)
307
123
  return
308
124
  }
309
- const info = await fetchUsageStatus()
125
+ const info = await fetchUsageStatus(createGetApiKey(ctx), env.value)
310
126
  if (latestToken !== token) return
311
127
  ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageStatus(info))
312
128
  } catch {
@@ -314,27 +130,69 @@ export async function updateUsageStatus(ctx: ExtensionContext): Promise<void> {
314
130
  }
315
131
  }
316
132
 
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
133
  export function formatUsageStatus(info: ApiKeyInfo): string {
327
134
  const spend = `$${info.monthlySpend.toFixed(2)}`
328
135
  if (info.monthlyLimit <= 0) {
329
- return `Requesty Usage (${info.name}): ${spend} (unlimited)`
136
+ return `${info.name}: ${spend} (unlimited)`
330
137
  }
331
138
  const limit = `$${info.monthlyLimit.toFixed(2)}`
332
139
  const percent = Math.floor((info.monthlySpend / info.monthlyLimit) * 100)
333
- return `Requesty Usage (${info.name}): ${spend}/${limit} (${percent}%)`
140
+ return `${info.name}: ${spend}/${limit} (${percent}%)`
141
+ }
142
+
143
+ let lastFetched: { value: ApiKeyInfo; time: Date } | undefined
144
+
145
+ async function fetchUsageStatus(getApiKey: GetApiKey, env: Env): Promise<ApiKeyInfo> {
146
+ const now = new Date()
147
+ if (lastFetched?.time && now.getTime() - lastFetched.time.getTime() < 2000) {
148
+ return lastFetched.value
149
+ }
150
+ const { provider } = await getRequestyConfig(getApiKey, env)
151
+ const value = await fetchApiUsage(provider)
152
+ lastFetched = { value, time: new Date() }
153
+ return value
154
+ }
155
+
156
+ /**
157
+ * For testing purposes, reset the cache after each run
158
+ */
159
+ export function resetUsageStatusCache(): void {
160
+ lastFetched = undefined
161
+ }
162
+
163
+ /** TUI adapter: routes the workflow's UI needs to Pi's ctx.ui. */
164
+ export function createTuiUi(ctx: ExtensionContext, loader?: RequestyStatusLoader): DiscoveryUi {
165
+ return {
166
+ notify: (message, level) => ctx.ui.notify(`${COMMAND_NAME}: ${message}`, level),
167
+ confirm: (title, message) => ctx.ui.confirm(title, message),
168
+ setStatus: message => loader?.setMessage(message),
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Console adapter for non-interactive modes. Always confirms: print mode is
174
+ * non-interactive, so the write proceeds without a confirmation dialog.
175
+ */
176
+ export function createConsoleUi(): DiscoveryUi {
177
+ return {
178
+ notify: (message, level) => console.log(`[${level}] ${message}`),
179
+ // eslint-disable-next-line @typescript-eslint/require-await -- must match the DiscoveryUi promise signature
180
+ confirm: async () => true,
181
+ setStatus: message => console.log(message),
182
+ }
183
+ }
184
+
185
+ /** Re-reads models.json through Pi's model registry so new models are usable without /reload. */
186
+ export function createRefreshRegistry(ctx: ExtensionContext): RefreshModelsRegistry {
187
+ return () =>
188
+ ctx.modelRegistry.refresh({ allowNetwork: false }).then(result => {
189
+ if (result.aborted || result.errors.size > 0) {
190
+ const detail = [...result.errors.entries()].map(([id, error]) => `${id}: ${error.message}`).join(', ')
191
+ throw new Error(detail || 'refresh aborted')
192
+ }
193
+ })
334
194
  }
335
195
 
336
- async function fetchUsageStatus(): Promise<ApiKeyInfo> {
337
- const env = getEnv()
338
- const { provider } = getRequestyConfig(env)
339
- return await fetchApiUsage(provider)
196
+ function createGetApiKey(ctx: ExtensionContext): GetApiKey {
197
+ return providerId => ctx.modelRegistry.getApiKeyForProvider(providerId)
340
198
  }
@@ -1,17 +1,21 @@
1
- import type { ProviderConfig, ProviderModelConfig } from '@earendil-works/pi-coding-agent'
1
+ import { ProviderConfig, ProviderModelConfig } from '@earendil-works/pi-coding-agent'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { z } from 'zod'
5
- import { getEnv, type Env } from './env'
5
+ import { type Env, getEnv } from './env'
6
6
 
7
7
  const DEFAULT_BASE_URL = 'https://router.requesty.ai/v1'
8
8
  const DEFAULT_NAME = 'Requesty'
9
9
 
10
+ /** Resolves the API key for a provider through Pi's model registry. */
11
+ export type GetApiKey = (providerId: string) => Promise<string | undefined>
12
+
10
13
  const ProviderSchema = z
11
14
  .object({
12
15
  name: z.string().optional(),
13
16
  baseUrl: z.string().optional(),
14
17
  apiKey: z.string().optional(),
18
+ api: z.string().optional(),
15
19
  models: z.array(z.object({ id: z.string() }).catchall(z.unknown())).optional(),
16
20
  })
17
21
  .catchall(z.unknown())
@@ -22,10 +26,10 @@ const ModelsJsonSchema = z
22
26
  })
23
27
  .catchall(z.unknown())
24
28
 
25
- type ModelsJson = z.infer<typeof ModelsJsonSchema>
29
+ export type ModelsJson = z.infer<typeof ModelsJsonSchema>
26
30
  type ModelsJsonProvider = z.infer<typeof ProviderSchema>
27
31
 
28
- type RequestyProvider = ModelsJsonProvider & {
32
+ export type RequestyProvider = ModelsJsonProvider & {
29
33
  name: string
30
34
  baseUrl: string
31
35
  apiKey: string
@@ -42,7 +46,7 @@ export type ModelsDiff = {
42
46
  removed: string[]
43
47
  }
44
48
 
45
- export function getRequestyConfig(envConfig: Env = getEnv()): RequestyConfig {
49
+ export async function getRequestyConfig(getApiKey: GetApiKey, envConfig: Env = getEnv()): Promise<RequestyConfig> {
46
50
  const data = readModelsJson(envConfig)
47
51
  const provider = data.providers[envConfig.provider_id]
48
52
 
@@ -50,7 +54,10 @@ export function getRequestyConfig(envConfig: Env = getEnv()): RequestyConfig {
50
54
  throw new Error(`${envConfig.models_json_path} does not define providers.${envConfig.provider_id}`)
51
55
  }
52
56
 
53
- const apiKey = envConfig.requesty_api_key
57
+ const apiKey = await getApiKey(envConfig.provider_id)
58
+ if (!apiKey) {
59
+ throw new Error(`No API key found for provider ${envConfig.provider_id}`)
60
+ }
54
61
  return {
55
62
  data,
56
63
  existingModelIds: (provider.models ?? []).map(m => m.id),
@@ -83,9 +90,13 @@ export function formatModelsDiffSummary(diff: ModelsDiff): string {
83
90
 
84
91
  export function updateModelsJson(data: ModelsJson, models: ProviderModelConfig[], envConfig: Env = getEnv()): void {
85
92
  const provider = data.providers[envConfig.provider_id]
93
+ const { name, baseUrl, api, apiKey, models: _existingModels, ...passthrough } = provider
86
94
  data.providers[envConfig.provider_id] = {
87
- ...provider,
88
- apiKey: nonEmptyString(provider.apiKey) ?? '$REQUESTY_API_KEY',
95
+ name,
96
+ baseUrl,
97
+ api,
98
+ apiKey,
99
+ ...passthrough,
89
100
  models: models.map(model => ({
90
101
  id: model.id,
91
102
  name: model.name,
@@ -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) {