pi-requesty-discover 1.2.0 → 2.1.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/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
@@ -16,7 +16,9 @@ This is a fork of https://github.com/requestyai/pi-requesty[pi-requesty] with ad
16
16
 
17
17
  * Use `REQUESTY_API_KEY` from the environment
18
18
  * Health-check available models and write a log about them
19
- * Provide a dry-run mode that does not change anything
19
+ * Brief information about what was found, including added/removed models, health summary
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
20
22
 
21
23
  == When and why use it
22
24
 
@@ -25,7 +27,8 @@ Use this extension if
25
27
  - you are on Requesty.ai
26
28
  - you are limited to certain models by a company policy (for instance, for data residency/protection reasons)
27
29
  - you don't want to handcraft your own curated list of models constantly
28
- - you want to health check the list to complain about non-working models via a dry-run
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
29
32
 
30
33
  == Installation
31
34
 
@@ -124,12 +127,40 @@ Inside Pi:
124
127
  /requesty-discover
125
128
  ----
126
129
 
127
- The command fetches Requesty models using `~/.pi/agent/models.json` and writes the discovered model IDs back to the same file.
128
- Run `/reload` after syncing.
130
+ The command:
131
+
132
+ - discovers Requesty models (and optional health checks)
133
+ - shows a summary notification (added/removed IDs, health summary)
134
+ - asks for confirmation before writing `~/.pi/agent/models.json`
135
+ - reports whether the file was updated
136
+
137
+ If there are no model ID changes, confirmation still offers a metadata refresh write, which might have changed as well.
138
+
139
+ After a successful write, run `/reload`.
129
140
 
130
141
  [source,text]
131
142
  ----
132
143
  /requesty-discover --dry-run
133
144
  ----
134
145
 
135
- This will not overwrite the `models.json` file, but will do everything else, including the health checks.
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
@@ -8,7 +8,9 @@ This is a fork of [pi-requesty](https://github.com/requestyai/pi-requesty) with
8
8
 
9
9
  * Use `REQUESTY_API_KEY` from the environment
10
10
  * Health-check available models and write a log about them
11
- * Provide a dry-run mode that does not change anything
11
+ * Brief information about what was found, including added/removed models, health summary
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
12
14
 
13
15
  ## When and why use it
14
16
 
@@ -17,7 +19,8 @@ Use this extension if
17
19
  * you are on Requesty.ai
18
20
  * you are limited to certain models by a company policy (for instance, for data residency/protection reasons)
19
21
  * you don’t want to handcraft your own curated list of models constantly
20
- * you want to health check the list to complain about non-working models via a dry-run
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
21
24
 
22
25
  ## Installation
23
26
 
@@ -91,11 +94,38 @@ Inside Pi:
91
94
  /requesty-discover
92
95
  ```
93
96
 
94
- The command fetches Requesty models using `~/.pi/agent/models.json` and writes the discovered model IDs back to the same file.
95
- Run `/reload` after syncing.
97
+ The command:
98
+
99
+ * discovers Requesty models (and optional health checks)
100
+ * shows a summary notification (added/removed IDs, health summary)
101
+ * asks for confirmation before writing `~/.pi/agent/models.json`
102
+ * reports whether the file was updated
103
+
104
+ If there are no model ID changes, confirmation still offers a metadata refresh write, which might have changed as well.
105
+
106
+ After a successful write, run `/reload`.
96
107
 
97
108
  ```text
98
109
  /requesty-discover --dry-run
99
110
  ```
100
111
 
101
- This will not overwrite the `models.json` file, but will do everything else, including the health checks.
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,36 @@
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
+ == Not a provider — a models.json-writing extension
18
+
19
+ Context: Requesty already works as a Pi provider via `models.json`. The temptation
20
+ is to make this extension *be* the provider: read the model list from the Requesty
21
+ API, hold it in memory, surface usage stats, let Pi manage the API key.
22
+
23
+ Why not:
24
+
25
+ - Health-checking is the core value and has no home in a provider. A provider serves
26
+ models; it doesn't vet them. The health check plus the diff/confirm/write flow is a
27
+ discovery-and-selection step, not serving.
28
+ - A provider keeps its model list in-memory and refreshes occasionally. Here the
29
+ list is a persistent on-disk snapshot (`models.json`) — constant until you re-run
30
+ `/requesty-discover`. That matches the intent: an explicit, reviewed, user-confirmed write,
31
+ after which Pi's normal provider machinery serves the models.
32
+
33
+ The provider-like parts (API model fetch, usage footer, wanting Pi-managed auth) are
34
+ real, but the health check and the snapshot model break the provider fit.
35
+
36
+ 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": "1.2.0",
3
+ "version": "2.1.0",
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,18 +1,24 @@
1
- import { ExtensionAPI, ExtensionCommandContext, ProviderModelConfig } from '@earendil-works/pi-coding-agent'
2
- import { getEnv } from './env'
1
+ import {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ ProviderModelConfig,
6
+ } from '@earendil-works/pi-coding-agent'
7
+ import { type Env, getEnv } from './env'
3
8
  import {
4
9
  diffModels,
5
10
  formatModelsDiffSummary,
6
11
  getRequestyConfig,
7
- updateModelsJson,
8
12
  type ModelsDiff,
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
@@ -26,143 +32,256 @@ export type Notifier = {
26
32
  notify(message: string, level: NotificationLevel): void
27
33
  }
28
34
 
35
+ export type Confirmer = {
36
+ confirm(title: string, message: string): Promise<boolean>
37
+ }
38
+
29
39
  export type StatusReporter = {
30
40
  set(message: string): void
31
- clear(): void
32
41
  }
33
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
65
+ let latestToken: object = {}
66
+
34
67
  // noinspection JSUnusedGlobalSymbols
35
68
  export default function (pi: ExtensionAPI) {
36
69
  pi.registerCommand(COMMAND_NAME, {
37
70
  description: 'Dynamically discover Requesty models, run health checks, and update the local models.json.',
38
71
  getArgumentCompletions,
39
72
  handler: async (args, ctx) => {
40
- await runWithStatusUi(ctx, 'Discovering models...', (status, notifier) => runCommand(args, status, notifier))
73
+ await runDiscoveryWorkflow(ctx, args)
41
74
  },
42
75
  })
76
+
77
+ pi.on('turn_end', (_event, ctx) => {
78
+ void updateUsageStatus(ctx)
79
+ })
80
+
81
+ pi.on('session_start', (_event, ctx) => {
82
+ void updateUsageStatus(ctx)
83
+ })
84
+
85
+ pi.on('model_select', (_event, ctx) => {
86
+ void updateUsageStatus(ctx)
87
+ })
88
+ }
89
+
90
+ export async function runDiscoveryWorkflow(ctx: ExtensionCommandContext, args: string) {
91
+ // Interactive TUI command; no print/json/rpc path.
92
+ if (ctx.mode !== 'tui') {
93
+ return
94
+ }
95
+
96
+ const notifier = createUiNotifier(ctx)
97
+ const confirmer = createUiConfirmer(ctx)
98
+
99
+ // Phase A: progress UI only. Loader must close before confirm (Phase B).
100
+ // Errors are wrapped because ctx.ui.custom resolves via done() and does not reject.
101
+ const evaluationResult: EvaluationResult = await runWithStatusUi(ctx, 'Discovering models...', async status => {
102
+ try {
103
+ const evaluation = await evaluateDiscovery(args, status)
104
+ return { ok: true, value: evaluation }
105
+ } catch (error) {
106
+ return { ok: false, error }
107
+ }
108
+ })
109
+
110
+ if (!evaluationResult.ok) {
111
+ notifier.notify(formatDiscoveryFailure(evaluationResult.error), 'error')
112
+ return
113
+ }
114
+
115
+ // Phases B + C: decide, optional write, final notify — outside the loader.
116
+ await finalizeDiscovery(evaluationResult.value, confirmer, notifier)
43
117
  }
44
118
 
45
119
  async function runWithStatusUi<T>(
46
120
  ctx: ExtensionCommandContext,
47
121
  initialMessage: string,
48
- fn: (status: StatusReporter, notifier: Notifier) => Promise<T>,
122
+ fn: (status: StatusReporter) => Promise<T>,
49
123
  ): Promise<T> {
50
- if (ctx.mode !== 'tui') {
51
- return fn(createNoopStatusReporter(), createNoopNotifier())
52
- }
53
-
54
124
  return ctx.ui.custom<T>((_tui, theme, _kb, done) => {
55
125
  const loader = new RequestyStatusLoader(_tui, theme, initialMessage)
56
- const notifier = createUiNotifier(ctx)
57
126
  const status = createLoaderStatusReporter(loader)
58
127
  void Promise.resolve()
59
- .then(() => fn(status, notifier))
128
+ .then(() => fn(status))
60
129
  .then(done)
61
130
  .catch(done)
62
131
  return loader
63
132
  })
64
133
  }
65
134
 
66
- export async function runCommand(args: string, status: StatusReporter, notifier: Notifier): Promise<void> {
135
+ function formatDiscoveryFailure(error: unknown): string {
136
+ const detail = error instanceof Error ? error.message : String(error)
137
+ return `Discovery failed: ${detail}`
138
+ }
139
+
140
+ async function evaluateDiscovery(args: string, status: StatusReporter): Promise<DiscoveryEvaluation> {
67
141
  status.set('Discovering Requesty models...')
68
- const parts = args.split(' ')
69
- const dryRun = parts.includes(DRY_RUN_ARG)
70
- if (dryRun) {
71
- notifier.notify('running in dry mode, no changes will be done', 'info')
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)
72
175
  }
73
176
 
74
- try {
75
- const env = getEnv()
76
- const { data, provider, existingModelIds } = getRequestyConfig(env)
77
- const models = await discoverModels(provider)
78
- const modelsMap = new Map(models.map(m => [m.id, m]))
79
-
80
- let diff: ModelsDiff
81
- let failed = []
82
- let passing: ProviderModelConfig[]
83
- let logNote = ''
84
- let healthCheckSummary = ''
85
-
86
- if (env.health_check_mode !== 'off') {
87
- status.set(`Checking models 0/${models.length}...`)
88
- const healthResults = await checkModels(provider, models, env.health_check_mode === 'full', {
89
- onProgress: ({ completed, total }) => {
90
- status.set(`Checking models ${completed}/${total}...`)
91
- },
92
- })
93
- const sortedResults = healthResults.toSorted((a, b) => a.modelId.localeCompare(b.modelId))
94
- failed = sortedResults.filter(r => !r.ok)
95
- passing = sortedResults.flatMap(r => {
96
- const model = modelsMap.get(r.modelId)
97
- return r.ok && model ? [model] : []
98
- })
99
- diff = diffModels(existingModelIds, passing)
100
- healthCheckSummary = formatHealthSummary(sortedResults)
101
- writeHealthCheckLog(provider, sortedResults, diff, env)
102
- logNote = `Full health check log: ${env.health_check_log_path}\n`
103
- } else {
104
- passing = models
105
- diff = diffModels(existingModelIds, passing)
106
- }
177
+ return {
178
+ dryRun,
179
+ modelCount: models.length,
180
+ failedCount,
181
+ passing,
182
+ diff,
183
+ healthCheckSummary,
184
+ logNote,
185
+ data,
186
+ env,
187
+ }
188
+ }
107
189
 
108
- const shouldUpdate = passing.length > 0 && !dryRun
109
- if (shouldUpdate) {
110
- updateModelsJson(data, passing, env)
111
- }
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)
112
197
 
113
- const writeNote = shouldUpdate ? 'Run /reload to use models.json changes.' : 'models.json was not updated.'
114
- const message = `Discovered ${models.length} Requesty model(s).\n${healthCheckSummary}${formatModelsDiffSummary(diff)}\n${logNote}${writeNote}`
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
+ }
115
207
 
116
- if (failed.length === 0) {
117
- notifier.notify(message, 'info')
118
- } else if (failed.length < models.length) {
119
- notifier.notify(message, 'warning')
120
- } else {
121
- notifier.notify(message, 'error')
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.',
122
246
  }
123
- } catch (error) {
124
- notifier.notify(`Discovery failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
125
- } finally {
126
- status.clear()
247
+ }
248
+ return {
249
+ title: 'Refresh models.json?',
250
+ message: 'No model ID changes. Rewrite the file to refresh metadata anyway?',
127
251
  }
128
252
  }
129
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
+
130
260
  function getArgumentCompletions(prefix: string): AutocompleteItem[] {
131
261
  const options = [
132
262
  {
133
263
  value: DRY_RUN_ARG,
134
264
  label: DRY_RUN_ARG,
135
- description: 'Preview without writing into the new model.json file',
265
+ description: 'Preview discovery without offering to write models.json',
136
266
  },
137
267
  ]
138
268
  if (!prefix) return options
139
269
  return options.filter(o => o.value.toLowerCase().startsWith(prefix.toLowerCase()))
140
270
  }
141
271
 
142
- function createNoopStatusReporter(): StatusReporter {
143
- return {
144
- set() {
145
- // Status output is only rendered in TUI mode.
146
- },
147
- clear() {
148
- // Status output is only rendered in TUI mode.
149
- },
150
- }
151
- }
152
-
153
- function createNoopNotifier(): Notifier {
272
+ function createUiNotifier(ctx: ExtensionCommandContext): Notifier {
154
273
  return {
155
- notify() {
156
- // Notifications are only rendered in TUI mode.
274
+ notify(message: string, level?: NotificationLevel) {
275
+ const prefixedMessage = `${COMMAND_NAME}: ${message}`
276
+ ctx.ui.notify(prefixedMessage, level)
157
277
  },
158
278
  }
159
279
  }
160
280
 
161
- function createUiNotifier(ctx: ExtensionCommandContext): Notifier {
281
+ function createUiConfirmer(ctx: ExtensionCommandContext): Confirmer {
162
282
  return {
163
- notify(message: string, level?: NotificationLevel) {
164
- const prefixedMessage = `${COMMAND_NAME}: ${message}`
165
- ctx.ui.notify(prefixedMessage, level)
283
+ confirm(title, message) {
284
+ return ctx.ui.confirm(title, message)
166
285
  },
167
286
  }
168
287
  }
@@ -172,8 +291,50 @@ function createLoaderStatusReporter(loader: RequestyStatusLoader): StatusReporte
172
291
  set(message: string) {
173
292
  loader.setMessage(message)
174
293
  },
175
- clear() {
176
- // Nothing to clear: the loader closes when ctx.ui.custom() resolves.
177
- },
178
294
  }
179
295
  }
296
+
297
+ export async function updateUsageStatus(ctx: ExtensionContext): Promise<void> {
298
+ if (!ctx.hasUI) return // no footer to write to (print/json mode): skip the wasted fetch
299
+ const token: object = {}
300
+ latestToken = token
301
+ const providerId = getRequestyProviderId()
302
+ if (providerId === undefined) return // not configured for Requesty: nothing to do
303
+ const shouldClear = ctx.model?.provider !== providerId
304
+ try {
305
+ if (shouldClear) {
306
+ ctx.ui.setStatus(USAGE_STATUS_KEY, undefined)
307
+ return
308
+ }
309
+ const info = await fetchUsageStatus()
310
+ if (latestToken !== token) return
311
+ ctx.ui.setStatus(USAGE_STATUS_KEY, formatUsageStatus(info))
312
+ } catch {
313
+ // best-effort footer update: swallow fetch errors and stale-ctx throws (e.g. after /reload)
314
+ }
315
+ }
316
+
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
+ export function formatUsageStatus(info: ApiKeyInfo): string {
327
+ const spend = `$${info.monthlySpend.toFixed(2)}`
328
+ if (info.monthlyLimit <= 0) {
329
+ return `Requesty Usage (${info.name}): ${spend} (unlimited)`
330
+ }
331
+ const limit = `$${info.monthlyLimit.toFixed(2)}`
332
+ const percent = Math.floor((info.monthlySpend / info.monthlyLimit) * 100)
333
+ return `Requesty Usage (${info.name}): ${spend}/${limit} (${percent}%)`
334
+ }
335
+
336
+ async function fetchUsageStatus(): Promise<ApiKeyInfo> {
337
+ const env = getEnv()
338
+ const { provider } = getRequestyConfig(env)
339
+ return await fetchApiUsage(provider)
340
+ }
@@ -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({
@@ -66,3 +67,31 @@ function pricePerMillionTokens(value: number | null | undefined): number {
66
67
  const raw = (value ?? 0) * 1_000_000
67
68
  return Number(raw.toFixed(3))
68
69
  }
70
+
71
+ const ApiKeyInfoSchema = z
72
+ .object({
73
+ name: z.string().min(1),
74
+ monthly_spend: z.coerce.number(),
75
+ monthly_limit: z.coerce.number(),
76
+ })
77
+ .transform(value => ({
78
+ name: value.name,
79
+ monthlySpend: value.monthly_spend,
80
+ monthlyLimit: value.monthly_limit,
81
+ }))
82
+
83
+ export type ApiKeyInfo = z.infer<typeof ApiKeyInfoSchema>
84
+
85
+ export async function fetchApiUsage(provider: Provider): Promise<ApiKeyInfo> {
86
+ const url = `${REQUESTY_MANAGE_URL}/apikey/self`
87
+ const response = await fetch(url, {
88
+ headers: { Authorization: `Bearer ${provider.apiKey}` },
89
+ })
90
+
91
+ if (!response.ok) {
92
+ throw new Error(`HTTP ${response.status} ${response.statusText}`)
93
+ }
94
+
95
+ const rawData = await response.json()
96
+ return ApiKeyInfoSchema.parse(rawData)
97
+ }