pi-requesty-discover 1.0.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/LICENSE +25 -0
- package/README.adoc +129 -0
- package/index.ts +4 -0
- package/package.json +56 -0
- package/src/env.ts +41 -0
- package/src/health-check.ts +281 -0
- package/src/index.ts +118 -0
- package/src/models-json.ts +103 -0
- package/src/requesty-api.ts +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christoph Welcz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
Copyright for portions of project pi-requesty-discover are held by [Requesty, 2026]
|
|
25
|
+
as part of project pi-requesty. All other copyright are held by [Christoph Welcz, 2026].
|
package/README.adoc
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
ifdef::env-github[]
|
|
2
|
+
:tip-caption: :bulb:
|
|
3
|
+
:note-caption: :information_source:
|
|
4
|
+
:important-caption: :heavy_exclamation_mark:
|
|
5
|
+
:caution-caption: :fire:
|
|
6
|
+
:warning-caption: :warning:
|
|
7
|
+
endif::[]
|
|
8
|
+
|
|
9
|
+
= pi-requesty-discover
|
|
10
|
+
|
|
11
|
+
This extension helps you discover currently available models in requesty.ai for your Pi Coding Agent.
|
|
12
|
+
|
|
13
|
+
This is a fork of https://github.com/requestyai/pi-requesty[pi-requesty] with additional features:
|
|
14
|
+
|
|
15
|
+
* Use `REQUESTY_API_KEY` from the environment
|
|
16
|
+
* Health-check available models and write a log about them
|
|
17
|
+
* Provide a dry-run mode that does not change anything
|
|
18
|
+
|
|
19
|
+
== When and why use it
|
|
20
|
+
|
|
21
|
+
Use this extension if
|
|
22
|
+
|
|
23
|
+
- you are on Requesty.ai
|
|
24
|
+
- you are limited to certain models by a company policy (for instance, for data residency/protection reasons)
|
|
25
|
+
- you don't want to handcraft your own curated list of models constantly
|
|
26
|
+
- you want to health check the list to complain about non-working models via a dry-run
|
|
27
|
+
|
|
28
|
+
== Recommended Installation from GitHub repo
|
|
29
|
+
|
|
30
|
+
[source,bash]
|
|
31
|
+
----
|
|
32
|
+
pi install git:github.com/enolive/pi-requesty-discover
|
|
33
|
+
----
|
|
34
|
+
|
|
35
|
+
== Install locally
|
|
36
|
+
|
|
37
|
+
Check out the code from the official code repository `https://github.com/enolive/pi-requesty-discover`, and then:
|
|
38
|
+
|
|
39
|
+
[source,bash]
|
|
40
|
+
----
|
|
41
|
+
pi install ./pi-requesty-discover
|
|
42
|
+
----
|
|
43
|
+
|
|
44
|
+
To run once without installing:
|
|
45
|
+
|
|
46
|
+
[source,bash]
|
|
47
|
+
----
|
|
48
|
+
pi -e ./pi-requesty-discover
|
|
49
|
+
----
|
|
50
|
+
|
|
51
|
+
== Configuration
|
|
52
|
+
|
|
53
|
+
The extension reads the `requesty-export` provider from `~/.pi/agent/models.json`.
|
|
54
|
+
You can override the provider id with the `REQUESTY_PROVIDER_ID` variable.
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
|
|
58
|
+
[source,json]
|
|
59
|
+
----
|
|
60
|
+
{
|
|
61
|
+
"providers": {
|
|
62
|
+
"requesty-export": {
|
|
63
|
+
"name": "Requesty",
|
|
64
|
+
"baseUrl": "https://router.requesty.ai/v1",
|
|
65
|
+
"apiKey": "rqsty-sk-...",
|
|
66
|
+
"api": "openai-completions",
|
|
67
|
+
"models": []
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
----
|
|
72
|
+
|
|
73
|
+
IMPORTANT: The `apiKey` from `models.json` is not used by this extension.
|
|
74
|
+
It may contain Pi-specific environment-variable substitution that the extension cannot reliably resolve.
|
|
75
|
+
Set `REQUESTY_API_KEY` instead. This variable is required.
|
|
76
|
+
|
|
77
|
+
IMPORTANT: Health checks are designed to use only a few tokens, but they still make real Requesty API calls and may consume credits.
|
|
78
|
+
|
|
79
|
+
On startup, the extension fetches `<baseUrl>/models` using `REQUESTY_API_KEY` as the bearer token and registers discovered and healthy models with Pi.
|
|
80
|
+
|
|
81
|
+
=== Environment variables
|
|
82
|
+
|
|
83
|
+
[cols="1,1,1,1,2", options="header"]
|
|
84
|
+
|===
|
|
85
|
+
| Name | Required | Type | Default | Meaning
|
|
86
|
+
|
|
87
|
+
| `REQUESTY_API_KEY`
|
|
88
|
+
| yes
|
|
89
|
+
| `string`
|
|
90
|
+
|
|
|
91
|
+
| Requesty API key used for model discovery and health checks.
|
|
92
|
+
|
|
93
|
+
| `REQUESTY_PROVIDER_ID`
|
|
94
|
+
| no
|
|
95
|
+
| `string`
|
|
96
|
+
| `requesty-export`
|
|
97
|
+
| The id of the provider.
|
|
98
|
+
|
|
99
|
+
| `REQUESTY_HEALTH_CHECK_MODE`
|
|
100
|
+
| no
|
|
101
|
+
| `off`, `basic`, `full`
|
|
102
|
+
| `full`
|
|
103
|
+
| The health-check mode.
|
|
104
|
+
|
|
105
|
+
| `PI_CODING_AGENT_DIR`
|
|
106
|
+
| no
|
|
107
|
+
| `string`
|
|
108
|
+
| `~/.pi/agent`
|
|
109
|
+
| pi configuration directory
|
|
110
|
+
|===
|
|
111
|
+
|
|
112
|
+
== Command
|
|
113
|
+
|
|
114
|
+
Inside Pi:
|
|
115
|
+
|
|
116
|
+
[source,text]
|
|
117
|
+
----
|
|
118
|
+
/requesty-discover
|
|
119
|
+
----
|
|
120
|
+
|
|
121
|
+
The command fetches Requesty models using `~/.pi/agent/models.json` and writes the discovered model IDs back to the same file.
|
|
122
|
+
Run `/reload` after syncing.
|
|
123
|
+
|
|
124
|
+
[source,text]
|
|
125
|
+
----
|
|
126
|
+
/requesty-discover --dry-run
|
|
127
|
+
----
|
|
128
|
+
|
|
129
|
+
This will not overwrite the `models.json` file, but will do everything else, including the health checks.
|
package/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-requesty-discover",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Discover available Requesty models for the Pi Coding Agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"pi-coding-agent",
|
|
11
|
+
"pi-extensions",
|
|
12
|
+
"requesty",
|
|
13
|
+
"openai-compatible",
|
|
14
|
+
"models"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"agent-lint": "agnix . -v",
|
|
18
|
+
"format:check": "prettier --check src test",
|
|
19
|
+
"format": "prettier --write src test",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"lint": "eslint .",
|
|
22
|
+
"release": "npm version patch && git push --follow-tags",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:unit": "vitest run src",
|
|
25
|
+
"test:unit:coverage": "vitest run src --coverage",
|
|
26
|
+
"test:integration": "vitest run test/integration.test.ts",
|
|
27
|
+
"test:watch": "vitest"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
32
|
+
},
|
|
33
|
+
"pi": {
|
|
34
|
+
"extensions": [
|
|
35
|
+
"./index.ts"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@eslint/js": "^10.0.1",
|
|
41
|
+
"@types/node": "^26.1.0",
|
|
42
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
43
|
+
"agnix": "^0.37.0",
|
|
44
|
+
"eslint": "^10.6.0",
|
|
45
|
+
"globals": "^17.7.0",
|
|
46
|
+
"jiti": "^2.7.0",
|
|
47
|
+
"msw": "^2.14.6",
|
|
48
|
+
"prettier": "^3.9.4",
|
|
49
|
+
"typescript": "^6.0.3",
|
|
50
|
+
"typescript-eslint": "^8.62.1",
|
|
51
|
+
"vitest": "^4.1.9"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"zod": "^4.4.3"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { prettifyError, z } from 'zod'
|
|
5
|
+
|
|
6
|
+
const HealthCheckModeSchema = z.enum(['off', 'basic', 'full']).default('full')
|
|
7
|
+
|
|
8
|
+
export type Env = {
|
|
9
|
+
models_json_path: string
|
|
10
|
+
health_check_log_path: string
|
|
11
|
+
provider_id: string
|
|
12
|
+
requesty_api_key: string
|
|
13
|
+
health_check_mode: z.infer<typeof HealthCheckModeSchema>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getEnv(): Env {
|
|
17
|
+
const envVars = process.env
|
|
18
|
+
const configuredAgentPath = envVars.PI_CODING_AGENT_DIR
|
|
19
|
+
const defaultAgentPath = path.join(os.homedir(), '.pi', 'agent')
|
|
20
|
+
const agentPath = configuredAgentPath || defaultAgentPath
|
|
21
|
+
|
|
22
|
+
const result = HealthCheckModeSchema.safeParse(envVars.REQUESTY_HEALTH_CHECK_MODE)
|
|
23
|
+
if (!result.success) {
|
|
24
|
+
throw new Error(prettifyError(result.error))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const apiKey = envVars.REQUESTY_API_KEY
|
|
28
|
+
if (!apiKey) {
|
|
29
|
+
throw new Error(`apiKey must be set via REQUESTY_API_KEY env var`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
models_json_path: path.join(agentPath, 'models.json'),
|
|
34
|
+
health_check_log_path: path.join(agentPath, 'requesty-health-check.log'),
|
|
35
|
+
provider_id: envVars.REQUESTY_PROVIDER_ID ?? 'requesty-export',
|
|
36
|
+
requesty_api_key: apiKey,
|
|
37
|
+
health_check_mode: result.data,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default getEnv
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type { ProviderModelConfig } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { getEnv, type Env } from './env'
|
|
6
|
+
|
|
7
|
+
const HEALTH_CHECK_CONCURRENCY = 10
|
|
8
|
+
const HEALTH_CHECK_TIMEOUT_MS = 15_000
|
|
9
|
+
const HEALTH_CHECK_TIMEOUT_RETRIES = 2
|
|
10
|
+
const HEALTH_CHECK_RETRY_DELAY_MS = 500
|
|
11
|
+
|
|
12
|
+
const defaultHealthCheckOptions = {
|
|
13
|
+
concurrency: HEALTH_CHECK_CONCURRENCY,
|
|
14
|
+
timeoutMs: HEALTH_CHECK_TIMEOUT_MS,
|
|
15
|
+
retries: HEALTH_CHECK_TIMEOUT_RETRIES,
|
|
16
|
+
retryDelayMs: HEALTH_CHECK_RETRY_DELAY_MS,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ChatCompletionResponseSchema = z.object({
|
|
20
|
+
choices: z.array(z.unknown()).min(1),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export type Provider = {
|
|
24
|
+
baseUrl: string
|
|
25
|
+
apiKey: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type HealthCheckResult = {
|
|
29
|
+
modelId: string
|
|
30
|
+
ok: boolean
|
|
31
|
+
latencyMs: number
|
|
32
|
+
error?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type ModelCheckResult = Omit<HealthCheckResult, 'modelId'>
|
|
36
|
+
|
|
37
|
+
export type HealthCheckProgress = {
|
|
38
|
+
completed: number
|
|
39
|
+
total: number
|
|
40
|
+
modelId: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type HealthCheckOptions = {
|
|
44
|
+
concurrency?: number
|
|
45
|
+
timeoutMs?: number
|
|
46
|
+
retries?: number
|
|
47
|
+
retryDelayMs?: number
|
|
48
|
+
onProgress?: (progress: HealthCheckProgress) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type ResolvedHealthCheckOptions = Required<Omit<HealthCheckOptions, 'onProgress'>> &
|
|
52
|
+
Pick<HealthCheckOptions, 'onProgress'>
|
|
53
|
+
|
|
54
|
+
export async function checkModels(
|
|
55
|
+
provider: Provider,
|
|
56
|
+
models: ProviderModelConfig[],
|
|
57
|
+
checkReasoning: boolean,
|
|
58
|
+
options: HealthCheckOptions = {},
|
|
59
|
+
): Promise<HealthCheckResult[]> {
|
|
60
|
+
const healthCheckOptions = resolveHealthCheckOptions(options)
|
|
61
|
+
const results: HealthCheckResult[] = []
|
|
62
|
+
const queue = [...models]
|
|
63
|
+
const total = models.length
|
|
64
|
+
let active = 0
|
|
65
|
+
let completed = 0
|
|
66
|
+
|
|
67
|
+
await new Promise<void>(resolve => {
|
|
68
|
+
function next() {
|
|
69
|
+
while (active < healthCheckOptions.concurrency && queue.length > 0) {
|
|
70
|
+
// note: due to the single-threaded nature of JS, race conditions are not possible here
|
|
71
|
+
const model = queue.shift()!
|
|
72
|
+
active++
|
|
73
|
+
void checkModel(provider, model, checkReasoning, healthCheckOptions).then(result => {
|
|
74
|
+
const healthCheckResult = { modelId: model.id, ...result }
|
|
75
|
+
results.push(healthCheckResult)
|
|
76
|
+
completed++
|
|
77
|
+
healthCheckOptions.onProgress?.({ completed, total, modelId: model.id })
|
|
78
|
+
active--
|
|
79
|
+
if (queue.length === 0 && active === 0) resolve()
|
|
80
|
+
else next()
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
if (queue.length === 0 && active === 0) resolve()
|
|
84
|
+
}
|
|
85
|
+
next()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return results
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function formatHealthSummary(results: HealthCheckResult[]): string {
|
|
92
|
+
const passed = results.filter(r => r.ok)
|
|
93
|
+
const failed = results.filter(r => !r.ok)
|
|
94
|
+
|
|
95
|
+
if (failed.length === 0) {
|
|
96
|
+
return `Health check: all ${passed.length} OK.`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const failedModels = failed.map(r => `- ${r.modelId}`).join('\n')
|
|
100
|
+
|
|
101
|
+
return `Health check: ${passed.length} OK, ${failed.length} failed:\n${failedModels}\n`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function writeHealthCheckLog(provider: Provider, results: HealthCheckResult[], envConfig: Env = getEnv()): void {
|
|
105
|
+
const passed = results.filter(r => r.ok)
|
|
106
|
+
const failed = results.filter(r => !r.ok)
|
|
107
|
+
const lines = [
|
|
108
|
+
`Requesty health check log`,
|
|
109
|
+
`Timestamp: ${new Date().toISOString()}`,
|
|
110
|
+
`Provider: ${envConfig.provider_id}`,
|
|
111
|
+
`Base URL: ${provider.baseUrl}`,
|
|
112
|
+
`Total: ${results.length}`,
|
|
113
|
+
`Passed: ${passed.length}`,
|
|
114
|
+
`Failed: ${failed.length}`,
|
|
115
|
+
'',
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
if (failed.length === 0) {
|
|
119
|
+
lines.push('No failed models.')
|
|
120
|
+
} else {
|
|
121
|
+
lines.push('Failed models:', '')
|
|
122
|
+
for (const result of failed) {
|
|
123
|
+
lines.push(
|
|
124
|
+
`Model: ${result.modelId}`,
|
|
125
|
+
`Latency: ${result.latencyMs}ms`,
|
|
126
|
+
'Error:',
|
|
127
|
+
result.error || 'Unknown error',
|
|
128
|
+
'',
|
|
129
|
+
'---',
|
|
130
|
+
'',
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fs.mkdirSync(path.dirname(envConfig.health_check_log_path), { recursive: true })
|
|
136
|
+
fs.writeFileSync(envConfig.health_check_log_path, `${lines.join('\n')}\n`, 'utf8')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function postChatCompletion(
|
|
140
|
+
provider: Provider,
|
|
141
|
+
body: unknown,
|
|
142
|
+
options: HealthCheckOptions = {},
|
|
143
|
+
): Promise<ModelCheckResult> {
|
|
144
|
+
const healthCheckOptions = resolveHealthCheckOptions(options)
|
|
145
|
+
const start = Date.now()
|
|
146
|
+
try {
|
|
147
|
+
const response = await fetchWithTimeoutRetries(
|
|
148
|
+
`${provider.baseUrl}/chat/completions`,
|
|
149
|
+
{
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: {
|
|
152
|
+
Authorization: `Bearer ${provider.apiKey}`,
|
|
153
|
+
'Content-Type': 'application/json',
|
|
154
|
+
},
|
|
155
|
+
body: JSON.stringify(body),
|
|
156
|
+
},
|
|
157
|
+
healthCheckOptions,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const latencyMs = Date.now() - start
|
|
161
|
+
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
const text = await response.text().catch(() => '')
|
|
164
|
+
return { ok: false, latencyMs, error: `HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ''}` }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result = ChatCompletionResponseSchema.safeParse(await response.json())
|
|
168
|
+
if (!result.success) {
|
|
169
|
+
return { ok: false, latencyMs, error: 'Empty choices in response' }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { ok: true, latencyMs }
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const latencyMs = Date.now() - start
|
|
175
|
+
const attempts = healthCheckOptions.retries + 1
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
latencyMs,
|
|
179
|
+
error: isTimeoutError(err)
|
|
180
|
+
? `Timed out after ${attempts} attempt(s); per-attempt timeout is ${healthCheckOptions.timeoutMs / 1000}s`
|
|
181
|
+
: String(err),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function checkModel(
|
|
187
|
+
provider: Provider,
|
|
188
|
+
model: ProviderModelConfig,
|
|
189
|
+
checkReasoning: boolean,
|
|
190
|
+
options: ResolvedHealthCheckOptions,
|
|
191
|
+
): Promise<ModelCheckResult> {
|
|
192
|
+
const basicResult = await postChatCompletion(
|
|
193
|
+
provider,
|
|
194
|
+
{
|
|
195
|
+
model: model.id,
|
|
196
|
+
messages: [{ role: 'user', content: 'Say OK' }],
|
|
197
|
+
max_tokens: 16,
|
|
198
|
+
},
|
|
199
|
+
options,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if (!basicResult.ok || !model.reasoning || !checkReasoning) {
|
|
203
|
+
return basicResult
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const reasoningResult = await postChatCompletion(
|
|
207
|
+
provider,
|
|
208
|
+
{
|
|
209
|
+
model: model.id,
|
|
210
|
+
messages: [{ role: 'user', content: 'Say OK. Do not call any tools.' }],
|
|
211
|
+
tools: [
|
|
212
|
+
{
|
|
213
|
+
type: 'function',
|
|
214
|
+
function: {
|
|
215
|
+
name: 'health_check_noop',
|
|
216
|
+
description: 'A no-op tool used only to verify tool compatibility during model health checks.',
|
|
217
|
+
parameters: {
|
|
218
|
+
type: 'object',
|
|
219
|
+
properties: {},
|
|
220
|
+
additionalProperties: false,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
reasoning_effort: 'low',
|
|
226
|
+
},
|
|
227
|
+
options,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if (!reasoningResult.ok) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
latencyMs: reasoningResult.latencyMs,
|
|
234
|
+
error: `Reasoning/tool check failed: ${reasoningResult.error}`,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
latencyMs: basicResult.latencyMs + reasoningResult.latencyMs,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function resolveHealthCheckOptions(options: HealthCheckOptions): ResolvedHealthCheckOptions {
|
|
245
|
+
return {
|
|
246
|
+
...defaultHealthCheckOptions,
|
|
247
|
+
...options,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function fetchWithTimeoutRetries(
|
|
252
|
+
url: string,
|
|
253
|
+
options: RequestInit,
|
|
254
|
+
{ timeoutMs, retries, retryDelayMs }: ResolvedHealthCheckOptions,
|
|
255
|
+
) {
|
|
256
|
+
function sleep(ms: number) {
|
|
257
|
+
if (ms <= 0) {
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (let attempt = 0; ; attempt++) {
|
|
264
|
+
try {
|
|
265
|
+
return await fetch(url, {
|
|
266
|
+
...options,
|
|
267
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
268
|
+
})
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (!isTimeoutError(error) || attempt >= retries) {
|
|
271
|
+
throw error
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await sleep(retryDelayMs)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isTimeoutError(error: unknown): boolean {
|
|
280
|
+
return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')
|
|
281
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BorderedLoader,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionCommandContext,
|
|
5
|
+
ProviderModelConfig,
|
|
6
|
+
} from '@earendil-works/pi-coding-agent'
|
|
7
|
+
import { getEnv } from './env'
|
|
8
|
+
import { getRequestyConfig, updateModelsJson } from './models-json'
|
|
9
|
+
import { discoverModels } from './requesty-api'
|
|
10
|
+
import { checkModels, formatHealthSummary, writeHealthCheckLog } from './health-check'
|
|
11
|
+
|
|
12
|
+
const COMMAND_NAME = 'requesty-discover'
|
|
13
|
+
const DRY_RUN_ARG = '--dry-run'
|
|
14
|
+
|
|
15
|
+
interface AutocompleteItem {
|
|
16
|
+
value: string
|
|
17
|
+
label: string
|
|
18
|
+
description: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type NotificationLevel = 'info' | 'warning' | 'error'
|
|
22
|
+
|
|
23
|
+
// noinspection JSUnusedGlobalSymbols
|
|
24
|
+
export default function (pi: ExtensionAPI) {
|
|
25
|
+
pi.registerCommand(COMMAND_NAME, {
|
|
26
|
+
description: 'Dynamically discover Requesty models, run health checks, and update the local models.json.',
|
|
27
|
+
getArgumentCompletions,
|
|
28
|
+
handler: async (args, ctx) => {
|
|
29
|
+
await withLoader(ctx, 'Discovering models...', () => runCommand(args, ctx))
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function withLoader<T>(ctx: ExtensionCommandContext, message: string, fn: () => Promise<T>): Promise<T> {
|
|
35
|
+
return ctx.ui.custom<T>((_tui, theme, _kb, done) => {
|
|
36
|
+
const loader = new BorderedLoader(_tui, theme, message, { cancellable: false })
|
|
37
|
+
fn().then(done).catch(done)
|
|
38
|
+
return loader
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getArgumentCompletions(prefix: string): AutocompleteItem[] {
|
|
43
|
+
const options = [
|
|
44
|
+
{
|
|
45
|
+
value: DRY_RUN_ARG,
|
|
46
|
+
label: DRY_RUN_ARG,
|
|
47
|
+
description: 'Preview without writing into the new model.json file',
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
if (!prefix) return options
|
|
51
|
+
return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function runCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
55
|
+
ctx.ui.setStatus(COMMAND_NAME, 'Discovering Requesty models...')
|
|
56
|
+
const parts = args.split(' ')
|
|
57
|
+
const dryRun = parts.includes(DRY_RUN_ARG)
|
|
58
|
+
if (dryRun) {
|
|
59
|
+
notify(ctx, 'running in dry mode, no changes will be done')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const env = getEnv()
|
|
64
|
+
const { data, provider } = getRequestyConfig(env)
|
|
65
|
+
const models = await discoverModels(provider)
|
|
66
|
+
const modelsMap = new Map(models.map(m => [m.id, m]))
|
|
67
|
+
|
|
68
|
+
let failed = []
|
|
69
|
+
let passing: ProviderModelConfig[]
|
|
70
|
+
let logNote = ''
|
|
71
|
+
let healthCheckSummary = ''
|
|
72
|
+
|
|
73
|
+
if (env.health_check_mode !== 'off') {
|
|
74
|
+
ctx.ui.setStatus(COMMAND_NAME, `Checking models 0/${models.length}...`)
|
|
75
|
+
const healthResults = await checkModels(provider, models, env.health_check_mode === 'full', {
|
|
76
|
+
onProgress: ({ completed, total }) => {
|
|
77
|
+
ctx.ui.setStatus(COMMAND_NAME, `Checking models ${completed}/${total}...`)
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
const sortedResults = healthResults.toSorted((a, b) => a.modelId.localeCompare(b.modelId))
|
|
81
|
+
failed = sortedResults.filter(r => !r.ok)
|
|
82
|
+
passing = sortedResults.flatMap(r => {
|
|
83
|
+
const model = modelsMap.get(r.modelId)
|
|
84
|
+
return r.ok && model ? [model] : []
|
|
85
|
+
})
|
|
86
|
+
healthCheckSummary = formatHealthSummary(sortedResults)
|
|
87
|
+
writeHealthCheckLog(provider, sortedResults, env)
|
|
88
|
+
logNote = `Full health check log: ${env.health_check_log_path}\n`
|
|
89
|
+
} else {
|
|
90
|
+
passing = models
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const shouldUpdate = passing.length > 0 && !dryRun
|
|
94
|
+
if (shouldUpdate) {
|
|
95
|
+
updateModelsJson(data, passing, env)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const writeNote = shouldUpdate ? 'Run /reload to use models.json changes.' : 'models.json was not updated.'
|
|
99
|
+
const message = `Discovered ${models.length} Requesty model(s).\n${healthCheckSummary}${logNote}${writeNote}`
|
|
100
|
+
|
|
101
|
+
if (failed.length === 0) {
|
|
102
|
+
notify(ctx, message, 'info')
|
|
103
|
+
} else if (failed.length < models.length) {
|
|
104
|
+
notify(ctx, message, 'warning')
|
|
105
|
+
} else {
|
|
106
|
+
notify(ctx, message, 'error')
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
notify(ctx, `Discovery failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
110
|
+
} finally {
|
|
111
|
+
ctx.ui.setStatus(COMMAND_NAME, undefined)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function notify(ctx: ExtensionCommandContext, message: string, level?: NotificationLevel): void {
|
|
116
|
+
const prefixedMessage = `${COMMAND_NAME}: ${message}`
|
|
117
|
+
ctx.ui.notify(prefixedMessage, level)
|
|
118
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ProviderConfig, ProviderModelConfig } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { getEnv, type Env } from './env'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_BASE_URL = 'https://router.requesty.ai/v1'
|
|
8
|
+
const DEFAULT_NAME = 'Requesty'
|
|
9
|
+
|
|
10
|
+
const ProviderSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
name: z.string().optional(),
|
|
13
|
+
baseUrl: z.string().optional(),
|
|
14
|
+
apiKey: z.string().optional(),
|
|
15
|
+
})
|
|
16
|
+
.catchall(z.unknown())
|
|
17
|
+
|
|
18
|
+
const ModelsJsonSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
providers: z.record(z.string(), ProviderSchema),
|
|
21
|
+
})
|
|
22
|
+
.catchall(z.unknown())
|
|
23
|
+
|
|
24
|
+
type ModelsJson = z.infer<typeof ModelsJsonSchema>
|
|
25
|
+
type ModelsJsonProvider = z.infer<typeof ProviderSchema>
|
|
26
|
+
|
|
27
|
+
type RequestyProvider = ModelsJsonProvider & {
|
|
28
|
+
name: string
|
|
29
|
+
baseUrl: string
|
|
30
|
+
apiKey: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type RequestyConfig = {
|
|
34
|
+
data: ModelsJson
|
|
35
|
+
provider: RequestyProvider
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getRequestyConfig(envConfig: Env = getEnv()): RequestyConfig {
|
|
39
|
+
const data = readModelsJson(envConfig)
|
|
40
|
+
const provider = data.providers[envConfig.provider_id]
|
|
41
|
+
|
|
42
|
+
if (!provider) {
|
|
43
|
+
throw new Error(`${envConfig.models_json_path} does not define providers.${envConfig.provider_id}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const apiKey = envConfig.requesty_api_key
|
|
47
|
+
return {
|
|
48
|
+
data,
|
|
49
|
+
provider: {
|
|
50
|
+
...provider,
|
|
51
|
+
name: nonEmptyString(provider.name) ?? DEFAULT_NAME,
|
|
52
|
+
baseUrl: normalizeBaseUrl(nonEmptyString(provider.baseUrl) ?? DEFAULT_BASE_URL),
|
|
53
|
+
apiKey,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function updateModelsJson(data: ModelsJson, models: ProviderModelConfig[], envConfig: Env = getEnv()): void {
|
|
59
|
+
data.providers[envConfig.provider_id] = {
|
|
60
|
+
...data.providers[envConfig.provider_id],
|
|
61
|
+
models: models.map(model => ({
|
|
62
|
+
id: model.id,
|
|
63
|
+
name: model.name,
|
|
64
|
+
reasoning: model.reasoning,
|
|
65
|
+
input: model.input,
|
|
66
|
+
cost: model.cost,
|
|
67
|
+
contextWindow: model.contextWindow,
|
|
68
|
+
maxTokens: model.maxTokens,
|
|
69
|
+
})),
|
|
70
|
+
} satisfies ProviderConfig
|
|
71
|
+
|
|
72
|
+
writeModelsJson(data, envConfig)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeBaseUrl(baseUrl: string): string {
|
|
76
|
+
return baseUrl.replace(/\/+$/, '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readModelsJson(envConfig: Env = getEnv()): ModelsJson {
|
|
80
|
+
if (!fs.existsSync(envConfig.models_json_path)) {
|
|
81
|
+
throw new Error(`${envConfig.models_json_path} does not exist`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const data = JSON.parse(fs.readFileSync(envConfig.models_json_path, 'utf8')) as unknown
|
|
85
|
+
const result = ModelsJsonSchema.safeParse(data)
|
|
86
|
+
|
|
87
|
+
if (!result.success) {
|
|
88
|
+
throw new Error(`${envConfig.models_json_path} is invalid: ${z.prettifyError(result.error)}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return result.data
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeModelsJson(data: ModelsJson, envConfig: Env = getEnv()): void {
|
|
95
|
+
fs.mkdirSync(path.dirname(envConfig.models_json_path), { recursive: true })
|
|
96
|
+
const tmpPath = `${envConfig.models_json_path}.tmp`
|
|
97
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
|
98
|
+
fs.renameSync(tmpPath, envConfig.models_json_path)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function nonEmptyString(value: string | undefined): string | undefined {
|
|
102
|
+
return value && value.length > 0 ? value : undefined
|
|
103
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ProviderModelConfig } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONTEXT_WINDOW = 128000
|
|
5
|
+
const DEFAULT_MAX_TOKENS = 4096
|
|
6
|
+
|
|
7
|
+
const RequestyModelSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
id: z.string().min(1),
|
|
10
|
+
name: z.string().optional(),
|
|
11
|
+
supports_reasoning: z.boolean().optional(),
|
|
12
|
+
supports_vision: z.boolean().optional(),
|
|
13
|
+
input_price: z.number().optional().nullable(),
|
|
14
|
+
output_price: z.number().optional().nullable(),
|
|
15
|
+
cached_price: z.number().optional().nullable(),
|
|
16
|
+
caching_price: z.number().optional().nullable(),
|
|
17
|
+
context_window: z.number().optional().nullable(),
|
|
18
|
+
max_output_tokens: z.number().optional().nullable(),
|
|
19
|
+
})
|
|
20
|
+
.catchall(z.unknown())
|
|
21
|
+
|
|
22
|
+
export type RequestyModel = z.infer<typeof RequestyModelSchema>
|
|
23
|
+
|
|
24
|
+
const ListModelsResponseSchema = z.object({
|
|
25
|
+
data: z.array(z.unknown()),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
type Provider = {
|
|
29
|
+
baseUrl: string
|
|
30
|
+
apiKey: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function discoverModels(provider: Provider): Promise<ProviderModelConfig[]> {
|
|
34
|
+
const response = await fetch(`${provider.baseUrl}/models`, {
|
|
35
|
+
headers: { Authorization: `Bearer ${provider.apiKey}` },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const rawData = await response.json()
|
|
43
|
+
const payload = ListModelsResponseSchema.parse(rawData)
|
|
44
|
+
|
|
45
|
+
return payload.data
|
|
46
|
+
.map(model => RequestyModelSchema.safeParse(model))
|
|
47
|
+
.filter(result => result.success)
|
|
48
|
+
.map(result => result.data)
|
|
49
|
+
.map(model => ({
|
|
50
|
+
id: model.id,
|
|
51
|
+
name: model.name && model.name.length > 0 ? model.name : model.id,
|
|
52
|
+
reasoning: model.supports_reasoning === true,
|
|
53
|
+
input: model.supports_vision === true ? ['text', 'image'] : ['text'],
|
|
54
|
+
cost: {
|
|
55
|
+
input: pricePerMillionTokens(model.input_price),
|
|
56
|
+
output: pricePerMillionTokens(model.output_price),
|
|
57
|
+
cacheRead: pricePerMillionTokens(model.cached_price),
|
|
58
|
+
cacheWrite: pricePerMillionTokens(model.caching_price),
|
|
59
|
+
},
|
|
60
|
+
contextWindow: model.context_window || DEFAULT_CONTEXT_WINDOW,
|
|
61
|
+
maxTokens: model.max_output_tokens || DEFAULT_MAX_TOKENS,
|
|
62
|
+
}))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pricePerMillionTokens(value: number | null | undefined): number {
|
|
66
|
+
const raw = (value ?? 0) * 1_000_000
|
|
67
|
+
return Number(raw.toFixed(3))
|
|
68
|
+
}
|