@marcusrbrown/infra 0.9.0 → 0.9.2

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": "@marcusrbrown/infra",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -251,6 +251,73 @@ describe('verifyModelsAvailable', () => {
251
251
  await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
252
252
  })
253
253
 
254
+ it('regression: entries WITH owned_by openai still parse and are detected as OpenAI', async () => {
255
+ const fixture = {
256
+ data: [
257
+ {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
258
+ {id: 'gpt-5.4-mini', owned_by: 'openai'},
259
+ ],
260
+ object: 'list',
261
+ }
262
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
263
+
264
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
265
+ })
266
+
267
+ it('v7 compatibility: entries omitting owned_by parse without error and OpenAI is detected via id prefix', async () => {
268
+ // CLIProxyAPI v7 may return entries without owned_by — these must not fail Zod parse,
269
+ // and an entry like {id: 'openai/gpt-5.4-mini'} should be detected as an OpenAI model.
270
+ const v7Fixture = {
271
+ data: [
272
+ {id: 'openai/gpt-5.4-mini', object: 'model'},
273
+ {id: 'anthropic/claude-sonnet-4-6', object: 'model'},
274
+ ],
275
+ object: 'list',
276
+ }
277
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(v7Fixture))) as unknown as typeof fetch
278
+
279
+ // Requesting a model that doesn't exist so we can observe which check throws.
280
+ // The error must be "not found on proxy" — not a Zod parse error or "No OpenAI models on proxy".
281
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/nonexistent-model')).rejects.toThrow(
282
+ 'not found on proxy',
283
+ )
284
+ })
285
+
286
+ it('v7 compatibility: mixed entries (some with owned_by, some without) resolve correctly', async () => {
287
+ const mixedFixture = {
288
+ data: [
289
+ {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
290
+ // v7-style OpenAI entry — no owned_by, id has openai/ prefix
291
+ {id: 'openai/gpt-5.4-mini', object: 'model'},
292
+ ],
293
+ object: 'list',
294
+ }
295
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(mixedFixture))) as unknown as typeof fetch
296
+
297
+ // Should detect OpenAI via id inference and pass the OpenAI presence check.
298
+ // The error must be "not found on proxy" — not a Zod parse error or "No OpenAI models on proxy".
299
+ await expect(
300
+ verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'openai/nonexistent-model'),
301
+ ).rejects.toThrow('not found on proxy')
302
+ })
303
+
304
+ it('v7 compatibility: entry with unrecognizable id and no owned_by does not crash (falls through as non-OpenAI)', async () => {
305
+ const unknownIdFixture = {
306
+ data: [
307
+ // No owned_by, id doesn't map to any known provider
308
+ {id: 'some-unknown-model', object: 'model'},
309
+ // A real OpenAI entry with owned_by — detection should succeed via this entry
310
+ {id: 'gpt-5.4-mini', owned_by: 'openai'},
311
+ ],
312
+ object: 'list',
313
+ }
314
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(unknownIdFixture))) as unknown as typeof fetch
315
+
316
+ // Should still pass — openai is detected via owned_by on the second entry,
317
+ // and the unknown entry does not cause a crash.
318
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
319
+ })
320
+
254
321
  it('error path: dual providers, no owned_by=openai entries — throws no-openai-models message', async () => {
255
322
  const anthropicOnlyData = {
256
323
  data: [
@@ -337,6 +404,94 @@ describe('validateSetupOptions — providers/model validation', () => {
337
404
  })
338
405
  })
339
406
 
407
+ // ── FIX 1: owned_by:'' falls back to id inference ────────────────────────────
408
+
409
+ describe('verifyModelsAvailable — owned_by empty string falls back to id inference', () => {
410
+ let originalFetch: typeof globalThis.fetch
411
+ afterEach(() => {
412
+ globalThis.fetch = originalFetch
413
+ })
414
+ originalFetch = globalThis.fetch
415
+
416
+ it('entry with owned_by:"" and id "openai/gpt-5.4-mini" is detected as OpenAI (does not throw no-openai-models)', async () => {
417
+ const fixture = {
418
+ data: [{id: 'openai/gpt-5.4-mini', owned_by: ''}],
419
+ }
420
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
421
+
422
+ // Must resolve — empty owned_by should fall back to id prefix inference
423
+ await expect(
424
+ verifyModelsAvailable('https://cliproxy.fro.bot', 'sk-test-key', ['openai'], 'openai/gpt-5.4-mini'),
425
+ ).resolves.toBeUndefined()
426
+ })
427
+ })
428
+
429
+ // ── FIX 2: v7-prefixed model presence ────────────────────────────────────────
430
+
431
+ describe('verifyModelsAvailable — v7-prefixed model id matched without owned_by', () => {
432
+ let originalFetch: typeof globalThis.fetch
433
+ afterEach(() => {
434
+ globalThis.fetch = originalFetch
435
+ })
436
+ originalFetch = globalThis.fetch
437
+
438
+ it('entry with id "openai/gpt-5.4-mini" (no owned_by) resolves when requesting "openai/gpt-5.4-mini"', async () => {
439
+ const fixture = {
440
+ data: [{id: 'openai/gpt-5.4-mini'}],
441
+ }
442
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
443
+
444
+ await expect(
445
+ verifyModelsAvailable('https://cliproxy.fro.bot', 'sk-test-key', ['openai'], 'openai/gpt-5.4-mini'),
446
+ ).resolves.toBeUndefined()
447
+ })
448
+
449
+ it('model not found error lists requested model and available openai id but not anthropic ids', async () => {
450
+ const fixture = {
451
+ data: [{id: 'openai/gpt-5.4-mini'}, {id: 'anthropic/claude-sonnet-4-6'}],
452
+ }
453
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
454
+
455
+ let errorMessage = ''
456
+ try {
457
+ await verifyModelsAvailable('https://cliproxy.fro.bot', 'sk-test-key', ['openai'], 'openai/nonexistent')
458
+ } catch (error) {
459
+ errorMessage = error instanceof Error ? error.message : String(error)
460
+ }
461
+
462
+ expect(errorMessage).toContain('nonexistent')
463
+ expect(errorMessage).toContain('openai/gpt-5.4-mini')
464
+ expect(errorMessage).not.toContain('anthropic')
465
+ expect(errorMessage).not.toContain('claude')
466
+ })
467
+ })
468
+
469
+ // ── FIX 3: exact key redacted in /v1/models error body ───────────────────────
470
+
471
+ describe('verifyModelsAvailable — exact key redacted in error body', () => {
472
+ let originalFetch: typeof globalThis.fetch
473
+ afterEach(() => {
474
+ globalThis.fetch = originalFetch
475
+ })
476
+ originalFetch = globalThis.fetch
477
+
478
+ it('500 response body containing the raw key does not expose the key in thrown message', async () => {
479
+ const rawKey = 'my-plain-key-no-bearer-prefix'
480
+ const body = `Internal error: key=${rawKey} was invalid`
481
+ globalThis.fetch = mock(async () => new Response(body, {status: 500})) as unknown as typeof fetch
482
+
483
+ let errorMessage = ''
484
+ try {
485
+ await verifyModelsAvailable('https://cliproxy.fro.bot', rawKey, ['openai'], 'openai/gpt-5.4-mini')
486
+ } catch (error) {
487
+ errorMessage = error instanceof Error ? error.message : String(error)
488
+ }
489
+
490
+ expect(errorMessage).toContain('500')
491
+ expect(errorMessage).not.toContain(rawKey)
492
+ })
493
+ })
494
+
340
495
  // ── assertProxyReachable (new TDD tests) ──────────────────────────────────────
341
496
 
342
497
  describe('assertProxyReachable', () => {
@@ -366,6 +521,19 @@ describe('assertProxyReachable', () => {
366
521
 
367
522
  await expect(assertProxyReachable('https://bad.example')).rejects.toThrow(/Proxy check failed/)
368
523
  })
524
+
525
+ it('probes /healthz: resolves when /healthz returns 200 and bare base returns 404', async () => {
526
+ const BASE = 'https://proxy.example'
527
+ let fetchedUrl: string | undefined
528
+ globalThis.fetch = mock(async (url: string) => {
529
+ fetchedUrl = url
530
+ if (url === `${BASE}/healthz`) return new Response('{"status":"ok"}', {status: 200})
531
+ return new Response('Not Found', {status: 404})
532
+ }) as unknown as typeof fetch
533
+
534
+ await expect(assertProxyReachable(BASE)).resolves.toBeUndefined()
535
+ expect(fetchedUrl).toBe(`${BASE}/healthz`)
536
+ })
369
537
  })
370
538
 
371
539
  // ── assertProxyKeyWorks (new TDD tests) ───────────────────────────────────────
@@ -4,11 +4,12 @@ import {z} from 'zod'
4
4
 
5
5
  import {parseProviders, type ProviderId} from './providers'
6
6
 
7
- // Permissive schema: unknown fields preserved via passthrough()
7
+ // Permissive schema: unknown fields preserved via passthrough().
8
+ // owned_by is optional — CLIProxyAPI v7 may omit it; provider is inferred from id instead.
8
9
  const modelEntrySchema = z
9
10
  .object({
10
11
  id: z.string(),
11
- owned_by: z.string(),
12
+ owned_by: z.string().optional(),
12
13
  })
13
14
  .passthrough()
14
15
 
@@ -22,6 +23,32 @@ export const MODEL_ID_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])
22
23
 
23
24
  const HTTP_TIMEOUT_MS = 10_000
24
25
 
26
+ type ModelEntry = z.infer<typeof modelEntrySchema>
27
+
28
+ // Known bare-id patterns per provider, used only when owned_by is absent
29
+ // (e.g. CLIProxyAPI v7 omits owned_by from /v1/models entries).
30
+ const PROVIDER_ID_PATTERNS: Record<string, RegExp> = {
31
+ openai: /^(?:gpt-|o1-|o3-|codex)/i,
32
+ anthropic: /^claude-/i,
33
+ }
34
+
35
+ /**
36
+ * Decides whether a /v1/models entry belongs to a provider.
37
+ * Prefers owned_by when present; falls back to id prefix / known bare-id
38
+ * patterns when absent. Display/validation-only — never an auth or trust signal.
39
+ */
40
+ function entryMatchesProvider(entry: ModelEntry, provider: string): boolean {
41
+ if (entry.owned_by !== undefined && entry.owned_by.trim() !== '') {
42
+ return entry.owned_by === provider
43
+ }
44
+ const id = entry.id ?? ''
45
+ if (id.startsWith(`${provider}/`)) {
46
+ return true
47
+ }
48
+ const pattern = PROVIDER_ID_PATTERNS[provider]
49
+ return pattern === undefined ? false : pattern.test(id)
50
+ }
51
+
25
52
  /** Local copy — avoids a circular import with setup.ts (validation → setup → validation). */
26
53
  function extractErrorMessage(error: unknown): string {
27
54
  return error instanceof Error ? error.message : String(error)
@@ -97,8 +124,8 @@ export async function verifyModelsAvailable(
97
124
 
98
125
  if (!response.ok) {
99
126
  const rawBody = await response.text()
100
- // Redact any Authorization headers or sk-* token-shaped strings that the server might echo
101
- const redacted = rawBody
127
+ // Redact the literal key value, Authorization headers, and sk-* shaped tokens
128
+ const redacted = (key.length > 0 ? rawBody.replaceAll(key, '<redacted>') : rawBody)
102
129
  .replaceAll(/Bearer\s+[^\s"]+/g, 'Bearer <redacted>')
103
130
  .replaceAll(/sk-[\w.-]{8,}/g, 'sk-<redacted>')
104
131
  const excerpt = redacted.slice(0, 200)
@@ -120,9 +147,9 @@ export async function verifyModelsAvailable(
120
147
 
121
148
  const entries = parsed.data
122
149
 
123
- // OpenAI presence check
150
+ // OpenAI presence check.
124
151
  if (providers.includes('openai')) {
125
- const hasOpenAi = entries.some(e => e.owned_by === 'openai')
152
+ const hasOpenAi = entries.some(e => entryMatchesProvider(e, 'openai'))
126
153
  if (!hasOpenAi) {
127
154
  throw new Error('No OpenAI models on proxy — is the Codex token loaded? Try `cliproxy login codex`.')
128
155
  }
@@ -133,11 +160,11 @@ export async function verifyModelsAvailable(
133
160
  const bareId = slashIndex === -1 ? model : model.slice(slashIndex + 1)
134
161
  const providerPrefix = slashIndex >= 0 ? model.slice(0, slashIndex) : undefined
135
162
 
136
- const modelPresent = entries.some(e => e.id === bareId)
163
+ const modelPresent = entries.some(e => e.id === bareId || e.id === model)
137
164
  if (!modelPresent) {
138
165
  // List available ids for the matching provider only
139
166
  const matchingIds = providerPrefix
140
- ? entries.filter(e => e.owned_by === providerPrefix).map(e => e.id)
167
+ ? entries.filter(e => entryMatchesProvider(e, providerPrefix)).map(e => e.id)
141
168
  : entries.map(e => e.id)
142
169
  const available = matchingIds.length > 0 ? matchingIds.join(', ') : '(none)'
143
170
  throw new Error(`Model "${bareId}" not found on proxy. Available ${providerPrefix ?? 'models'}: ${available}`)
@@ -146,7 +173,7 @@ export async function verifyModelsAvailable(
146
173
 
147
174
  export async function assertProxyReachable(baseUrl: string): Promise<void> {
148
175
  try {
149
- const response = await fetch(baseUrl, {
176
+ const response = await fetch(`${baseUrl}/healthz`, {
150
177
  signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
151
178
  })
152
179
 
@@ -8,6 +8,7 @@ import {
8
8
  cliproxyStatusAction,
9
9
  formatDurationMs,
10
10
  formatUsageSummaryLine,
11
+ getCliproxyStatusSummary,
11
12
  levelLabel,
12
13
  stripTrailingSlash,
13
14
  toNumber,
@@ -131,50 +132,54 @@ describe('cliproxy status helpers', () => {
131
132
  })
132
133
 
133
134
  describe('checkUsageStats', () => {
134
- it('returns ok when failures are zero (nested usage object)', async () => {
135
+ it('returns ok for empty array (idle)', async () => {
135
136
  globalThis.fetch = createFetchImplementation(
136
- async () =>
137
- new Response(
138
- JSON.stringify({failed_requests: 0, usage: {total_requests: 10, failure_count: 0, success_count: 10}}),
139
- {status: 200, headers: {'content-type': 'application/json'}},
140
- ),
137
+ async () => new Response('[]', {status: 200, headers: {'content-type': 'application/json'}}),
141
138
  )
142
139
 
143
140
  const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
144
141
 
145
142
  expect(result.level).toBe('ok')
146
- expect(result.summary).toBe('total_requests=10, failure_count=0')
143
+ expect(result.summary).toMatch(/idle|recent: 0/)
147
144
  })
148
145
 
149
- it('returns ok with flat payload (backwards compat)', async () => {
146
+ it('returns ok for all-success queue array', async () => {
147
+ const queue = [{status: 200}, {status: 201}, {status: 200}]
150
148
  globalThis.fetch = createFetchImplementation(
151
- async () =>
152
- new Response(JSON.stringify({total_requests: 10, failure_count: 0}), {
153
- status: 200,
154
- headers: {'content-type': 'application/json'},
155
- }),
149
+ async () => new Response(JSON.stringify(queue), {status: 200, headers: {'content-type': 'application/json'}}),
156
150
  )
157
151
 
158
152
  const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
159
153
 
160
154
  expect(result.level).toBe('ok')
161
- expect(result.summary).toBe('total_requests=10, failure_count=0')
155
+ expect(result.summary).toContain('recent: 3')
156
+ })
157
+
158
+ it('returns warning for queue with error-status records', async () => {
159
+ const queue = [{status: 200}, {status: 500}, {status: 200}]
160
+ globalThis.fetch = createFetchImplementation(
161
+ async () => new Response(JSON.stringify(queue), {status: 200, headers: {'content-type': 'application/json'}}),
162
+ )
163
+
164
+ const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
165
+
166
+ expect(result.level).toBe('warning')
167
+ expect(result.summary).toContain('recent: 3')
168
+ expect(result.summary).toContain('errors: 1')
162
169
  })
163
170
 
164
- it('returns warning when token refresh is likely needed', async () => {
171
+ it('returns warning when usage-queue returns a non-array object', async () => {
165
172
  globalThis.fetch = createFetchImplementation(
166
173
  async () =>
167
- new Response(
168
- JSON.stringify({failed_requests: 3, usage: {total_requests: 10, failure_count: 3, success_count: 7}}),
169
- {status: 200, headers: {'content-type': 'application/json'}},
170
- ),
174
+ new Response(JSON.stringify({unexpected: 'object'}), {
175
+ status: 200,
176
+ headers: {'content-type': 'application/json'},
177
+ }),
171
178
  )
172
179
 
173
180
  const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
174
181
 
175
182
  expect(result.level).toBe('warning')
176
- expect(result.summary).toContain('total_requests=10, failure_count=3')
177
- expect(result.summary).toContain('token refresh likely needed')
178
183
  })
179
184
 
180
185
  it('returns warning when rate limited', async () => {
@@ -258,54 +263,54 @@ describe('cliproxy status helpers', () => {
258
263
  })
259
264
 
260
265
  describe('formatUsageSummaryLine', () => {
261
- it('formats zero-failure summary', () => {
266
+ it('formats a recent-activity summary with no errors', () => {
262
267
  const result = formatUsageSummaryLine({
263
268
  title: 'Usage stats',
264
269
  level: 'ok',
265
- summary: 'total_requests=10, failure_count=0',
270
+ summary: 'recent: 10',
266
271
  })
267
272
 
268
- expect(result).toBe('Requests: 10 total, 0 failed (0.0% failure rate)')
273
+ expect(result).toBe('Recent requests: 10')
269
274
  })
270
275
 
271
- it('formats non-zero failure summary with correct rate', () => {
276
+ it('formats a recent-activity summary with errors appended', () => {
272
277
  const result = formatUsageSummaryLine({
273
278
  title: 'Usage stats',
274
279
  level: 'warning',
275
- summary: 'total_requests=10, failure_count=3 (token refresh likely needed)',
280
+ summary: 'recent: 10, errors: 3',
276
281
  })
277
282
 
278
- expect(result).toBe('Requests: 10 total, 3 failed (30.0% failure rate)')
283
+ expect(result).toBe('Recent requests: 10, 3 errors')
279
284
  })
280
285
 
281
- it('returns null when summary has no numeric fields', () => {
286
+ it('formats an idle recent summary', () => {
282
287
  const result = formatUsageSummaryLine({
283
288
  title: 'Usage stats',
284
- level: 'warning',
285
- summary: 'Rate limited by management API (HTTP 429). Retry in a few moments.',
289
+ level: 'ok',
290
+ summary: 'recent: 0 (idle)',
286
291
  })
287
292
 
288
- expect(result).toBeNull()
293
+ expect(result).toBe('Recent requests: 0')
289
294
  })
290
295
 
291
- it('returns null for error summaries without numeric fields', () => {
296
+ it('returns null when summary has no recent-activity field', () => {
292
297
  const result = formatUsageSummaryLine({
293
298
  title: 'Usage stats',
294
- level: 'error',
295
- summary: 'Unable to read usage stats: socket hang up',
299
+ level: 'warning',
300
+ summary: 'Rate limited by management API (HTTP 429). Retry in a few moments.',
296
301
  })
297
302
 
298
303
  expect(result).toBeNull()
299
304
  })
300
305
 
301
- it('handles zero total requests without division by zero', () => {
306
+ it('returns null for error summaries without a recent field', () => {
302
307
  const result = formatUsageSummaryLine({
303
308
  title: 'Usage stats',
304
- level: 'ok',
305
- summary: 'total_requests=0, failure_count=0',
309
+ level: 'error',
310
+ summary: 'Unable to read usage stats: socket hang up',
306
311
  })
307
312
 
308
- expect(result).toBe('Requests: 0 total, 0 failed (0.0% failure rate)')
313
+ expect(result).toBeNull()
309
314
  })
310
315
  })
311
316
  })
@@ -387,3 +392,328 @@ describe('cliproxyStatusAction (Tier-2 ctx capture)', () => {
387
392
  }
388
393
  })
389
394
  })
395
+
396
+ describe('usage-queue migration', () => {
397
+ afterEach(() => {
398
+ globalThis.fetch = originalFetch
399
+ })
400
+
401
+ it('populated usage-queue returns recent-activity summary with correct total and error count', async () => {
402
+ const queue = [
403
+ {status: 200, model: 'claude-3-5-sonnet'},
404
+ {status: 500, model: 'claude-3-5-sonnet'},
405
+ {status: 200, model: 'claude-3-5-sonnet'},
406
+ ]
407
+ globalThis.fetch = createFetchImplementation(async url => {
408
+ if (url.includes('/v0/management/usage-queue')) {
409
+ return new Response(JSON.stringify(queue), {status: 200, headers: {'content-type': 'application/json'}})
410
+ }
411
+
412
+ throw new Error(`Unexpected fetch: ${url}`)
413
+ })
414
+
415
+ const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
416
+
417
+ expect(result.level).not.toBe('error')
418
+ expect(result.summary).toContain('recent: 3')
419
+ expect(result.summary).toContain('errors: 1')
420
+ })
421
+
422
+ it('empty usage-queue returns ok/idle result, not an error', async () => {
423
+ globalThis.fetch = createFetchImplementation(async url => {
424
+ if (url.includes('/v0/management/usage-queue')) {
425
+ return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
426
+ }
427
+
428
+ throw new Error(`Unexpected fetch: ${url}`)
429
+ })
430
+
431
+ const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
432
+
433
+ expect(result.level).toBe('ok')
434
+ expect(result.summary).toMatch(/idle|recent: 0/)
435
+ })
436
+
437
+ it('malformed usage-queue response returns warning, not error, and does not throw', async () => {
438
+ globalThis.fetch = createFetchImplementation(async url => {
439
+ if (url.includes('/v0/management/usage-queue')) {
440
+ return new Response('{"not":"an-array"}', {status: 200, headers: {'content-type': 'application/json'}})
441
+ }
442
+
443
+ throw new Error(`Unexpected fetch: ${url}`)
444
+ })
445
+
446
+ const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
447
+
448
+ expect(result.level).toBe('warning')
449
+ })
450
+
451
+ it('formatUsageSummaryLine returns a human-friendly line for recent-window summary', () => {
452
+ const result = formatUsageSummaryLine({
453
+ title: 'Usage stats',
454
+ level: 'ok',
455
+ summary: 'recent: 5, errors: 1',
456
+ })
457
+
458
+ expect(result).not.toBeNull()
459
+ expect(result).toContain('5')
460
+ })
461
+ })
462
+
463
+ describe('management auth failure surfaces in unified summary (FIX 4)', () => {
464
+ afterEach(() => {
465
+ globalThis.fetch = originalFetch
466
+ })
467
+
468
+ it('getCliproxyStatusSummary with a bad key (401) shows auth failure in version and usageStats, not "— (no key)"', async () => {
469
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
470
+ const hdrs = init?.headers
471
+ const hasKey =
472
+ hdrs instanceof Headers
473
+ ? hdrs.has('x-management-key')
474
+ : hdrs !== null && hdrs !== undefined && typeof hdrs === 'object' && 'x-management-key' in hdrs
475
+ if (url.includes('/v0/management/') && hasKey) {
476
+ return new Response('Unauthorized', {status: 401})
477
+ }
478
+
479
+ return new Response('ok', {status: 200})
480
+ })
481
+
482
+ const summary = await getCliproxyStatusSummary('https://cliproxy.example.com', 'bad-key', false)
483
+
484
+ // Must NOT show the no-key sentinel — a bad key is distinct from no key
485
+ expect(summary.version).not.toBe('— (no key)')
486
+ expect(summary.usageStats).not.toBe('— (no key)')
487
+ // Must contain some error/auth indicator
488
+ expect(summary.version.toLowerCase()).toMatch(/error|auth|401|management|unauthorized/i)
489
+ expect(summary.usageStats.toLowerCase()).toMatch(/error|auth|401|management|unauthorized/i)
490
+ })
491
+ })
492
+
493
+ describe('ban body word-boundary detection (FIX 5)', () => {
494
+ afterEach(() => {
495
+ globalThis.fetch = originalFetch
496
+ })
497
+
498
+ it('403 with body {detail:"bandwidth exceeded"} is NOT treated as a ban (generic 403)', async () => {
499
+ globalThis.fetch = createFetchImplementation(async url => {
500
+ if (url.includes('/v0/management/config')) {
501
+ return new Response(JSON.stringify({detail: 'bandwidth exceeded'}), {
502
+ status: 403,
503
+ headers: {'content-type': 'application/json'},
504
+ })
505
+ }
506
+
507
+ return new Response('ok', {status: 200})
508
+ })
509
+
510
+ const {ctx, captured} = createCapturedCtx()
511
+ try {
512
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: 'any-key'}, ctx)
513
+ } catch (error) {
514
+ if (!(error instanceof MockProcessExit)) throw error
515
+ }
516
+
517
+ const output = [...captured.stdout, ...captured.stderr].join('\n')
518
+ expect(output.toLowerCase()).not.toMatch(/ip.?ban/)
519
+ })
520
+
521
+ it('403 with body {error:"IP banned"} IS treated as a ban', async () => {
522
+ globalThis.fetch = createFetchImplementation(async url => {
523
+ if (url.includes('/v0/management/config')) {
524
+ return new Response(JSON.stringify({error: 'IP banned'}), {
525
+ status: 403,
526
+ headers: {'content-type': 'application/json'},
527
+ })
528
+ }
529
+
530
+ return new Response('ok', {status: 200})
531
+ })
532
+
533
+ const {ctx, captured} = createCapturedCtx()
534
+ try {
535
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: 'any-key'}, ctx)
536
+ } catch (error) {
537
+ if (!(error instanceof MockProcessExit)) throw error
538
+ }
539
+
540
+ const output = [...captured.stdout, ...captured.stderr].join('\n')
541
+ expect(output.toLowerCase()).toMatch(/ip.?ban/)
542
+ })
543
+ })
544
+
545
+ describe('reachability probe targets /healthz liveness endpoint', () => {
546
+ afterEach(() => {
547
+ globalThis.fetch = originalFetch
548
+ })
549
+
550
+ it('cliproxyStatusAction reports HTTP reachable when /healthz returns 200 and bare base returns 404', async () => {
551
+ const BASE = 'https://cliproxy.example.com'
552
+ globalThis.fetch = createFetchImplementation(async url => {
553
+ if (url === `${BASE}/healthz`) return new Response('{"status":"ok"}', {status: 200})
554
+ if (url === BASE) return new Response('Not Found', {status: 404})
555
+ return new Response('ok', {status: 200})
556
+ })
557
+
558
+ const {ctx, captured} = createCapturedCtx()
559
+ await cliproxyStatusAction({url: BASE}, ctx)
560
+
561
+ const output = [...captured.stdout, ...captured.stderr].join('\n')
562
+ expect(output).toContain('OK')
563
+ expect(output).not.toMatch(/ERROR.*HTTP reachability|HTTP reachability.*ERROR/)
564
+ })
565
+
566
+ it('getCliproxyStatusSummary reports http ok when /healthz returns 200 and bare base returns 404', async () => {
567
+ const BASE = 'https://cliproxy.example.com'
568
+ globalThis.fetch = createFetchImplementation(async url => {
569
+ if (url === `${BASE}/healthz`) return new Response('{"status":"ok"}', {status: 200})
570
+ if (url === BASE) return new Response('Not Found', {status: 404})
571
+ return new Response('ok', {status: 200})
572
+ })
573
+
574
+ const summary = await getCliproxyStatusSummary(BASE, '', false)
575
+
576
+ expect(summary.http).toMatch(/^OK/)
577
+ })
578
+ })
579
+
580
+ describe('management auth probe (ban-awareness)', () => {
581
+ afterEach(() => {
582
+ globalThis.fetch = originalFetch
583
+ })
584
+
585
+ it('bad management key causes at most one management fetch and skips version+usage calls', async () => {
586
+ const managementFetchUrls: string[] = []
587
+
588
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
589
+ const hdrs = init?.headers
590
+ const hasManagementKey =
591
+ hdrs instanceof Headers
592
+ ? hdrs.has('x-management-key')
593
+ : hdrs !== null && hdrs !== undefined && typeof hdrs === 'object' && 'x-management-key' in hdrs
594
+ if (url.includes('/v0/management/') && hasManagementKey) {
595
+ managementFetchUrls.push(url)
596
+ return new Response('Unauthorized', {status: 401})
597
+ }
598
+
599
+ if (url.includes('/healthz') || !url.includes('/v0/management/')) {
600
+ return new Response('ok', {status: 200})
601
+ }
602
+
603
+ throw new Error(`Unexpected fetch: ${url}`)
604
+ })
605
+
606
+ const {ctx} = createCapturedCtx()
607
+ // Auth failure yields error-level result → exit(1) → MockProcessExit thrown
608
+ try {
609
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: 'bad-key'}, ctx)
610
+ } catch (error) {
611
+ if (!(error instanceof MockProcessExit)) throw error
612
+ }
613
+
614
+ expect(managementFetchUrls.length).toBe(1)
615
+ expect(managementFetchUrls[0]).toContain('/v0/management/config')
616
+ })
617
+
618
+ it('403 with ban body surfaces a distinct IP-banned message', async () => {
619
+ globalThis.fetch = createFetchImplementation(async url => {
620
+ if (url.includes('/v0/management/config')) {
621
+ return new Response(JSON.stringify({error: 'IP banned'}), {
622
+ status: 403,
623
+ headers: {'content-type': 'application/json'},
624
+ })
625
+ }
626
+
627
+ return new Response('ok', {status: 200})
628
+ })
629
+
630
+ const {ctx, captured} = createCapturedCtx()
631
+ // 403+ban → error level → exit(1)
632
+ try {
633
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: 'any-key'}, ctx)
634
+ } catch (error) {
635
+ if (!(error instanceof MockProcessExit)) throw error
636
+ }
637
+
638
+ const output = [...captured.stdout, ...captured.stderr].join('\n')
639
+ expect(output.toLowerCase()).toMatch(/ip.?ban/)
640
+ })
641
+
642
+ it('auth error message does not contain the management key value', async () => {
643
+ const secretKey = 'super-secret-mgmt-key-12345'
644
+
645
+ globalThis.fetch = createFetchImplementation(async url => {
646
+ if (url.includes('/v0/management/config')) {
647
+ return new Response('Unauthorized', {status: 401})
648
+ }
649
+
650
+ return new Response('ok', {status: 200})
651
+ })
652
+
653
+ const {ctx, captured} = createCapturedCtx()
654
+ // 401 → error level → exit(1)
655
+ try {
656
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: secretKey}, ctx)
657
+ } catch (error) {
658
+ if (!(error instanceof MockProcessExit)) throw error
659
+ }
660
+
661
+ const allOutput = [...captured.stdout, ...captured.stderr].join('\n')
662
+ expect(allOutput).not.toContain(secretKey)
663
+ })
664
+
665
+ it('successful probe allows version and usage checks to proceed in parallel', async () => {
666
+ const fetchedUrls: string[] = []
667
+
668
+ globalThis.fetch = createFetchImplementation(async url => {
669
+ fetchedUrls.push(url)
670
+ if (url.includes('/v0/management/config')) {
671
+ return new Response(JSON.stringify({config: 'ok'}), {
672
+ status: 200,
673
+ headers: {'content-type': 'application/json'},
674
+ })
675
+ }
676
+
677
+ if (url.includes('/v0/management/usage-queue')) {
678
+ return new Response('[]', {status: 200, headers: {'content-type': 'application/json'}})
679
+ }
680
+
681
+ if (url.includes('/v0/management/latest-version')) {
682
+ return new Response(JSON.stringify({'latest-version': 'v7.1.31'}), {
683
+ status: 200,
684
+ headers: {'content-type': 'application/json'},
685
+ })
686
+ }
687
+
688
+ return new Response('ok', {status: 200})
689
+ })
690
+
691
+ const {ctx} = createCapturedCtx()
692
+ await cliproxyStatusAction({url: 'https://cliproxy.example.com', key: 'valid-key'}, ctx)
693
+
694
+ expect(fetchedUrls.some(u => u.includes('/v0/management/latest-version'))).toBe(true)
695
+ expect(fetchedUrls.some(u => u.includes('/v0/management/usage-queue'))).toBe(true)
696
+ })
697
+
698
+ it('getCliproxyStatusSummary with bad key fires only one management fetch', async () => {
699
+ const managementFetchUrls: string[] = []
700
+
701
+ globalThis.fetch = createFetchImplementation(async (url, init) => {
702
+ const hdrs = init?.headers
703
+ const hasKey =
704
+ hdrs instanceof Headers
705
+ ? hdrs.has('x-management-key')
706
+ : hdrs !== null && hdrs !== undefined && typeof hdrs === 'object' && 'x-management-key' in hdrs
707
+ if (url.includes('/v0/management/') && hasKey) {
708
+ managementFetchUrls.push(url)
709
+ return new Response('Unauthorized', {status: 401})
710
+ }
711
+
712
+ return new Response('ok', {status: 200})
713
+ })
714
+
715
+ await getCliproxyStatusSummary('https://cliproxy.example.com', 'bad-key', false)
716
+
717
+ expect(managementFetchUrls.length).toBe(1)
718
+ })
719
+ })
@@ -94,8 +94,34 @@ export async function checkHttpReachability(url: string, verbose: boolean): Prom
94
94
  }
95
95
  }
96
96
 
97
+ /** Count records in a usage-queue array that look like errors/failures. Defensive: only counts when a recognizable field exists. */
98
+ function countQueueErrors(records: unknown[]): number {
99
+ let count = 0
100
+ for (const record of records) {
101
+ if (record === null || typeof record !== 'object') {
102
+ continue
103
+ }
104
+
105
+ const rec = record as Record<string, unknown>
106
+
107
+ // Status field >= 400 indicates an HTTP-level error
108
+ const status = toNumber(rec.status)
109
+ if (status !== null && status >= 400) {
110
+ count++
111
+ continue
112
+ }
113
+
114
+ // Explicit error/failure markers
115
+ if (rec.error !== undefined || rec.failed === true || rec.failure === true) {
116
+ count++
117
+ }
118
+ }
119
+
120
+ return count
121
+ }
122
+
97
123
  export async function checkUsageStats(baseUrl: string, key: string): Promise<CheckResult> {
98
- const endpoint = `${baseUrl}/v0/management/usage`
124
+ const endpoint = `${baseUrl}/v0/management/usage-queue?count=50`
99
125
 
100
126
  try {
101
127
  const response = await fetch(endpoint, {
@@ -115,31 +141,35 @@ export async function checkUsageStats(baseUrl: string, key: string): Promise<Che
115
141
  return {
116
142
  title: 'Usage stats',
117
143
  level: 'error',
118
- summary: `GET /v0/management/usage failed with HTTP ${response.status}`,
144
+ summary: `GET /v0/management/usage-queue failed with HTTP ${response.status}`,
119
145
  }
120
146
  }
121
147
 
122
148
  const payload = await parseJsonResponse(response)
123
- const top = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
124
- const usage = top.usage && typeof top.usage === 'object' ? (top.usage as Record<string, unknown>) : top
125
- const totalRequests = toNumber(usage.total_requests)
126
- const failureCount = toNumber(usage.failure_count)
127
149
 
128
- if (totalRequests === null || failureCount === null) {
150
+ if (!Array.isArray(payload)) {
129
151
  return {
130
152
  title: 'Usage stats',
131
153
  level: 'warning',
132
- summary: 'Management usage payload is missing expected numeric fields.',
154
+ summary: 'Usage-queue response was not an array cannot parse recent activity.',
155
+ }
156
+ }
157
+
158
+ const total = payload.length
159
+ const errors = countQueueErrors(payload)
160
+
161
+ if (total === 0) {
162
+ return {
163
+ title: 'Usage stats',
164
+ level: 'ok',
165
+ summary: 'recent: 0 (idle)',
133
166
  }
134
167
  }
135
168
 
136
169
  return {
137
170
  title: 'Usage stats',
138
- level: failureCount > 0 ? 'warning' : 'ok',
139
- summary:
140
- failureCount > 0
141
- ? `total_requests=${totalRequests}, failure_count=${failureCount} (token refresh likely needed)`
142
- : `total_requests=${totalRequests}, failure_count=${failureCount}`,
171
+ level: errors > 0 ? 'warning' : 'ok',
172
+ summary: errors > 0 ? `recent: ${total}, errors: ${errors}` : `recent: ${total}`,
143
173
  }
144
174
  } catch (error) {
145
175
  const message = error instanceof Error ? error.message : String(error)
@@ -196,6 +226,95 @@ export async function checkVersion(baseUrl: string, key: string): Promise<CheckR
196
226
  }
197
227
  }
198
228
 
229
+ /**
230
+ * Probe the management API with a cheap auth check before issuing parallel calls.
231
+ * Returns null on success, or a CheckResult describing the auth failure.
232
+ * Never throws.
233
+ */
234
+ async function probeManagementAuth(baseUrl: string, key: string): Promise<CheckResult | null> {
235
+ const endpoint = `${baseUrl}/v0/management/config`
236
+
237
+ try {
238
+ const response = await fetch(endpoint, {
239
+ headers: managementHeaders(key),
240
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
241
+ })
242
+
243
+ if (response.ok) {
244
+ return null
245
+ }
246
+
247
+ if (response.status === 403) {
248
+ // Could be an IP ban — inspect body for ban-ish markers
249
+ const payload = await parseJsonResponse(response)
250
+ const isBanBody =
251
+ payload !== null &&
252
+ typeof payload === 'object' &&
253
+ Object.values(payload as Record<string, unknown>).some(
254
+ v => typeof v === 'string' && /\b(?:ip[- ]?)?bann?ed\b/i.test(v),
255
+ )
256
+
257
+ if (isBanBody) {
258
+ return {
259
+ title: 'Management access',
260
+ level: 'error',
261
+ summary: 'IP banned — stop retrying for ~30 min. Management checks skipped.',
262
+ }
263
+ }
264
+
265
+ return {
266
+ title: 'Management access',
267
+ level: 'error',
268
+ summary: 'Management API returned HTTP 403. Management checks skipped.',
269
+ }
270
+ }
271
+
272
+ if (response.status === 401) {
273
+ return {
274
+ title: 'Management access',
275
+ level: 'error',
276
+ summary: 'Management API returned HTTP 401 (invalid key). Management checks skipped.',
277
+ }
278
+ }
279
+
280
+ return {
281
+ title: 'Management access',
282
+ level: 'warning',
283
+ summary: `Management auth probe returned HTTP ${response.status}. Management checks skipped.`,
284
+ }
285
+ } catch (error) {
286
+ const message = error instanceof Error ? error.message : String(error)
287
+ return {
288
+ title: 'Management access',
289
+ level: 'error',
290
+ summary: `Management auth probe failed: ${message}`,
291
+ }
292
+ }
293
+ }
294
+
295
+ type ManagementChecks =
296
+ | {kind: 'no-key'}
297
+ | {kind: 'auth-failure'; result: CheckResult}
298
+ | {kind: 'checks'; usage: CheckResult; version: CheckResult}
299
+
300
+ /**
301
+ * Run management checks with single probe + parallel data fetches.
302
+ * Returns a discriminated union so callers handle no-key, auth-failure, and success distinctly.
303
+ */
304
+ async function runManagementChecks(baseUrl: string, key: string | undefined): Promise<ManagementChecks> {
305
+ if (!key) {
306
+ return {kind: 'no-key'}
307
+ }
308
+
309
+ const authFailure = await probeManagementAuth(baseUrl, key)
310
+ if (authFailure !== null) {
311
+ return {kind: 'auth-failure', result: authFailure}
312
+ }
313
+
314
+ const [usage, version] = await Promise.all([checkUsageStats(baseUrl, key), checkVersion(baseUrl, key)])
315
+ return {kind: 'checks', usage, version}
316
+ }
317
+
199
318
  function printCheckResult(result: CheckResult, ctx: ActionCtx): void {
200
319
  ctx.console.log(`[${levelLabel(result.level)}] ${result.title}`)
201
320
  ctx.console.log(` ${result.summary}`)
@@ -212,37 +331,46 @@ function formatCheckSummary(result: CheckResult): string {
212
331
  }
213
332
 
214
333
  export function formatUsageSummaryLine(result: CheckResult): string | null {
215
- const totalMatch = /total_requests=(\d+)/.exec(result.summary)
216
- const failureMatch = /failure_count=(\d+)/.exec(result.summary)
217
-
218
- if (!totalMatch || !failureMatch) {
334
+ // v7 recent-window format: "recent: N" or "recent: N, errors: M"
335
+ const recentMatch = /recent:\s*(\d+)/.exec(result.summary)
336
+ if (!recentMatch) {
219
337
  return null
220
338
  }
221
-
222
- const total = Number(totalMatch[1])
223
- const failed = Number(failureMatch[1])
224
- const failureRate = total === 0 ? 0 : (failed / total) * 100
225
-
226
- return `Requests: ${total} total, ${failed} failed (${failureRate.toFixed(1)}% failure rate)`
339
+ const total = Number(recentMatch[1])
340
+ const errorMatch = /errors:\s*(\d+)/.exec(result.summary)
341
+ const errors = errorMatch ? Number(errorMatch[1]) : 0
342
+ return `Recent requests: ${total}${errors > 0 ? `, ${errors} errors` : ''}`
227
343
  }
228
344
 
229
345
  export async function getCliproxyStatusSummary(baseUrl: string, key: string, verbose: boolean): Promise<StatusSummary> {
230
346
  const normalizedBaseUrl = stripTrailingSlash(baseUrl)
231
- const httpPromise = checkHttpReachability(normalizedBaseUrl, verbose)
232
- const managementResultsPromise = key
233
- ? Promise.all([checkUsageStats(normalizedBaseUrl, key), checkVersion(normalizedBaseUrl, key)])
234
- : Promise.resolve(null)
235
-
236
- const [httpResult, managementResults] = await Promise.all([httpPromise, managementResultsPromise])
237
- const [usageResult, versionResult] = managementResults ?? [null, null]
347
+ const [httpResult, mgmt] = await Promise.all([
348
+ checkHttpReachability(`${normalizedBaseUrl}/healthz`, verbose),
349
+ runManagementChecks(normalizedBaseUrl, key || undefined),
350
+ ])
351
+
352
+ let version: string
353
+ let usageStats: string
354
+
355
+ if (mgmt.kind === 'no-key') {
356
+ version = '— (no key)'
357
+ usageStats = '— (no key)'
358
+ } else if (mgmt.kind === 'auth-failure') {
359
+ const authSummary = formatCheckSummary(mgmt.result)
360
+ version = authSummary
361
+ usageStats = authSummary
362
+ } else {
363
+ version = formatCheckSummary(mgmt.version)
364
+ usageStats = formatCheckSummary(mgmt.usage)
365
+ }
238
366
 
239
367
  return {
240
368
  app: 'cliproxy',
241
369
  http: formatCheckSummary(httpResult),
242
370
  lastDeploy: '—',
243
- version: versionResult ? formatCheckSummary(versionResult) : '— (no key)',
371
+ version,
244
372
  contentHash: '—',
245
- usageStats: usageResult ? formatCheckSummary(usageResult) : '— (no key)',
373
+ usageStats,
246
374
  }
247
375
  }
248
376
 
@@ -262,25 +390,24 @@ export async function cliproxyStatusAction(options: StatusOptions, ctx: ActionCt
262
390
  ctx.console.log('CLIProxyAPI status')
263
391
  ctx.console.log('')
264
392
 
265
- const results: CheckResult[] = [await checkHttpReachability(baseUrl, verbose)]
393
+ const results: CheckResult[] = [await checkHttpReachability(`${baseUrl}/healthz`, verbose)]
266
394
 
267
395
  let capturedUsageResult: CheckResult | undefined
268
396
 
269
- if (managementKey) {
270
- const [usageResult, versionResult] = await Promise.all([
271
- checkUsageStats(baseUrl, managementKey),
272
- checkVersion(baseUrl, managementKey),
273
- ])
397
+ const mgmt = await runManagementChecks(baseUrl, managementKey)
274
398
 
275
- capturedUsageResult = usageResult
276
- results.push(usageResult, versionResult)
277
- } else {
399
+ if (mgmt.kind === 'no-key') {
278
400
  results.push({
279
401
  title: 'Management checks',
280
402
  level: 'warning',
281
403
  summary:
282
404
  'CLIPROXY_MANAGEMENT_KEY is not set. Skipping usage stats and version checks. Provide --key or set env var.',
283
405
  })
406
+ } else if (mgmt.kind === 'auth-failure') {
407
+ results.push(mgmt.result)
408
+ } else {
409
+ capturedUsageResult = mgmt.usage
410
+ results.push(mgmt.usage, mgmt.version)
284
411
  }
285
412
 
286
413
  for (const result of results) {
@@ -150,12 +150,6 @@ describe('mcp integration (Tier-1, in-process)', () => {
150
150
  // Mode C: when a command returns structured data AND prints to stdout,
151
151
  // the CallToolResult must contain BOTH a stdout text block AND a
152
152
  // stringified return-value text block.
153
- //
154
- // Re-enable after Unit 4 lands (cliproxy keys list refactor to return
155
- // structured data alongside ctx-printed text).
156
-
157
- // Re-enable after Unit 4 lands (cliproxy keys list refactor to return
158
- // structured data alongside ctx-printed text).
159
153
  test('cliproxy_keys_list returns BOTH stdout block AND structured return block (Mode C contract)', async () => {
160
154
  const originalFetch = globalThis.fetch
161
155
  const originalKey = process.env.CLIPROXY_MANAGEMENT_KEY