pi-requesty-discover 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.adoc +21 -23
- package/README.md +18 -17
- package/docs/DECISIONS.adoc +17 -0
- package/package.json +3 -2
- package/src/discovery.ts +224 -0
- package/src/env.ts +0 -6
- package/src/index.ts +124 -266
- package/src/models-json.ts +19 -8
- package/src/requesty-api.ts +9 -2
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,
|
|
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 `
|
|
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:
|
|
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 `
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
package/docs/DECISIONS.adoc
CHANGED
|
@@ -14,6 +14,22 @@ token/model state. No "extra footer" API exists.
|
|
|
14
14
|
Decision: keep `setStatus`. Revisit when the line overflows visibly with other
|
|
15
15
|
extensions' statuses, or if the usage string grows past a typical width.
|
|
16
16
|
|
|
17
|
+
== Hand-rolled `Try<T>` instead of an effects library
|
|
18
|
+
|
|
19
|
+
Context: env loading and discovery evaluation both need a caught-error result value
|
|
20
|
+
(`{ ok: true, value } | { ok: false, error }`). Libraries like Effect ship an
|
|
21
|
+
industrial `Either`/`Effect.try` for exactly this.
|
|
22
|
+
|
|
23
|
+
Why not: Effect is a runtime, a generator DSL, and a worldview — not a utility.
|
|
24
|
+
Two call sites in a Pi extension don't justify the dependency weight or the
|
|
25
|
+
learning curve for contributors. The hand-rolled version is ~10 lines
|
|
26
|
+
(`Try<T>`, `runCatching`, `runCatchingAsync`, named after Kotlin's `Result`),
|
|
27
|
+
lives in `src/discovery.ts`, and documents intent just as well.
|
|
28
|
+
|
|
29
|
+
Decision: keep the hand-rolled `Try<T>`. No `monads.ts` module either — rule of
|
|
30
|
+
three. Revisit if the extension ever needs typed errors, retries, or resource
|
|
31
|
+
scoping; that's the day Effect earns its place.
|
|
32
|
+
|
|
17
33
|
== Not a provider — a models.json-writing extension
|
|
18
34
|
|
|
19
35
|
Context: Requesty already works as a Pi provider via `models.json`. The temptation
|
|
@@ -34,3 +50,4 @@ The provider-like parts (API model fetch, usage footer, wanting Pi-managed auth)
|
|
|
34
50
|
real, but the health check and the snapshot model break the provider fit.
|
|
35
51
|
|
|
36
52
|
Decision: stay a `/requesty-discover` command that writes `models.json`.
|
|
53
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-requesty-discover",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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",
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { ProviderModelConfig } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { type Env } from './env'
|
|
3
|
+
import {
|
|
4
|
+
diffModels,
|
|
5
|
+
formatModelsDiffSummary,
|
|
6
|
+
GetApiKey,
|
|
7
|
+
getRequestyConfig,
|
|
8
|
+
type ModelsDiff,
|
|
9
|
+
ModelsJson,
|
|
10
|
+
updateModelsJson,
|
|
11
|
+
} from './models-json'
|
|
12
|
+
import { checkModels, formatHealthSummary, writeHealthCheckLog } from './health-check'
|
|
13
|
+
import { discoverModels } from './requesty-api'
|
|
14
|
+
|
|
15
|
+
const DRY_RUN_ARG = '--dry-run'
|
|
16
|
+
|
|
17
|
+
interface AutocompleteItem {
|
|
18
|
+
value: string
|
|
19
|
+
label: string
|
|
20
|
+
description: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type NotificationLevel = 'info' | 'warning' | 'error'
|
|
24
|
+
|
|
25
|
+
/** Everything the discovery workflow needs from the UI. One port, two adapters (tui/console) in index.ts. */
|
|
26
|
+
export type DiscoveryUi = {
|
|
27
|
+
notify(message: string, level: NotificationLevel): void
|
|
28
|
+
confirm(title: string, message: string): Promise<boolean>
|
|
29
|
+
setStatus(message: string): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RefreshModelsRegistry = () => Promise<void>
|
|
33
|
+
|
|
34
|
+
export type DiscoveryEvaluation = {
|
|
35
|
+
dryRun: boolean
|
|
36
|
+
modelCount: number
|
|
37
|
+
failedCount: number
|
|
38
|
+
passing: ProviderModelConfig[]
|
|
39
|
+
diff: ModelsDiff
|
|
40
|
+
healthCheckSummary: string
|
|
41
|
+
logNote: string
|
|
42
|
+
data: ModelsJson
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type Try<T> = { ok: true; value: T } | { ok: false; error: unknown }
|
|
46
|
+
|
|
47
|
+
export function formatDiscoveryFailure(error: unknown): string {
|
|
48
|
+
const detail = formatError(error)
|
|
49
|
+
return `Discovery failed: ${detail}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function evaluateDiscovery(
|
|
53
|
+
args: string,
|
|
54
|
+
env: Env,
|
|
55
|
+
ui: DiscoveryUi,
|
|
56
|
+
getApiKey: GetApiKey,
|
|
57
|
+
): Promise<DiscoveryEvaluation> {
|
|
58
|
+
ui.setStatus('Discovering Requesty models...')
|
|
59
|
+
const dryRun = args.split(' ').includes(DRY_RUN_ARG)
|
|
60
|
+
|
|
61
|
+
const { data, provider, existingModelIds } = await getRequestyConfig(getApiKey, env)
|
|
62
|
+
const models = await discoverModels(provider)
|
|
63
|
+
const modelsMap = new Map(models.map(m => [m.id, m]))
|
|
64
|
+
|
|
65
|
+
let diff: ModelsDiff
|
|
66
|
+
let failedCount = 0
|
|
67
|
+
let passing: ProviderModelConfig[]
|
|
68
|
+
let logNote = ''
|
|
69
|
+
let healthCheckSummary = ''
|
|
70
|
+
|
|
71
|
+
if (env.health_check_mode !== 'off') {
|
|
72
|
+
ui.setStatus(`Checking models 0/${models.length}...`)
|
|
73
|
+
const healthResults = await checkModels(provider, models, env.health_check_mode === 'full', {
|
|
74
|
+
onProgress: ({ completed, total }) => {
|
|
75
|
+
ui.setStatus(`Checking models ${completed}/${total}...`)
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
const sortedResults = healthResults.toSorted((a, b) => a.modelId.localeCompare(b.modelId))
|
|
79
|
+
failedCount = sortedResults.filter(r => !r.ok).length
|
|
80
|
+
passing = sortedResults.flatMap(r => {
|
|
81
|
+
const model = modelsMap.get(r.modelId)
|
|
82
|
+
return r.ok && model ? [model] : []
|
|
83
|
+
})
|
|
84
|
+
diff = diffModels(existingModelIds, passing)
|
|
85
|
+
healthCheckSummary = formatHealthSummary(sortedResults)
|
|
86
|
+
writeHealthCheckLog(provider, sortedResults, diff, env)
|
|
87
|
+
logNote = `Full health check log: ${env.health_check_log_path}\n`
|
|
88
|
+
} else {
|
|
89
|
+
passing = models
|
|
90
|
+
diff = diffModels(existingModelIds, passing)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
dryRun,
|
|
95
|
+
modelCount: models.length,
|
|
96
|
+
failedCount,
|
|
97
|
+
passing,
|
|
98
|
+
diff,
|
|
99
|
+
healthCheckSummary,
|
|
100
|
+
logNote,
|
|
101
|
+
data,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function finalizeDiscovery(
|
|
106
|
+
evaluation: DiscoveryEvaluation,
|
|
107
|
+
env: Env,
|
|
108
|
+
ui: DiscoveryUi,
|
|
109
|
+
refresh?: RefreshModelsRegistry,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const level = notificationLevel(evaluation)
|
|
112
|
+
const summary = buildDiscoverySummary(evaluation)
|
|
113
|
+
|
|
114
|
+
// Always surface the discovery result first (toast styling), then decide.
|
|
115
|
+
if (evaluation.dryRun) {
|
|
116
|
+
ui.notify(
|
|
117
|
+
`${summary}
|
|
118
|
+
Dry run: left models.json unchanged.`,
|
|
119
|
+
level,
|
|
120
|
+
)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (evaluation.passing.length === 0) {
|
|
125
|
+
ui.notify(
|
|
126
|
+
`${summary}
|
|
127
|
+
Left models.json unchanged.`,
|
|
128
|
+
level,
|
|
129
|
+
)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
ui.notify(summary, level)
|
|
134
|
+
const { title, message } = buildConfirmPrompt(evaluation)
|
|
135
|
+
const shouldUpdate = await ui.confirm(title, message)
|
|
136
|
+
if (shouldUpdate) {
|
|
137
|
+
updateModelsJson(evaluation.data, evaluation.passing, env)
|
|
138
|
+
if (!refresh) {
|
|
139
|
+
ui.notify('Updated models.json.', 'info')
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
const refreshResult = await runCatchingAsync(() => refresh())
|
|
143
|
+
if (refreshResult.ok) {
|
|
144
|
+
ui.notify('Updated models.json. New models are available in /model.', 'info')
|
|
145
|
+
} else {
|
|
146
|
+
ui.notify(
|
|
147
|
+
`Updated models.json, but the model registry could not be refreshed: ${formatError(refreshResult.error)}. Run /reload or restart Pi to use the changes.`,
|
|
148
|
+
'warning',
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
ui.notify('Left models.json unchanged.', 'info')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildDiscoverySummary(evaluation: DiscoveryEvaluation): string {
|
|
158
|
+
return [
|
|
159
|
+
`Discovered ${evaluation.modelCount} Requesty model(s).`,
|
|
160
|
+
evaluation.healthCheckSummary.trimEnd(),
|
|
161
|
+
formatModelsDiffSummary(evaluation.diff),
|
|
162
|
+
evaluation.logNote.trimEnd(),
|
|
163
|
+
]
|
|
164
|
+
.filter(part => part.length > 0)
|
|
165
|
+
.join('\n')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function buildConfirmPrompt(evaluation: DiscoveryEvaluation): { title: string; message: string } {
|
|
169
|
+
const hasIdChanges = evaluation.diff.added.length > 0 || evaluation.diff.removed.length > 0
|
|
170
|
+
if (hasIdChanges) {
|
|
171
|
+
return {
|
|
172
|
+
title: `Write ${evaluation.passing.length} model(s)?`,
|
|
173
|
+
message: 'Update models.json with the discovery result above.',
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
title: 'Refresh models.json?',
|
|
178
|
+
message: 'No model ID changes. Rewrite the file to refresh metadata anyway?',
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function notificationLevel(evaluation: DiscoveryEvaluation): NotificationLevel {
|
|
183
|
+
if (evaluation.failedCount === 0) return 'info'
|
|
184
|
+
if (evaluation.failedCount < evaluation.modelCount) return 'warning'
|
|
185
|
+
return 'error'
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function getArgumentCompletions(prefix: string): AutocompleteItem[] {
|
|
189
|
+
const options = [
|
|
190
|
+
{
|
|
191
|
+
value: DRY_RUN_ARG,
|
|
192
|
+
label: DRY_RUN_ARG,
|
|
193
|
+
description: 'Preview discovery without offering to write models.json',
|
|
194
|
+
},
|
|
195
|
+
]
|
|
196
|
+
if (!prefix) return options
|
|
197
|
+
return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function formatError(error: unknown) {
|
|
201
|
+
return error instanceof Error ? error.message : String(error)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function runCatching<T>(fn: () => T): Try<T> {
|
|
205
|
+
try {
|
|
206
|
+
return { ok: true, value: fn() }
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return { ok: false, error }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function runCatchingAsync<T>(fn: () => Promise<T>): Promise<Try<T>> {
|
|
213
|
+
try {
|
|
214
|
+
return { ok: true, value: await fn() }
|
|
215
|
+
} catch (error) {
|
|
216
|
+
return { ok: false, error }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function complainOnBrokenEnv(ui: DiscoveryUi, env: Try<Env>) {
|
|
221
|
+
if (!env.ok) {
|
|
222
|
+
ui.notify(`failed to load env: ${formatError(env.error)}`, 'error')
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/env.ts
CHANGED
|
@@ -11,7 +11,6 @@ export type Env = {
|
|
|
11
11
|
models_json_path: string
|
|
12
12
|
health_check_log_path: string
|
|
13
13
|
provider_id: string
|
|
14
|
-
requesty_api_key: string
|
|
15
14
|
health_check_mode: z.infer<typeof HealthCheckModeSchema>
|
|
16
15
|
}
|
|
17
16
|
|
|
@@ -22,16 +21,11 @@ export function getEnv(): Env {
|
|
|
22
21
|
throw new Error(prettifyError(result.error))
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
const apiKey = envVars.REQUESTY_API_KEY
|
|
26
|
-
if (!apiKey) {
|
|
27
|
-
throw new Error(`apiKey must be set via REQUESTY_API_KEY env var`)
|
|
28
|
-
}
|
|
29
24
|
const agentPath = getAgentDir()
|
|
30
25
|
return {
|
|
31
26
|
models_json_path: path.join(agentPath, 'models.json'),
|
|
32
27
|
health_check_log_path: path.join(agentPath, 'requesty-health-check.log'),
|
|
33
28
|
provider_id: envVars.REQUESTY_PROVIDER_ID ?? DEFAULT_PROVIDER_ID,
|
|
34
|
-
requesty_api_key: apiKey,
|
|
35
29
|
health_check_mode: result.data,
|
|
36
30
|
}
|
|
37
31
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,312 +1,128 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionCommandContext,
|
|
4
|
-
ExtensionContext,
|
|
5
|
-
ProviderModelConfig,
|
|
6
|
-
} from '@earendil-works/pi-coding-agent'
|
|
1
|
+
import { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
7
2
|
import { type Env, getEnv } from './env'
|
|
8
|
-
import {
|
|
9
|
-
|
|
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
|
-
|
|
24
|
-
value: string
|
|
25
|
-
label: string
|
|
26
|
-
description: string
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export type NotificationLevel = 'info' | 'warning' | 'error'
|
|
30
|
-
|
|
31
|
-
export type Notifier = {
|
|
32
|
-
notify(message: string, level: NotificationLevel): void
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export type Confirmer = {
|
|
36
|
-
confirm(title: string, message: string): Promise<boolean>
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export type StatusReporter = {
|
|
40
|
-
set(message: string): void
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
type ModelsJsonData = ReturnType<typeof getRequestyConfig>['data']
|
|
44
|
-
|
|
45
|
-
type DiscoveryEvaluation = {
|
|
46
|
-
dryRun: boolean
|
|
47
|
-
modelCount: number
|
|
48
|
-
failedCount: number
|
|
49
|
-
passing: ProviderModelConfig[]
|
|
50
|
-
diff: ModelsDiff
|
|
51
|
-
healthCheckSummary: string
|
|
52
|
-
logNote: string
|
|
53
|
-
data: ModelsJsonData
|
|
54
|
-
env: Env
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
type EvaluationResult =
|
|
58
|
-
| {
|
|
59
|
-
ok: true
|
|
60
|
-
value: DiscoveryEvaluation
|
|
61
|
-
}
|
|
62
|
-
| { ok: false; error: unknown }
|
|
63
|
-
|
|
64
|
-
// A per-instance token suppresses stale writes when turns overlap
|
|
25
|
+
// token suppresses stale writes when turns overlap
|
|
65
26
|
let latestToken: object = {}
|
|
66
27
|
|
|
67
28
|
// noinspection JSUnusedGlobalSymbols
|
|
68
29
|
export default function (pi: ExtensionAPI) {
|
|
30
|
+
const env = runCatching(() => getEnv())
|
|
31
|
+
|
|
69
32
|
pi.registerCommand(COMMAND_NAME, {
|
|
70
33
|
description: 'Dynamically discover Requesty models, run health checks, and update the local models.json.',
|
|
71
34
|
getArgumentCompletions,
|
|
72
35
|
handler: async (args, ctx) => {
|
|
73
|
-
await runDiscoveryWorkflow(ctx, args)
|
|
36
|
+
await runDiscoveryWorkflow(ctx, env, args)
|
|
74
37
|
},
|
|
75
38
|
})
|
|
76
39
|
|
|
77
40
|
pi.on('turn_end', (_event, ctx) => {
|
|
78
|
-
void updateUsageStatus(ctx)
|
|
41
|
+
void updateUsageStatus(ctx, env)
|
|
79
42
|
})
|
|
80
43
|
|
|
81
44
|
pi.on('session_start', (_event, ctx) => {
|
|
82
|
-
|
|
45
|
+
const ui = createTuiUi(ctx)
|
|
46
|
+
complainOnBrokenEnv(ui, env)
|
|
47
|
+
void updateUsageStatus(ctx, env)
|
|
83
48
|
})
|
|
84
49
|
|
|
85
50
|
pi.on('model_select', (_event, ctx) => {
|
|
86
|
-
void updateUsageStatus(ctx)
|
|
51
|
+
void updateUsageStatus(ctx, env)
|
|
87
52
|
})
|
|
88
53
|
}
|
|
89
54
|
|
|
90
|
-
export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, args: string) {
|
|
91
|
-
|
|
55
|
+
export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, env: Try<Env>, args: string) {
|
|
56
|
+
const ui = ctx.mode === 'tui' ? createTuiUi(ctx) : createConsoleUi()
|
|
57
|
+
complainOnBrokenEnv(ui, env)
|
|
58
|
+
if (!env.ok) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
92
62
|
if (ctx.mode !== 'tui') {
|
|
63
|
+
return runSilentDiscoveryWorkflow(ctx, env.value, args, ui)
|
|
64
|
+
} else {
|
|
65
|
+
return runInteractiveDiscoverWorkflow(ctx, env.value, args, ui)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function runSilentDiscoveryWorkflow(
|
|
70
|
+
ctx: ExtensionCommandContext,
|
|
71
|
+
env: Env,
|
|
72
|
+
args: string,
|
|
73
|
+
ui: DiscoveryUi,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const evaluationResult = await runCatchingAsync(() => evaluateDiscovery(args, env, ui, createGetApiKey(ctx)))
|
|
76
|
+
if (!evaluationResult.ok) {
|
|
77
|
+
ui.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
|
|
93
78
|
return
|
|
94
79
|
}
|
|
95
80
|
|
|
96
|
-
|
|
97
|
-
|
|
81
|
+
await finalizeDiscovery(evaluationResult.value, env, ui)
|
|
82
|
+
}
|
|
98
83
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
} catch (error) {
|
|
106
|
-
return { ok: false, error }
|
|
107
|
-
}
|
|
108
|
-
})
|
|
84
|
+
async function runInteractiveDiscoverWorkflow(ctx: ExtensionCommandContext, env: Env, args: string, ui: DiscoveryUi) {
|
|
85
|
+
const evaluationResult: Try<DiscoveryEvaluation> = await runWithStatusUi(
|
|
86
|
+
ctx,
|
|
87
|
+
'Discovering models...',
|
|
88
|
+
async statusUi => await runCatchingAsync(() => evaluateDiscovery(args, env, statusUi, createGetApiKey(ctx))),
|
|
89
|
+
)
|
|
109
90
|
|
|
110
91
|
if (!evaluationResult.ok) {
|
|
111
|
-
|
|
92
|
+
ui.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
|
|
112
93
|
return
|
|
113
94
|
}
|
|
114
95
|
|
|
115
|
-
|
|
116
|
-
await finalizeDiscovery(evaluationResult.value, confirmer, notifier)
|
|
96
|
+
await finalizeDiscovery(evaluationResult.value, env, ui, createRefreshRegistry(ctx))
|
|
117
97
|
}
|
|
118
98
|
|
|
119
99
|
async function runWithStatusUi<T>(
|
|
120
100
|
ctx: ExtensionCommandContext,
|
|
121
101
|
initialMessage: string,
|
|
122
|
-
fn: (
|
|
102
|
+
fn: (statusUi: DiscoveryUi) => Promise<T>,
|
|
123
103
|
): Promise<T> {
|
|
124
|
-
return ctx.ui.custom<T>((
|
|
125
|
-
const loader = new RequestyStatusLoader(
|
|
126
|
-
const status = createLoaderStatusReporter(loader)
|
|
104
|
+
return ctx.ui.custom<T>((tui, theme, _kb, done) => {
|
|
105
|
+
const loader = new RequestyStatusLoader(tui, theme, initialMessage)
|
|
127
106
|
void Promise.resolve()
|
|
128
|
-
.then(() => fn(
|
|
107
|
+
.then(() => fn(createTuiUi(ctx, loader)))
|
|
129
108
|
.then(done)
|
|
130
109
|
.catch(done)
|
|
131
110
|
return loader
|
|
132
111
|
})
|
|
133
112
|
}
|
|
134
113
|
|
|
135
|
-
function
|
|
136
|
-
|
|
137
|
-
return `Discovery failed: ${detail}`
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function evaluateDiscovery(args: string, status: StatusReporter): Promise<DiscoveryEvaluation> {
|
|
141
|
-
status.set('Discovering Requesty models...')
|
|
142
|
-
const dryRun = args.split(' ').includes(DRY_RUN_ARG)
|
|
143
|
-
|
|
144
|
-
const env = getEnv()
|
|
145
|
-
const { data, provider, existingModelIds } = getRequestyConfig(env)
|
|
146
|
-
const models = await discoverModels(provider)
|
|
147
|
-
const modelsMap = new Map(models.map(m => [m.id, m]))
|
|
148
|
-
|
|
149
|
-
let diff: ModelsDiff
|
|
150
|
-
let failedCount = 0
|
|
151
|
-
let passing: ProviderModelConfig[]
|
|
152
|
-
let logNote = ''
|
|
153
|
-
let healthCheckSummary = ''
|
|
154
|
-
|
|
155
|
-
if (env.health_check_mode !== 'off') {
|
|
156
|
-
status.set(`Checking models 0/${models.length}...`)
|
|
157
|
-
const healthResults = await checkModels(provider, models, env.health_check_mode === 'full', {
|
|
158
|
-
onProgress: ({ completed, total }) => {
|
|
159
|
-
status.set(`Checking models ${completed}/${total}...`)
|
|
160
|
-
},
|
|
161
|
-
})
|
|
162
|
-
const sortedResults = healthResults.toSorted((a, b) => a.modelId.localeCompare(b.modelId))
|
|
163
|
-
failedCount = sortedResults.filter(r => !r.ok).length
|
|
164
|
-
passing = sortedResults.flatMap(r => {
|
|
165
|
-
const model = modelsMap.get(r.modelId)
|
|
166
|
-
return r.ok && model ? [model] : []
|
|
167
|
-
})
|
|
168
|
-
diff = diffModels(existingModelIds, passing)
|
|
169
|
-
healthCheckSummary = formatHealthSummary(sortedResults)
|
|
170
|
-
writeHealthCheckLog(provider, sortedResults, diff, env)
|
|
171
|
-
logNote = `Full health check log: ${env.health_check_log_path}\n`
|
|
172
|
-
} else {
|
|
173
|
-
passing = models
|
|
174
|
-
diff = diffModels(existingModelIds, passing)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return {
|
|
178
|
-
dryRun,
|
|
179
|
-
modelCount: models.length,
|
|
180
|
-
failedCount,
|
|
181
|
-
passing,
|
|
182
|
-
diff,
|
|
183
|
-
healthCheckSummary,
|
|
184
|
-
logNote,
|
|
185
|
-
data,
|
|
186
|
-
env,
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async function finalizeDiscovery(
|
|
191
|
-
evaluation: DiscoveryEvaluation,
|
|
192
|
-
confirmer: Confirmer,
|
|
193
|
-
notifier: Notifier,
|
|
194
|
-
): Promise<void> {
|
|
195
|
-
const level = notificationLevel(evaluation)
|
|
196
|
-
const summary = buildDiscoverySummary(evaluation)
|
|
197
|
-
|
|
198
|
-
// Always surface the discovery result first (toast styling), then decide.
|
|
199
|
-
if (evaluation.dryRun) {
|
|
200
|
-
notifier.notify(
|
|
201
|
-
`${summary}
|
|
202
|
-
Dry run: left models.json unchanged.`,
|
|
203
|
-
level,
|
|
204
|
-
)
|
|
205
|
-
return
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (evaluation.passing.length === 0) {
|
|
209
|
-
notifier.notify(
|
|
210
|
-
`${summary}
|
|
211
|
-
Left models.json unchanged.`,
|
|
212
|
-
level,
|
|
213
|
-
)
|
|
214
|
-
return
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
notifier.notify(summary, level)
|
|
218
|
-
const { title, message } = buildConfirmPrompt(evaluation)
|
|
219
|
-
const shouldUpdate = await confirmer.confirm(title, message)
|
|
220
|
-
if (shouldUpdate) {
|
|
221
|
-
updateModelsJson(evaluation.data, evaluation.passing, evaluation.env)
|
|
222
|
-
notifier.notify('Updated models.json. Run /reload to use the changes.', 'info')
|
|
223
|
-
return
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
notifier.notify('Left models.json unchanged.', 'info')
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function buildDiscoverySummary(evaluation: DiscoveryEvaluation): string {
|
|
230
|
-
return [
|
|
231
|
-
`Discovered ${evaluation.modelCount} Requesty model(s).`,
|
|
232
|
-
evaluation.healthCheckSummary.trimEnd(),
|
|
233
|
-
formatModelsDiffSummary(evaluation.diff),
|
|
234
|
-
evaluation.logNote.trimEnd(),
|
|
235
|
-
]
|
|
236
|
-
.filter(part => part.length > 0)
|
|
237
|
-
.join('\n')
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function buildConfirmPrompt(evaluation: DiscoveryEvaluation): { title: string; message: string } {
|
|
241
|
-
const hasIdChanges = evaluation.diff.added.length > 0 || evaluation.diff.removed.length > 0
|
|
242
|
-
if (hasIdChanges) {
|
|
243
|
-
return {
|
|
244
|
-
title: `Write ${evaluation.passing.length} model(s)?`,
|
|
245
|
-
message: 'Update models.json with the discovery result above.',
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
return {
|
|
249
|
-
title: 'Refresh models.json?',
|
|
250
|
-
message: 'No model ID changes. Rewrite the file to refresh metadata anyway?',
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function notificationLevel(evaluation: DiscoveryEvaluation): NotificationLevel {
|
|
255
|
-
if (evaluation.failedCount === 0) return 'info'
|
|
256
|
-
if (evaluation.failedCount < evaluation.modelCount) return 'warning'
|
|
257
|
-
return 'error'
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function getArgumentCompletions(prefix: string): AutocompleteItem[] {
|
|
261
|
-
const options = [
|
|
262
|
-
{
|
|
263
|
-
value: DRY_RUN_ARG,
|
|
264
|
-
label: DRY_RUN_ARG,
|
|
265
|
-
description: 'Preview discovery without offering to write models.json',
|
|
266
|
-
},
|
|
267
|
-
]
|
|
268
|
-
if (!prefix) return options
|
|
269
|
-
return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function createUiNotifier(ctx: ExtensionCommandContext): Notifier {
|
|
273
|
-
return {
|
|
274
|
-
notify(message: string, level?: NotificationLevel) {
|
|
275
|
-
const prefixedMessage = `${COMMAND_NAME}: ${message}`
|
|
276
|
-
ctx.ui.notify(prefixedMessage, level)
|
|
277
|
-
},
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function createUiConfirmer(ctx: ExtensionCommandContext): Confirmer {
|
|
282
|
-
return {
|
|
283
|
-
confirm(title, message) {
|
|
284
|
-
return ctx.ui.confirm(title, message)
|
|
285
|
-
},
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function createLoaderStatusReporter(loader: RequestyStatusLoader): StatusReporter {
|
|
290
|
-
return {
|
|
291
|
-
set(message: string) {
|
|
292
|
-
loader.setMessage(message)
|
|
293
|
-
},
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export async function updateUsageStatus(ctx: ExtensionContext): Promise<void> {
|
|
114
|
+
async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<void> {
|
|
115
|
+
if (!env.ok) return
|
|
298
116
|
if (!ctx.hasUI) return // no footer to write to (print/json mode): skip the wasted fetch
|
|
299
117
|
const token: object = {}
|
|
300
118
|
latestToken = token
|
|
301
|
-
const
|
|
302
|
-
if (providerId === undefined) return // not configured for Requesty: nothing to do
|
|
303
|
-
const shouldClear = ctx.model?.provider !== providerId
|
|
119
|
+
const shouldClear = ctx.model?.provider !== env.value.provider_id
|
|
304
120
|
try {
|
|
305
121
|
if (shouldClear) {
|
|
306
122
|
ctx.ui.setStatus(USAGE_STATUS_KEY, undefined)
|
|
307
123
|
return
|
|
308
124
|
}
|
|
309
|
-
const info = await fetchUsageStatus()
|
|
125
|
+
const info = await fetchUsageStatus(createGetApiKey(ctx), env.value)
|
|
310
126
|
if (latestToken !== token) return
|
|
311
127
|
ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageStatus(info))
|
|
312
128
|
} catch {
|
|
@@ -314,27 +130,69 @@ export async function updateUsageStatus(ctx: ExtensionContext): Promise<void> {
|
|
|
314
130
|
}
|
|
315
131
|
}
|
|
316
132
|
|
|
317
|
-
/** Returns the configured Requesty provider id, or undefined when Requesty is not configured. */
|
|
318
|
-
function getRequestyProviderId(): string | undefined {
|
|
319
|
-
try {
|
|
320
|
-
return getEnv().provider_id
|
|
321
|
-
} catch {
|
|
322
|
-
return undefined
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
133
|
export function formatUsageStatus(info: ApiKeyInfo): string {
|
|
327
134
|
const spend = `$${info.monthlySpend.toFixed(2)}`
|
|
328
135
|
if (info.monthlyLimit <= 0) {
|
|
329
|
-
return
|
|
136
|
+
return `${info.name}: ${spend} (unlimited)`
|
|
330
137
|
}
|
|
331
138
|
const limit = `$${info.monthlyLimit.toFixed(2)}`
|
|
332
139
|
const percent = Math.floor((info.monthlySpend / info.monthlyLimit) * 100)
|
|
333
|
-
return
|
|
140
|
+
return `${info.name}: ${spend}/${limit} (${percent}%)`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let lastFetched: { value: ApiKeyInfo; time: Date } | undefined
|
|
144
|
+
|
|
145
|
+
async function fetchUsageStatus(getApiKey: GetApiKey, env: Env): Promise<ApiKeyInfo> {
|
|
146
|
+
const now = new Date()
|
|
147
|
+
if (lastFetched?.time && now.getTime() - lastFetched.time.getTime() < 2000) {
|
|
148
|
+
return lastFetched.value
|
|
149
|
+
}
|
|
150
|
+
const { provider } = await getRequestyConfig(getApiKey, env)
|
|
151
|
+
const value = await fetchApiUsage(provider)
|
|
152
|
+
lastFetched = { value, time: new Date() }
|
|
153
|
+
return value
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* For testing purposes, reset the cache after each run
|
|
158
|
+
*/
|
|
159
|
+
export function resetUsageStatusCache(): void {
|
|
160
|
+
lastFetched = undefined
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** TUI adapter: routes the workflow's UI needs to Pi's ctx.ui. */
|
|
164
|
+
export function createTuiUi(ctx: ExtensionContext, loader?: RequestyStatusLoader): DiscoveryUi {
|
|
165
|
+
return {
|
|
166
|
+
notify: (message, level) => ctx.ui.notify(`${COMMAND_NAME}: ${message}`, level),
|
|
167
|
+
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
168
|
+
setStatus: message => loader?.setMessage(message),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Console adapter for non-interactive modes. Always confirms: print mode is
|
|
174
|
+
* non-interactive, so the write proceeds without a confirmation dialog.
|
|
175
|
+
*/
|
|
176
|
+
export function createConsoleUi(): DiscoveryUi {
|
|
177
|
+
return {
|
|
178
|
+
notify: (message, level) => console.log(`[${level}] ${message}`),
|
|
179
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- must match the DiscoveryUi promise signature
|
|
180
|
+
confirm: async () => true,
|
|
181
|
+
setStatus: message => console.log(message),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Re-reads models.json through Pi's model registry so new models are usable without /reload. */
|
|
186
|
+
export function createRefreshRegistry(ctx: ExtensionContext): RefreshModelsRegistry {
|
|
187
|
+
return () =>
|
|
188
|
+
ctx.modelRegistry.refresh({ allowNetwork: false }).then(result => {
|
|
189
|
+
if (result.aborted || result.errors.size > 0) {
|
|
190
|
+
const detail = [...result.errors.entries()].map(([id, error]) => `${id}: ${error.message}`).join(', ')
|
|
191
|
+
throw new Error(detail || 'refresh aborted')
|
|
192
|
+
}
|
|
193
|
+
})
|
|
334
194
|
}
|
|
335
195
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const { provider } = getRequestyConfig(env)
|
|
339
|
-
return await fetchApiUsage(provider)
|
|
196
|
+
function createGetApiKey(ctx: ExtensionContext): GetApiKey {
|
|
197
|
+
return providerId => ctx.modelRegistry.getApiKeyForProvider(providerId)
|
|
340
198
|
}
|
package/src/models-json.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
5
|
+
import { type Env, getEnv } from './env'
|
|
6
6
|
|
|
7
7
|
const DEFAULT_BASE_URL = 'https://router.requesty.ai/v1'
|
|
8
8
|
const DEFAULT_NAME = 'Requesty'
|
|
9
9
|
|
|
10
|
+
/** Resolves the API key for a provider through Pi's model registry. */
|
|
11
|
+
export type GetApiKey = (providerId: string) => Promise<string | undefined>
|
|
12
|
+
|
|
10
13
|
const ProviderSchema = z
|
|
11
14
|
.object({
|
|
12
15
|
name: z.string().optional(),
|
|
13
16
|
baseUrl: z.string().optional(),
|
|
14
17
|
apiKey: z.string().optional(),
|
|
18
|
+
api: z.string().optional(),
|
|
15
19
|
models: z.array(z.object({ id: z.string() }).catchall(z.unknown())).optional(),
|
|
16
20
|
})
|
|
17
21
|
.catchall(z.unknown())
|
|
@@ -22,10 +26,10 @@ const ModelsJsonSchema = z
|
|
|
22
26
|
})
|
|
23
27
|
.catchall(z.unknown())
|
|
24
28
|
|
|
25
|
-
type ModelsJson = z.infer<typeof ModelsJsonSchema>
|
|
29
|
+
export type ModelsJson = z.infer<typeof ModelsJsonSchema>
|
|
26
30
|
type ModelsJsonProvider = z.infer<typeof ProviderSchema>
|
|
27
31
|
|
|
28
|
-
type RequestyProvider = ModelsJsonProvider & {
|
|
32
|
+
export type RequestyProvider = ModelsJsonProvider & {
|
|
29
33
|
name: string
|
|
30
34
|
baseUrl: string
|
|
31
35
|
apiKey: string
|
|
@@ -42,7 +46,7 @@ export type ModelsDiff = {
|
|
|
42
46
|
removed: string[]
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
export function getRequestyConfig(envConfig: Env = getEnv()): RequestyConfig {
|
|
49
|
+
export async function getRequestyConfig(getApiKey: GetApiKey, envConfig: Env = getEnv()): Promise<RequestyConfig> {
|
|
46
50
|
const data = readModelsJson(envConfig)
|
|
47
51
|
const provider = data.providers[envConfig.provider_id]
|
|
48
52
|
|
|
@@ -50,7 +54,10 @@ export function getRequestyConfig(envConfig: Env = getEnv()): RequestyConfig {
|
|
|
50
54
|
throw new Error(`${envConfig.models_json_path} does not define providers.${envConfig.provider_id}`)
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
const apiKey = envConfig.
|
|
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
|
-
|
|
88
|
-
|
|
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,
|
package/src/requesty-api.ts
CHANGED
|
@@ -31,9 +31,14 @@ type Provider = {
|
|
|
31
31
|
apiKey: string
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export async function discoverModels(
|
|
34
|
+
export async function discoverModels(
|
|
35
|
+
provider: Provider,
|
|
36
|
+
options: { timeoutMs?: number } = {},
|
|
37
|
+
): Promise<ProviderModelConfig[]> {
|
|
38
|
+
const timeoutMs = options.timeoutMs ?? 5_000
|
|
35
39
|
const response = await fetch(`${provider.baseUrl}/models`, {
|
|
36
40
|
headers: { Authorization: `Bearer ${provider.apiKey}` },
|
|
41
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
37
42
|
})
|
|
38
43
|
|
|
39
44
|
if (!response.ok) {
|
|
@@ -82,10 +87,12 @@ const ApiKeyInfoSchema = z
|
|
|
82
87
|
|
|
83
88
|
export type ApiKeyInfo = z.infer<typeof ApiKeyInfoSchema>
|
|
84
89
|
|
|
85
|
-
export async function fetchApiUsage(provider: Provider): Promise<ApiKeyInfo> {
|
|
90
|
+
export async function fetchApiUsage(provider: Provider, options: { timeoutMs?: number } = {}): Promise<ApiKeyInfo> {
|
|
86
91
|
const url = `${REQUESTY_MANAGE_URL}/apikey/self`
|
|
92
|
+
const timeoutMs = options.timeoutMs ?? 2_000
|
|
87
93
|
const response = await fetch(url, {
|
|
88
94
|
headers: { Authorization: `Bearer ${provider.apiKey}` },
|
|
95
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
89
96
|
})
|
|
90
97
|
|
|
91
98
|
if (!response.ok) {
|