pi-requesty-discover 2.0.0 β†’ 2.1.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/CONTRIBUTING.adoc CHANGED
@@ -32,4 +32,5 @@ Notes:
32
32
  * Keep implementation in `src/`; root `index.ts` is just the package entrypoint.
33
33
  * Edit `README.adoc` if necessary.
34
34
  * See `AGENTS.md` for detailed repository conventions.
35
+ * See `docs/DECISIONS.adoc` for deliberate design decisions.
35
36
  * I am pretty good at identifying AI slop 😁.
package/README.adoc CHANGED
@@ -18,6 +18,7 @@ This is a fork of https://github.com/requestyai/pi-requesty[pi-requesty] with ad
18
18
  * Health-check available models and write a log about them
19
19
  * Brief information about what was found, including added/removed models, health summary
20
20
  * Confirm before writing `models.json`, with a dry-run mode that only previews
21
+ * A footer status line showing your monthly Requesty spend against your monthly limit
21
22
 
22
23
  == When and why use it
23
24
 
@@ -27,6 +28,7 @@ Use this extension if
27
28
  - you are limited to certain models by a company policy (for instance, for data residency/protection reasons)
28
29
  - you don't want to handcraft your own curated list of models constantly
29
30
  - you want to health-check models and preview the diff before writing
31
+ - you want to keep in touch with your usage without having to constantly visit the Requesty dashboard
30
32
 
31
33
  == Installation
32
34
 
@@ -142,3 +144,23 @@ After a successful write, run `/reload`.
142
144
  ----
143
145
 
144
146
  Same discovery and health checks, but never prompts to write and leaves `models.json` unchanged.
147
+
148
+ == Usage status
149
+
150
+ The extension shows your current Requesty monthly spend as a persistent line in Pi's footer, e.g.:
151
+
152
+ [source,text]
153
+ ----
154
+ Requesty Usage (<API Key Name>): $63.55/$150.00 (42%)
155
+ ----
156
+
157
+ When your API key has no monthly limit (`monthly_limit` is `0`), the limit is treated as unlimited:
158
+
159
+ [source,text]
160
+ ----
161
+ Requesty Usage (<API Key Name>): $63.55 (unlimited)
162
+ ----
163
+
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. If `REQUESTY_API_KEY` is not configured, the status is left untouched.
165
+
166
+ NOTE: The footer update is best-effort and never blocks a turn. If it fails (network error, stale context after `/reload`), it fails silently without spamming notifications.
package/README.md CHANGED
@@ -10,6 +10,7 @@ This is a fork of [pi-requesty](https://github.com/requestyai/pi-requesty) with
10
10
  * Health-check available models and write a log about them
11
11
  * Brief information about what was found, including added/removed models, health summary
12
12
  * Confirm before writing `models.json`, with a dry-run mode that only previews
13
+ * A footer status line showing your monthly Requesty spend against your monthly limit
13
14
 
14
15
  ## When and why use it
15
16
 
@@ -19,6 +20,7 @@ Use this extension if
19
20
  * you are limited to certain models by a company policy (for instance, for data residency/protection reasons)
20
21
  * you don’t want to handcraft your own curated list of models constantly
21
22
  * you want to health-check models and preview the diff before writing
23
+ * you want to keep in touch with your usage without having to constantly visit the Requesty dashboard
22
24
 
23
25
  ## Installation
24
26
 
@@ -108,3 +110,22 @@ After a successful write, run `/reload`.
108
110
  ```
109
111
 
110
112
  Same discovery and health checks, but never prompts to write and leaves `models.json` unchanged.
113
+
114
+ ## Usage status
115
+
116
+ The extension shows your current Requesty monthly spend as a persistent line in Pi’s footer, e.g.:
117
+
118
+ ```text
119
+ Requesty Usage (<API Key Name>): $63.55/$150.00 (42%)
120
+ ```
121
+
122
+ When your API key has no monthly limit (`monthly_limit` is `0`), the limit is treated as unlimited:
123
+
124
+ ```text
125
+ Requesty Usage (<API Key Name>): $63.55 (unlimited)
126
+ ```
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. If `REQUESTY_API_KEY` is not configured, the status is left untouched.
129
+
130
+ **πŸ“Œ NOTE**\
131
+ The footer update is best-effort and never blocks a turn. If it fails (network error, stale context after `/reload`), it fails silently without spamming notifications.
@@ -0,0 +1,52 @@
1
+ = Decision records
2
+
3
+ Short notes for non-obvious decisions: context, decision, why not the obvious alternative.
4
+
5
+ == Usage status stays on `setStatus` (shared, truncated footer line)
6
+
7
+ Pi's `FooterComponent` joins all extensions' `setStatus` entries on one line and
8
+ ellipsis-truncates the joined line to terminal width. No per-key layout, no wrap.
9
+
10
+ `ctx.ui.setFooter(factory)` is replacement-only β€” it swaps the whole footer (pwd,
11
+ tokens, context %, model, statuses), and the factory gets `footerData` but not
12
+ token/model state. No "extra footer" API exists.
13
+
14
+ Decision: keep `setStatus`. Revisit when the line overflows visibly with other
15
+ extensions' statuses, or if the usage string grows past a typical width.
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/index.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
+
33
+ == Not a provider β€” a models.json-writing extension
34
+
35
+ Context: Requesty already works as a Pi provider via `models.json`. The temptation
36
+ is to make this extension *be* the provider: read the model list from the Requesty
37
+ API, hold it in memory, surface usage stats, let Pi manage the API key.
38
+
39
+ Why not:
40
+
41
+ - Health-checking is the core value and has no home in a provider. A provider serves
42
+ models; it doesn't vet them. The health check plus the diff/confirm/write flow is a
43
+ discovery-and-selection step, not serving.
44
+ - A provider keeps its model list in-memory and refreshes occasionally. Here the
45
+ list is a persistent on-disk snapshot (`models.json`) β€” constant until you re-run
46
+ `/requesty-discover`. That matches the intent: an explicit, reviewed, user-confirmed write,
47
+ after which Pi's normal provider machinery serves the models.
48
+
49
+ The provider-like parts (API model fetch, usage footer, wanting Pi-managed auth) are
50
+ real, but the health check and the snapshot model break the provider fit.
51
+
52
+ Decision: stay a `/requesty-discover` command that writes `models.json`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-requesty-discover",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "Discover available Requesty models for the Pi Coding Agent",
5
5
  "repository": {
6
6
  "type": "git",
package/src/env.ts CHANGED
@@ -5,6 +5,8 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent'
5
5
 
6
6
  const HealthCheckModeSchema = z.enum(['off', 'basic', 'full']).default('full')
7
7
 
8
+ export const DEFAULT_PROVIDER_ID = 'requesty-export'
9
+
8
10
  export type Env = {
9
11
  models_json_path: string
10
12
  health_check_log_path: string
@@ -28,7 +30,7 @@ export function getEnv(): Env {
28
30
  return {
29
31
  models_json_path: path.join(agentPath, 'models.json'),
30
32
  health_check_log_path: path.join(agentPath, 'requesty-health-check.log'),
31
- provider_id: envVars.REQUESTY_PROVIDER_ID ?? 'requesty-export',
33
+ provider_id: envVars.REQUESTY_PROVIDER_ID ?? DEFAULT_PROVIDER_ID,
32
34
  requesty_api_key: apiKey,
33
35
  health_check_mode: result.data,
34
36
  }
package/src/index.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { ExtensionAPI, ExtensionCommandContext, ProviderModelConfig } from '@earendil-works/pi-coding-agent'
1
+ import {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ ProviderModelConfig,
6
+ } from '@earendil-works/pi-coding-agent'
2
7
  import { type Env, getEnv } from './env'
3
8
  import {
4
9
  diffModels,
@@ -7,12 +12,13 @@ import {
7
12
  type ModelsDiff,
8
13
  updateModelsJson,
9
14
  } from './models-json'
10
- import { discoverModels } from './requesty-api'
15
+ import { type ApiKeyInfo, discoverModels, fetchApiUsage } from './requesty-api'
11
16
  import { checkModels, formatHealthSummary, writeHealthCheckLog } from './health-check'
12
17
  import { RequestyStatusLoader } from './ui/requesty-status-loader.ts'
13
18
 
14
19
  const COMMAND_NAME = 'requesty-discover'
15
20
  const DRY_RUN_ARG = '--dry-run'
21
+ export const USAGE_STATUS_KEY = 'requesty-usage'
16
22
 
17
23
  interface AutocompleteItem {
18
24
  value: string
@@ -45,28 +51,42 @@ type DiscoveryEvaluation = {
45
51
  healthCheckSummary: string
46
52
  logNote: string
47
53
  data: ModelsJsonData
48
- env: Env
49
54
  }
50
55
 
51
- type EvaluationResult =
52
- | {
53
- ok: true
54
- value: DiscoveryEvaluation
55
- }
56
- | { ok: false; error: unknown }
56
+ export type Try<T> = { ok: true; value: T } | { ok: false; error: unknown }
57
+
58
+ // token suppresses stale writes when turns overlap
59
+ let latestToken: object = {}
57
60
 
58
61
  // noinspection JSUnusedGlobalSymbols
59
62
  export default function (pi: ExtensionAPI) {
63
+ const env = runCatching(() => getEnv())
64
+
60
65
  pi.registerCommand(COMMAND_NAME, {
61
66
  description: 'Dynamically discover Requesty models, run health checks, and update the local models.json.',
62
67
  getArgumentCompletions,
63
68
  handler: async (args, ctx) => {
64
- await runDiscoveryWorkflow(ctx, args)
69
+ await runDiscoveryWorkflow(ctx, env, args)
65
70
  },
66
71
  })
72
+
73
+ pi.on('turn_end', (_event, ctx) => {
74
+ void updateUsageStatus(ctx, env)
75
+ })
76
+
77
+ pi.on('session_start', (_event, ctx) => {
78
+ complainOnBrokenEnv(ctx, env)
79
+ void updateUsageStatus(ctx, env)
80
+ })
81
+
82
+ pi.on('model_select', (_event, ctx) => {
83
+ void updateUsageStatus(ctx, env)
84
+ })
67
85
  }
68
86
 
69
- export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, args: string) {
87
+ export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, env: Try<Env>, args: string) {
88
+ complainOnBrokenEnv(ctx, env)
89
+ if (!env.ok) return
70
90
  // Interactive TUI command; no print/json/rpc path.
71
91
  if (ctx.mode !== 'tui') {
72
92
  return
@@ -77,14 +97,11 @@ export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, args: s
77
97
 
78
98
  // Phase A: progress UI only. Loader must close before confirm (Phase B).
79
99
  // Errors are wrapped because ctx.ui.custom resolves via done() and does not reject.
80
- const evaluationResult: EvaluationResult = await runWithStatusUi(ctx, 'Discovering models...', async status => {
81
- try {
82
- const evaluation = await evaluateDiscovery(args, status)
83
- return { ok: true, value: evaluation }
84
- } catch (error) {
85
- return { ok: false, error }
86
- }
87
- })
100
+ const evaluationResult: Try<DiscoveryEvaluation> = await runWithStatusUi(
101
+ ctx,
102
+ 'Discovering models...',
103
+ async status => await runCatchingAsync(() => evaluateDiscovery(args, env.value, status)),
104
+ )
88
105
 
89
106
  if (!evaluationResult.ok) {
90
107
  notifier.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
@@ -92,7 +109,12 @@ export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, args: s
92
109
  }
93
110
 
94
111
  // Phases B + C: decide, optional write, final notify β€” outside the loader.
95
- await finalizeDiscovery(evaluationResult.value, confirmer, notifier)
112
+ await finalizeDiscovery(evaluationResult.value, confirmer, notifier, env.value)
113
+ }
114
+
115
+ function formatDiscoveryFailure(error: unknown): string {
116
+ const detail = formatError(error)
117
+ return `Discovery failed: ${detail}`
96
118
  }
97
119
 
98
120
  async function runWithStatusUi<T>(
@@ -111,16 +133,10 @@ async function runWithStatusUi<T>(
111
133
  })
112
134
  }
113
135
 
114
- function formatDiscoveryFailure(error: unknown): string {
115
- const detail = error instanceof Error ? error.message : String(error)
116
- return `Discovery failed: ${detail}`
117
- }
118
-
119
- async function evaluateDiscovery(args: string, status: StatusReporter): Promise<DiscoveryEvaluation> {
136
+ async function evaluateDiscovery(args: string, env: Env, status: StatusReporter): Promise<DiscoveryEvaluation> {
120
137
  status.set('Discovering Requesty models...')
121
138
  const dryRun = args.split(' ').includes(DRY_RUN_ARG)
122
139
 
123
- const env = getEnv()
124
140
  const { data, provider, existingModelIds } = getRequestyConfig(env)
125
141
  const models = await discoverModels(provider)
126
142
  const modelsMap = new Map(models.map(m => [m.id, m]))
@@ -162,7 +178,6 @@ async function evaluateDiscovery(args: string, status: StatusReporter): Promise<
162
178
  healthCheckSummary,
163
179
  logNote,
164
180
  data,
165
- env,
166
181
  }
167
182
  }
168
183
 
@@ -170,6 +185,7 @@ async function finalizeDiscovery(
170
185
  evaluation: DiscoveryEvaluation,
171
186
  confirmer: Confirmer,
172
187
  notifier: Notifier,
188
+ env: Env,
173
189
  ): Promise<void> {
174
190
  const level = notificationLevel(evaluation)
175
191
  const summary = buildDiscoverySummary(evaluation)
@@ -197,7 +213,7 @@ Left models.json unchanged.`,
197
213
  const { title, message } = buildConfirmPrompt(evaluation)
198
214
  const shouldUpdate = await confirmer.confirm(title, message)
199
215
  if (shouldUpdate) {
200
- updateModelsJson(evaluation.data, evaluation.passing, evaluation.env)
216
+ updateModelsJson(evaluation.data, evaluation.passing, env)
201
217
  notifier.notify('Updated models.json. Run /reload to use the changes.', 'info')
202
218
  return
203
219
  }
@@ -248,7 +264,7 @@ function getArgumentCompletions(prefix: string): AutocompleteItem[] {
248
264
  return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
249
265
  }
250
266
 
251
- function createUiNotifier(ctx: ExtensionCommandContext): Notifier {
267
+ function createUiNotifier(ctx: ExtensionContext): Notifier {
252
268
  return {
253
269
  notify(message: string, level?: NotificationLevel) {
254
270
  const prefixedMessage = `${COMMAND_NAME}: ${message}`
@@ -257,7 +273,7 @@ function createUiNotifier(ctx: ExtensionCommandContext): Notifier {
257
273
  }
258
274
  }
259
275
 
260
- function createUiConfirmer(ctx: ExtensionCommandContext): Confirmer {
276
+ function createUiConfirmer(ctx: ExtensionContext): Confirmer {
261
277
  return {
262
278
  confirm(title, message) {
263
279
  return ctx.ui.confirm(title, message)
@@ -272,3 +288,79 @@ function createLoaderStatusReporter(loader: RequestyStatusLoader): StatusReporte
272
288
  },
273
289
  }
274
290
  }
291
+
292
+ async function updateUsageStatus(ctx: ExtensionContext, env: Try<Env>): Promise<void> {
293
+ if (!env.ok) return
294
+ if (!ctx.hasUI) return // no footer to write to (print/json mode): skip the wasted fetch
295
+ const token: object = {}
296
+ latestToken = token
297
+ const shouldClear = ctx.model?.provider !== env.value.provider_id
298
+ try {
299
+ if (shouldClear) {
300
+ ctx.ui.setStatus(USAGE_STATUS_KEY, undefined)
301
+ return
302
+ }
303
+ const info = await fetchUsageStatus(env.value)
304
+ if (latestToken !== token) return
305
+ ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageStatus(info))
306
+ } catch {
307
+ // best-effort footer update: swallow fetch errors and stale-ctx throws (e.g. after /reload)
308
+ }
309
+ }
310
+
311
+ export function formatUsageStatus(info: ApiKeyInfo): string {
312
+ const spend = `$${info.monthlySpend.toFixed(2)}`
313
+ if (info.monthlyLimit <= 0) {
314
+ return `Requesty Usage (${info.name}): ${spend} (unlimited)`
315
+ }
316
+ const limit = `$${info.monthlyLimit.toFixed(2)}`
317
+ const percent = Math.floor((info.monthlySpend / info.monthlyLimit) * 100)
318
+ return `Requesty Usage (${info.name}): ${spend}/${limit} (${percent}%)`
319
+ }
320
+
321
+ let lastFetched: { value: ApiKeyInfo; time: Date } | undefined
322
+
323
+ async function fetchUsageStatus(env: Env): Promise<ApiKeyInfo> {
324
+ const now = new Date()
325
+ if (lastFetched?.time && now.getTime() - lastFetched.time.getTime() < 2000) {
326
+ return lastFetched.value
327
+ }
328
+ const { provider } = getRequestyConfig(env)
329
+ const value = await fetchApiUsage(provider)
330
+ lastFetched = { value, time: new Date() }
331
+ return value
332
+ }
333
+
334
+ function formatError(error: unknown) {
335
+ return error instanceof Error ? error.message : String(error)
336
+ }
337
+
338
+ /**
339
+ * For testing purposes, reset the cache after each run
340
+ */
341
+ export function resetUsageStatusCache(): void {
342
+ lastFetched = undefined
343
+ }
344
+
345
+ function runCatching<T>(fn: () => T): Try<T> {
346
+ try {
347
+ return { ok: true, value: fn() }
348
+ } catch (error) {
349
+ return { ok: false, error }
350
+ }
351
+ }
352
+
353
+ async function runCatchingAsync<T>(fn: () => Promise<T>): Promise<Try<T>> {
354
+ try {
355
+ return { ok: true, value: await fn() }
356
+ } catch (error) {
357
+ return { ok: false, error }
358
+ }
359
+ }
360
+
361
+ function complainOnBrokenEnv(ctx: ExtensionContext, env: Try<Env>) {
362
+ if (!env.ok) {
363
+ const notifier = createUiNotifier(ctx)
364
+ notifier.notify(`failed to load env: ${formatError(env.error)}`, 'error')
365
+ }
366
+ }
@@ -25,7 +25,7 @@ const ModelsJsonSchema = z
25
25
  type ModelsJson = z.infer<typeof ModelsJsonSchema>
26
26
  type ModelsJsonProvider = z.infer<typeof ProviderSchema>
27
27
 
28
- type RequestyProvider = ModelsJsonProvider & {
28
+ export type RequestyProvider = ModelsJsonProvider & {
29
29
  name: string
30
30
  baseUrl: string
31
31
  apiKey: string
@@ -3,6 +3,7 @@ import { z } from 'zod'
3
3
 
4
4
  const DEFAULT_CONTEXT_WINDOW = 128000
5
5
  const DEFAULT_MAX_TOKENS = 4096
6
+ const REQUESTY_MANAGE_URL = 'https://api-v2.requesty.ai/v1/manage'
6
7
 
7
8
  const RequestyModelSchema = z
8
9
  .object({
@@ -30,9 +31,14 @@ type Provider = {
30
31
  apiKey: string
31
32
  }
32
33
 
33
- export async function discoverModels(provider: Provider): Promise<ProviderModelConfig[]> {
34
+ export async function discoverModels(
35
+ provider: Provider,
36
+ options: { timeoutMs?: number } = {},
37
+ ): Promise<ProviderModelConfig[]> {
38
+ const timeoutMs = options.timeoutMs ?? 5_000
34
39
  const response = await fetch(`${provider.baseUrl}/models`, {
35
40
  headers: { Authorization: `Bearer ${provider.apiKey}` },
41
+ signal: AbortSignal.timeout(timeoutMs),
36
42
  })
37
43
 
38
44
  if (!response.ok) {
@@ -66,3 +72,33 @@ function pricePerMillionTokens(value: number | null | undefined): number {
66
72
  const raw = (value ?? 0) * 1_000_000
67
73
  return Number(raw.toFixed(3))
68
74
  }
75
+
76
+ const ApiKeyInfoSchema = z
77
+ .object({
78
+ name: z.string().min(1),
79
+ monthly_spend: z.coerce.number(),
80
+ monthly_limit: z.coerce.number(),
81
+ })
82
+ .transform(value => ({
83
+ name: value.name,
84
+ monthlySpend: value.monthly_spend,
85
+ monthlyLimit: value.monthly_limit,
86
+ }))
87
+
88
+ export type ApiKeyInfo = z.infer<typeof ApiKeyInfoSchema>
89
+
90
+ export async function fetchApiUsage(provider: Provider, options: { timeoutMs?: number } = {}): Promise<ApiKeyInfo> {
91
+ const url = `${REQUESTY_MANAGE_URL}/apikey/self`
92
+ const timeoutMs = options.timeoutMs ?? 2_000
93
+ const response = await fetch(url, {
94
+ headers: { Authorization: `Bearer ${provider.apiKey}` },
95
+ signal: AbortSignal.timeout(timeoutMs),
96
+ })
97
+
98
+ if (!response.ok) {
99
+ throw new Error(`HTTP ${response.status} ${response.statusText}`)
100
+ }
101
+
102
+ const rawData = await response.json()
103
+ return ApiKeyInfoSchema.parse(rawData)
104
+ }