pi-requesty-discover 2.1.1 → 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 +46 -35
- package/README.md +42 -19
- package/docs/DECISIONS.adoc +23 -29
- package/package.json +4 -2
- package/src/discovery.ts +222 -0
- package/src/env.ts +2 -22
- package/src/health-check.ts +63 -34
- package/src/index.ts +106 -261
- package/src/models-json.ts +32 -11
- package/src/settings.ts +47 -0
- package/src/ui/requesty-status-loader.ts +1 -1
- package/src/utils.ts +24 -0
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,306 +1,141 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionCommandContext,
|
|
4
|
-
ExtensionContext,
|
|
5
|
-
ProviderModelConfig,
|
|
6
|
-
} from '@earendil-works/pi-coding-agent'
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
7
2
|
import { type Env, getEnv } from './env'
|
|
3
|
+
import { type GetApiKey, getRequestyConfig } from './models-json'
|
|
4
|
+
import { type ApiKeyInfo, fetchApiUsage } from './requesty-api'
|
|
5
|
+
import { RequestyStatusLoader } from './ui/requesty-status-loader'
|
|
8
6
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
import {
|
|
7
|
+
type DiscoveryEvaluation,
|
|
8
|
+
type DiscoveryUi,
|
|
9
|
+
type RefreshModelsRegistry,
|
|
10
|
+
evaluateDiscovery,
|
|
11
|
+
finalizeDiscovery,
|
|
12
|
+
formatDiscoveryFailure,
|
|
13
|
+
getArgumentCompletions,
|
|
14
|
+
} from './discovery'
|
|
15
|
+
import { type DiscoverySettings, readDiscoverySettings } from './settings'
|
|
16
|
+
import { runCatching, runCatchingAsync, type Try } from './utils'
|
|
18
17
|
|
|
19
18
|
const COMMAND_NAME = 'requesty-discover'
|
|
20
|
-
const DRY_RUN_ARG = '--dry-run'
|
|
21
19
|
export const USAGE_STATUS_KEY = 'requesty-usage'
|
|
22
20
|
|
|
23
|
-
interface AutocompleteItem {
|
|
24
|
-
value: string
|
|
25
|
-
label: string
|
|
26
|
-
description: string
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export type NotificationLevel = 'info' | 'warning' | 'error'
|
|
30
|
-
|
|
31
|
-
export type Notifier = {
|
|
32
|
-
notify(message: string, level: NotificationLevel): void
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export type Confirmer = {
|
|
36
|
-
confirm(title: string, message: string): Promise<boolean>
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export type StatusReporter = {
|
|
40
|
-
set(message: string): void
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
type ModelsJsonData = ReturnType<typeof getRequestyConfig>['data']
|
|
44
|
-
|
|
45
|
-
type DiscoveryEvaluation = {
|
|
46
|
-
dryRun: boolean
|
|
47
|
-
modelCount: number
|
|
48
|
-
failedCount: number
|
|
49
|
-
passing: ProviderModelConfig[]
|
|
50
|
-
diff: ModelsDiff
|
|
51
|
-
healthCheckSummary: string
|
|
52
|
-
logNote: string
|
|
53
|
-
data: ModelsJsonData
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export type Try<T> = { ok: true; value: T } | { ok: false; error: unknown }
|
|
57
|
-
|
|
58
21
|
// token suppresses stale writes when turns overlap
|
|
59
22
|
let latestToken: object = {}
|
|
60
23
|
|
|
61
24
|
// noinspection JSUnusedGlobalSymbols
|
|
62
25
|
export default function (pi: ExtensionAPI) {
|
|
63
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
|
+
}
|
|
64
36
|
|
|
65
37
|
pi.registerCommand(COMMAND_NAME, {
|
|
66
38
|
description: 'Dynamically discover Requesty models, run health checks, and update the local models.json.',
|
|
67
39
|
getArgumentCompletions,
|
|
68
40
|
handler: async (args, ctx) => {
|
|
69
|
-
await runDiscoveryWorkflow(ctx, env, args)
|
|
41
|
+
await runDiscoveryWorkflow(ctx, settings.value, env.value, args)
|
|
70
42
|
},
|
|
71
43
|
})
|
|
72
44
|
|
|
73
45
|
pi.on('turn_end', (_event, ctx) => {
|
|
74
|
-
void updateUsageStatus(ctx, env)
|
|
46
|
+
void updateUsageStatus(ctx, settings.value, env.value)
|
|
75
47
|
})
|
|
76
48
|
|
|
77
49
|
pi.on('session_start', (_event, ctx) => {
|
|
78
|
-
|
|
79
|
-
void updateUsageStatus(ctx, env)
|
|
50
|
+
void updateUsageStatus(ctx, settings.value, env.value)
|
|
80
51
|
})
|
|
81
52
|
|
|
82
53
|
pi.on('model_select', (_event, ctx) => {
|
|
83
|
-
void updateUsageStatus(ctx, env)
|
|
54
|
+
void updateUsageStatus(ctx, settings.value, env.value)
|
|
84
55
|
})
|
|
85
56
|
}
|
|
86
57
|
|
|
87
|
-
export async function runDiscoveryWorkflow(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
58
|
+
export async function runDiscoveryWorkflow(
|
|
59
|
+
ctx: ExtensionCommandContext,
|
|
60
|
+
settings: DiscoverySettings,
|
|
61
|
+
env: Env,
|
|
62
|
+
args: string,
|
|
63
|
+
) {
|
|
64
|
+
const ui = ctx.mode === 'tui' ? createTuiUi(ctx) : createConsoleUi()
|
|
91
65
|
if (ctx.mode !== 'tui') {
|
|
66
|
+
return runSilentDiscoveryWorkflow(ctx, settings, env, args, ui)
|
|
67
|
+
} else {
|
|
68
|
+
return runInteractiveDiscoverWorkflow(ctx, settings, env, args, ui)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function runSilentDiscoveryWorkflow(
|
|
73
|
+
ctx: ExtensionCommandContext,
|
|
74
|
+
settings: DiscoverySettings,
|
|
75
|
+
env: Env,
|
|
76
|
+
args: string,
|
|
77
|
+
ui: DiscoveryUi,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const evaluationResult = await runCatchingAsync(() =>
|
|
80
|
+
evaluateDiscovery(args, settings, env, ui, createGetApiKey(ctx)),
|
|
81
|
+
)
|
|
82
|
+
if (!evaluationResult.ok) {
|
|
83
|
+
ui.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
|
|
92
84
|
return
|
|
93
85
|
}
|
|
94
86
|
|
|
95
|
-
|
|
96
|
-
|
|
87
|
+
await finalizeDiscovery(evaluationResult.value, settings, env, ui)
|
|
88
|
+
}
|
|
97
89
|
|
|
98
|
-
|
|
99
|
-
|
|
90
|
+
async function runInteractiveDiscoverWorkflow(
|
|
91
|
+
ctx: ExtensionCommandContext,
|
|
92
|
+
settings: DiscoverySettings,
|
|
93
|
+
env: Env,
|
|
94
|
+
args: string,
|
|
95
|
+
ui: DiscoveryUi,
|
|
96
|
+
) {
|
|
100
97
|
const evaluationResult: Try<DiscoveryEvaluation> = await runWithStatusUi(
|
|
101
98
|
ctx,
|
|
102
99
|
'Discovering models...',
|
|
103
|
-
async
|
|
100
|
+
async statusUi =>
|
|
101
|
+
await runCatchingAsync(() => evaluateDiscovery(args, settings, env, statusUi, createGetApiKey(ctx))),
|
|
104
102
|
)
|
|
105
103
|
|
|
106
104
|
if (!evaluationResult.ok) {
|
|
107
|
-
|
|
105
|
+
ui.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
|
|
108
106
|
return
|
|
109
107
|
}
|
|
110
108
|
|
|
111
|
-
|
|
112
|
-
await
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function formatDiscoveryFailure(error: unknown): string {
|
|
116
|
-
const detail = formatError(error)
|
|
117
|
-
return `Discovery failed: ${detail}`
|
|
109
|
+
await finalizeDiscovery(evaluationResult.value, settings, env, ui, createRefreshRegistry(ctx))
|
|
110
|
+
await updateUsageStatus(ctx, settings, env)
|
|
118
111
|
}
|
|
119
112
|
|
|
120
113
|
async function runWithStatusUi<T>(
|
|
121
114
|
ctx: ExtensionCommandContext,
|
|
122
115
|
initialMessage: string,
|
|
123
|
-
fn: (
|
|
116
|
+
fn: (statusUi: DiscoveryUi) => Promise<T>,
|
|
124
117
|
): Promise<T> {
|
|
125
|
-
return ctx.ui.custom<T>((
|
|
126
|
-
const loader = new RequestyStatusLoader(
|
|
127
|
-
const status = createLoaderStatusReporter(loader)
|
|
118
|
+
return ctx.ui.custom<T>((tui, theme, _kb, done) => {
|
|
119
|
+
const loader = new RequestyStatusLoader(tui, theme, initialMessage)
|
|
128
120
|
void Promise.resolve()
|
|
129
|
-
.then(() => fn(
|
|
121
|
+
.then(() => fn(createTuiUi(ctx, loader)))
|
|
130
122
|
.then(done)
|
|
131
123
|
.catch(done)
|
|
132
124
|
return loader
|
|
133
125
|
})
|
|
134
126
|
}
|
|
135
127
|
|
|
136
|
-
async function
|
|
137
|
-
status.set('Discovering Requesty models...')
|
|
138
|
-
const dryRun = args.split(' ').includes(DRY_RUN_ARG)
|
|
139
|
-
|
|
140
|
-
const { data, provider, existingModelIds } = getRequestyConfig(env)
|
|
141
|
-
const models = await discoverModels(provider)
|
|
142
|
-
const modelsMap = new Map(models.map(m => [m.id, m]))
|
|
143
|
-
|
|
144
|
-
let diff: ModelsDiff
|
|
145
|
-
let failedCount = 0
|
|
146
|
-
let passing: ProviderModelConfig[]
|
|
147
|
-
let logNote = ''
|
|
148
|
-
let healthCheckSummary = ''
|
|
149
|
-
|
|
150
|
-
if (env.health_check_mode !== 'off') {
|
|
151
|
-
status.set(`Checking models 0/${models.length}...`)
|
|
152
|
-
const healthResults = await checkModels(provider, models, env.health_check_mode === 'full', {
|
|
153
|
-
onProgress: ({ completed, total }) => {
|
|
154
|
-
status.set(`Checking models ${completed}/${total}...`)
|
|
155
|
-
},
|
|
156
|
-
})
|
|
157
|
-
const sortedResults = healthResults.toSorted((a, b) => a.modelId.localeCompare(b.modelId))
|
|
158
|
-
failedCount = sortedResults.filter(r => !r.ok).length
|
|
159
|
-
passing = sortedResults.flatMap(r => {
|
|
160
|
-
const model = modelsMap.get(r.modelId)
|
|
161
|
-
return r.ok && model ? [model] : []
|
|
162
|
-
})
|
|
163
|
-
diff = diffModels(existingModelIds, passing)
|
|
164
|
-
healthCheckSummary = formatHealthSummary(sortedResults)
|
|
165
|
-
writeHealthCheckLog(provider, sortedResults, diff, env)
|
|
166
|
-
logNote = `Full health check log: ${env.health_check_log_path}\n`
|
|
167
|
-
} else {
|
|
168
|
-
passing = models
|
|
169
|
-
diff = diffModels(existingModelIds, passing)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return {
|
|
173
|
-
dryRun,
|
|
174
|
-
modelCount: models.length,
|
|
175
|
-
failedCount,
|
|
176
|
-
passing,
|
|
177
|
-
diff,
|
|
178
|
-
healthCheckSummary,
|
|
179
|
-
logNote,
|
|
180
|
-
data,
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
async function finalizeDiscovery(
|
|
185
|
-
evaluation: DiscoveryEvaluation,
|
|
186
|
-
confirmer: Confirmer,
|
|
187
|
-
notifier: Notifier,
|
|
188
|
-
env: Env,
|
|
189
|
-
): Promise<void> {
|
|
190
|
-
const level = notificationLevel(evaluation)
|
|
191
|
-
const summary = buildDiscoverySummary(evaluation)
|
|
192
|
-
|
|
193
|
-
// Always surface the discovery result first (toast styling), then decide.
|
|
194
|
-
if (evaluation.dryRun) {
|
|
195
|
-
notifier.notify(
|
|
196
|
-
`${summary}
|
|
197
|
-
Dry run: left models.json unchanged.`,
|
|
198
|
-
level,
|
|
199
|
-
)
|
|
200
|
-
return
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (evaluation.passing.length === 0) {
|
|
204
|
-
notifier.notify(
|
|
205
|
-
`${summary}
|
|
206
|
-
Left models.json unchanged.`,
|
|
207
|
-
level,
|
|
208
|
-
)
|
|
209
|
-
return
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
notifier.notify(summary, level)
|
|
213
|
-
const { title, message } = buildConfirmPrompt(evaluation)
|
|
214
|
-
const shouldUpdate = await confirmer.confirm(title, message)
|
|
215
|
-
if (shouldUpdate) {
|
|
216
|
-
updateModelsJson(evaluation.data, evaluation.passing, env)
|
|
217
|
-
notifier.notify('Updated models.json. Run /reload to use the changes.', 'info')
|
|
218
|
-
return
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
notifier.notify('Left models.json unchanged.', 'info')
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function buildDiscoverySummary(evaluation: DiscoveryEvaluation): string {
|
|
225
|
-
return [
|
|
226
|
-
`Discovered ${evaluation.modelCount} Requesty model(s).`,
|
|
227
|
-
evaluation.healthCheckSummary.trimEnd(),
|
|
228
|
-
formatModelsDiffSummary(evaluation.diff),
|
|
229
|
-
evaluation.logNote.trimEnd(),
|
|
230
|
-
]
|
|
231
|
-
.filter(part => part.length > 0)
|
|
232
|
-
.join('\n')
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function buildConfirmPrompt(evaluation: DiscoveryEvaluation): { title: string; message: string } {
|
|
236
|
-
const hasIdChanges = evaluation.diff.added.length > 0 || evaluation.diff.removed.length > 0
|
|
237
|
-
if (hasIdChanges) {
|
|
238
|
-
return {
|
|
239
|
-
title: `Write ${evaluation.passing.length} model(s)?`,
|
|
240
|
-
message: 'Update models.json with the discovery result above.',
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return {
|
|
244
|
-
title: 'Refresh models.json?',
|
|
245
|
-
message: 'No model ID changes. Rewrite the file to refresh metadata anyway?',
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function notificationLevel(evaluation: DiscoveryEvaluation): NotificationLevel {
|
|
250
|
-
if (evaluation.failedCount === 0) return 'info'
|
|
251
|
-
if (evaluation.failedCount < evaluation.modelCount) return 'warning'
|
|
252
|
-
return 'error'
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function getArgumentCompletions(prefix: string): AutocompleteItem[] {
|
|
256
|
-
const options = [
|
|
257
|
-
{
|
|
258
|
-
value: DRY_RUN_ARG,
|
|
259
|
-
label: DRY_RUN_ARG,
|
|
260
|
-
description: 'Preview discovery without offering to write models.json',
|
|
261
|
-
},
|
|
262
|
-
]
|
|
263
|
-
if (!prefix) return options
|
|
264
|
-
return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function createUiNotifier(ctx: ExtensionContext): Notifier {
|
|
268
|
-
return {
|
|
269
|
-
notify(message: string, level?: NotificationLevel) {
|
|
270
|
-
const prefixedMessage = `${COMMAND_NAME}: ${message}`
|
|
271
|
-
ctx.ui.notify(prefixedMessage, level)
|
|
272
|
-
},
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function createUiConfirmer(ctx: ExtensionContext): Confirmer {
|
|
277
|
-
return {
|
|
278
|
-
confirm(title, message) {
|
|
279
|
-
return ctx.ui.confirm(title, message)
|
|
280
|
-
},
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function createLoaderStatusReporter(loader: RequestyStatusLoader): StatusReporter {
|
|
285
|
-
return {
|
|
286
|
-
set(message: string) {
|
|
287
|
-
loader.setMessage(message)
|
|
288
|
-
},
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<void> {
|
|
293
|
-
if (!env.ok) return
|
|
128
|
+
async function updateUsageStatus(ctx: ExtensionContext, settings: DiscoverySettings, env: Env): Promise<void> {
|
|
294
129
|
if (!ctx.hasUI) return // no footer to write to (print/json mode): skip the wasted fetch
|
|
295
130
|
const token: object = {}
|
|
296
131
|
latestToken = token
|
|
297
|
-
const shouldClear = ctx.model?.provider !==
|
|
132
|
+
const shouldClear = ctx.model?.provider !== settings.providerId
|
|
298
133
|
try {
|
|
299
134
|
if (shouldClear) {
|
|
300
135
|
ctx.ui.setStatus(USAGE_STATUS_KEY, undefined)
|
|
301
136
|
return
|
|
302
137
|
}
|
|
303
|
-
const info = await fetchUsageStatus(env
|
|
138
|
+
const info = await fetchUsageStatus(createGetApiKey(ctx), settings, env)
|
|
304
139
|
if (latestToken !== token) return
|
|
305
140
|
ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageStatus(info))
|
|
306
141
|
} catch {
|
|
@@ -311,30 +146,26 @@ async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<
|
|
|
311
146
|
export function formatUsageStatus(info: ApiKeyInfo): string {
|
|
312
147
|
const spend = `$${info.monthlySpend.toFixed(2)}`
|
|
313
148
|
if (info.monthlyLimit <= 0) {
|
|
314
|
-
return
|
|
149
|
+
return `${info.name}: ${spend} (unlimited)`
|
|
315
150
|
}
|
|
316
151
|
const limit = `$${info.monthlyLimit.toFixed(2)}`
|
|
317
152
|
const percent = Math.floor((info.monthlySpend / info.monthlyLimit) * 100)
|
|
318
|
-
return
|
|
153
|
+
return `${info.name}: ${spend}/${limit} (${percent}%)`
|
|
319
154
|
}
|
|
320
155
|
|
|
321
156
|
let lastFetched: { value: ApiKeyInfo; time: Date } | undefined
|
|
322
157
|
|
|
323
|
-
async function fetchUsageStatus(env: Env): Promise<ApiKeyInfo> {
|
|
158
|
+
async function fetchUsageStatus(getApiKey: GetApiKey, settings: DiscoverySettings, env: Env): Promise<ApiKeyInfo> {
|
|
324
159
|
const now = new Date()
|
|
325
160
|
if (lastFetched?.time && now.getTime() - lastFetched.time.getTime() < 2000) {
|
|
326
161
|
return lastFetched.value
|
|
327
162
|
}
|
|
328
|
-
const { provider } = getRequestyConfig(env)
|
|
163
|
+
const { provider } = await getRequestyConfig(getApiKey, settings, env)
|
|
329
164
|
const value = await fetchApiUsage(provider)
|
|
330
165
|
lastFetched = { value, time: new Date() }
|
|
331
166
|
return value
|
|
332
167
|
}
|
|
333
168
|
|
|
334
|
-
function formatError(error: unknown) {
|
|
335
|
-
return error instanceof Error ? error.message : String(error)
|
|
336
|
-
}
|
|
337
|
-
|
|
338
169
|
/**
|
|
339
170
|
* For testing purposes, reset the cache after each run
|
|
340
171
|
*/
|
|
@@ -342,25 +173,39 @@ export function resetUsageStatusCache(): void {
|
|
|
342
173
|
lastFetched = undefined
|
|
343
174
|
}
|
|
344
175
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
176
|
+
/** TUI adapter: routes the workflow's UI needs to Pi's ctx.ui. */
|
|
177
|
+
export function createTuiUi(ctx: ExtensionContext, loader?: RequestyStatusLoader): DiscoveryUi {
|
|
178
|
+
return {
|
|
179
|
+
notify: (message, level) => ctx.ui.notify(`${COMMAND_NAME}: ${message}`, level),
|
|
180
|
+
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
181
|
+
setStatus: message => loader?.setMessage(message),
|
|
350
182
|
}
|
|
351
183
|
}
|
|
352
184
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Console adapter for non-interactive modes. Always confirms: print mode is
|
|
187
|
+
* non-interactive, so the write proceeds without a confirmation dialog.
|
|
188
|
+
*/
|
|
189
|
+
export function createConsoleUi(): DiscoveryUi {
|
|
190
|
+
return {
|
|
191
|
+
notify: (message, level) => console.log(`[${level}] ${message}`),
|
|
192
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- must match the DiscoveryUi promise signature
|
|
193
|
+
confirm: async () => true,
|
|
194
|
+
setStatus: message => console.log(message),
|
|
358
195
|
}
|
|
359
196
|
}
|
|
360
197
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
198
|
+
/** Re-reads models.json through Pi's model registry so new models are usable without /reload. */
|
|
199
|
+
export function createRefreshRegistry(ctx: ExtensionContext): RefreshModelsRegistry {
|
|
200
|
+
return () =>
|
|
201
|
+
ctx.modelRegistry.refresh({ allowNetwork: false }).then(result => {
|
|
202
|
+
if (result.aborted || result.errors.size > 0) {
|
|
203
|
+
const detail = [...result.errors.entries()].map(([id, error]) => `${id}: ${error.message}`).join(', ')
|
|
204
|
+
throw new Error(detail || 'refresh aborted')
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function createGetApiKey(ctx: ExtensionContext): GetApiKey {
|
|
210
|
+
return providerId => ctx.modelRegistry.getApiKeyForProvider(providerId)
|
|
366
211
|
}
|