pi-requesty-discover 2.2.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.adoc +26 -13
- package/README.md +25 -3
- package/docs/DECISIONS.adoc +22 -29
- package/package.json +2 -1
- package/src/discovery.ts +54 -56
- package/src/env.ts +2 -16
- package/src/health-check.ts +63 -34
- package/src/index.ts +48 -35
- package/src/models-json.ts +19 -9
- package/src/settings.ts +47 -0
- package/src/ui/requesty-status-loader.ts +1 -1
- package/src/utils.ts +24 -0
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
|
|
64
|
+
You can override the provider id via the settings.
|
|
64
65
|
|
|
65
66
|
Example:
|
|
66
67
|
|
|
@@ -83,24 +84,36 @@ 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. It is optional; missing settings default to empty.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
|
|
94
|
+
[source,json5]
|
|
95
|
+
----
|
|
96
|
+
{
|
|
97
|
+
// override the default provider id
|
|
98
|
+
providerId: "requesty-export",
|
|
99
|
+
// configure the health check mode
|
|
100
|
+
healthCheckMode: "full", // or "basic", "off"
|
|
101
|
+
// models excluded from discovery; useful for models that keep failing health checks
|
|
102
|
+
bannedModels: [
|
|
103
|
+
// hangs on tool calls
|
|
104
|
+
'requesty/unstable-model',
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
----
|
|
108
|
+
|
|
109
|
+
Banned models are excluded before health checks run (so they cost no credits) and are removed from `models.json` on the next discovery write.
|
|
110
|
+
|
|
86
111
|
=== Environment variables
|
|
87
112
|
|
|
88
113
|
[cols="1,1,1,1,2",options="header"]
|
|
89
114
|
|===
|
|
90
115
|
| Name | Required | Type | Default | Meaning
|
|
91
116
|
|
|
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
117
|
| `PI_CODING_AGENT_DIR`
|
|
105
118
|
| no
|
|
106
119
|
| `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
|
|
53
|
+
You can override the provider id via the settings.
|
|
53
54
|
|
|
54
55
|
Example:
|
|
55
56
|
|
|
@@ -72,12 +73,33 @@ 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. It is optional; missing settings default to empty.
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
|
|
83
|
+
```json5
|
|
84
|
+
{
|
|
85
|
+
// override the default provider id
|
|
86
|
+
providerId: "requesty-export",
|
|
87
|
+
// configure the health check mode
|
|
88
|
+
healthCheckMode: "full", // or "basic", "off"
|
|
89
|
+
// models excluded from discovery; useful for models that keep failing health checks
|
|
90
|
+
bannedModels: [
|
|
91
|
+
// hangs on tool calls
|
|
92
|
+
'requesty/unstable-model',
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Banned models are excluded before health checks run (so they cost no credits) and are removed from `models.json` on the next discovery write.
|
|
98
|
+
|
|
75
99
|
### Environment variables
|
|
76
100
|
|
|
77
101
|
| Name | Required | Type | Default | Meaning |
|
|
78
102
|
| --- | --- | --- | --- | --- |
|
|
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
103
|
| `PI_CODING_AGENT_DIR` | no | `string` | `~/.pi/agent` | pi configuration directory |
|
|
82
104
|
|
|
83
105
|
## Command
|
package/docs/DECISIONS.adoc
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`.
|
|
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
|
-
|
|
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
|
-
|
|
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>`.
|
|
30
|
-
|
|
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`.
|
|
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.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
- A provider keeps its model list in-memory and refreshes occasionally.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-requesty-discover",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Discover available Requesty models for the Pi Coding Agent",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"vitest": "^4.1.9"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
+
"json5": "^2.2.3",
|
|
67
68
|
"zod": "^4.4.3"
|
|
68
69
|
}
|
|
69
70
|
}
|
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 =
|
|
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
|
|
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
|
|
75
|
+
let warningCount = 0
|
|
76
|
+
let passing: ProviderModelConfig[] = []
|
|
68
77
|
let logNote = ''
|
|
69
78
|
let healthCheckSummary = ''
|
|
70
79
|
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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: ${
|
|
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
|
|
184
|
-
if (evaluation.
|
|
185
|
-
return '
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
health_check_mode: result.data,
|
|
15
|
+
settings_path: path.join(agentPath, 'requesty-discovery-settings.json5'),
|
|
30
16
|
}
|
|
31
17
|
}
|
package/src/health-check.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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: ${
|
|
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
|
|
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: ${
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 (
|
|
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 (
|
|
308
|
+
if (reasoningResult.status !== 'ok') {
|
|
280
309
|
return {
|
|
281
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
66
|
+
return runSilentDiscoveryWorkflow(ctx, settings, env, args, ui)
|
|
64
67
|
} else {
|
|
65
|
-
return runInteractiveDiscoverWorkflow(ctx, env
|
|
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(() =>
|
|
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(
|
|
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 =>
|
|
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:
|
|
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 !==
|
|
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
|
|
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
|
package/src/models-json.ts
CHANGED
|
@@ -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(
|
|
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[
|
|
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.${
|
|
59
|
+
throw new Error(`${envConfig.models_json_path} does not define providers.${settings.providerId}`)
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
const apiKey = await getApiKey(
|
|
62
|
+
const apiKey = await getApiKey(settings.providerId)
|
|
58
63
|
if (!apiKey) {
|
|
59
|
-
throw new Error(`No API key found for provider ${
|
|
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(
|
|
92
|
-
|
|
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[
|
|
104
|
+
data.providers[settings.providerId] = {
|
|
95
105
|
name,
|
|
96
106
|
baseUrl,
|
|
97
107
|
api,
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { parse as JSON5Parse } from 'json5'
|
|
3
|
+
import { prettifyError, z } from 'zod'
|
|
4
|
+
import { type Env } from './env'
|
|
5
|
+
import { formatErrorMessage } from './utils'
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_PROVIDER_ID = 'requesty-export'
|
|
8
|
+
|
|
9
|
+
const HealthCheckModeSchema = z.enum(['off', 'basic', 'full']).default('full')
|
|
10
|
+
|
|
11
|
+
const DiscoverySettingsSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
bannedModels: z.array(z.string().min(1)).default([]),
|
|
14
|
+
healthCheckMode: HealthCheckModeSchema,
|
|
15
|
+
providerId: z.string().default(DEFAULT_PROVIDER_ID),
|
|
16
|
+
})
|
|
17
|
+
.strip()
|
|
18
|
+
|
|
19
|
+
export type DiscoverySettings = z.infer<typeof DiscoverySettingsSchema>
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reads the discovery settings file (JSON5, so it supports comments).
|
|
23
|
+
* Returns defaults when the file does not exist; throws when it exists but is invalid.
|
|
24
|
+
*/
|
|
25
|
+
export function readDiscoverySettings(envConfig: Env): DiscoverySettings {
|
|
26
|
+
if (!fs.existsSync(envConfig.settings_path)) {
|
|
27
|
+
return {
|
|
28
|
+
bannedModels: [],
|
|
29
|
+
healthCheckMode: 'full',
|
|
30
|
+
providerId: DEFAULT_PROVIDER_ID,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const raw = fs.readFileSync(envConfig.settings_path, 'utf8')
|
|
35
|
+
let data: unknown
|
|
36
|
+
try {
|
|
37
|
+
data = JSON5Parse(raw)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new Error(`Failed to parse ${envConfig.settings_path}: ${formatErrorMessage(error)}`, { cause: error })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const result = DiscoverySettingsSchema.safeParse(data)
|
|
43
|
+
if (!result.success) {
|
|
44
|
+
throw new Error(`${envConfig.settings_path} is invalid: ${prettifyError(result.error)}`)
|
|
45
|
+
}
|
|
46
|
+
return result.data
|
|
47
|
+
}
|
|
@@ -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
|
+
}
|