pi-requesty-discover 1.0.6 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-requesty-discover",
3
- "version": "1.0.6",
3
+ "version": "1.2.0",
4
4
  "description": "Discover available Requesty models for the Pi Coding Agent",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,8 +1,8 @@
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 { z } from 'zod'
5
4
  import { getEnv, type Env } from './env'
5
+ import { formatModelsDiffSummary, type ModelsDiff } from './models-json'
6
6
 
7
7
  const HEALTH_CHECK_CONCURRENCY = 10
8
8
  const HEALTH_CHECK_TIMEOUT_MS = 15_000
@@ -16,9 +16,7 @@ const defaultHealthCheckOptions = {
16
16
  retryDelayMs: HEALTH_CHECK_RETRY_DELAY_MS,
17
17
  }
18
18
 
19
- const ChatCompletionResponseSchema = z.object({
20
- choices: z.array(z.unknown()).min(1),
21
- })
19
+ const DATA_PREFIX = 'data:'
22
20
 
23
21
  export type Provider = {
24
22
  baseUrl: string
@@ -101,7 +99,12 @@ export function formatHealthSummary(results: HealthCheckResult[]): string {
101
99
  return `Health check: ${passed.length} OK, ${failed.length} failed:\n${failedModels}\n`
102
100
  }
103
101
 
104
- export function writeHealthCheckLog(provider: Provider, results: HealthCheckResult[], envConfig: Env = getEnv()): void {
102
+ export function writeHealthCheckLog(
103
+ provider: Provider,
104
+ results: HealthCheckResult[],
105
+ diff: ModelsDiff,
106
+ envConfig: Env = getEnv(),
107
+ ): void {
105
108
  const passed = results.filter(r => r.ok)
106
109
  const failed = results.filter(r => !r.ok)
107
110
  const lines = [
@@ -113,6 +116,8 @@ export function writeHealthCheckLog(provider: Provider, results: HealthCheckResu
113
116
  `Passed: ${passed.length}`,
114
117
  `Failed: ${failed.length}`,
115
118
  '',
119
+ formatModelsDiffSummary(diff),
120
+ '',
116
121
  ]
117
122
 
118
123
  if (failed.length === 0) {
@@ -138,48 +143,92 @@ export function writeHealthCheckLog(provider: Provider, results: HealthCheckResu
138
143
 
139
144
  export async function postChatCompletion(
140
145
  provider: Provider,
141
- body: unknown,
146
+ body: Record<string, unknown>,
142
147
  options: HealthCheckOptions = {},
143
148
  ): Promise<ModelCheckResult> {
144
149
  const healthCheckOptions = resolveHealthCheckOptions(options)
145
150
  const start = Date.now()
146
- try {
147
- const response = await fetchWithTimeoutRetries(
148
- `${provider.baseUrl}/chat/completions`,
149
- {
151
+
152
+ for (let attempt = 0; attempt <= healthCheckOptions.retries; attempt++) {
153
+ try {
154
+ const response = await fetch(`${provider.baseUrl}/chat/completions`, {
150
155
  method: 'POST',
151
156
  headers: {
152
157
  Authorization: `Bearer ${provider.apiKey}`,
153
158
  'Content-Type': 'application/json',
159
+ Accept: 'text/event-stream',
154
160
  },
155
- body: JSON.stringify(body),
156
- },
157
- healthCheckOptions,
158
- )
161
+ body: JSON.stringify({ ...body, stream: true }),
162
+ signal: AbortSignal.timeout(healthCheckOptions.timeoutMs),
163
+ })
159
164
 
160
- const latencyMs = Date.now() - start
165
+ const latencyMs = Date.now() - start
161
166
 
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
- }
167
+ if (!response.ok) {
168
+ const text = await response.text().catch(() => '')
169
+ return {
170
+ ok: false,
171
+ latencyMs,
172
+ error: `HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ''}`,
173
+ }
174
+ }
166
175
 
167
- const result = ChatCompletionResponseSchema.safeParse(await response.json())
168
- if (!result.success) {
169
- return { ok: false, latencyMs, error: 'Empty choices in response' }
176
+ return await verifyFirstStreamChunk(response.body!, start)
177
+ } catch (err) {
178
+ if (isTimeoutError(err) && attempt < healthCheckOptions.retries) {
179
+ if (healthCheckOptions.retryDelayMs > 0) {
180
+ await new Promise(resolve => setTimeout(resolve, healthCheckOptions.retryDelayMs))
181
+ }
182
+ continue
183
+ }
184
+ const attempts = attempt + 1
185
+ return {
186
+ ok: false,
187
+ latencyMs: Date.now() - start,
188
+ error: isTimeoutError(err)
189
+ ? `Timed out after ${attempts} attempt(s); per-attempt timeout is ${healthCheckOptions.timeoutMs / 1000}s`
190
+ : String(err),
191
+ }
170
192
  }
193
+ }
171
194
 
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),
195
+ return { ok: false, latencyMs: Date.now() - start, error: 'Unknown error' }
196
+ }
197
+
198
+ async function verifyFirstStreamChunk(body: ReadableStream<Uint8Array>, start: number): Promise<ModelCheckResult> {
199
+ const reader = body.getReader()
200
+ const decoder = new TextDecoder()
201
+ let tail = ''
202
+ try {
203
+ while (true) {
204
+ const { done, value } = await reader.read()
205
+ if (done) {
206
+ break
207
+ }
208
+ tail += decoder.decode(value, { stream: true })
209
+ const lines = tail.split('\n')
210
+ tail = lines.pop()!
211
+ for (const line of lines) {
212
+ if (!line.startsWith(DATA_PREFIX)) {
213
+ continue
214
+ }
215
+ const payload = line.slice(DATA_PREFIX.length).trim()
216
+ if (payload === '[DONE]') {
217
+ continue
218
+ }
219
+ try {
220
+ const parsed = JSON.parse(payload) as { choices?: unknown }
221
+ if (Array.isArray(parsed.choices) && parsed.choices.length > 0) {
222
+ return { ok: true, latencyMs: Date.now() - start }
223
+ }
224
+ } catch {
225
+ // partial JSON or non-choices chunk — keep reading
226
+ }
227
+ }
182
228
  }
229
+ return { ok: false, latencyMs: Date.now() - start, error: 'Stream ended without content' }
230
+ } finally {
231
+ await reader.cancel()
183
232
  }
184
233
  }
185
234
 
@@ -248,34 +297,6 @@ function resolveHealthCheckOptions(options: HealthCheckOptions): ResolvedHealthC
248
297
  }
249
298
  }
250
299
 
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
300
  function isTimeoutError(error: unknown): boolean {
280
301
  return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')
281
302
  }
package/src/index.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { ExtensionAPI, ExtensionCommandContext, ProviderModelConfig } from '@earendil-works/pi-coding-agent'
2
2
  import { getEnv } from './env'
3
- import { getRequestyConfig, updateModelsJson } from './models-json'
3
+ import {
4
+ diffModels,
5
+ formatModelsDiffSummary,
6
+ getRequestyConfig,
7
+ updateModelsJson,
8
+ type ModelsDiff,
9
+ } from './models-json'
4
10
  import { discoverModels } from './requesty-api'
5
11
  import { checkModels, formatHealthSummary, writeHealthCheckLog } from './health-check'
6
12
  import { RequestyStatusLoader } from './ui/requesty-status-loader.ts'
@@ -67,10 +73,11 @@ export async function runCommand(args: string, status: StatusReporter, notifier:
67
73
 
68
74
  try {
69
75
  const env = getEnv()
70
- const { data, provider } = getRequestyConfig(env)
76
+ const { data, provider, existingModelIds } = getRequestyConfig(env)
71
77
  const models = await discoverModels(provider)
72
78
  const modelsMap = new Map(models.map(m => [m.id, m]))
73
79
 
80
+ let diff: ModelsDiff
74
81
  let failed = []
75
82
  let passing: ProviderModelConfig[]
76
83
  let logNote = ''
@@ -89,11 +96,13 @@ export async function runCommand(args: string, status: StatusReporter, notifier:
89
96
  const model = modelsMap.get(r.modelId)
90
97
  return r.ok && model ? [model] : []
91
98
  })
99
+ diff = diffModels(existingModelIds, passing)
92
100
  healthCheckSummary = formatHealthSummary(sortedResults)
93
- writeHealthCheckLog(provider, sortedResults, env)
101
+ writeHealthCheckLog(provider, sortedResults, diff, env)
94
102
  logNote = `Full health check log: ${env.health_check_log_path}\n`
95
103
  } else {
96
104
  passing = models
105
+ diff = diffModels(existingModelIds, passing)
97
106
  }
98
107
 
99
108
  const shouldUpdate = passing.length > 0 && !dryRun
@@ -102,7 +111,7 @@ export async function runCommand(args: string, status: StatusReporter, notifier:
102
111
  }
103
112
 
104
113
  const writeNote = shouldUpdate ? 'Run /reload to use models.json changes.' : 'models.json was not updated.'
105
- const message = `Discovered ${models.length} Requesty model(s).\n${healthCheckSummary}${logNote}${writeNote}`
114
+ const message = `Discovered ${models.length} Requesty model(s).\n${healthCheckSummary}${formatModelsDiffSummary(diff)}\n${logNote}${writeNote}`
106
115
 
107
116
  if (failed.length === 0) {
108
117
  notifier.notify(message, 'info')
@@ -12,6 +12,7 @@ const ProviderSchema = z
12
12
  name: z.string().optional(),
13
13
  baseUrl: z.string().optional(),
14
14
  apiKey: z.string().optional(),
15
+ models: z.array(z.object({ id: z.string() }).catchall(z.unknown())).optional(),
15
16
  })
16
17
  .catchall(z.unknown())
17
18
 
@@ -33,6 +34,12 @@ type RequestyProvider = ModelsJsonProvider & {
33
34
  type RequestyConfig = {
34
35
  data: ModelsJson
35
36
  provider: RequestyProvider
37
+ existingModelIds: string[]
38
+ }
39
+
40
+ export type ModelsDiff = {
41
+ added: string[]
42
+ removed: string[]
36
43
  }
37
44
 
38
45
  export function getRequestyConfig(envConfig: Env = getEnv()): RequestyConfig {
@@ -46,6 +53,7 @@ export function getRequestyConfig(envConfig: Env = getEnv()): RequestyConfig {
46
53
  const apiKey = envConfig.requesty_api_key
47
54
  return {
48
55
  data,
56
+ existingModelIds: (provider.models ?? []).map(m => m.id),
49
57
  provider: {
50
58
  ...provider,
51
59
  name: nonEmptyString(provider.name) ?? DEFAULT_NAME,
@@ -55,6 +63,24 @@ export function getRequestyConfig(envConfig: Env = getEnv()): RequestyConfig {
55
63
  }
56
64
  }
57
65
 
66
+ export function diffModels(previousIds: string[], nextModels: ProviderModelConfig[]): ModelsDiff {
67
+ const previous = new Set(previousIds)
68
+ const next = new Set(nextModels.map(m => m.id))
69
+ return {
70
+ added: [...next].filter(id => !previous.has(id)).toSorted(),
71
+ removed: [...previous].filter(id => !next.has(id)).toSorted(),
72
+ }
73
+ }
74
+
75
+ export function formatModelsDiffSummary(diff: ModelsDiff): string {
76
+ return [
77
+ diff.added.length === 0 ? 'No added models.' : 'Added models:',
78
+ ...diff.added.map(id => `- ${id}`),
79
+ diff.removed.length === 0 ? 'No removed models.' : 'Removed models:',
80
+ ...diff.removed.map(id => `- ${id}`),
81
+ ].join('\n')
82
+ }
83
+
58
84
  export function updateModelsJson(data: ModelsJson, models: ProviderModelConfig[], envConfig: Env = getEnv()): void {
59
85
  const provider = data.providers[envConfig.provider_id]
60
86
  data.providers[envConfig.provider_id] = {