pi-requesty-discover 2.1.1 → 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.
@@ -24,7 +24,7 @@ Why not: Effect is a runtime, a generator DSL, and a worldview — not a utility
24
24
  Two call sites in a Pi extension don't justify the dependency weight or the
25
25
  learning curve for contributors. The hand-rolled version is ~10 lines
26
26
  (`Try<T>`, `runCatching`, `runCatchingAsync`, named after Kotlin's `Result`),
27
- lives in `src/index.ts`, and documents intent just as well.
27
+ lives in `src/discovery.ts`, and documents intent just as well.
28
28
 
29
29
  Decision: keep the hand-rolled `Try<T>`. No `monads.ts` module either — rule of
30
30
  three. Revisit if the extension ever needs typed errors, retries, or resource
@@ -50,3 +50,4 @@ The provider-like parts (API model fetch, usage footer, wanting Pi-managed auth)
50
50
  real, but the health check and the snapshot model break the provider fit.
51
51
 
52
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.1",
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,60 +1,27 @@
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
- }
55
-
56
- export type Try<T> = { ok: true; value: T } | { ok: false; error: unknown }
57
-
58
25
  // token suppresses stale writes when turns overlap
59
26
  let latestToken: object = {}
60
27
 
@@ -75,7 +42,8 @@ export default function (pi: ExtensionAPI) {
75
42
  })
76
43
 
77
44
  pi.on('session_start', (_event, ctx) => {
78
- complainOnBrokenEnv(ctx, env)
45
+ const ui = createTuiUi(ctx)
46
+ complainOnBrokenEnv(ui, env)
79
47
  void updateUsageStatus(ctx, env)
80
48
  })
81
49
 
@@ -85,210 +53,64 @@ export default function (pi: ExtensionAPI) {
85
53
  }
86
54
 
87
55
  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.
56
+ const ui = ctx.mode === 'tui' ? createTuiUi(ctx) : createConsoleUi()
57
+ complainOnBrokenEnv(ui, env)
58
+ if (!env.ok) {
59
+ return
60
+ }
61
+
91
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')
92
78
  return
93
79
  }
94
80
 
95
- const notifier = createUiNotifier(ctx)
96
- const confirmer = createUiConfirmer(ctx)
81
+ await finalizeDiscovery(evaluationResult.value, env, ui)
82
+ }
97
83
 
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.
84
+ async function runInteractiveDiscoverWorkflow(ctx: ExtensionCommandContext, env: Env, args: string, ui: DiscoveryUi) {
100
85
  const evaluationResult: Try<DiscoveryEvaluation> = await runWithStatusUi(
101
86
  ctx,
102
87
  'Discovering models...',
103
- async status => await runCatchingAsync(() => evaluateDiscovery(args, env.value, status)),
88
+ async statusUi => await runCatchingAsync(() => evaluateDiscovery(args, env, statusUi, createGetApiKey(ctx))),
104
89
  )
105
90
 
106
91
  if (!evaluationResult.ok) {
107
- notifier.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
92
+ ui.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
108
93
  return
109
94
  }
110
95
 
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}`
96
+ await finalizeDiscovery(evaluationResult.value, env, ui, createRefreshRegistry(ctx))
118
97
  }
119
98
 
120
99
  async function runWithStatusUi<T>(
121
100
  ctx: ExtensionCommandContext,
122
101
  initialMessage: string,
123
- fn: (status: StatusReporter) => Promise<T>,
102
+ fn: (statusUi: DiscoveryUi) => Promise<T>,
124
103
  ): 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)
104
+ return ctx.ui.custom<T>((tui, theme, _kb, done) => {
105
+ const loader = new RequestyStatusLoader(tui, theme, initialMessage)
128
106
  void Promise.resolve()
129
- .then(() => fn(status))
107
+ .then(() => fn(createTuiUi(ctx, loader)))
130
108
  .then(done)
131
109
  .catch(done)
132
110
  return loader
133
111
  })
134
112
  }
135
113
 
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
114
  async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<void> {
293
115
  if (!env.ok) return
294
116
  if (!ctx.hasUI) return // no footer to write to (print/json mode): skip the wasted fetch
@@ -300,7 +122,7 @@ async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<
300
122
  ctx.ui.setStatus(USAGE_STATUS_KEY, undefined)
301
123
  return
302
124
  }
303
- const info = await fetchUsageStatus(env.value)
125
+ const info = await fetchUsageStatus(createGetApiKey(ctx), env.value)
304
126
  if (latestToken !== token) return
305
127
  ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageStatus(info))
306
128
  } catch {
@@ -311,30 +133,26 @@ async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<
311
133
  export function formatUsageStatus(info: ApiKeyInfo): string {
312
134
  const spend = `$${info.monthlySpend.toFixed(2)}`
313
135
  if (info.monthlyLimit <= 0) {
314
- return `Requesty Usage (${info.name}): ${spend} (unlimited)`
136
+ return `${info.name}: ${spend} (unlimited)`
315
137
  }
316
138
  const limit = `$${info.monthlyLimit.toFixed(2)}`
317
139
  const percent = Math.floor((info.monthlySpend / info.monthlyLimit) * 100)
318
- return `Requesty Usage (${info.name}): ${spend}/${limit} (${percent}%)`
140
+ return `${info.name}: ${spend}/${limit} (${percent}%)`
319
141
  }
320
142
 
321
143
  let lastFetched: { value: ApiKeyInfo; time: Date } | undefined
322
144
 
323
- async function fetchUsageStatus(env: Env): Promise<ApiKeyInfo> {
145
+ async function fetchUsageStatus(getApiKey: GetApiKey, env: Env): Promise<ApiKeyInfo> {
324
146
  const now = new Date()
325
147
  if (lastFetched?.time && now.getTime() - lastFetched.time.getTime() < 2000) {
326
148
  return lastFetched.value
327
149
  }
328
- const { provider } = getRequestyConfig(env)
150
+ const { provider } = await getRequestyConfig(getApiKey, env)
329
151
  const value = await fetchApiUsage(provider)
330
152
  lastFetched = { value, time: new Date() }
331
153
  return value
332
154
  }
333
155
 
334
- function formatError(error: unknown) {
335
- return error instanceof Error ? error.message : String(error)
336
- }
337
-
338
156
  /**
339
157
  * For testing purposes, reset the cache after each run
340
158
  */
@@ -342,25 +160,39 @@ export function resetUsageStatusCache(): void {
342
160
  lastFetched = undefined
343
161
  }
344
162
 
345
- function runCatching<T>(fn: () => T): Try<T> {
346
- try {
347
- return { ok: true, value: fn() }
348
- } catch (error) {
349
- return { ok: false, error }
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),
350
169
  }
351
170
  }
352
171
 
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 }
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),
358
182
  }
359
183
  }
360
184
 
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
- }
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
+ })
194
+ }
195
+
196
+ function createGetApiKey(ctx: ExtensionContext): GetApiKey {
197
+ return providerId => ctx.modelRegistry.getApiKeyForProvider(providerId)
366
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,7 +26,7 @@ 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
32
  export type RequestyProvider = ModelsJsonProvider & {
@@ -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,