pi-requesty-discover 2.2.0 → 3.0.2

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
@@ -20,6 +20,7 @@ https://pi.dev/docs/latest/models#value-resolution[pi's full value resolution] c
20
20
  * Brief information about what was found, including added/removed models, health summary
21
21
  * Confirm before writing `models.json`, with a dry-run mode that only previews
22
22
  * A footer status line showing your monthly Requesty spend against your monthly limit
23
+ * Optional Settings file in *json5* format
23
24
 
24
25
  == When and why use it
25
26
 
@@ -60,7 +61,7 @@ pi -e ./pi-requesty-discover
60
61
  == Configuration
61
62
 
62
63
  The extension reads the `requesty-export` provider from `~/.pi/agent/models.json`.
63
- You can override the provider id with the `REQUESTY_PROVIDER_ID` variable.
64
+ You can override the provider id via the settings.
64
65
 
65
66
  Example:
66
67
 
@@ -83,24 +84,39 @@ IMPORTANT: Health checks are designed to use only a few tokens, but they still m
83
84
 
84
85
  On startup, the extension fetches `<baseUrl>/models` using the configured `apiKey` as the bearer token and registers discovered and healthy models with Pi.
85
86
 
87
+ === Discovery settings
88
+
89
+ The discovery workflow reads optional settings from `requesty-discovery-settings.json5` in the Pi config directory (`PI_CODING_AGENT_DIR`).
90
+ The file uses JSON5, so it supports comments.
91
+ It is optional. When missing, it will be created including defaults.
92
+
93
+ Example:
94
+
95
+ [source,json5]
96
+ ----
97
+ {
98
+ "$schema": "https://raw.githubusercontent.com/enolive/pi-requesty-discover/main/docs/requesty-discovery-settings.schema.json",
99
+
100
+ // override the default provider id
101
+ providerId: "requesty-export",
102
+ // configure the health check mode
103
+ healthCheckMode: "full", // or "basic", "off"
104
+ // models excluded from discovery; useful for models that keep failing health checks
105
+ bannedModels: [
106
+ // hangs on tool calls
107
+ 'requesty/unstable-model',
108
+ ],
109
+ }
110
+ ----
111
+
112
+ Banned models are excluded before health checks run (so they cost no credits) and are removed from `models.json` on the next discovery write.
113
+
86
114
  === Environment variables
87
115
 
88
116
  [cols="1,1,1,1,2",options="header"]
89
117
  |===
90
118
  | Name | Required | Type | Default | Meaning
91
119
 
92
- | `REQUESTY_PROVIDER_ID`
93
- | no
94
- | `string`
95
- | `requesty-export`
96
- | The id of the provider.
97
-
98
- | `REQUESTY_HEALTH_CHECK_MODE`
99
- | no
100
- | `off`, `basic`, `full`
101
- | `full`
102
- | The health-check mode.
103
-
104
120
  | `PI_CODING_AGENT_DIR`
105
121
  | no
106
122
  | `string`
package/README.md CHANGED
@@ -12,6 +12,7 @@ This is a fork of [pi-requesty](https://github.com/requestyai/pi-requesty) with
12
12
  * Brief information about what was found, including added/removed models, health summary
13
13
  * Confirm before writing `models.json`, with a dry-run mode that only previews
14
14
  * A footer status line showing your monthly Requesty spend against your monthly limit
15
+ * Optional Settings file in **json5** format
15
16
 
16
17
  ## When and why use it
17
18
 
@@ -49,7 +50,7 @@ pi -e ./pi-requesty-discover
49
50
  ## Configuration
50
51
 
51
52
  The extension reads the `requesty-export` provider from `~/.pi/agent/models.json`.
52
- You can override the provider id with the `REQUESTY_PROVIDER_ID` variable.
53
+ You can override the provider id via the settings.
53
54
 
54
55
  Example:
55
56
 
@@ -72,12 +73,36 @@ Health checks are designed to use only a few tokens, but they still make real Re
72
73
 
73
74
  On startup, the extension fetches `<baseUrl>/models` using the configured `apiKey` as the bearer token and registers discovered and healthy models with Pi.
74
75
 
76
+ ### Discovery settings
77
+
78
+ The discovery workflow reads optional settings from `requesty-discovery-settings.json5` in the Pi config directory (`PI_CODING_AGENT_DIR`).
79
+ The file uses JSON5, so it supports comments.
80
+ It is optional. When missing, it will be created including defaults.
81
+
82
+ Example:
83
+
84
+ ```json5
85
+ {
86
+ "$schema": "https://raw.githubusercontent.com/enolive/pi-requesty-discover/main/docs/requesty-discovery-settings.schema.json",
87
+
88
+ // override the default provider id
89
+ providerId: "requesty-export",
90
+ // configure the health check mode
91
+ healthCheckMode: "full", // or "basic", "off"
92
+ // models excluded from discovery; useful for models that keep failing health checks
93
+ bannedModels: [
94
+ // hangs on tool calls
95
+ 'requesty/unstable-model',
96
+ ],
97
+ }
98
+ ```
99
+
100
+ Banned models are excluded before health checks run (so they cost no credits) and are removed from `models.json` on the next discovery write.
101
+
75
102
  ### Environment variables
76
103
 
77
104
  | Name | Required | Type | Default | Meaning |
78
105
  | --- | --- | --- | --- | --- |
79
- | `REQUESTY_PROVIDER_ID` | no | `string` | `requesty-export` | The id of the provider. |
80
- | `REQUESTY_HEALTH_CHECK_MODE` | no | `off`, `basic`, `full` | `full` | The health-check mode. |
81
106
  | `PI_CODING_AGENT_DIR` | no | `string` | `~/.pi/agent` | pi configuration directory |
82
107
 
83
108
  ## Command
@@ -4,50 +4,43 @@ Short notes for non-obvious decisions: context, decision, why not the obvious al
4
4
 
5
5
  == Usage status stays on `setStatus` (shared, truncated footer line)
6
6
 
7
- Pi's `FooterComponent` joins all extensions' `setStatus` entries on one line and
8
- ellipsis-truncates the joined line to terminal width. No per-key layout, no wrap.
7
+ Pi's `FooterComponent` joins all extensions' `setStatus` entries on one line and ellipsis-truncates the joined line to terminal width.
8
+ No per-key layout, no wrap.
9
9
 
10
- `ctx.ui.setFooter(factory)` is replacement-only — it swaps the whole footer (pwd,
11
- tokens, context %, model, statuses), and the factory gets `footerData` but not
12
- token/model state. No "extra footer" API exists.
10
+ `ctx.ui.setFooter(factory)` is replacement-only — it swaps the whole footer (pwd, tokens, context %, model, statuses), and the factory gets `footerData` but not token/model state.
11
+ No "extra footer" API exists.
13
12
 
14
- Decision: keep `setStatus`. Revisit when the line overflows visibly with other
15
- extensions' statuses, or if the usage string grows past a typical width.
13
+ Decision: keep `setStatus`.
14
+ Revisit when the line overflows visibly with other extensions' statuses, or if the usage string grows past a typical width.
16
15
 
17
16
  == Hand-rolled `Try<T>` instead of an effects library
18
17
 
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.
18
+ Context: env loading and discovery evaluation both need a caught-error result value (`{ ok: true, value } | { ok: false, error }`).
19
+ Libraries like Effect ship an industrial `Either`/`Effect.try` for exactly this.
22
20
 
23
21
  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.
22
+ Two call sites in a Pi extension don't justify the dependency weight or the learning curve for contributors.
23
+ The hand-rolled version is ~10 lines (`Try<T>`, `runCatching`, `runCatchingAsync`, named after Kotlin's `Result`), lives in `utils.ts`, and documents intent just as well.
28
24
 
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.
25
+ Decision: keep the hand-rolled `Try<T>`.
26
+ Revisit if the extension ever needs typed errors, retries, or resource scoping; that's the day Effect earns its place.
32
27
 
33
28
  == Not a provider — a models.json-writing extension
34
29
 
35
- Context: Requesty already works as a Pi provider via `models.json`. The temptation
36
- is to make this extension *be* the provider: read the model list from the Requesty
37
- API, hold it in memory, surface usage stats, let Pi manage the API key.
30
+ Context: Requesty already works as a Pi provider via `models.json`.
31
+ The temptation is to make this extension *be* the provider: read the model list from the Requesty API, hold it in memory, surface usage stats, let Pi manage the API key.
38
32
 
39
33
  Why not:
40
34
 
41
- - Health-checking is the core value and has no home in a provider. A provider serves
42
- models; it doesn't vet them. The health check plus the diff/confirm/write flow is a
43
- discovery-and-selection step, not serving.
44
- - A provider keeps its model list in-memory and refreshes occasionally. Here the
45
- list is a persistent on-disk snapshot (`models.json`) — constant until you re-run
46
- `/requesty-discover`. That matches the intent: an explicit, reviewed, user-confirmed write,
47
- after which Pi's normal provider machinery serves the models.
35
+ - Health-checking is the core value and has no home in a provider.
36
+ A provider serves models; it doesn't vet them.
37
+ The health check plus the diff/confirm/write flow is a discovery-and-selection step, not serving.
38
+ - A provider keeps its model list in-memory and refreshes occasionally.
39
+ Here the list is a persistent on-disk snapshot (`models.json`) — constant until you re-run
40
+ `/requesty-discover`.
41
+ That matches the intent: an explicit, reviewed, user-confirmed write, after which Pi's normal provider machinery serves the models.
48
42
 
49
- The provider-like parts (API model fetch, usage footer, wanting Pi-managed auth) are
50
- real, but the health check and the snapshot model break the provider fit.
43
+ The provider-like parts (API model fetch, usage footer, wanting Pi-managed auth) are real, but the health check and the snapshot model break the provider fit.
51
44
 
52
45
  Decision: stay a `/requesty-discover` command that writes `models.json`.
53
46
 
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "bannedModels": {
6
+ "default": [],
7
+ "type": "array",
8
+ "items": {
9
+ "type": "string",
10
+ "minLength": 1
11
+ }
12
+ },
13
+ "healthCheckMode": {
14
+ "default": "full",
15
+ "type": "string",
16
+ "enum": [
17
+ "off",
18
+ "basic",
19
+ "full"
20
+ ]
21
+ },
22
+ "providerId": {
23
+ "default": "requesty-export",
24
+ "type": "string"
25
+ }
26
+ },
27
+ "required": [
28
+ "bannedModels",
29
+ "healthCheckMode",
30
+ "providerId"
31
+ ],
32
+ "additionalProperties": false,
33
+ "$id": "https://raw.githubusercontent.com/enolive/pi-requesty/main/docs/requesty-discovery-settings.schema.json",
34
+ "title": "Requesty discovery settings",
35
+ "description": "Settings for the pi-requesty discovery extension. The file uses JSON5, so comments are allowed."
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-requesty-discover",
3
- "version": "2.2.0",
3
+ "version": "3.0.2",
4
4
  "description": "Discover available Requesty models for the Pi Coding Agent",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,10 @@
34
34
  "test:unit:coverage": "vitest run src --coverage",
35
35
  "test:integration": "vitest run test/integration.test.ts",
36
36
  "test:watch": "vitest",
37
- "prepack": "downdoc README.adoc"
37
+ "prepack": "downdoc README.adoc",
38
+ "preversion": "npm run check && npm test",
39
+ "version": "npm run generate:schema && git add docs/requesty-discovery-settings.schema.json",
40
+ "generate:schema": "jiti scripts/generate-settings-schema.ts"
38
41
  },
39
42
  "license": "MIT",
40
43
  "peerDependencies": {
@@ -64,6 +67,7 @@
64
67
  "vitest": "^4.1.9"
65
68
  },
66
69
  "dependencies": {
70
+ "json5": "^2.2.3",
67
71
  "zod": "^4.4.3"
68
72
  }
69
73
  }
@@ -0,0 +1,17 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { z } from 'zod'
4
+ import { DiscoverySettingsSchema } from '../src/settings'
5
+
6
+ const OUT_PATH = path.resolve('docs/requesty-discovery-settings.schema.json')
7
+ const SCHEMA_URL =
8
+ 'https://raw.githubusercontent.com/enolive/pi-requesty/main/docs/requesty-discovery-settings.schema.json'
9
+
10
+ const schema = z.toJSONSchema(DiscoverySettingsSchema, { target: 'draft-2020-12', io: 'output' })
11
+ schema.$id = SCHEMA_URL
12
+ schema.title = 'Requesty discovery settings'
13
+ schema.description = 'Settings for the pi-requesty discovery extension. The file uses JSON5, so comments are allowed.'
14
+
15
+ fs.mkdirSync(path.dirname(OUT_PATH), { recursive: true })
16
+ fs.writeFileSync(OUT_PATH, `${JSON.stringify(schema, null, 2)}\n`, 'utf8')
17
+ console.log(`wrote ${OUT_PATH}`)
package/src/discovery.ts CHANGED
@@ -1,16 +1,18 @@
1
- import { ProviderModelConfig } from '@earendil-works/pi-coding-agent'
1
+ import type { ProviderModelConfig } from '@earendil-works/pi-coding-agent'
2
2
  import { type Env } from './env'
3
3
  import {
4
+ type GetApiKey,
5
+ type ModelsDiff,
6
+ type ModelsJson,
4
7
  diffModels,
5
8
  formatModelsDiffSummary,
6
- GetApiKey,
7
9
  getRequestyConfig,
8
- type ModelsDiff,
9
- ModelsJson,
10
10
  updateModelsJson,
11
11
  } from './models-json'
12
12
  import { checkModels, formatHealthSummary, writeHealthCheckLog } from './health-check'
13
13
  import { discoverModels } from './requesty-api'
14
+ import type { DiscoverySettings } from './settings'
15
+ import { formatErrorMessage, runCatchingAsync } from './utils'
14
16
 
15
17
  const DRY_RUN_ARG = '--dry-run'
16
18
 
@@ -35,6 +37,7 @@ export type DiscoveryEvaluation = {
35
37
  dryRun: boolean
36
38
  modelCount: number
37
39
  failedCount: number
40
+ warningCount: number
38
41
  passing: ProviderModelConfig[]
39
42
  diff: ModelsDiff
40
43
  healthCheckSummary: string
@@ -42,15 +45,14 @@ export type DiscoveryEvaluation = {
42
45
  data: ModelsJson
43
46
  }
44
47
 
45
- export type Try<T> = { ok: true; value: T } | { ok: false; error: unknown }
46
-
47
48
  export function formatDiscoveryFailure(error: unknown): string {
48
- const detail = formatError(error)
49
+ const detail = formatErrorMessage(error)
49
50
  return `Discovery failed: ${detail}`
50
51
  }
51
52
 
52
53
  export async function evaluateDiscovery(
53
54
  args: string,
55
+ settings: DiscoverySettings,
54
56
  env: Env,
55
57
  ui: DiscoveryUi,
56
58
  getApiKey: GetApiKey,
@@ -58,32 +60,52 @@ export async function evaluateDiscovery(
58
60
  ui.setStatus('Discovering Requesty models...')
59
61
  const dryRun = args.split(' ').includes(DRY_RUN_ARG)
60
62
 
61
- const { data, provider, existingModelIds } = await getRequestyConfig(getApiKey, env)
62
- const models = await discoverModels(provider)
63
+ const { data, provider, existingModelIds } = await getRequestyConfig(getApiKey, settings, env)
64
+ const bannedModels = new Set(settings.bannedModels)
65
+ const allModels = await discoverModels(provider)
66
+ const foundBannedModels = allModels
67
+ .filter(model => bannedModels.has(model.id))
68
+ .map(model => model.id)
69
+ .toSorted()
70
+ const models = allModels.filter(model => !bannedModels.has(model.id))
63
71
  const modelsMap = new Map(models.map(m => [m.id, m]))
64
72
 
65
73
  let diff: ModelsDiff
66
74
  let failedCount = 0
67
- let passing: ProviderModelConfig[]
75
+ let warningCount = 0
76
+ let passing: ProviderModelConfig[] = []
68
77
  let logNote = ''
69
78
  let healthCheckSummary = ''
70
79
 
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
- })
80
+ if (settings.healthCheckMode !== 'off') {
81
+ if (models.length > 0) {
82
+ ui.setStatus(`Checking models 0/${models.length}...`)
83
+ const healthResults = await checkModels(provider, models, settings.healthCheckMode === 'full', {
84
+ onProgress: ({ completed, total }) => {
85
+ ui.setStatus(`Checking models ${completed}/${total}...`)
86
+ },
87
+ })
88
+ const sortedResults = healthResults.toSorted((a, b) => a.modelId.localeCompare(b.modelId))
89
+ failedCount = sortedResults.filter(r => r.status === 'error').length
90
+ warningCount = sortedResults.filter(r => r.status === 'warning').length
91
+ passing = sortedResults.flatMap(r => {
92
+ const model = modelsMap.get(r.modelId)
93
+ if (!model) return []
94
+ if (r.status === 'ok') return [model]
95
+ // warnings are transient: assume that the error will go away and add them anyway
96
+ if (r.status === 'warning') return [model]
97
+ return []
98
+ })
99
+ healthCheckSummary = formatHealthSummary(sortedResults)
100
+ writeHealthCheckLog(
101
+ provider,
102
+ sortedResults,
103
+ diffModels(existingModelIds, passing),
104
+ { providerId: settings.providerId, bannedModels: foundBannedModels },
105
+ env,
106
+ )
107
+ }
84
108
  diff = diffModels(existingModelIds, passing)
85
- healthCheckSummary = formatHealthSummary(sortedResults)
86
- writeHealthCheckLog(provider, sortedResults, diff, env)
87
109
  logNote = `Full health check log: ${env.health_check_log_path}\n`
88
110
  } else {
89
111
  passing = models
@@ -94,6 +116,7 @@ export async function evaluateDiscovery(
94
116
  dryRun,
95
117
  modelCount: models.length,
96
118
  failedCount,
119
+ warningCount,
97
120
  passing,
98
121
  diff,
99
122
  healthCheckSummary,
@@ -104,6 +127,7 @@ export async function evaluateDiscovery(
104
127
 
105
128
  export async function finalizeDiscovery(
106
129
  evaluation: DiscoveryEvaluation,
130
+ settings: DiscoverySettings,
107
131
  env: Env,
108
132
  ui: DiscoveryUi,
109
133
  refresh?: RefreshModelsRegistry,
@@ -134,7 +158,7 @@ Left models.json unchanged.`,
134
158
  const { title, message } = buildConfirmPrompt(evaluation)
135
159
  const shouldUpdate = await ui.confirm(title, message)
136
160
  if (shouldUpdate) {
137
- updateModelsJson(evaluation.data, evaluation.passing, env)
161
+ updateModelsJson(evaluation.data, evaluation.passing, settings, env)
138
162
  if (!refresh) {
139
163
  ui.notify('Updated models.json.', 'info')
140
164
  return
@@ -144,7 +168,7 @@ Left models.json unchanged.`,
144
168
  ui.notify('Updated models.json. New models are available in /model.', 'info')
145
169
  } else {
146
170
  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.`,
171
+ `Updated models.json, but the model registry could not be refreshed: ${formatErrorMessage(refreshResult.error)}. Run /reload or restart Pi to use the changes.`,
148
172
  'warning',
149
173
  )
150
174
  }
@@ -180,9 +204,9 @@ function buildConfirmPrompt(evaluation: DiscoveryEvaluation): { title: string; m
180
204
  }
181
205
 
182
206
  function notificationLevel(evaluation: DiscoveryEvaluation): NotificationLevel {
183
- if (evaluation.failedCount === 0) return 'info'
184
- if (evaluation.failedCount < evaluation.modelCount) return 'warning'
185
- return 'error'
207
+ if (evaluation.failedCount > 0) return 'error'
208
+ if (evaluation.warningCount > 0) return 'warning'
209
+ return 'info'
186
210
  }
187
211
 
188
212
  export function getArgumentCompletions(prefix: string): AutocompleteItem[] {
@@ -196,29 +220,3 @@ export function getArgumentCompletions(prefix: string): AutocompleteItem[] {
196
220
  if (!prefix) return options
197
221
  return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
198
222
  }
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
@@ -1,31 +1,17 @@
1
1
  import path from 'node:path'
2
- import process from 'node:process'
3
- import { prettifyError, z } from 'zod'
4
2
  import { getAgentDir } from '@earendil-works/pi-coding-agent'
5
3
 
6
- const HealthCheckModeSchema = z.enum(['off', 'basic', 'full']).default('full')
7
-
8
- export const DEFAULT_PROVIDER_ID = 'requesty-export'
9
-
10
4
  export type Env = {
11
5
  models_json_path: string
12
6
  health_check_log_path: string
13
- provider_id: string
14
- health_check_mode: z.infer<typeof HealthCheckModeSchema>
7
+ settings_path: string
15
8
  }
16
9
 
17
10
  export function getEnv(): Env {
18
- const envVars = process.env
19
- const result = HealthCheckModeSchema.safeParse(envVars.REQUESTY_HEALTH_CHECK_MODE)
20
- if (!result.success) {
21
- throw new Error(prettifyError(result.error))
22
- }
23
-
24
11
  const agentPath = getAgentDir()
25
12
  return {
26
13
  models_json_path: path.join(agentPath, 'models.json'),
27
14
  health_check_log_path: path.join(agentPath, 'requesty-health-check.log'),
28
- provider_id: envVars.REQUESTY_PROVIDER_ID ?? DEFAULT_PROVIDER_ID,
29
- health_check_mode: result.data,
15
+ settings_path: path.join(agentPath, 'requesty-discovery-settings.json5'),
30
16
  }
31
17
  }
@@ -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,23 +1,19 @@
1
- import { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
2
2
  import { type Env, getEnv } from './env'
3
- import { GetApiKey, getRequestyConfig } from './models-json'
3
+ import { type GetApiKey, getRequestyConfig } from './models-json'
4
4
  import { type ApiKeyInfo, fetchApiUsage } from './requesty-api'
5
- import { RequestyStatusLoader } from './ui/requesty-status-loader.ts'
5
+ import { RequestyStatusLoader } from './ui/requesty-status-loader'
6
6
  import {
7
- complainOnBrokenEnv,
8
7
  type DiscoveryEvaluation,
9
8
  type DiscoveryUi,
9
+ type RefreshModelsRegistry,
10
10
  evaluateDiscovery,
11
11
  finalizeDiscovery,
12
12
  formatDiscoveryFailure,
13
13
  getArgumentCompletions,
14
- RefreshModelsRegistry,
15
- runCatching,
16
- runCatchingAsync,
17
- type Try,
18
14
  } from './discovery'
19
-
20
- export { type Try }
15
+ import { type DiscoverySettings, readDiscoverySettings } from './settings'
16
+ import { runCatching, runCatchingAsync, type Try } from './utils'
21
17
 
22
18
  const COMMAND_NAME = 'requesty-discover'
23
19
  export const USAGE_STATUS_KEY = 'requesty-usage'
@@ -28,64 +24,81 @@ let latestToken: object = {}
28
24
  // noinspection JSUnusedGlobalSymbols
29
25
  export default function (pi: ExtensionAPI) {
30
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
+ }
31
36
 
32
37
  pi.registerCommand(COMMAND_NAME, {
33
38
  description: 'Dynamically discover Requesty models, run health checks, and update the local models.json.',
34
39
  getArgumentCompletions,
35
40
  handler: async (args, ctx) => {
36
- await runDiscoveryWorkflow(ctx, env, args)
41
+ await runDiscoveryWorkflow(ctx, settings.value, env.value, args)
37
42
  },
38
43
  })
39
44
 
40
45
  pi.on('turn_end', (_event, ctx) => {
41
- void updateUsageStatus(ctx, env)
46
+ void updateUsageStatus(ctx, settings.value, env.value)
42
47
  })
43
48
 
44
49
  pi.on('session_start', (_event, ctx) => {
45
- const ui = createTuiUi(ctx)
46
- complainOnBrokenEnv(ui, env)
47
- void updateUsageStatus(ctx, env)
50
+ void updateUsageStatus(ctx, settings.value, env.value)
48
51
  })
49
52
 
50
53
  pi.on('model_select', (_event, ctx) => {
51
- void updateUsageStatus(ctx, env)
54
+ void updateUsageStatus(ctx, settings.value, env.value)
52
55
  })
53
56
  }
54
57
 
55
- export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, env: Try<Env>, args: string) {
58
+ export async function runDiscoveryWorkflow(
59
+ ctx: ExtensionCommandContext,
60
+ settings: DiscoverySettings,
61
+ env: Env,
62
+ args: string,
63
+ ) {
56
64
  const ui = ctx.mode === 'tui' ? createTuiUi(ctx) : createConsoleUi()
57
- complainOnBrokenEnv(ui, env)
58
- if (!env.ok) {
59
- return
60
- }
61
-
62
65
  if (ctx.mode !== 'tui') {
63
- return runSilentDiscoveryWorkflow(ctx, env.value, args, ui)
66
+ return runSilentDiscoveryWorkflow(ctx, settings, env, args, ui)
64
67
  } else {
65
- return runInteractiveDiscoverWorkflow(ctx, env.value, args, ui)
68
+ return runInteractiveDiscoverWorkflow(ctx, settings, env, args, ui)
66
69
  }
67
70
  }
68
71
 
69
72
  async function runSilentDiscoveryWorkflow(
70
73
  ctx: ExtensionCommandContext,
74
+ settings: DiscoverySettings,
71
75
  env: Env,
72
76
  args: string,
73
77
  ui: DiscoveryUi,
74
78
  ): Promise<void> {
75
- const evaluationResult = await runCatchingAsync(() => evaluateDiscovery(args, env, ui, createGetApiKey(ctx)))
79
+ const evaluationResult = await runCatchingAsync(() =>
80
+ evaluateDiscovery(args, settings, env, ui, createGetApiKey(ctx)),
81
+ )
76
82
  if (!evaluationResult.ok) {
77
83
  ui.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
78
84
  return
79
85
  }
80
86
 
81
- await finalizeDiscovery(evaluationResult.value, env, ui)
87
+ await finalizeDiscovery(evaluationResult.value, settings, env, ui)
82
88
  }
83
89
 
84
- async function runInteractiveDiscoverWorkflow(ctx: ExtensionCommandContext, env: Env, args: string, ui: DiscoveryUi) {
90
+ async function runInteractiveDiscoverWorkflow(
91
+ ctx: ExtensionCommandContext,
92
+ settings: DiscoverySettings,
93
+ env: Env,
94
+ args: string,
95
+ ui: DiscoveryUi,
96
+ ) {
85
97
  const evaluationResult: Try<DiscoveryEvaluation> = await runWithStatusUi(
86
98
  ctx,
87
99
  'Discovering models...',
88
- async statusUi => await runCatchingAsync(() => evaluateDiscovery(args, env, statusUi, createGetApiKey(ctx))),
100
+ async statusUi =>
101
+ await runCatchingAsync(() => evaluateDiscovery(args, settings, env, statusUi, createGetApiKey(ctx))),
89
102
  )
90
103
 
91
104
  if (!evaluationResult.ok) {
@@ -93,7 +106,8 @@ async function runInteractiveDiscoverWorkflow(ctx: ExtensionCommandContext, env:
93
106
  return
94
107
  }
95
108
 
96
- await finalizeDiscovery(evaluationResult.value, env, ui, createRefreshRegistry(ctx))
109
+ await finalizeDiscovery(evaluationResult.value, settings, env, ui, createRefreshRegistry(ctx))
110
+ await updateUsageStatus(ctx, settings, env)
97
111
  }
98
112
 
99
113
  async function runWithStatusUi<T>(
@@ -111,18 +125,17 @@ async function runWithStatusUi<T>(
111
125
  })
112
126
  }
113
127
 
114
- async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<void> {
115
- if (!env.ok) return
128
+ async function updateUsageStatus(ctx: ExtensionContext, settings: DiscoverySettings, env: Env): Promise<void> {
116
129
  if (!ctx.hasUI) return // no footer to write to (print/json mode): skip the wasted fetch
117
130
  const token: object = {}
118
131
  latestToken = token
119
- const shouldClear = ctx.model?.provider !== env.value.provider_id
132
+ const shouldClear = ctx.model?.provider !== settings.providerId
120
133
  try {
121
134
  if (shouldClear) {
122
135
  ctx.ui.setStatus(USAGE_STATUS_KEY, undefined)
123
136
  return
124
137
  }
125
- const info = await fetchUsageStatus(createGetApiKey(ctx), env.value)
138
+ const info = await fetchUsageStatus(createGetApiKey(ctx), settings, env)
126
139
  if (latestToken !== token) return
127
140
  ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageStatus(info))
128
141
  } catch {
@@ -142,12 +155,12 @@ export function formatUsageStatus(info: ApiKeyInfo): string {
142
155
 
143
156
  let lastFetched: { value: ApiKeyInfo; time: Date } | undefined
144
157
 
145
- async function fetchUsageStatus(getApiKey: GetApiKey, env: Env): Promise<ApiKeyInfo> {
158
+ async function fetchUsageStatus(getApiKey: GetApiKey, settings: DiscoverySettings, env: Env): Promise<ApiKeyInfo> {
146
159
  const now = new Date()
147
160
  if (lastFetched?.time && now.getTime() - lastFetched.time.getTime() < 2000) {
148
161
  return lastFetched.value
149
162
  }
150
- const { provider } = await getRequestyConfig(getApiKey, env)
163
+ const { provider } = await getRequestyConfig(getApiKey, settings, env)
151
164
  const value = await fetchApiUsage(provider)
152
165
  lastFetched = { value, time: new Date() }
153
166
  return value
@@ -1,8 +1,9 @@
1
- import { ProviderConfig, ProviderModelConfig } from '@earendil-works/pi-coding-agent'
1
+ import type { 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
5
  import { type Env, getEnv } from './env'
6
+ import type { DiscoverySettings } from './settings'
6
7
 
7
8
  const DEFAULT_BASE_URL = 'https://router.requesty.ai/v1'
8
9
  const DEFAULT_NAME = 'Requesty'
@@ -46,17 +47,21 @@ export type ModelsDiff = {
46
47
  removed: string[]
47
48
  }
48
49
 
49
- export async function getRequestyConfig(getApiKey: GetApiKey, envConfig: Env = getEnv()): Promise<RequestyConfig> {
50
+ export async function getRequestyConfig(
51
+ getApiKey: GetApiKey,
52
+ settings: DiscoverySettings,
53
+ envConfig: Env = getEnv(),
54
+ ): Promise<RequestyConfig> {
50
55
  const data = readModelsJson(envConfig)
51
- const provider = data.providers[envConfig.provider_id]
56
+ const provider = data.providers[settings.providerId]
52
57
 
53
58
  if (!provider) {
54
- throw new Error(`${envConfig.models_json_path} does not define providers.${envConfig.provider_id}`)
59
+ throw new Error(`${envConfig.models_json_path} does not define providers.${settings.providerId}`)
55
60
  }
56
61
 
57
- const apiKey = await getApiKey(envConfig.provider_id)
62
+ const apiKey = await getApiKey(settings.providerId)
58
63
  if (!apiKey) {
59
- throw new Error(`No API key found for provider ${envConfig.provider_id}`)
64
+ throw new Error(`No API key found for provider ${settings.providerId}`)
60
65
  }
61
66
  return {
62
67
  data,
@@ -88,10 +93,15 @@ export function formatModelsDiffSummary(diff: ModelsDiff): string {
88
93
  ].join('\n')
89
94
  }
90
95
 
91
- export function updateModelsJson(data: ModelsJson, models: ProviderModelConfig[], envConfig: Env = getEnv()): void {
92
- const provider = data.providers[envConfig.provider_id]
96
+ export function updateModelsJson(
97
+ data: ModelsJson,
98
+ models: ProviderModelConfig[],
99
+ settings: DiscoverySettings,
100
+ envConfig = getEnv(),
101
+ ): void {
102
+ const provider = data.providers[settings.providerId]
93
103
  const { name, baseUrl, api, apiKey, models: _existingModels, ...passthrough } = provider
94
- data.providers[envConfig.provider_id] = {
104
+ data.providers[settings.providerId] = {
95
105
  name,
96
106
  baseUrl,
97
107
  api,
@@ -0,0 +1,68 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { parse as JSON5Parse } from 'json5'
4
+ import { prettifyError, z } from 'zod'
5
+ import { type Env } from './env'
6
+ import { formatErrorMessage } from './utils'
7
+
8
+ export const DEFAULT_PROVIDER_ID = 'requesty-export'
9
+
10
+ const HealthCheckModeSchema = z.enum(['off', 'basic', 'full']).default('full')
11
+
12
+ export const DiscoverySettingsSchema = z
13
+ .object({
14
+ bannedModels: z.array(z.string().min(1)).default([]),
15
+ healthCheckMode: HealthCheckModeSchema,
16
+ providerId: z.string().default(DEFAULT_PROVIDER_ID),
17
+ })
18
+ .strip()
19
+
20
+ export type DiscoverySettings = z.infer<typeof DiscoverySettingsSchema>
21
+
22
+ const SCHEMA_URL =
23
+ 'https://raw.githubusercontent.com/enolive/pi-requesty-discover/main/docs/requesty-discovery-settings.schema.json'
24
+
25
+ /**
26
+ * Reads the discovery settings file (JSON5, so it supports comments).
27
+ * Writes a commented default file (including the $schema editor reference) when it does not
28
+ * exist yet; throws when it exists but is invalid.
29
+ */
30
+ export function readDiscoverySettings(envConfig: Env): DiscoverySettings {
31
+ if (!fs.existsSync(envConfig.settings_path)) {
32
+ writeDefaultSettingsFile(envConfig)
33
+ return {
34
+ bannedModels: [],
35
+ healthCheckMode: 'full',
36
+ providerId: DEFAULT_PROVIDER_ID,
37
+ }
38
+ }
39
+
40
+ const raw = fs.readFileSync(envConfig.settings_path, 'utf8')
41
+ let data: unknown
42
+ try {
43
+ data = JSON5Parse(raw)
44
+ } catch (error) {
45
+ throw new Error(`Failed to parse ${envConfig.settings_path}: ${formatErrorMessage(error)}`, { cause: error })
46
+ }
47
+
48
+ const result = DiscoverySettingsSchema.safeParse(data)
49
+ if (!result.success) {
50
+ throw new Error(`${envConfig.settings_path} is invalid: ${prettifyError(result.error)}`)
51
+ }
52
+ return result.data
53
+ }
54
+
55
+ function writeDefaultSettingsFile(envConfig: Env): void {
56
+ const content = `{
57
+ $schema: "${SCHEMA_URL}",
58
+ // the provider id in models.json whose models are managed by this extension
59
+ providerId: "${DEFAULT_PROVIDER_ID}",
60
+ // health check mode: "full" (basic + reasoning/tool check), "basic", or "off"
61
+ healthCheckMode: "full",
62
+ // models excluded from discovery; useful for models that keep failing health checks
63
+ bannedModels: []
64
+ }
65
+ `
66
+ fs.mkdirSync(path.dirname(envConfig.settings_path), { recursive: true })
67
+ fs.writeFileSync(envConfig.settings_path, content, 'utf8')
68
+ }
@@ -1,5 +1,5 @@
1
1
  import { DynamicBorder, Theme } from '@earendil-works/pi-coding-agent'
2
- import { Container, Loader, Spacer, TUI } from '@earendil-works/pi-tui'
2
+ import { Container, Loader, Spacer, type TUI } from '@earendil-works/pi-tui'
3
3
 
4
4
  /**
5
5
  * A loader that allows us to change the displayed message.
package/src/utils.ts ADDED
@@ -0,0 +1,24 @@
1
+ export type Try<T> = { ok: true; value: T } | { ok: false; error: unknown }
2
+
3
+ export function runCatching<T>(fn: () => T): Try<T> {
4
+ try {
5
+ return { ok: true, value: fn() }
6
+ } catch (error) {
7
+ return { ok: false, error }
8
+ }
9
+ }
10
+
11
+ export async function runCatchingAsync<T>(fn: () => Promise<T>): Promise<Try<T>> {
12
+ try {
13
+ return { ok: true, value: await fn() }
14
+ } catch (error) {
15
+ return { ok: false, error }
16
+ }
17
+ }
18
+
19
+ export function formatErrorMessage(error: unknown): string {
20
+ if (error instanceof Error) {
21
+ return error.message
22
+ }
23
+ return String(error)
24
+ }