sentinelayer-cli 0.28.0 → 0.28.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/ai/proxy.js CHANGED
@@ -15,6 +15,298 @@ const DEFAULT_PROXY_MODEL = "gpt-5.3-codex";
15
15
  const PROXY_TIMEOUT_MS = 120_000;
16
16
  const PROXY_MAX_RETRIES = 2;
17
17
  const PROXY_RETRY_STATUSES = new Set([429, 502, 503, 504]);
18
+ const ACTIONABLE_PROXY_DENIAL_CODES = new Set([
19
+ "FREE_TRIAL_EXPIRED",
20
+ "DAILY_SCAN_LIMIT_EXCEEDED",
21
+ "WEEKLY_SCAN_LIMIT_EXCEEDED",
22
+ "DAILY_BUDGET_EXCEEDED",
23
+ "RATE_LIMIT_EXCEEDED",
24
+ ]);
25
+
26
+ export class SentinelayerProxyError extends Error {
27
+ constructor(message, {
28
+ status = 0,
29
+ code = "PROXY_ERROR",
30
+ requestId = "",
31
+ retryAfterMs = undefined,
32
+ quota = null,
33
+ raw = null,
34
+ } = {}) {
35
+ super(message);
36
+ this.name = "SentinelayerProxyError";
37
+ this.status = status;
38
+ this.code = code;
39
+ this.requestId = requestId;
40
+ this.retryAfterMs = retryAfterMs;
41
+ this.quota = quota;
42
+ this.raw = raw;
43
+ }
44
+ }
45
+
46
+ export function serializeProxyError(error) {
47
+ if (!error || typeof error !== "object") {
48
+ return null;
49
+ }
50
+ const status = normalizeInteger(error.status);
51
+ const code = String(error.code || "").trim();
52
+ const requestId = String(error.requestId || "").trim();
53
+ const retryAfterMs = normalizeInteger(error.retryAfterMs);
54
+ const quota =
55
+ error.quota && typeof error.quota === "object" && !Array.isArray(error.quota)
56
+ ? JSON.parse(JSON.stringify(error.quota))
57
+ : null;
58
+ const serialized = {
59
+ status,
60
+ code: code || undefined,
61
+ requestId: requestId || undefined,
62
+ retryAfterMs,
63
+ quota,
64
+ };
65
+ for (const key of Object.keys(serialized)) {
66
+ if (serialized[key] === undefined || serialized[key] === null || serialized[key] === "") {
67
+ delete serialized[key];
68
+ }
69
+ }
70
+ return Object.keys(serialized).length > 0 ? serialized : null;
71
+ }
72
+
73
+ function normalizeNumber(value) {
74
+ if (value === undefined || value === null || value === "") {
75
+ return undefined;
76
+ }
77
+ const numeric = Number(value);
78
+ return Number.isFinite(numeric) ? numeric : undefined;
79
+ }
80
+
81
+ function normalizeInteger(value) {
82
+ const numeric = normalizeNumber(value);
83
+ return numeric === undefined ? undefined : Math.max(0, Math.floor(numeric));
84
+ }
85
+
86
+ function getHeader(headers, name) {
87
+ if (!headers || typeof headers.get !== "function") {
88
+ return null;
89
+ }
90
+ return headers.get(name) || headers.get(name.toLowerCase()) || null;
91
+ }
92
+
93
+ function parseRetryAfterMs(raw) {
94
+ if (typeof raw !== "string") {
95
+ return undefined;
96
+ }
97
+ const trimmed = raw.trim();
98
+ if (!trimmed) {
99
+ return undefined;
100
+ }
101
+ const asSeconds = Number.parseFloat(trimmed);
102
+ if (Number.isFinite(asSeconds) && asSeconds >= 0) {
103
+ return Math.ceil(asSeconds * 1000);
104
+ }
105
+ const asDate = Date.parse(trimmed);
106
+ if (Number.isNaN(asDate)) {
107
+ return undefined;
108
+ }
109
+ return Math.max(0, asDate - Date.now());
110
+ }
111
+
112
+ function chooseObject(...candidates) {
113
+ for (const candidate of candidates) {
114
+ if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
115
+ return candidate;
116
+ }
117
+ }
118
+ return {};
119
+ }
120
+
121
+ function normalizeQuotaDetails(details = {}, headers = null) {
122
+ const policy = String(details.policy || "").trim();
123
+ const scope = String(details.scope || "").trim();
124
+ const unit = String(details.unit || "").trim();
125
+ const resetAt = String(details.resetAt || details.reset_at || "").trim();
126
+ const upgradeUrl = String(details.upgradeUrl || details.upgrade_url || "").trim();
127
+ const checkoutMode = String(details.checkoutMode || details.checkout_mode || "").trim();
128
+ const limit =
129
+ normalizeInteger(details.limit) ??
130
+ normalizeInteger(getHeader(headers, "X-RateLimit-Limit"));
131
+ const remaining =
132
+ normalizeInteger(details.remaining) ??
133
+ normalizeInteger(getHeader(headers, "X-RateLimit-Remaining"));
134
+ const used = normalizeInteger(details.used);
135
+ const resetAfterSeconds =
136
+ normalizeInteger(details.resetAfterSeconds ?? details.reset_after_seconds) ??
137
+ normalizeInteger(details.retryAfterSeconds ?? details.retry_after_seconds);
138
+ const retryAfterSeconds =
139
+ normalizeInteger(details.retryAfterSeconds ?? details.retry_after_seconds) ??
140
+ normalizeInteger(resetAfterSeconds);
141
+ const retryAfterMs =
142
+ retryAfterSeconds !== undefined
143
+ ? retryAfterSeconds * 1000
144
+ : parseRetryAfterMs(getHeader(headers, "Retry-After"));
145
+ const headerReset = getHeader(headers, "X-RateLimit-Reset");
146
+ const headerPolicy = getHeader(headers, "X-RateLimit-Policy");
147
+
148
+ const quota = {
149
+ policy: policy || String(headerPolicy || "").trim() || undefined,
150
+ scope: scope || undefined,
151
+ limit,
152
+ remaining,
153
+ used,
154
+ unit: unit || undefined,
155
+ resetAfterSeconds,
156
+ resetAt: resetAt || String(headerReset || "").trim() || undefined,
157
+ retryAfterSeconds,
158
+ retryAfterMs,
159
+ upgradeUrl: upgradeUrl || undefined,
160
+ checkoutMode: checkoutMode || undefined,
161
+ };
162
+
163
+ for (const key of Object.keys(quota)) {
164
+ if (quota[key] === undefined || quota[key] === "") {
165
+ delete quota[key];
166
+ }
167
+ }
168
+
169
+ return Object.keys(quota).length > 0 ? quota : null;
170
+ }
171
+
172
+ function formatSeconds(seconds) {
173
+ const normalized = normalizeInteger(seconds);
174
+ if (normalized === undefined) {
175
+ return "";
176
+ }
177
+ if (normalized < 60) {
178
+ return `${normalized}s`;
179
+ }
180
+ const minutes = Math.ceil(normalized / 60);
181
+ if (minutes < 60) {
182
+ return `${minutes}m`;
183
+ }
184
+ const hours = Math.ceil(minutes / 60);
185
+ if (hours < 48) {
186
+ return `${hours}h`;
187
+ }
188
+ const days = Math.ceil(hours / 24);
189
+ return `${days}d`;
190
+ }
191
+
192
+ function buildProxyErrorMessage({ status, code, message, quota }) {
193
+ const parts = [`SentinelLayer LLM proxy error (${status}${code ? ` ${code}` : ""}): ${message || "Request denied"}`];
194
+ if (quota?.policy || quota?.scope) {
195
+ parts.push(`policy=${quota.policy || "unknown"}${quota.scope ? ` scope=${quota.scope}` : ""}`);
196
+ }
197
+ if (quota?.limit !== undefined || quota?.remaining !== undefined || quota?.used !== undefined) {
198
+ const unit = quota.unit ? ` ${quota.unit}` : "";
199
+ const limit = quota.limit !== undefined ? quota.limit : "?";
200
+ const remaining = quota.remaining !== undefined ? quota.remaining : "?";
201
+ const used = quota.used !== undefined ? quota.used : "?";
202
+ parts.push(`quota used=${used} remaining=${remaining} limit=${limit}${unit}`);
203
+ }
204
+ if (quota?.resetAt) {
205
+ parts.push(`resets at ${quota.resetAt}`);
206
+ } else if (quota?.resetAfterSeconds !== undefined) {
207
+ parts.push(`resets in ${formatSeconds(quota.resetAfterSeconds)}`);
208
+ } else if (quota?.retryAfterSeconds !== undefined) {
209
+ parts.push(`retry after ${formatSeconds(quota.retryAfterSeconds)}`);
210
+ }
211
+ if (quota?.upgradeUrl) {
212
+ parts.push(`upgrade: ${quota.upgradeUrl}`);
213
+ }
214
+ if (quota?.checkoutMode) {
215
+ parts.push(`checkout=${quota.checkoutMode}`);
216
+ }
217
+ return parts.join(" | ");
218
+ }
219
+
220
+ async function parseProxyErrorResponse(response) {
221
+ let payload = null;
222
+ let text = "";
223
+ try {
224
+ payload = await response.json();
225
+ } catch {
226
+ text = await response.text().catch(() => "");
227
+ }
228
+
229
+ const detailObject =
230
+ payload?.detail && typeof payload.detail === "object" && !Array.isArray(payload.detail)
231
+ ? payload.detail
232
+ : null;
233
+ const nestedError = chooseObject(payload?.error, detailObject?.error);
234
+ const details = chooseObject(
235
+ nestedError?.details,
236
+ nestedError?.detail,
237
+ payload?.details,
238
+ detailObject?.details,
239
+ detailObject
240
+ );
241
+ const code = String(
242
+ nestedError?.code ||
243
+ payload?.code ||
244
+ detailObject?.code ||
245
+ "PROXY_ERROR"
246
+ ).trim() || "PROXY_ERROR";
247
+ const message = String(
248
+ nestedError?.message ||
249
+ payload?.message ||
250
+ (typeof payload?.detail === "string" ? payload.detail : "") ||
251
+ detailObject?.message ||
252
+ text ||
253
+ "Request denied"
254
+ ).trim();
255
+ const requestId = String(
256
+ nestedError?.request_id ||
257
+ nestedError?.requestId ||
258
+ payload?.request_id ||
259
+ payload?.requestId ||
260
+ detailObject?.request_id ||
261
+ detailObject?.requestId ||
262
+ getHeader(response.headers, "x-request-id") ||
263
+ ""
264
+ ).trim();
265
+ const quota = normalizeQuotaDetails(details, response.headers);
266
+ const retryAfterMs =
267
+ quota?.retryAfterMs ??
268
+ parseRetryAfterMs(getHeader(response.headers, "Retry-After")) ??
269
+ normalizeInteger(nestedError?.retry_after_ms ?? payload?.retry_after_ms);
270
+
271
+ return {
272
+ code,
273
+ message,
274
+ requestId,
275
+ quota,
276
+ retryAfterMs,
277
+ raw: payload || text,
278
+ };
279
+ }
280
+
281
+ function isActionableProxyDenial(status, parsed) {
282
+ if (status === 402 || status === 403) {
283
+ return true;
284
+ }
285
+ if (ACTIONABLE_PROXY_DENIAL_CODES.has(String(parsed?.code || "").trim())) {
286
+ return true;
287
+ }
288
+ return Boolean(parsed?.quota?.policy || parsed?.quota?.upgradeUrl || parsed?.quota?.resetAt);
289
+ }
290
+
291
+ function buildProxyError(status, parsed) {
292
+ const retryAfterMs = parsed.retryAfterMs ?? parsed.quota?.retryAfterMs;
293
+ return new SentinelayerProxyError(
294
+ buildProxyErrorMessage({
295
+ status,
296
+ code: parsed.code,
297
+ message: parsed.message,
298
+ quota: parsed.quota,
299
+ }),
300
+ {
301
+ status,
302
+ code: parsed.code,
303
+ requestId: parsed.requestId,
304
+ retryAfterMs,
305
+ quota: parsed.quota,
306
+ raw: parsed.raw,
307
+ }
308
+ );
309
+ }
18
310
 
19
311
  /**
20
312
  * Invoke LLM via SentinelLayer proxy.
@@ -128,15 +420,25 @@ export async function invokeViaProxy({
128
420
  clearTimeout(timeoutHandle);
129
421
  }
130
422
 
131
- if (PROXY_RETRY_STATUSES.has(response.status) && attempt < PROXY_MAX_RETRIES) {
132
- const retryAfter = response.headers.get("Retry-After");
133
- const delay = retryAfter && !isNaN(retryAfter) ? Math.min(Number(retryAfter), 5) * 1000 : 500 * (attempt + 1);
423
+ if (response.ok) {
424
+ break;
425
+ }
426
+
427
+ const parsedError = await parseProxyErrorResponse(response);
428
+ const hardDenial = isActionableProxyDenial(response.status, parsedError);
429
+ if (!hardDenial && PROXY_RETRY_STATUSES.has(response.status) && attempt < PROXY_MAX_RETRIES) {
430
+ const retryAfter = getHeader(response.headers, "Retry-After");
431
+ const retryAfterSeconds = retryAfter && !Number.isNaN(Number(retryAfter)) ? Number(retryAfter) : null;
432
+ const delay = retryAfterSeconds !== null ? Math.min(retryAfterSeconds, 5) * 1000 : 500 * (attempt + 1);
134
433
  await new Promise((r) => setTimeout(r, delay));
135
434
  continue;
136
435
  }
137
436
 
138
- break;
437
+ throw buildProxyError(response.status, parsedError);
139
438
  } catch (err) {
439
+ if (err instanceof SentinelayerProxyError) {
440
+ throw err;
441
+ }
140
442
  lastError = err;
141
443
  if (attempt >= PROXY_MAX_RETRIES) break;
142
444
  await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
@@ -147,17 +449,6 @@ export async function invokeViaProxy({
147
449
  throw new Error(`SentinelLayer LLM proxy request failed: ${lastError?.message || "no response"}`);
148
450
  }
149
451
 
150
- if (!response.ok) {
151
- let detail = "";
152
- try {
153
- const errBody = await response.json();
154
- detail = errBody?.error?.message || errBody?.detail || JSON.stringify(errBody).slice(0, 200);
155
- } catch {
156
- detail = await response.text().catch(() => "");
157
- }
158
- throw new Error(`SentinelLayer LLM proxy error (${response.status}): ${detail}`);
159
- }
160
-
161
452
  const result = await response.json();
162
453
 
163
454
  return {
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
3
 
4
4
  import { createMultiProviderApiClient } from "../ai/client.js";
5
+ import { serializeProxyError } from "../ai/proxy.js";
5
6
  import { evaluateBudget } from "../cost/budget.js";
6
7
  import { estimateTokens } from "../cost/tokenizer.js";
7
8
  import { createAgentEvent } from "../events/schema.js";
@@ -866,9 +867,11 @@ async function runSinglePersonaAgenticLoop({
866
867
  });
867
868
  } catch (error) {
868
869
  status = "llm_error_fallback";
870
+ const proxyError = serializeProxyError(error);
869
871
  emit("llm_error", {
870
872
  turn: turnCount,
871
873
  error: error instanceof Error ? error.message : String(error),
874
+ ...(proxyError ? { proxyError } : {}),
872
875
  });
873
876
  break;
874
877
  }
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import process from "node:process";
3
3
  import fsp from "node:fs/promises";
4
4
 
5
- import { invokeViaProxy } from "../ai/proxy.js";
5
+ import { invokeViaProxy, serializeProxyError } from "../ai/proxy.js";
6
6
  import { createAgentEvent } from "../events/schema.js";
7
7
  import {
8
8
  buildDocumentsFromBlackboardEntries,
@@ -496,6 +496,7 @@ async function buildHelpResponseMessage(
496
496
  let llmText = "";
497
497
  let fallbackPath = false;
498
498
  let fallbackReason = "";
499
+ let proxyError = null;
499
500
  let usage = {
500
501
  inputTokens: 0,
501
502
  outputTokens: 0,
@@ -556,6 +557,7 @@ async function buildHelpResponseMessage(
556
557
  } catch (error) {
557
558
  fallbackPath = true;
558
559
  fallbackReason = normalizeString(error?.message || error) || "Senti model invocation failed.";
560
+ proxyError = serializeProxyError(error);
559
561
  }
560
562
 
561
563
  if (!usage.latencyMs) {
@@ -579,6 +581,7 @@ async function buildHelpResponseMessage(
579
581
  usage,
580
582
  fallbackPath,
581
583
  fallbackReason,
584
+ proxyError,
582
585
  contextSignals: {
583
586
  documentCount: documents.length,
584
587
  memoryHits: memoryHits.length,
@@ -641,6 +644,7 @@ async function maybeRespondToHelpRequest(
641
644
  latencyMs: response.usage.latencyMs,
642
645
  fallbackPath: Boolean(response.fallbackPath),
643
646
  fallbackReason: response.fallbackReason || null,
647
+ proxyError: response.proxyError || null,
644
648
  contextSignals: response.contextSignals,
645
649
  },
646
650
  {