@ngockhoale/ukit 2.3.15 → 2.3.17
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/CHANGELOG.md +122 -19
- package/manifests/platform.full.yaml +26 -1
- package/package.json +1 -1
- package/src/cli/commands/doctor.js +49 -2
- package/src/cli/commands/install.js +5 -0
- package/src/core/applyPlan.js +1 -1
- package/src/core/diffPlan.js +80 -0
- package/src/core/gatewayProbe.js +441 -0
- package/src/core/gatewayResilienceEnv.js +292 -0
- package/src/core/runInstallPipeline.js +11 -0
- package/src/core/taskBudgetValidator.js +241 -0
- package/src/core/taskProgressGuard.js +157 -0
- package/src/manifest/validateManifest.js +1 -1
- package/templates/.claude/agents/feature-implementer.md +30 -0
- package/templates/.claude/agents/handoff-planner.md +12 -0
- package/templates/.claude/commands/ukit/handoff-fullstack.md +4 -1
- package/templates/.claude/hooks/task-watchdog.sh +221 -0
- package/templates/.claude/settings.json +10 -0
- package/templates/.claude/ukit/index/task-budget-validator.mjs +236 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +299 -0
- package/templates/.gitignore +5 -4
- package/templates/.omp/agents/feature-implementer.md +30 -0
- package/templates/.omp/agents/handoff-planner.md +12 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +2 -1
- package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +17 -0
- package/templates/ukit/storage/config.json +8 -1
|
@@ -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
|
+
}
|