circle-ir-ai 2.43.0 → 2.45.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/CHANGELOG.md +122 -0
- package/dist/agents/mastra/swarm.js +3 -3
- package/dist/agents/mastra/swarm.js.map +1 -1
- package/dist/agents/mastra/workflow.d.ts.map +1 -1
- package/dist/agents/mastra/workflow.js +11 -1
- package/dist/agents/mastra/workflow.js.map +1 -1
- package/dist/llm/ax-client.d.ts +60 -4
- package/dist/llm/ax-client.d.ts.map +1 -1
- package/dist/llm/ax-client.js +265 -47
- package/dist/llm/ax-client.js.map +1 -1
- package/dist/llm/call-limiter.d.ts +16 -6
- package/dist/llm/call-limiter.d.ts.map +1 -1
- package/dist/llm/call-limiter.js +26 -7
- package/dist/llm/call-limiter.js.map +1 -1
- package/dist/llm/call-logger.d.ts +1 -1
- package/dist/llm/call-logger.d.ts.map +1 -1
- package/dist/llm/call-logger.js.map +1 -1
- package/dist/security-scan/scanner.d.ts.map +1 -1
- package/dist/security-scan/scanner.js +69 -46
- package/dist/security-scan/scanner.js.map +1 -1
- package/package.json +1 -1
package/dist/llm/ax-client.js
CHANGED
|
@@ -104,6 +104,86 @@ export const crossFileTaintSignature = AxSignature.create(`
|
|
|
104
104
|
* Maximum consecutive LLM failures before skipping further calls
|
|
105
105
|
*/
|
|
106
106
|
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
107
|
+
/**
|
|
108
|
+
* cognium-ai#217 — project-level dead-endpoint breaker threshold.
|
|
109
|
+
*
|
|
110
|
+
* The per-file breaker (`MAX_CONSECUTIVE_FAILURES`) is reset between files
|
|
111
|
+
* (`resetCircuitBreaker`) so one bad file doesn't disable LLM for the whole
|
|
112
|
+
* project. But when the *endpoint itself* is dead (empty-body 60s hangs,
|
|
113
|
+
* timeouts, connection failures on every call — the #217 Gemma-vLLM stall),
|
|
114
|
+
* that per-file reset means each new file re-pays ~5×60s of dead time before
|
|
115
|
+
* re-tripping, consuming the entire per-repo wall-clock budget (16/72 Tier-1
|
|
116
|
+
* repos hit the 60-min cap with zero output).
|
|
117
|
+
*
|
|
118
|
+
* This counts *endpoint-dead* failures (empty/timeout/connection) that are
|
|
119
|
+
* NOT interrupted by a success, and — unlike the per-file breaker — is NOT
|
|
120
|
+
* cleared by `resetCircuitBreaker`. Once it trips, LLM stays off for the rest
|
|
121
|
+
* of the scan and the run finishes static-only. Set to 4× the per-file
|
|
122
|
+
* threshold so a single unlucky file can't project-kill, while a genuinely
|
|
123
|
+
* dead endpoint trips after ~4 files (~2 min wall-clock at concurrency 10,
|
|
124
|
+
* vs the 60-min cap). A single success resets the counter, so a healthy
|
|
125
|
+
* endpoint with scattered blips never trips.
|
|
126
|
+
*/
|
|
127
|
+
const PROJECT_DEAD_THRESHOLD = 20;
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// cognium-ai#228 — MODULE-LEVEL circuit-breaker state.
|
|
130
|
+
//
|
|
131
|
+
// `getAxLLMClient()` returns a FRESH `AxLLMClient` per request (intentional —
|
|
132
|
+
// the per-call `lastErrorCategory`/`lastUsage`/`lastAttemptCount` fields would
|
|
133
|
+
// race under concurrency if a client were shared). But that means an
|
|
134
|
+
// *instance*-level breaker never accumulates across the throwaway clients —
|
|
135
|
+
// discovery/enrichment/verification each spin up their own client, so the
|
|
136
|
+
// per-file breaker (#16) and the project-dead breaker (#217) were both no-ops
|
|
137
|
+
// in the real scan path (confirmed on apache/camel: 613 empty-response calls,
|
|
138
|
+
// breaker never tripped). The cap (#160) worked only because it lives in
|
|
139
|
+
// call-limiter.ts at module scope. So the breaker state lives here, shared
|
|
140
|
+
// across every client; the instance methods below are thin accessors.
|
|
141
|
+
let breakerConsecutiveFailures = 0;
|
|
142
|
+
let breakerLLMDisabled = false;
|
|
143
|
+
let breakerConnectionWarned = false;
|
|
144
|
+
let breakerProjectDeadCalls = 0;
|
|
145
|
+
let breakerLLMProjectDead = false;
|
|
146
|
+
let breakerProjectDeadWarned = false;
|
|
147
|
+
/**
|
|
148
|
+
* cognium-ai#228 — reset ALL breaker state for a fresh scan. Called at the
|
|
149
|
+
* true top-level scan entry (scanner's file loop, single-file workflow) so a
|
|
150
|
+
* long-lived process (circle-pack) doesn't carry a tripped breaker across
|
|
151
|
+
* repos. The per-file `resetCircuitBreaker()` deliberately does NOT clear the
|
|
152
|
+
* project-dead state (#217); this does.
|
|
153
|
+
*/
|
|
154
|
+
export function resetLLMBreakerForScan() {
|
|
155
|
+
breakerConsecutiveFailures = 0;
|
|
156
|
+
breakerLLMDisabled = false;
|
|
157
|
+
breakerConnectionWarned = false;
|
|
158
|
+
breakerProjectDeadCalls = 0;
|
|
159
|
+
breakerLLMProjectDead = false;
|
|
160
|
+
breakerProjectDeadWarned = false;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* cognium-ai#217 — reset only the PER-FILE breaker (consecutive-failure count
|
|
164
|
+
* + the soft disable) between files within a scan, so one bad file doesn't
|
|
165
|
+
* disable LLM for the whole run. Deliberately does NOT clear the project-dead
|
|
166
|
+
* state — that's the cross-file backstop that must survive per-file resets.
|
|
167
|
+
*/
|
|
168
|
+
export function resetLLMPerFileBreaker() {
|
|
169
|
+
breakerConsecutiveFailures = 0;
|
|
170
|
+
breakerLLMDisabled = false;
|
|
171
|
+
breakerConnectionWarned = false;
|
|
172
|
+
}
|
|
173
|
+
/** cognium-ai#217 — true once the project-dead breaker has tripped. Module
|
|
174
|
+
* accessor so callers (CLI degraded-status) don't need a client handle. */
|
|
175
|
+
export function isLLMEndpointDead() {
|
|
176
|
+
return breakerLLMProjectDead;
|
|
177
|
+
}
|
|
178
|
+
/** cognium-ai#228 — test/inspection accessor for the shared breaker state. */
|
|
179
|
+
export function getLLMBreakerState() {
|
|
180
|
+
return {
|
|
181
|
+
consecutiveFailures: breakerConsecutiveFailures,
|
|
182
|
+
llmDisabled: breakerLLMDisabled,
|
|
183
|
+
projectDeadCalls: breakerProjectDeadCalls,
|
|
184
|
+
llmProjectDead: breakerLLMProjectDead,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
107
187
|
/**
|
|
108
188
|
* Maximum code context length to send to LLM (in characters).
|
|
109
189
|
* Prevents 400 errors from exceeding model context limits AND keeps
|
|
@@ -214,6 +294,53 @@ const TRANSIENT_5XX_STATUSES = new Set([500, 502, 503, 504]);
|
|
|
214
294
|
* proxy doesn't burn the rate-limit budget.
|
|
215
295
|
*/
|
|
216
296
|
const MAX_TRANSIENT_5XX_RETRIES = 3;
|
|
297
|
+
/**
|
|
298
|
+
* cognium-ai#222 — Maximum retries for an *opaque* HTTP 400. Some
|
|
299
|
+
* OpenAI-compatible providers (observed: Novita.ai `qwen/qwen3-coder-next`)
|
|
300
|
+
* intermittently return `HTTP 400 invalid_request_error` for requests that
|
|
301
|
+
* succeed on a later attempt — a transient server-side rejection dressed as
|
|
302
|
+
* a client error. Small budget, independent of the 5xx/429 budgets.
|
|
303
|
+
*/
|
|
304
|
+
const MAX_TRANSIENT_400_RETRIES = 2;
|
|
305
|
+
/**
|
|
306
|
+
* cognium-ai#222 — decide whether an HTTP 400 body looks *transient*
|
|
307
|
+
* (retryable) rather than a deterministic request defect.
|
|
308
|
+
*
|
|
309
|
+
* Deterministic 400s carry an actionable, request-shape message we must NOT
|
|
310
|
+
* retry — OpenAI: `"Unsupported parameter: 'temperature'"`,
|
|
311
|
+
* `"Use 'max_completion_tokens' instead of 'max_tokens'"`, unknown model,
|
|
312
|
+
* context-length-exceeded, content-policy. Retrying those just loops.
|
|
313
|
+
*
|
|
314
|
+
* Opaque 400s carry only a generic `invalid_request_error` + a `trace_id`
|
|
315
|
+
* and no actionable parameter name — empirically these are transient
|
|
316
|
+
* provider hiccups and recover on retry. Default to NOT retrying when the
|
|
317
|
+
* body is missing/ambiguous (conservative — preserves prior behavior).
|
|
318
|
+
*/
|
|
319
|
+
export function isTransient400(body) {
|
|
320
|
+
if (!body)
|
|
321
|
+
return false; // no body → can't prove transient → terminal
|
|
322
|
+
const b = body.toLowerCase();
|
|
323
|
+
// Deterministic client-fix signals → never retry.
|
|
324
|
+
if (b.includes('unsupported parameter') ||
|
|
325
|
+
b.includes('max_completion_tokens') ||
|
|
326
|
+
b.includes('unsupported value') ||
|
|
327
|
+
b.includes('context length') ||
|
|
328
|
+
b.includes('context_length') ||
|
|
329
|
+
b.includes('maximum context') ||
|
|
330
|
+
b.includes('too many tokens') ||
|
|
331
|
+
b.includes('model_not_found') ||
|
|
332
|
+
b.includes('does not exist') ||
|
|
333
|
+
b.includes('content management policy') ||
|
|
334
|
+
b.includes('content_policy') ||
|
|
335
|
+
b.includes('content filter')) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
// Opaque provider rejection → treat as transient. Novita emits
|
|
339
|
+
// `{"message":"invalid request error trace_id: …","type":"invalid_request_error"}`.
|
|
340
|
+
return (b.includes('invalid_request_error') ||
|
|
341
|
+
b.includes('invalid request error') ||
|
|
342
|
+
b.includes('trace_id'));
|
|
343
|
+
}
|
|
217
344
|
/**
|
|
218
345
|
* Strip `<think>...</think>` reasoning blocks from LLM output.
|
|
219
346
|
*
|
|
@@ -298,9 +425,9 @@ export class AxLLMClient {
|
|
|
298
425
|
config;
|
|
299
426
|
ai;
|
|
300
427
|
generators = new Map();
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
428
|
+
// cognium-ai#228 — circuit-breaker state is module-level (see top of file),
|
|
429
|
+
// NOT per-instance, because `getAxLLMClient()` hands out a fresh client per
|
|
430
|
+
// request. The breaker methods below read/write the shared `breaker*` vars.
|
|
304
431
|
/**
|
|
305
432
|
* Set by `_chatJSONImpl` on failure so the outer `chatJSON` wrapper
|
|
306
433
|
* can write a categorical reason to the JSONL log without
|
|
@@ -386,7 +513,7 @@ export class AxLLMClient {
|
|
|
386
513
|
* Check if LLM is currently available (not disabled due to failures)
|
|
387
514
|
*/
|
|
388
515
|
isAvailable() {
|
|
389
|
-
return !
|
|
516
|
+
return !breakerLLMDisabled;
|
|
390
517
|
}
|
|
391
518
|
/**
|
|
392
519
|
* Reset the circuit breaker so LLM calls are re-enabled.
|
|
@@ -394,23 +521,55 @@ export class AxLLMClient {
|
|
|
394
521
|
* LLM for the entire project analysis.
|
|
395
522
|
*/
|
|
396
523
|
resetCircuitBreaker() {
|
|
397
|
-
|
|
398
|
-
|
|
524
|
+
resetLLMPerFileBreaker();
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* cognium-ai#217 — true once the project-level dead-endpoint breaker has
|
|
528
|
+
* tripped (the LLM endpoint returned empty/timeout/connection failures on
|
|
529
|
+
* ~4 files' worth of calls with no intervening success). The scan then
|
|
530
|
+
* runs static-only; callers can surface a `degraded: llm-unavailable`
|
|
531
|
+
* status instead of reporting a plain size timeout.
|
|
532
|
+
*/
|
|
533
|
+
isLLMProjectDead() {
|
|
534
|
+
return breakerLLMProjectDead;
|
|
399
535
|
}
|
|
400
536
|
/**
|
|
401
|
-
* Reset failure counter
|
|
537
|
+
* Reset the per-file failure counter. Called on any HTTP 200 — note an
|
|
538
|
+
* HTTP-200-but-empty body is NOT a genuine success (it's the #217
|
|
539
|
+
* dead-endpoint signature), so the project-dead counter is reset elsewhere
|
|
540
|
+
* (on genuine content success in `chatJSON`), not here.
|
|
402
541
|
*/
|
|
403
542
|
resetFailures() {
|
|
404
|
-
|
|
543
|
+
breakerConsecutiveFailures = 0;
|
|
405
544
|
}
|
|
406
545
|
/**
|
|
407
546
|
* Track a failure and potentially disable LLM
|
|
408
547
|
*/
|
|
409
548
|
trackFailure() {
|
|
410
|
-
|
|
411
|
-
if (
|
|
549
|
+
breakerConsecutiveFailures++;
|
|
550
|
+
if (breakerConsecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
412
551
|
console.warn(`LLM disabled after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
|
|
413
|
-
|
|
552
|
+
breakerLLMDisabled = true;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* cognium-ai#217 — track an *endpoint-dead* failure (empty response,
|
|
557
|
+
* timeout, or connection error). Unlike {@link trackFailure}, this counter
|
|
558
|
+
* survives the per-file `resetCircuitBreaker()`, so a wholesale endpoint
|
|
559
|
+
* stall trips a project-level breaker and the scan finishes static-only
|
|
560
|
+
* rather than re-paying dead time on every file to the wall-clock cap.
|
|
561
|
+
*/
|
|
562
|
+
trackDeadCall() {
|
|
563
|
+
breakerProjectDeadCalls++;
|
|
564
|
+
if (!breakerLLMProjectDead &&
|
|
565
|
+
breakerProjectDeadCalls >= PROJECT_DEAD_THRESHOLD) {
|
|
566
|
+
breakerLLMProjectDead = true;
|
|
567
|
+
if (!breakerProjectDeadWarned) {
|
|
568
|
+
breakerProjectDeadWarned = true;
|
|
569
|
+
console.warn(`\nLLM endpoint appears dead: ${breakerProjectDeadCalls} empty/timeout/connection ` +
|
|
570
|
+
`failures with no success (${this.config.baseUrl}). Disabling LLM for the rest ` +
|
|
571
|
+
`of this scan and continuing static-only. Use --no-llm to silence.\n`);
|
|
572
|
+
}
|
|
414
573
|
}
|
|
415
574
|
}
|
|
416
575
|
/**
|
|
@@ -450,14 +609,15 @@ export class AxLLMClient {
|
|
|
450
609
|
* default LLM endpoint isn't reachable).
|
|
451
610
|
*/
|
|
452
611
|
tripBreakerOnConnectionFailure(err) {
|
|
453
|
-
if (!
|
|
454
|
-
|
|
612
|
+
if (!breakerConnectionWarned) {
|
|
613
|
+
breakerConnectionWarned = true;
|
|
455
614
|
const msg = err instanceof Error ? err.message : String(err);
|
|
456
615
|
console.warn(`\nLLM endpoint unreachable (${this.config.baseUrl}): ${msg}`);
|
|
457
616
|
console.warn('Falling back to static analysis. Use --no-llm to silence, or set LLM_BASE_URL/LLM_API_KEY.\n');
|
|
458
617
|
}
|
|
459
|
-
|
|
460
|
-
|
|
618
|
+
breakerConsecutiveFailures = MAX_CONSECUTIVE_FAILURES;
|
|
619
|
+
breakerLLMDisabled = true;
|
|
620
|
+
this.trackDeadCall(); // #217: also counts toward the project-dead breaker
|
|
461
621
|
}
|
|
462
622
|
/**
|
|
463
623
|
* Get or create a generator for a signature.
|
|
@@ -783,6 +943,14 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
783
943
|
catch (e) {
|
|
784
944
|
errorMsg = e instanceof Error ? e.message : String(e);
|
|
785
945
|
}
|
|
946
|
+
// cognium-ai#217 — a genuine content success proves the endpoint is
|
|
947
|
+
// alive: reset the project-dead counter so scattered failures on a
|
|
948
|
+
// healthy endpoint never accumulate to the trip. (An HTTP-200-but-empty
|
|
949
|
+
// body returns null here, so it does NOT reset — that's the dead
|
|
950
|
+
// signature the breaker is counting.)
|
|
951
|
+
if (result !== null && errorMsg === undefined) {
|
|
952
|
+
breakerProjectDeadCalls = 0;
|
|
953
|
+
}
|
|
786
954
|
// Derive the category: thrown exceptions take precedence; otherwise
|
|
787
955
|
// use whatever `_chatJSONImpl` recorded. A null result with no
|
|
788
956
|
// category is the historical "parse_error" path (no closer cause).
|
|
@@ -851,8 +1019,9 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
851
1019
|
* consecutive-failure circuit breaker (rate limits are transient).
|
|
852
1020
|
*/
|
|
853
1021
|
async _chatJSONImpl(systemPrompt, userPrompt, phase = 'enrichment', retryCount = 0, rateLimitRetryCount = 0) {
|
|
854
|
-
// Skip if LLM has been disabled due to repeated failures
|
|
855
|
-
|
|
1022
|
+
// Skip if LLM has been disabled due to repeated failures (per-file
|
|
1023
|
+
// breaker) or the endpoint is dead across the whole scan (#217).
|
|
1024
|
+
if (breakerLLMDisabled || breakerLLMProjectDead) {
|
|
856
1025
|
return null;
|
|
857
1026
|
}
|
|
858
1027
|
// Use the correct phase config
|
|
@@ -864,13 +1033,16 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
864
1033
|
const maxRetries = 1; // parse / timeout / network retries
|
|
865
1034
|
const maxRateLimitRetries = MAX_RATE_LIMIT_RETRIES;
|
|
866
1035
|
const maxTransient5xxRetries = MAX_TRANSIENT_5XX_RETRIES;
|
|
1036
|
+
const maxTransient400Retries = MAX_TRANSIENT_400_RETRIES;
|
|
867
1037
|
let attempt = retryCount; // 0 = initial; >0 = reduced-token retry
|
|
868
1038
|
let rateLimitAttempt = rateLimitRetryCount;
|
|
869
1039
|
let transient5xxAttempt = 0; // independent budget for 5xx
|
|
1040
|
+
let transient400Attempt = 0; // cognium-ai#222: budget for opaque 400
|
|
870
1041
|
// Per-attempt logging (cognium-ai#87). One JSONL `kind:'attempt'` entry per
|
|
871
1042
|
// loop iteration when LLM_LOG_JSONL or LLM_DEEP is set. Upper-bound on
|
|
872
1043
|
// total possible iterations covers all three independent retry budgets.
|
|
873
|
-
const maxAttempts = maxRetries + 1 + maxRateLimitRetries + maxTransient5xxRetries
|
|
1044
|
+
const maxAttempts = maxRetries + 1 + maxRateLimitRetries + maxTransient5xxRetries +
|
|
1045
|
+
maxTransient400Retries;
|
|
874
1046
|
let attemptIdx = 0;
|
|
875
1047
|
const promptChars = systemPrompt.length + userPrompt.length;
|
|
876
1048
|
const emitAttempt = (outcome, attemptStartMs, extras = {}) => {
|
|
@@ -905,13 +1077,13 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
905
1077
|
// error, so retrying is just burning the failure budget. One clear
|
|
906
1078
|
// diagnostic on stderr, then static-only for the rest of the run.
|
|
907
1079
|
const stripped = phaseConfig.model.replace(/^[a-z]+\//, '');
|
|
908
|
-
if (/-pro$/i.test(stripped) && !
|
|
1080
|
+
if (/-pro$/i.test(stripped) && !breakerLLMDisabled) {
|
|
909
1081
|
console.error(`LLM model '${phaseConfig.model}' is not a chat-completions model ` +
|
|
910
1082
|
`(OpenAI rejects */chat/completions for *-pro variants). ` +
|
|
911
1083
|
`Use a chat-capable model — gpt-5-nano, gpt-4o-mini, gpt-4o, or claude-*-latest. ` +
|
|
912
1084
|
`Falling back to static analysis. Use --no-llm to silence.`);
|
|
913
1085
|
this.lastErrorCategory = 'http_error';
|
|
914
|
-
|
|
1086
|
+
breakerLLMDisabled = true;
|
|
915
1087
|
return null;
|
|
916
1088
|
}
|
|
917
1089
|
// All LLM requests go through the shared throttled queue.
|
|
@@ -927,10 +1099,13 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
927
1099
|
while (true) {
|
|
928
1100
|
attemptIdx++;
|
|
929
1101
|
const attemptStart = Date.now();
|
|
930
|
-
//
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1102
|
+
// cognium-ai#223: `max_tokens` is the OUTPUT cap — lowering it makes
|
|
1103
|
+
// the model truncate *sooner*. The old heuristic shrank it to 4000
|
|
1104
|
+
// on retry "to reduce truncation risk", which is backwards when the
|
|
1105
|
+
// failure IS output truncation (the dominant discovery parse-error:
|
|
1106
|
+
// a verdict array cut off mid-`reasoning`). Keep the full budget on
|
|
1107
|
+
// retry so a re-attempt has room to finish the JSON.
|
|
1108
|
+
const maxTokens = phaseConfig.maxTokens;
|
|
934
1109
|
const controller = new AbortController();
|
|
935
1110
|
const timeoutMs = phaseConfig.timeout || 60000;
|
|
936
1111
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -997,6 +1172,7 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
997
1172
|
this.lastErrorCategory = 'connection_error';
|
|
998
1173
|
}
|
|
999
1174
|
this.trackFailure();
|
|
1175
|
+
this.trackDeadCall(); // #217: timeout / network error = endpoint-dead
|
|
1000
1176
|
return null;
|
|
1001
1177
|
}
|
|
1002
1178
|
if (!response.ok) {
|
|
@@ -1034,10 +1210,14 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
1034
1210
|
transient5xxAttempt++;
|
|
1035
1211
|
continue;
|
|
1036
1212
|
}
|
|
1037
|
-
// #43:
|
|
1213
|
+
// #43: read the response body once, not just the status code.
|
|
1038
1214
|
// OpenAI's 400s contain useful messages like "Unsupported
|
|
1039
1215
|
// parameter: 'temperature'" or "Use 'max_completion_tokens'
|
|
1040
|
-
// instead of 'max_tokens'" that would otherwise be invisible
|
|
1216
|
+
// instead of 'max_tokens'" that would otherwise be invisible —
|
|
1217
|
+
// and cognium-ai#222 uses the body to classify 400s as
|
|
1218
|
+
// transient-vs-terminal. `response.text()` can only be consumed
|
|
1219
|
+
// once, so this single read feeds both the retry decision and the
|
|
1220
|
+
// terminal log below.
|
|
1041
1221
|
let bodySnippet = '';
|
|
1042
1222
|
try {
|
|
1043
1223
|
bodySnippet = (await response.text()).slice(0, 300);
|
|
@@ -1045,6 +1225,25 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
1045
1225
|
catch {
|
|
1046
1226
|
// ignore — body may have been consumed or stream errored
|
|
1047
1227
|
}
|
|
1228
|
+
// cognium-ai#222: opaque 400 (e.g. Novita `invalid_request_error`)
|
|
1229
|
+
// is a transient provider rejection — retry with backoff, like a
|
|
1230
|
+
// 5xx. Deterministic parameter-error 400s (`isTransient400` →
|
|
1231
|
+
// false) fall through to the terminal path unchanged.
|
|
1232
|
+
if (response.status === 400 &&
|
|
1233
|
+
transient400Attempt < maxTransient400Retries &&
|
|
1234
|
+
isTransient400(bodySnippet)) {
|
|
1235
|
+
const sleepMs = parseRetryAfterMs(response.headers.get('Retry-After'), transient400Attempt);
|
|
1236
|
+
console.error(`LLM opaque 400 (invalid_request_error); sleeping ${sleepMs}ms ` +
|
|
1237
|
+
`before retry ${transient400Attempt + 1}/${maxTransient400Retries}`);
|
|
1238
|
+
emitAttempt('transient_client_error', attemptStart, {
|
|
1239
|
+
httpStatus: 400,
|
|
1240
|
+
backoffMs: sleepMs,
|
|
1241
|
+
responseSnippet: bodySnippet || undefined,
|
|
1242
|
+
});
|
|
1243
|
+
await sleep(sleepMs);
|
|
1244
|
+
transient400Attempt++;
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1048
1247
|
console.error(`LLM call failed: HTTP ${response.status}${bodySnippet ? ' — ' + bodySnippet : ''}`);
|
|
1049
1248
|
let terminalCategory;
|
|
1050
1249
|
if (response.status === 429) {
|
|
@@ -1100,6 +1299,7 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
1100
1299
|
this.lastErrorCategory = 'empty_response';
|
|
1101
1300
|
emitAttempt('empty_response', attemptStart, { httpStatus: response.status });
|
|
1102
1301
|
this.trackFailure();
|
|
1302
|
+
this.trackDeadCall(); // #217: empty body = endpoint-dead (the 60s stall)
|
|
1103
1303
|
return null;
|
|
1104
1304
|
}
|
|
1105
1305
|
// Parse JSON from response (handle markdown code blocks)
|
|
@@ -1198,9 +1398,7 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
1198
1398
|
* Handles cases where LLM output was cut off mid-response
|
|
1199
1399
|
*/
|
|
1200
1400
|
tryRecoverTruncatedJSON(jsonStr) {
|
|
1201
|
-
//
|
|
1202
|
-
let braces = 0;
|
|
1203
|
-
let brackets = 0;
|
|
1401
|
+
// First pass: detect a mid-string truncation (unbalanced quotes).
|
|
1204
1402
|
let inString = false;
|
|
1205
1403
|
let escaped = false;
|
|
1206
1404
|
for (const char of jsonStr) {
|
|
@@ -1216,18 +1414,8 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
1216
1414
|
inString = !inString;
|
|
1217
1415
|
continue;
|
|
1218
1416
|
}
|
|
1219
|
-
if (inString)
|
|
1220
|
-
continue;
|
|
1221
|
-
if (char === '{')
|
|
1222
|
-
braces++;
|
|
1223
|
-
if (char === '}')
|
|
1224
|
-
braces--;
|
|
1225
|
-
if (char === '[')
|
|
1226
|
-
brackets++;
|
|
1227
|
-
if (char === ']')
|
|
1228
|
-
brackets--;
|
|
1229
1417
|
}
|
|
1230
|
-
// If we're truncated mid-string, close it
|
|
1418
|
+
// If we're truncated mid-string, close it.
|
|
1231
1419
|
if (inString) {
|
|
1232
1420
|
jsonStr = jsonStr + '"';
|
|
1233
1421
|
}
|
|
@@ -1237,14 +1425,44 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
|
|
|
1237
1425
|
if (lastValidEnd > 0 && lastValidEnd < jsonStr.length - 1) {
|
|
1238
1426
|
jsonStr = jsonStr.substring(0, lastValidEnd);
|
|
1239
1427
|
}
|
|
1240
|
-
//
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1428
|
+
// cognium-ai#223: when the truncation point is a comma,
|
|
1429
|
+
// `findLastCompleteValue` keeps it, so appending closers yields `…,}]`
|
|
1430
|
+
// (invalid trailing comma). Strip a dangling `"key":` (no value) and any
|
|
1431
|
+
// trailing comma. A *complete* trailing `"key":"value"` ends in `"`, so
|
|
1432
|
+
// neither pattern touches it.
|
|
1433
|
+
jsonStr = jsonStr
|
|
1434
|
+
.replace(/,\s*"[^"]*"\s*:\s*$/, '') // drop `, "key":` with no value
|
|
1435
|
+
.replace(/,\s*$/, ''); // drop trailing comma
|
|
1436
|
+
// cognium-ai#223: close the OPEN delimiters in reverse nesting order.
|
|
1437
|
+
// The previous code closed all `]` then all `}`, which produced invalid
|
|
1438
|
+
// output like `…"path_traversal"]}` for a truncated `[{…` (array closed
|
|
1439
|
+
// before the object it contains). Rebuild the open-delimiter stack on
|
|
1440
|
+
// the final string and append the matching closers innermost-first.
|
|
1441
|
+
const stack = [];
|
|
1442
|
+
inString = false;
|
|
1443
|
+
escaped = false;
|
|
1444
|
+
for (const char of jsonStr) {
|
|
1445
|
+
if (escaped) {
|
|
1446
|
+
escaped = false;
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
if (char === '\\') {
|
|
1450
|
+
escaped = true;
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
if (char === '"') {
|
|
1454
|
+
inString = !inString;
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
if (inString)
|
|
1458
|
+
continue;
|
|
1459
|
+
if (char === '{' || char === '[')
|
|
1460
|
+
stack.push(char);
|
|
1461
|
+
else if (char === '}' || char === ']')
|
|
1462
|
+
stack.pop();
|
|
1244
1463
|
}
|
|
1245
|
-
|
|
1246
|
-
jsonStr += '}';
|
|
1247
|
-
braces--;
|
|
1464
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
1465
|
+
jsonStr += stack[i] === '{' ? '}' : ']';
|
|
1248
1466
|
}
|
|
1249
1467
|
// Quick validation - should start with { or [
|
|
1250
1468
|
const trimmed = jsonStr.trim();
|