@marcusrbrown/infra 0.9.0 → 0.9.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/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
143
|
+
expect(result.summary).toMatch(/idle|recent: 0/)
|
|
147
144
|
})
|
|
148
145
|
|
|
149
|
-
it('returns ok
|
|
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).
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
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: '
|
|
270
|
+
summary: 'recent: 10',
|
|
266
271
|
})
|
|
267
272
|
|
|
268
|
-
expect(result).toBe('
|
|
273
|
+
expect(result).toBe('Recent requests: 10')
|
|
269
274
|
})
|
|
270
275
|
|
|
271
|
-
it('formats
|
|
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: '
|
|
280
|
+
summary: 'recent: 10, errors: 3',
|
|
276
281
|
})
|
|
277
282
|
|
|
278
|
-
expect(result).toBe('
|
|
283
|
+
expect(result).toBe('Recent requests: 10, 3 errors')
|
|
279
284
|
})
|
|
280
285
|
|
|
281
|
-
it('
|
|
286
|
+
it('formats an idle recent summary', () => {
|
|
282
287
|
const result = formatUsageSummaryLine({
|
|
283
288
|
title: 'Usage stats',
|
|
284
|
-
level: '
|
|
285
|
-
summary: '
|
|
289
|
+
level: 'ok',
|
|
290
|
+
summary: 'recent: 0 (idle)',
|
|
286
291
|
})
|
|
287
292
|
|
|
288
|
-
expect(result).
|
|
293
|
+
expect(result).toBe('Recent requests: 0')
|
|
289
294
|
})
|
|
290
295
|
|
|
291
|
-
it('returns null
|
|
296
|
+
it('returns null when summary has no recent-activity field', () => {
|
|
292
297
|
const result = formatUsageSummaryLine({
|
|
293
298
|
title: 'Usage stats',
|
|
294
|
-
level: '
|
|
295
|
-
summary: '
|
|
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('
|
|
306
|
+
it('returns null for error summaries without a recent field', () => {
|
|
302
307
|
const result = formatUsageSummaryLine({
|
|
303
308
|
title: 'Usage stats',
|
|
304
|
-
level: '
|
|
305
|
-
summary: '
|
|
309
|
+
level: 'error',
|
|
310
|
+
summary: 'Unable to read usage stats: socket hang up',
|
|
306
311
|
})
|
|
307
312
|
|
|
308
|
-
expect(result).
|
|
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 (
|
|
150
|
+
if (!Array.isArray(payload)) {
|
|
129
151
|
return {
|
|
130
152
|
title: 'Usage stats',
|
|
131
153
|
level: 'warning',
|
|
132
|
-
summary: '
|
|
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:
|
|
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
|
-
|
|
216
|
-
const
|
|
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
|
|
223
|
-
const
|
|
224
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
371
|
+
version,
|
|
244
372
|
contentHash: '—',
|
|
245
|
-
usageStats
|
|
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
|
|
393
|
+
const results: CheckResult[] = [await checkHttpReachability(`${baseUrl}/healthz`, verbose)]
|
|
266
394
|
|
|
267
395
|
let capturedUsageResult: CheckResult | undefined
|
|
268
396
|
|
|
269
|
-
|
|
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
|
-
|
|
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) {
|
package/src/commands/mcp.test.ts
CHANGED
|
@@ -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
|