@hackerrank/astra-cli 0.1.24 → 0.1.25

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/model.js +178 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackerrank/astra-cli",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Minimal zero-dependency AI coding agent for the HackerRank AI Gateway.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/model.js CHANGED
@@ -42,7 +42,7 @@ export class GatewayModel {
42
42
  * @param {number} [opts.maxRetries]
43
43
  * @param {(info:object)=>void} [opts.onRetry] called before each retry sleep
44
44
  */
45
- constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 5, maxTokens = 8192, requestTimeoutMs = 0, onRetry } = {}) {
45
+ constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 8, maxTokens = 8192, requestTimeoutMs = 0, onRetry } = {}) {
46
46
  if (!model) throw new Error("GatewayModel: `model` is required");
47
47
  this.model = model;
48
48
  this.maxTokens = maxTokens;
@@ -124,14 +124,15 @@ export class GatewayModel {
124
124
  // Retry on transient server / rate-limit errors with exponential
125
125
  // backoff (honoring Retry-After when the server provides it).
126
126
  if ((res.status === 429 || res.status >= 500) && attempt < this.maxRetries) {
127
- const wait = retryAfterMs(res.headers) ?? backoffMs(attempt);
127
+ const serverWait = retryAfterMs(res.headers, text);
128
+ const wait = serverWait != null ? Math.max(serverWait + 250, 1000) : backoffMs(attempt);
128
129
  this.nRetries++;
129
130
  this.onRetry({
130
131
  attempt: attempt + 1,
131
132
  maxRetries: this.maxRetries,
132
133
  status: res.status,
133
134
  waitMs: wait,
134
- reason: `HTTP ${res.status}`,
135
+ reason: res.status === 429 ? "HTTP 429 (rate limited)" : `HTTP ${res.status}`,
135
136
  });
136
137
  await sleep(wait);
137
138
  continue;
@@ -282,14 +283,180 @@ function hintForStatus(status) {
282
283
  }
283
284
  }
284
285
 
285
- /** Parse a Retry-After header (seconds or HTTP-date) into milliseconds. */
286
- function retryAfterMs(headers) {
287
- const v = headers?.get?.("retry-after");
288
- if (!v) return null;
289
- const secs = Number(v);
290
- if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
291
- const when = Date.parse(v);
292
- if (Number.isFinite(when)) return Math.max(0, when - Date.now());
286
+ /**
287
+ * Parse a duration string, number, or date into milliseconds.
288
+ * Supports numbers (seconds or timestamps), units (ms, s, m, h, d),
289
+ * composite durations ("1m 30s"), and HTTP/ISO dates.
290
+ */
291
+ export function parseDurationMs(val) {
292
+ if (typeof val === "number" && Number.isFinite(val) && val >= 0) {
293
+ if (val > 1e11) return Math.max(0, val - Date.now());
294
+ if (val > 1e9) return Math.max(0, val * 1000 - Date.now());
295
+ return Math.max(0, Math.round(val * 1000));
296
+ }
297
+ if (!val || typeof val !== "string") return null;
298
+ const s = val.trim();
299
+
300
+ const rawNum = Number(s);
301
+ if (Number.isFinite(rawNum) && rawNum >= 0) {
302
+ if (rawNum > 1e11) return Math.max(0, rawNum - Date.now());
303
+ if (rawNum > 1e9) return Math.max(0, rawNum * 1000 - Date.now());
304
+ return Math.max(0, Math.round(rawNum * 1000));
305
+ }
306
+
307
+ const when = Date.parse(s);
308
+ if (Number.isFinite(when) && when > Date.now()) {
309
+ return Math.max(0, when - Date.now());
310
+ }
311
+
312
+ const pattern = /([0-9]+(?:\.[0-9]+)?)\s*(milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/gi;
313
+ let match;
314
+ let totalMs = 0;
315
+ let matched = false;
316
+ while ((match = pattern.exec(s)) !== null) {
317
+ matched = true;
318
+ const n = parseFloat(match[1]);
319
+ const unit = match[2].toLowerCase();
320
+ if (!Number.isFinite(n)) continue;
321
+ if (unit.startsWith("ms") || unit.startsWith("milli")) {
322
+ totalMs += n;
323
+ } else if (unit.startsWith("s")) {
324
+ totalMs += n * 1000;
325
+ } else if (unit.startsWith("m")) {
326
+ totalMs += n * 60 * 1000;
327
+ } else if (unit.startsWith("h")) {
328
+ totalMs += n * 3600 * 1000;
329
+ } else if (unit.startsWith("d")) {
330
+ totalMs += n * 86400 * 1000;
331
+ }
332
+ }
333
+ return matched ? Math.round(totalMs) : null;
334
+ }
335
+
336
+ /**
337
+ * Extract retry duration from unstructured error message text.
338
+ * Matches phrases like "retry after 12.5s", "try again in 5 seconds", "resets in 30s", "wait 10s".
339
+ */
340
+ export function extractDurationFromText(text) {
341
+ if (!text || typeof text !== "string") return null;
342
+
343
+ const p1 = /(?:retry(?:ing)?|try\s+again|wait(?:ing)?|resets?|available|back\s*off)\s+(?:again\s+)?(?:after|in|for)\s+([0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b(?:\s+[0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b)*|[0-9]+(?:\.[0-9]+)?)/i;
344
+ const m1 = text.match(p1);
345
+ if (m1) {
346
+ const parsed = parseDurationMs(m1[1]);
347
+ if (parsed != null) return parsed;
348
+ }
349
+
350
+ const p2 = /(?:wait(?:ing)?)\s+([0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b)/i;
351
+ const m2 = text.match(p2);
352
+ if (m2) {
353
+ const parsed = parseDurationMs(m2[1]);
354
+ if (parsed != null) return parsed;
355
+ }
356
+
357
+ const p3 = /in\s+([0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b)\s*[,.]?\s*(?:please\s+)?(?:retry|try)/i;
358
+ const m3 = text.match(p3);
359
+ if (m3) {
360
+ const parsed = parseDurationMs(m3[1]);
361
+ if (parsed != null) return parsed;
362
+ }
363
+
364
+ return null;
365
+ }
366
+
367
+ /**
368
+ * Parse retry delay from response headers or error response body into milliseconds.
369
+ * Checks standard and provider-specific rate-limit headers as well as JSON fields
370
+ * and error messages returned by AI gateways (HackerRank, OpenAI, Anthropic, LiteLLM, Gemini, etc.).
371
+ */
372
+ export function retryAfterMs(headers, bodyText) {
373
+ const msHeader = headers?.get?.("retry-after-ms");
374
+ if (msHeader) {
375
+ const ms = Number(msHeader);
376
+ if (Number.isFinite(ms) && ms >= 0) return Math.round(ms);
377
+ }
378
+
379
+ const headerKeys = [
380
+ "retry-after",
381
+ "x-retry-after",
382
+ "x-ratelimit-reset-requests",
383
+ "x-ratelimit-reset-tokens",
384
+ "x-ratelimit-reset",
385
+ ];
386
+ for (const k of headerKeys) {
387
+ const val = headers?.get?.(k);
388
+ if (val) {
389
+ const parsed = parseDurationMs(val);
390
+ if (parsed != null) return parsed;
391
+ }
392
+ }
393
+
394
+ if (bodyText) {
395
+ let json = null;
396
+ if (typeof bodyText === "object") {
397
+ json = bodyText;
398
+ } else if (typeof bodyText === "string") {
399
+ try {
400
+ json = JSON.parse(bodyText);
401
+ } catch {}
402
+ }
403
+
404
+ if (json && typeof json === "object") {
405
+ const msCandidates = [json.retry_after_ms, json.error?.retry_after_ms];
406
+ for (const c of msCandidates) {
407
+ if (Number.isFinite(Number(c)) && Number(c) >= 0) {
408
+ return Math.round(Number(c));
409
+ }
410
+ }
411
+
412
+ const durationCandidates = [
413
+ json.retry_after,
414
+ json.retryAfter,
415
+ json.retry_after_seconds,
416
+ json.retry_after_sec,
417
+ json.reset_in,
418
+ json.reset_after,
419
+ json.reset_at,
420
+ json.wait_seconds,
421
+ json.wait_time,
422
+ json.error?.retry_after,
423
+ json.error?.retryAfter,
424
+ json.error?.retry_after_seconds,
425
+ json.error?.retry_after_sec,
426
+ json.error?.reset_in,
427
+ json.error?.reset_after,
428
+ json.error?.reset_at,
429
+ json.error?.wait_seconds,
430
+ json.error?.wait_time,
431
+ ];
432
+ for (const c of durationCandidates) {
433
+ if (c != null) {
434
+ const parsed = parseDurationMs(c);
435
+ if (parsed != null) return parsed;
436
+ }
437
+ }
438
+
439
+ const msgCandidates = [
440
+ json.error?.message,
441
+ typeof json.error === "string" ? json.error : null,
442
+ json.message,
443
+ json.detail,
444
+ typeof json.details === "string" ? json.details : null,
445
+ ];
446
+ for (const msg of msgCandidates) {
447
+ if (msg) {
448
+ const extracted = extractDurationFromText(msg);
449
+ if (extracted != null) return extracted;
450
+ }
451
+ }
452
+ }
453
+
454
+ if (typeof bodyText === "string") {
455
+ const extracted = extractDurationFromText(bodyText);
456
+ if (extracted != null) return extracted;
457
+ }
458
+ }
459
+
293
460
  return null;
294
461
  }
295
462