@ngockhoale/ukit 2.3.15 → 2.3.19

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.
@@ -0,0 +1,441 @@
1
+ /**
2
+ * gatewayProbe.js (TASK-012)
3
+ *
4
+ * Live self-test for the gateway currently configured for Claude Code. Two sub-probes:
5
+ * - streaming: posts a tiny `stream: true` request and counts SSE frames + distinct body
6
+ * reads. All frames in one read = buffering (the 300s stall signature).
7
+ * - non-streaming: posts a tiny `stream: false` request. PASS only if HTTP 200 + body is
8
+ * an Anthropic Message (type === 'message' && Array.isArray(content)) AND a `request-id`
9
+ * header is present. This is the exact check whose absence let the 888-byte gateway
10
+ * error envelope slip through and kill the user's turn.
11
+ *
12
+ * Self-contained and hermetic: `fetchImpl` is injectable. `resolveGatewayBaseUrl` mirrors the
13
+ * 3-tier probe order from templates/.claude/ukit/index/unic-gateway.mjs (env → project
14
+ * .claude/settings.json → home ~/.claude/settings.json) but is intentionally duplicated
15
+ * here per PLAN.md §3 (cross-task coupling is the cheaper-than-it-looks debt — see plan).
16
+ *
17
+ * Never throws: any unexpected error in an individual probe is captured and reflected in the
18
+ * returned result so the doctor wiring stays safe and the test harness stays deterministic.
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+
24
+ const GATEWAY_DOC = 'docs/GATEWAY.md';
25
+
26
+ function safeReadFile(filePath) {
27
+ try {
28
+ return fs.readFileSync(filePath, 'utf8');
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function safeReadJson(filePath) {
35
+ const raw = safeReadFile(filePath);
36
+ if (raw == null) return null;
37
+ try {
38
+ return JSON.parse(raw);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function pickString(...candidates) {
45
+ for (const candidate of candidates) {
46
+ if (typeof candidate === 'string' && candidate.length > 0) return candidate;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function readEnvBaseUrl(env) {
52
+ const value = pickString(env?.ANTHROPIC_BASE_URL);
53
+ return value ? { baseUrl: value, source: 'env' } : null;
54
+ }
55
+
56
+ function readSettingsBaseUrl(filePath) {
57
+ const json = safeReadJson(filePath);
58
+ if (!json || typeof json !== 'object') return null;
59
+ const envBlock = json.env;
60
+ if (!envBlock || typeof envBlock !== 'object') return null;
61
+ const value = pickString(envBlock.ANTHROPIC_BASE_URL);
62
+ return value ? { baseUrl: value, source: 'claude-settings' } : null;
63
+ }
64
+
65
+ /**
66
+ * Detects the current custom-gateway base URL using the same 3-tier probe order as
67
+ * `templates/.claude/ukit/index/unic-gateway.mjs` (generalized — fires on any non-empty
68
+ * value, not only the UNIC token):
69
+ * 1. env.ANTHROPIC_BASE_URL
70
+ * 2. <projectRoot>/.claude/settings.json -> env.ANTHROPIC_BASE_URL
71
+ * 3. <homeDir>/.claude/settings.json -> env.ANTHROPIC_BASE_URL
72
+ *
73
+ * First hit wins. Never throws: a missing or malformed file in any tier is skipped.
74
+ *
75
+ * @param {{ projectRoot: string, homeDir?: string, env?: object }} options
76
+ * @returns {Promise<{ baseUrl: string|null, source: 'env'|'claude-settings'|null }>}
77
+ */
78
+ export async function resolveGatewayBaseUrl({
79
+ projectRoot,
80
+ homeDir = os.homedir(),
81
+ env = process.env,
82
+ }) {
83
+ const probes = [
84
+ () => readEnvBaseUrl(env ?? {}),
85
+ () => readSettingsBaseUrl(path.join(projectRoot, '.claude', 'settings.json')),
86
+ () => readSettingsBaseUrl(path.join(homeDir, '.claude', 'settings.json')),
87
+ ];
88
+ for (const probe of probes) {
89
+ try {
90
+ const hit = probe();
91
+ if (hit) return hit;
92
+ } catch {
93
+ // a single probe failing must never abort detection
94
+ }
95
+ }
96
+ return { baseUrl: null, source: null };
97
+ }
98
+
99
+ function pickApiKey(envLike) {
100
+ if (!envLike || typeof envLike !== 'object') return null;
101
+ // Source matters: ANTHROPIC_API_KEY is sent by Claude Code as `x-api-key`, while
102
+ // ANTHROPIC_AUTH_TOKEN is sent by Claude Code as `Authorization: Bearer`. Sending
103
+ // the wrong header causes token-auth gateways to 401 the probe.
104
+ if (typeof envLike.ANTHROPIC_API_KEY === 'string' && envLike.ANTHROPIC_API_KEY.length > 0) {
105
+ return { value: envLike.ANTHROPIC_API_KEY, source: 'api-key' };
106
+ }
107
+ if (typeof envLike.ANTHROPIC_AUTH_TOKEN === 'string' && envLike.ANTHROPIC_AUTH_TOKEN.length > 0) {
108
+ return { value: envLike.ANTHROPIC_AUTH_TOKEN, source: 'auth-token' };
109
+ }
110
+ return null;
111
+ }
112
+
113
+ function pickModel(envLike) {
114
+ if (!envLike || typeof envLike !== 'object') return null;
115
+ return pickString(envLike.ANTHROPIC_MODEL);
116
+ }
117
+
118
+ // Minimal VALID Anthropic Messages API body. The probe only measures transport shape
119
+ // (SSE framing, Message envelope, request-id header) — not model capability — so the
120
+ // prompt is intentionally tiny ("ping", 1 token of headroom). `max_tokens: 16` keeps
121
+ // the call essentially free on a billed gateway.
122
+ function buildRequestBody({ stream, model }) {
123
+ return JSON.stringify({
124
+ model,
125
+ max_tokens: 16,
126
+ stream,
127
+ messages: [{ role: 'user', content: 'ping' }],
128
+ });
129
+ }
130
+
131
+ // Auth headers must mirror how Claude Code itself forwards each env var:
132
+ // * ANTHROPIC_API_KEY -> x-api-key (Anthropic-native)
133
+ // * ANTHROPIC_AUTH_TOKEN -> Authorization: Bearer (OpenAI-style / proxy gateways)
134
+ // Without this split, token-auth gateways 401 the probe even when transport shape is
135
+ // valid (Reviewer Round-1 important finding #3).
136
+ function buildAuthHeaders(keyValue, keySource) {
137
+ if (!keyValue) return {};
138
+ if (keySource === 'auth-token') {
139
+ return { Authorization: `Bearer ${keyValue}` };
140
+ }
141
+ // Default to the Anthropic-native scheme — covers explicit apiKey arg and
142
+ // ANTHROPIC_API_KEY env (the dominant case).
143
+ return { 'x-api-key': keyValue, 'anthropic-version': '2023-06-01' };
144
+ }
145
+
146
+ function getRequestId(headers) {
147
+ if (!headers || typeof headers.get !== 'function') return null;
148
+ return (
149
+ pickString(headers.get('request-id'), headers.get('anthropic-request-id')) ?? null
150
+ );
151
+ }
152
+
153
+ function parseSseEvents(text) {
154
+ // SSE frame is the chunk between blank lines; we count distinct `data:` lines as a
155
+ // pragmatic proxy for "events received" (matches what the gateway actually streams).
156
+ const lines = text.split(/\r?\n/);
157
+ let count = 0;
158
+ for (const line of lines) {
159
+ if (line.startsWith('data:')) count += 1;
160
+ }
161
+ return count;
162
+ }
163
+
164
+ /**
165
+ * Streaming probe: posts `stream: true` and counts SSE frames + distinct body reads.
166
+ *
167
+ * Buffered (the stall signature) is defined as: the FIRST body read alone already yields
168
+ * >=2 SSE events. A non-buffering gateway delivers events incrementally across reads; a
169
+ * buffering gateway dumps everything in one read.
170
+ *
171
+ * @returns {Promise<{ ok: boolean, eventsReceived: number, firstEventMs: number|null,
172
+ * chunks: number, buffered: boolean, error?: string }>}
173
+ */
174
+ async function runStreamingProbe({ url, headers, body: requestBody, fetchImpl, timeoutMs }) {
175
+ const controller = new AbortController();
176
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
177
+ const startedAt = Date.now();
178
+ try {
179
+ const response = await fetchImpl(url, {
180
+ method: 'POST',
181
+ headers,
182
+ body: requestBody,
183
+ signal: controller.signal,
184
+ });
185
+ if (!response?.ok) {
186
+ return {
187
+ ok: false,
188
+ eventsReceived: 0,
189
+ firstEventMs: null,
190
+ chunks: 0,
191
+ buffered: false,
192
+ error: `HTTP ${response?.status ?? 'unknown'}`,
193
+ };
194
+ }
195
+ const body = response.body;
196
+ if (!body || typeof body.getReader !== 'function') {
197
+ return {
198
+ ok: false,
199
+ eventsReceived: 0,
200
+ firstEventMs: null,
201
+ chunks: 0,
202
+ buffered: false,
203
+ error: 'no readable body',
204
+ };
205
+ }
206
+ const reader = body.getReader();
207
+ const decoder = new TextDecoder();
208
+ let eventsReceived = 0;
209
+ let chunks = 0;
210
+ let firstEventMs = null;
211
+ let buffered = false;
212
+ let buffer = '';
213
+ try {
214
+ while (true) {
215
+ const { value, done } = await reader.read();
216
+ if (done) break;
217
+ if (!value) continue;
218
+ chunks += 1;
219
+ buffer += decoder.decode(value, { stream: true });
220
+ // Process complete SSE frames accumulated so far.
221
+ const frames = buffer.split(/\r?\n\r?\n/);
222
+ buffer = frames.pop() ?? ''; // keep incomplete trailing chunk
223
+ for (const frame of frames) {
224
+ if (!frame.trim()) continue;
225
+ const frameEvents = parseSseEvents(frame);
226
+ if (frameEvents > 0) {
227
+ if (firstEventMs === null) firstEventMs = Date.now() - startedAt;
228
+ eventsReceived += frameEvents;
229
+ }
230
+ }
231
+ // Buffered criterion: the first body read alone already yields >=2 SSE events.
232
+ if (chunks === 1 && eventsReceived >= 2) {
233
+ buffered = true;
234
+ }
235
+ }
236
+ // Flush any trailing buffer (no terminating blank line).
237
+ if (buffer.trim()) {
238
+ const trailing = parseSseEvents(buffer);
239
+ if (trailing > 0) {
240
+ if (firstEventMs === null) firstEventMs = Date.now() - startedAt;
241
+ eventsReceived += trailing;
242
+ // If we never got a second read, treat it as buffered when at least 2 events landed.
243
+ if (chunks < 2 && eventsReceived >= 2) buffered = true;
244
+ }
245
+ }
246
+ } finally {
247
+ try {
248
+ reader.releaseLock();
249
+ } catch {
250
+ // reader may already be released
251
+ }
252
+ }
253
+ const ok = eventsReceived >= 1 && !buffered;
254
+ return {
255
+ ok,
256
+ eventsReceived,
257
+ firstEventMs,
258
+ chunks,
259
+ buffered,
260
+ };
261
+ } catch (error) {
262
+ return {
263
+ ok: false,
264
+ eventsReceived: 0,
265
+ firstEventMs: null,
266
+ chunks: 0,
267
+ buffered: false,
268
+ error: error?.message ?? String(error),
269
+ };
270
+ } finally {
271
+ clearTimeout(timer);
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Non-streaming probe: posts `stream: false` and asserts the response is a real Anthropic
277
+ * Message with a `request-id` header. This is the missing check that let the 888-byte
278
+ * gateway error envelope slip through Claude Code's retry path and kill the user's turn.
279
+ *
280
+ * @returns {Promise<{ ok: boolean, httpOk: boolean, isAnthropicMessage: boolean,
281
+ * requestIdPresent: boolean, bodyBytes: number|null, error?: string }>}
282
+ */
283
+ async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl, timeoutMs }) {
284
+ const controller = new AbortController();
285
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
286
+ try {
287
+ const response = await fetchImpl(url, {
288
+ method: 'POST',
289
+ headers,
290
+ body: requestBody,
291
+ signal: controller.signal,
292
+ });
293
+ const httpOk = Boolean(response?.ok);
294
+ const requestIdPresent = getRequestId(response?.headers) !== null;
295
+ if (!response?.ok) {
296
+ return {
297
+ ok: false,
298
+ httpOk: false,
299
+ isAnthropicMessage: false,
300
+ requestIdPresent,
301
+ bodyBytes: null,
302
+ error: `HTTP ${response?.status ?? 'unknown'}`,
303
+ };
304
+ }
305
+ const body = response.body;
306
+ if (!body || typeof body.getReader !== 'function') {
307
+ return {
308
+ ok: false,
309
+ httpOk: true,
310
+ isAnthropicMessage: false,
311
+ requestIdPresent,
312
+ bodyBytes: null,
313
+ error: 'no readable body',
314
+ };
315
+ }
316
+ const reader = body.getReader();
317
+ const decoder = new TextDecoder();
318
+ let text = '';
319
+ try {
320
+ while (true) {
321
+ const { value, done } = await reader.read();
322
+ if (done) break;
323
+ if (value) text += decoder.decode(value, { stream: true });
324
+ }
325
+ } finally {
326
+ try {
327
+ reader.releaseLock();
328
+ } catch {
329
+ // reader may already be released
330
+ }
331
+ }
332
+ const bodyBytes = Buffer.byteLength(text, 'utf8');
333
+ let parsed = null;
334
+ try {
335
+ parsed = JSON.parse(text);
336
+ } catch {
337
+ parsed = null;
338
+ }
339
+ const isAnthropicMessage =
340
+ parsed !== null &&
341
+ typeof parsed === 'object' &&
342
+ parsed.type === 'message' &&
343
+ Array.isArray(parsed.content);
344
+ const ok = isAnthropicMessage && requestIdPresent;
345
+ return {
346
+ ok,
347
+ httpOk: true,
348
+ isAnthropicMessage,
349
+ requestIdPresent,
350
+ bodyBytes,
351
+ };
352
+ } catch (error) {
353
+ return {
354
+ ok: false,
355
+ httpOk: false,
356
+ isAnthropicMessage: false,
357
+ requestIdPresent: false,
358
+ bodyBytes: null,
359
+ error: error?.message ?? String(error),
360
+ };
361
+ } finally {
362
+ clearTimeout(timer);
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Runs both probes against `baseUrl`. Returns a structured result; never propagates exceptions
368
+ * — every failure is reflected in the returned object so the CLI can print it.
369
+ *
370
+ * @param {{ baseUrl: string, apiKey?: string|null, model?: string|null,
371
+ * env?: object, fetchImpl?: typeof fetch, timeoutMs?: number }} options
372
+ */
373
+ export async function probeGateway({
374
+ baseUrl,
375
+ apiKey = null,
376
+ apiKeySource = null,
377
+ model = null,
378
+ env = process.env,
379
+ fetchImpl = globalThis.fetch,
380
+ timeoutMs = 30000,
381
+ }) {
382
+ // Resolve effective api key + its source. Order of precedence matches Claude Code
383
+ // itself: explicit apiKey arg wins; otherwise we read from env, preserving which env
384
+ // var produced it so we can pick the matching header scheme.
385
+ const envKey = pickApiKey(env);
386
+ const resolvedValue = pickString(apiKey, envKey?.value) ?? null;
387
+ const resolvedSource = apiKey
388
+ ? (apiKeySource ?? 'api-key')
389
+ : (envKey?.source ?? null);
390
+ const effectiveModel = pickString(model, pickModel(env)) ?? 'claude-sonnet-4-5';
391
+ const url = `${baseUrl.replace(/\/+$/, '')}/v1/messages`;
392
+ const headers = {
393
+ 'content-type': 'application/json',
394
+ ...buildAuthHeaders(resolvedValue, resolvedSource),
395
+ };
396
+
397
+ const streamBody = buildRequestBody({ stream: true, model: effectiveModel });
398
+ const nonStreamBody = buildRequestBody({ stream: false, model: effectiveModel });
399
+
400
+ const [streaming, nonStreaming] = await Promise.all([
401
+ runStreamingProbe({ url, headers, body: streamBody, fetchImpl, timeoutMs }),
402
+ runNonStreamingProbe({ url, headers, body: nonStreamBody, fetchImpl, timeoutMs }),
403
+ ]);
404
+
405
+ const hints = [];
406
+ if (!streaming.ok) {
407
+ if (streaming.buffered) {
408
+ hints.push(
409
+ `Set CLAUDE_STREAM_IDLE_TIMEOUT_MS=600000 in .claude/settings.json env — gateway appears to buffer the entire stream; see ${GATEWAY_DOC}.`,
410
+ );
411
+ } else if (streaming.error) {
412
+ hints.push(
413
+ `Streaming probe failed: ${streaming.error} — see ${GATEWAY_DOC}.`,
414
+ );
415
+ } else {
416
+ hints.push(
417
+ `Streaming probe returned no SSE events — see ${GATEWAY_DOC}.`,
418
+ );
419
+ }
420
+ }
421
+ if (!nonStreaming.ok) {
422
+ if (nonStreaming.httpOk && !nonStreaming.isAnthropicMessage) {
423
+ hints.push(
424
+ `Set CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 in .claude/settings.json env — non-streaming route returned JSON but not an Anthropic Message; see ${GATEWAY_DOC}.`,
425
+ );
426
+ } else if (nonStreaming.httpOk && !nonStreaming.requestIdPresent) {
427
+ hints.push(
428
+ `Gateway must set the request-id (or anthropic-request-id) response header — see ${GATEWAY_DOC}.`,
429
+ );
430
+ } else if (nonStreaming.error) {
431
+ hints.push(
432
+ `Non-streaming probe failed: ${nonStreaming.error} — see ${GATEWAY_DOC}.`,
433
+ );
434
+ } else {
435
+ hints.push(`Non-streaming probe failed — see ${GATEWAY_DOC}.`);
436
+ }
437
+ }
438
+
439
+ const verdict = streaming.ok && nonStreaming.ok ? 'pass' : 'fail';
440
+ return { streaming, nonStreaming, verdict, hints };
441
+ }