llm-cli-gateway 2.12.0 → 2.12.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.
@@ -5,7 +5,7 @@ import { ProcessMonitor } from "./process-monitor.js";
5
5
  import { computeRequestKey, isValidationRunStore } from "./job-store.js";
6
6
  import { NoopFlightRecorder, } from "./flight-recorder.js";
7
7
  import { codexFrResponse } from "./codex-json-parser.js";
8
- import { getRequestContext, resolveOwnerPrincipal } from "./request-context.js";
8
+ import { getRequestContext, resolveOwnerPrincipal, principalCanAccess } from "./request-context.js";
9
9
  import { runApiRequest, ApiHttpError, } from "./api-provider.js";
10
10
  export function extractApiHttpStatus(error) {
11
11
  for (const candidate of [error, error?.cause]) {
@@ -26,10 +26,160 @@ export function extractApiErrorBody(error) {
26
26
  }
27
27
  return undefined;
28
28
  }
29
+ export function isAsyncJobInProgress(status) {
30
+ return status === "queued" || status === "running";
31
+ }
29
32
  const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
30
33
  const JOB_TTL_MS = 60 * 60 * 1000;
31
34
  const EVICTION_INTERVAL_MS = 5 * 60 * 1000;
32
35
  const OUTPUT_FLUSH_INTERVAL_MS = 1000;
36
+ const DEFAULT_MANAGER_JOB_LIMITS = {
37
+ maxRunningJobs: 1_000_000,
38
+ maxRunningJobsPerProvider: 1_000_000,
39
+ maxQueuedJobs: 1_000_000,
40
+ queueTimeoutMs: 10 * 60 * 1000,
41
+ completedJobMemoryTtlMs: JOB_TTL_MS,
42
+ maxJobOutputBytes: MAX_OUTPUT_SIZE,
43
+ };
44
+ function formatByteCap(bytes) {
45
+ if (bytes > 0 && bytes % (1024 * 1024) === 0)
46
+ return `${bytes / (1024 * 1024)}MB`;
47
+ if (bytes > 0 && bytes % 1024 === 0)
48
+ return `${bytes / 1024}KB`;
49
+ return `${bytes} bytes`;
50
+ }
51
+ export class JobSaturationError extends Error {
52
+ provider;
53
+ detail;
54
+ retryable = true;
55
+ constructor(provider, detail) {
56
+ super(`Gateway is at capacity for ${provider}: ${detail}`);
57
+ this.provider = provider;
58
+ this.detail = detail;
59
+ this.name = "JobSaturationError";
60
+ }
61
+ }
62
+ class JobLimiter {
63
+ cfg;
64
+ logger;
65
+ runningGlobal = 0;
66
+ runningByProvider = new Map();
67
+ queue = [];
68
+ queuedByProvider = new Map();
69
+ rejectedCount = 0;
70
+ timedOutCount = 0;
71
+ constructor(cfg, logger) {
72
+ this.cfg = cfg;
73
+ this.logger = logger;
74
+ }
75
+ canRunNow(provider) {
76
+ if (this.runningGlobal >= this.cfg.maxRunningJobs)
77
+ return false;
78
+ if ((this.runningByProvider.get(provider) ?? 0) >= this.cfg.maxRunningJobsPerProvider) {
79
+ return false;
80
+ }
81
+ return true;
82
+ }
83
+ grant(provider) {
84
+ this.runningGlobal++;
85
+ this.runningByProvider.set(provider, (this.runningByProvider.get(provider) ?? 0) + 1);
86
+ let released = false;
87
+ return {
88
+ release: () => {
89
+ if (released)
90
+ return;
91
+ released = true;
92
+ this.runningGlobal = Math.max(0, this.runningGlobal - 1);
93
+ const n = (this.runningByProvider.get(provider) ?? 1) - 1;
94
+ if (n <= 0)
95
+ this.runningByProvider.delete(provider);
96
+ else
97
+ this.runningByProvider.set(provider, n);
98
+ this.pump();
99
+ },
100
+ };
101
+ }
102
+ acquire(provider, onGrant, onTimeout) {
103
+ if (this.canRunNow(provider)) {
104
+ return { state: "granted", permit: this.grant(provider) };
105
+ }
106
+ if (this.queue.length >= this.cfg.maxQueuedJobs) {
107
+ this.rejectedCount++;
108
+ return { state: "rejected" };
109
+ }
110
+ const entry = {
111
+ provider,
112
+ onGrant,
113
+ timer: setTimeout(() => {
114
+ if (this.removeEntry(entry)) {
115
+ this.timedOutCount++;
116
+ onTimeout();
117
+ }
118
+ }, this.cfg.queueTimeoutMs),
119
+ };
120
+ if (entry.timer.unref)
121
+ entry.timer.unref();
122
+ this.queue.push(entry);
123
+ this.queuedByProvider.set(provider, (this.queuedByProvider.get(provider) ?? 0) + 1);
124
+ return {
125
+ state: "queued",
126
+ cancel: () => this.removeEntry(entry),
127
+ };
128
+ }
129
+ removeEntry(entry) {
130
+ const idx = this.queue.indexOf(entry);
131
+ if (idx < 0)
132
+ return false;
133
+ this.queue.splice(idx, 1);
134
+ clearTimeout(entry.timer);
135
+ const n = (this.queuedByProvider.get(entry.provider) ?? 1) - 1;
136
+ if (n <= 0)
137
+ this.queuedByProvider.delete(entry.provider);
138
+ else
139
+ this.queuedByProvider.set(entry.provider, n);
140
+ return true;
141
+ }
142
+ pump() {
143
+ let progressed = true;
144
+ while (progressed) {
145
+ progressed = false;
146
+ for (let i = 0; i < this.queue.length; i++) {
147
+ const entry = this.queue[i];
148
+ if (!this.canRunNow(entry.provider))
149
+ continue;
150
+ this.removeEntry(entry);
151
+ const permit = this.grant(entry.provider);
152
+ try {
153
+ entry.onGrant(permit);
154
+ }
155
+ catch (err) {
156
+ this.logger.error("JobLimiter onGrant callback threw; releasing permit", err);
157
+ permit.release();
158
+ }
159
+ progressed = true;
160
+ break;
161
+ }
162
+ }
163
+ }
164
+ snapshot() {
165
+ const hasProviderBackpressure = [...this.queuedByProvider.keys()].some(provider => (this.runningByProvider.get(provider) ?? 0) >= this.cfg.maxRunningJobsPerProvider);
166
+ const queueFullUnderPressure = this.queue.length > 0 && this.queue.length >= this.cfg.maxQueuedJobs;
167
+ return {
168
+ maxRunning: this.cfg.maxRunningJobs,
169
+ maxRunningPerProvider: this.cfg.maxRunningJobsPerProvider,
170
+ maxQueued: this.cfg.maxQueuedJobs,
171
+ running: this.runningGlobal,
172
+ queued: this.queue.length,
173
+ runningByProvider: Object.fromEntries(this.runningByProvider),
174
+ queuedByProvider: Object.fromEntries(this.queuedByProvider),
175
+ rejected: this.rejectedCount,
176
+ timedOut: this.timedOutCount,
177
+ saturated: this.runningGlobal >= this.cfg.maxRunningJobs ||
178
+ hasProviderBackpressure ||
179
+ queueFullUnderPressure,
180
+ };
181
+ }
182
+ }
33
183
  const STALL_CHECK_INTERVAL_MS = 60 * 1000;
34
184
  const STALL_WARNING_MARKS_MS = [5, 10, 15].map(min => min * 60 * 1000);
35
185
  function describeProcessLaunchError(cli, error) {
@@ -83,12 +233,25 @@ export class AsyncJobManager {
83
233
  processMonitor;
84
234
  store;
85
235
  flightRecorder;
86
- constructor(logger = noopLogger, onJobComplete, store = null, flightRecorder = new NoopFlightRecorder()) {
236
+ limits;
237
+ limiter;
238
+ completedJobMemoryTtlMs;
239
+ maxJobOutputBytes;
240
+ constructor(logger = noopLogger, onJobComplete, store = null, flightRecorder = new NoopFlightRecorder(), limits = DEFAULT_MANAGER_JOB_LIMITS) {
87
241
  this.logger = logger;
88
242
  this.onJobComplete = onJobComplete;
89
243
  this.processMonitor = new ProcessMonitor(logger);
90
244
  this.store = store;
91
245
  this.flightRecorder = flightRecorder;
246
+ this.limits = limits;
247
+ this.completedJobMemoryTtlMs = limits.completedJobMemoryTtlMs;
248
+ this.maxJobOutputBytes = limits.maxJobOutputBytes;
249
+ this.limiter = new JobLimiter({
250
+ maxRunningJobs: limits.maxRunningJobs,
251
+ maxRunningJobsPerProvider: limits.maxRunningJobsPerProvider,
252
+ maxQueuedJobs: limits.maxQueuedJobs,
253
+ queueTimeoutMs: limits.queueTimeoutMs,
254
+ }, logger);
92
255
  if (this.store) {
93
256
  try {
94
257
  const { count, orphaned } = this.store.markOrphanedOnStartup();
@@ -238,9 +401,9 @@ export class AsyncJobManager {
238
401
  }
239
402
  }
240
403
  for (const [id, job] of this.jobs) {
241
- if (job.status !== "running" && job.finishedAt) {
404
+ if (job.status !== "running" && job.status !== "queued" && job.finishedAt) {
242
405
  const finishedMs = new Date(job.finishedAt).getTime();
243
- if (now - finishedMs > JOB_TTL_MS) {
406
+ if (now - finishedMs > this.completedJobMemoryTtlMs) {
244
407
  this.jobs.delete(id);
245
408
  evicted++;
246
409
  }
@@ -266,9 +429,12 @@ export class AsyncJobManager {
266
429
  const withStdin = stdin === undefined ? extraEnv : `${extraEnv}|stdin:${stdin}`;
267
430
  const withCwd = cwd === undefined ? withStdin : `${withStdin}|cwd:${cwd}`;
268
431
  const extra = cli === "codex" ? `${withCwd}|fmt:${outputFormat ?? "text"}` : withCwd;
269
- return computeRequestKey(cli, args, extra);
432
+ const principal = resolveOwnerPrincipal(getRequestContext());
433
+ const scoped = principal === "local" ? extra : `${extra}|principal:${principal}`;
434
+ return computeRequestKey(cli, args, scoped);
270
435
  }
271
436
  buildHttpRequestKey(providerName, req) {
437
+ const principal = resolveOwnerPrincipal(getRequestContext());
272
438
  const canonical = {
273
439
  transport: "http",
274
440
  provider: providerName,
@@ -280,6 +446,7 @@ export class AsyncJobManager {
280
446
  reasoningEffort: req.reasoningEffort ?? null,
281
447
  maxOutputTokens: req.maxOutputTokens ?? null,
282
448
  previousResponseId: req.previousResponseId ?? null,
449
+ ...(principal === "local" ? {} : { principal }),
283
450
  };
284
451
  return computeRequestKey(`http:${providerName}`, [], JSON.stringify(canonical));
285
452
  }
@@ -295,6 +462,11 @@ export class AsyncJobManager {
295
462
  record = this.hydrateFromStore(existing.id) ?? undefined;
296
463
  if (!record)
297
464
  return null;
465
+ const caller = resolveOwnerPrincipal(getRequestContext());
466
+ if (!principalCanAccess(record.ownerPrincipal, caller)) {
467
+ this.logger.debug(`Dedup reuse refused for ${label}: caller cannot access job ${existing.id}`, { correlationId });
468
+ return null;
469
+ }
298
470
  this.logger.info(`Job ${existing.id} reused via dedup for ${label}`, {
299
471
  correlationId,
300
472
  originalCorrelationId: record.correlationId,
@@ -347,7 +519,7 @@ export class AsyncJobManager {
347
519
  args: [],
348
520
  requestKey,
349
521
  correlationId,
350
- status: "running",
522
+ status: "queued",
351
523
  startedAt,
352
524
  finishedAt: null,
353
525
  exitCode: null,
@@ -374,6 +546,23 @@ export class AsyncJobManager {
374
546
  flightCompleteArmed: writeFlightStart === true,
375
547
  };
376
548
  this.jobs.set(id, job);
549
+ const launch = (permit) => {
550
+ job.limiterPermit = permit;
551
+ job.queueCancel = undefined;
552
+ if (job.status !== "queued") {
553
+ this.releaseJobPermit(job);
554
+ return;
555
+ }
556
+ job.status = "running";
557
+ runApiRequest(provider, apiRequest, this.logger, { signal: abort.signal })
558
+ .then(result => this.finalizeHttpJob(job, result, null))
559
+ .catch(error => this.finalizeHttpJob(job, null, error));
560
+ };
561
+ const acq = this.limiter.acquire(provider.name, permit => launch(permit), () => this.failQueuedJob(job, "queue wait timed out before a run slot was free"));
562
+ if (acq.state === "rejected") {
563
+ this.jobs.delete(id);
564
+ throw new JobSaturationError(provider.name, `running limit (${this.limits.maxRunningJobs} global / ${this.limits.maxRunningJobsPerProvider} per provider) reached and the queue is full (max ${this.limits.maxQueuedJobs}); retry shortly`);
565
+ }
377
566
  this.safeStoreCall("recordStart", () => this.store.recordStart({
378
567
  id,
379
568
  correlationId,
@@ -401,10 +590,16 @@ export class AsyncJobManager {
401
590
  this.logger.error("Async-path flight recorder logStart failed", err);
402
591
  }
403
592
  }
404
- this.logger.info(`Job ${id} started for ${provider.name} (http)`, { correlationId });
405
- runApiRequest(provider, apiRequest, this.logger, { signal: abort.signal })
406
- .then(result => this.finalizeHttpJob(job, result, null))
407
- .catch(error => this.finalizeHttpJob(job, null, error));
593
+ if (acq.state === "granted") {
594
+ this.logger.info(`Job ${id} started for ${provider.name} (http)`, { correlationId });
595
+ launch(acq.permit);
596
+ }
597
+ else {
598
+ job.queueCancel = acq.cancel;
599
+ this.logger.info(`Job ${id} queued for ${provider.name} (http, limiter saturated)`, {
600
+ correlationId,
601
+ });
602
+ }
408
603
  return { snapshot: this.snapshot(job), deduped: false };
409
604
  }
410
605
  finalizeHttpJob(job, result, error) {
@@ -438,6 +633,7 @@ export class AsyncJobManager {
438
633
  this.fireOnComplete(job);
439
634
  }
440
635
  fireOnComplete(job) {
636
+ this.releaseJobPermit(job);
441
637
  if (job.onCompleteFired)
442
638
  return;
443
639
  if (!job.onComplete)
@@ -450,6 +646,13 @@ export class AsyncJobManager {
450
646
  this.logger.error(`Job ${job.id} onComplete hook threw`, err);
451
647
  }
452
648
  }
649
+ releaseJobPermit(job) {
650
+ const permit = job.limiterPermit;
651
+ if (!permit)
652
+ return;
653
+ job.limiterPermit = undefined;
654
+ permit.release();
655
+ }
453
656
  writeFlightComplete(job, finalStatus, overrideErrorMessage) {
454
657
  if (!job.flightRecorderEntry)
455
658
  return;
@@ -530,7 +733,7 @@ export class AsyncJobManager {
530
733
  if (job.flightCompleteArmed)
531
734
  return;
532
735
  job.flightCompleteArmed = true;
533
- if (job.status === "running")
736
+ if (isAsyncJobInProgress(job.status))
534
737
  return;
535
738
  const finalStatus = job.status === "completed" ? "completed" : "failed";
536
739
  const override = job.canceled ? "canceled by caller" : undefined;
@@ -561,14 +764,14 @@ export class AsyncJobManager {
561
764
  persistComplete(job) {
562
765
  if (!this.store)
563
766
  return;
564
- if (job.status === "running")
767
+ if (job.status === "running" || job.status === "queued")
565
768
  return;
566
769
  if (!job.finishedAt)
567
770
  return;
568
771
  job.outputDirty = false;
569
772
  this.safeStoreCall("recordComplete", () => this.store.recordComplete({
570
773
  id: job.id,
571
- status: job.status === "running" ? "failed" : job.status,
774
+ status: job.status === "running" || job.status === "queued" ? "failed" : job.status,
572
775
  exitCode: job.exitCode,
573
776
  stdout: job.stdout,
574
777
  stderr: job.stderr,
@@ -660,30 +863,6 @@ export class AsyncJobManager {
660
863
  }
661
864
  const id = randomUUID();
662
865
  const startedAt = new Date().toISOString();
663
- const command = providerCommandName(cli);
664
- const baseEnv = envWithExtendedPath(process.env, getExtendedPath());
665
- const child = spawnCliProcess(command, args, {
666
- cwd,
667
- stdio: stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
668
- env: { ...baseEnv, ...(extraEnv ?? {}) },
669
- });
670
- if (stdin !== undefined && child.stdin) {
671
- try {
672
- child.stdin.write(stdin);
673
- }
674
- catch (err) {
675
- this.logger.error(`Job ${id} failed to write stdin payload`, err);
676
- }
677
- child.stdin.end();
678
- }
679
- let groupCleaned = false;
680
- const cleanupGroup = () => {
681
- if (groupCleaned)
682
- return;
683
- groupCleaned = true;
684
- if (child.pid)
685
- unregisterProcessGroup(child.pid);
686
- };
687
866
  const ownerPrincipal = resolveOwnerPrincipal(getRequestContext());
688
867
  const job = {
689
868
  id,
@@ -692,7 +871,7 @@ export class AsyncJobManager {
692
871
  args: [...args],
693
872
  requestKey,
694
873
  correlationId,
695
- status: "running",
874
+ status: "queued",
696
875
  startedAt,
697
876
  finishedAt: null,
698
877
  exitCode: null,
@@ -701,14 +880,13 @@ export class AsyncJobManager {
701
880
  outputTruncated: false,
702
881
  canceled: false,
703
882
  error: null,
704
- process: child,
883
+ process: null,
705
884
  transport: "process",
706
885
  abort: null,
707
886
  httpStatus: null,
708
887
  exited: false,
709
888
  metricsRecorded: false,
710
889
  outputFormat,
711
- cleanupGroup,
712
890
  onComplete,
713
891
  onCompleteFired: false,
714
892
  outputDirty: false,
@@ -719,6 +897,37 @@ export class AsyncJobManager {
719
897
  flightCompleteArmed: writeFlightStart === true,
720
898
  };
721
899
  this.jobs.set(id, job);
900
+ const launch = (permit) => {
901
+ job.limiterPermit = permit;
902
+ job.queueCancel = undefined;
903
+ if (job.status !== "queued") {
904
+ this.releaseJobPermit(job);
905
+ return;
906
+ }
907
+ job.status = "running";
908
+ try {
909
+ this.launchProcessJob(job, { cli, args, cwd, stdin, extraEnv, idleTimeoutMs });
910
+ }
911
+ catch (err) {
912
+ const launchError = describeProcessLaunchError(cli, err);
913
+ job.status = "failed";
914
+ job.exitCode = launchError.exitCode;
915
+ job.error = launchError.message;
916
+ job.stderr = launchError.message;
917
+ job.finishedAt = new Date().toISOString();
918
+ job.exited = true;
919
+ this.logger.error(`Job ${id} failed to spawn: ${launchError.message}`, { correlationId });
920
+ this.emitMetrics(job);
921
+ this.persistComplete(job);
922
+ this.writeFlightComplete(job, "failed");
923
+ this.fireOnComplete(job);
924
+ }
925
+ };
926
+ const acq = this.limiter.acquire(cli, permit => launch(permit), () => this.failQueuedJob(job, "queue wait timed out before a run slot was free"));
927
+ if (acq.state === "rejected") {
928
+ this.jobs.delete(id);
929
+ throw new JobSaturationError(cli, `running limit (${this.limits.maxRunningJobs} global / ${this.limits.maxRunningJobsPerProvider} per provider) reached and the queue is full (max ${this.limits.maxQueuedJobs}); retry shortly`);
930
+ }
722
931
  this.safeStoreCall("recordStart", () => this.store.recordStart({
723
932
  id,
724
933
  correlationId,
@@ -727,7 +936,7 @@ export class AsyncJobManager {
727
936
  args: [...args],
728
937
  outputFormat,
729
938
  startedAt,
730
- pid: child.pid ?? null,
939
+ pid: null,
731
940
  ownerPrincipal,
732
941
  }));
733
942
  if (writeFlightStart && flightRecorderEntry) {
@@ -749,7 +958,46 @@ export class AsyncJobManager {
749
958
  this.logger.error("Async-path flight recorder logStart failed", err);
750
959
  }
751
960
  }
752
- this.logger.info(`Job ${id} started for ${cli}`, { correlationId });
961
+ if (acq.state === "granted") {
962
+ this.logger.info(`Job ${id} started for ${cli}`, { correlationId });
963
+ launch(acq.permit);
964
+ }
965
+ else {
966
+ job.queueCancel = acq.cancel;
967
+ this.logger.info(`Job ${id} queued for ${cli} (limiter saturated)`, { correlationId });
968
+ }
969
+ return { snapshot: this.snapshot(job), deduped: false };
970
+ }
971
+ launchProcessJob(job, spawn) {
972
+ const { cli, args, cwd, stdin, extraEnv, idleTimeoutMs } = spawn;
973
+ const id = job.id;
974
+ const correlationId = job.correlationId;
975
+ const command = providerCommandName(cli);
976
+ const baseEnv = envWithExtendedPath(process.env, getExtendedPath());
977
+ const child = spawnCliProcess(command, args, {
978
+ cwd,
979
+ stdio: stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
980
+ env: { ...baseEnv, ...(extraEnv ?? {}) },
981
+ });
982
+ job.process = child;
983
+ if (stdin !== undefined && child.stdin) {
984
+ try {
985
+ child.stdin.write(stdin);
986
+ }
987
+ catch (err) {
988
+ this.logger.error(`Job ${id} failed to write stdin payload`, err);
989
+ }
990
+ child.stdin.end();
991
+ }
992
+ let groupCleaned = false;
993
+ const cleanupGroup = () => {
994
+ if (groupCleaned)
995
+ return;
996
+ groupCleaned = true;
997
+ if (child.pid)
998
+ unregisterProcessGroup(child.pid);
999
+ };
1000
+ job.cleanupGroup = cleanupGroup;
753
1001
  let idleTimerId;
754
1002
  const resetIdleTimer = () => {
755
1003
  if (!idleTimeoutMs || idleTimeoutMs <= 0)
@@ -849,7 +1097,23 @@ export class AsyncJobManager {
849
1097
  this.writeFlightComplete(job, job.status === "completed" ? "completed" : "failed", job.status === "canceled" ? "canceled by caller" : undefined);
850
1098
  this.fireOnComplete(job);
851
1099
  });
852
- return { snapshot: this.snapshot(job), deduped: false };
1100
+ }
1101
+ failQueuedJob(job, reason) {
1102
+ if (job.status !== "queued")
1103
+ return;
1104
+ job.queueCancel = undefined;
1105
+ job.status = "failed";
1106
+ job.exitCode = 75;
1107
+ job.error = `Gateway is at capacity for ${job.cli}: ${reason}`;
1108
+ job.finishedAt = new Date().toISOString();
1109
+ job.exited = true;
1110
+ this.logger.info(`Job ${job.id} failed while queued: ${reason}`, {
1111
+ correlationId: job.correlationId,
1112
+ });
1113
+ this.emitMetrics(job);
1114
+ this.persistComplete(job);
1115
+ this.writeFlightComplete(job, "failed", job.error);
1116
+ this.fireOnComplete(job);
853
1117
  }
854
1118
  getJobSnapshot(jobId) {
855
1119
  let job = this.jobs.get(jobId);
@@ -894,6 +1158,22 @@ export class AsyncJobManager {
894
1158
  if (!job) {
895
1159
  return { canceled: false, reason: "Job not found" };
896
1160
  }
1161
+ if (job.status === "queued") {
1162
+ job.queueCancel?.();
1163
+ job.queueCancel = undefined;
1164
+ job.canceled = true;
1165
+ job.status = "canceled";
1166
+ job.finishedAt = new Date().toISOString();
1167
+ job.exited = true;
1168
+ this.logger.info(`Job ${jobId} canceled while queued`, {
1169
+ correlationId: job.correlationId,
1170
+ });
1171
+ this.emitMetrics(job);
1172
+ this.persistComplete(job);
1173
+ this.writeFlightComplete(job, "failed", "canceled by caller");
1174
+ this.fireOnComplete(job);
1175
+ return { canceled: true };
1176
+ }
897
1177
  if (job.status !== "running") {
898
1178
  return { canceled: false, reason: `Job is already ${job.status}` };
899
1179
  }
@@ -964,6 +1244,23 @@ export class AsyncJobManager {
964
1244
  jobs: health,
965
1245
  };
966
1246
  }
1247
+ acquireProcessSlot(provider) {
1248
+ return new Promise((resolve, reject) => {
1249
+ const acq = this.limiter.acquire(provider, permit => resolve(permit), () => reject(new JobSaturationError(provider, "queue wait timed out before a run slot was free; retry shortly")));
1250
+ if (acq.state === "granted") {
1251
+ resolve(acq.permit);
1252
+ }
1253
+ else if (acq.state === "rejected") {
1254
+ reject(new JobSaturationError(provider, `running limit (${this.limits.maxRunningJobs} global / ${this.limits.maxRunningJobsPerProvider} per provider) reached and the queue is full (max ${this.limits.maxQueuedJobs}); retry shortly`));
1255
+ }
1256
+ });
1257
+ }
1258
+ getLimiterSnapshot() {
1259
+ return this.limiter.snapshot();
1260
+ }
1261
+ getConfiguredLimits() {
1262
+ return { ...this.limits };
1263
+ }
967
1264
  getJobOutputFormat(jobId) {
968
1265
  return this.jobs.get(jobId)?.outputFormat;
969
1266
  }
@@ -988,12 +1285,13 @@ export class AsyncJobManager {
988
1285
  }
989
1286
  appendOutput(job, stream, chunk) {
990
1287
  const totalBytes = Buffer.byteLength(job.stdout) + Buffer.byteLength(job.stderr) + chunk.length;
991
- if (totalBytes > MAX_OUTPUT_SIZE) {
1288
+ if (totalBytes > this.maxJobOutputBytes) {
992
1289
  job.outputTruncated = true;
993
1290
  if (job.status === "running") {
1291
+ const overflowMsg = `Output exceeded maximum size (${formatByteCap(this.maxJobOutputBytes)})`;
994
1292
  job.status = "failed";
995
1293
  job.exitCode = 126;
996
- job.error = "Output exceeded maximum size (50MB)";
1294
+ job.error = overflowMsg;
997
1295
  job.finishedAt = new Date().toISOString();
998
1296
  job.clearIdleTimer?.();
999
1297
  if (job.process) {
@@ -1004,7 +1302,7 @@ export class AsyncJobManager {
1004
1302
  });
1005
1303
  this.emitMetrics(job);
1006
1304
  this.persistComplete(job);
1007
- this.writeFlightComplete(job, "failed", "Output exceeded maximum size (50MB)");
1305
+ this.writeFlightComplete(job, "failed", overflowMsg);
1008
1306
  this.fireOnComplete(job);
1009
1307
  if (job.process) {
1010
1308
  setTimeout(() => {
package/dist/config.d.ts CHANGED
@@ -36,6 +36,38 @@ export interface PersistenceConfigSources {
36
36
  }
37
37
  export declare function defaultGatewayConfigPath(): string;
38
38
  export declare function loadPersistenceConfig(logger?: Logger): PersistenceConfig;
39
+ export declare const DEFAULT_HTTP_MAX_SESSIONS = 100;
40
+ export declare const DEFAULT_HTTP_SESSION_IDLE_TTL_MS: number;
41
+ export declare const DEFAULT_HTTP_SESSION_REAPER_INTERVAL_MS: number;
42
+ export declare const DEFAULT_MAX_RUNNING_JOBS = 32;
43
+ export declare const DEFAULT_MAX_RUNNING_JOBS_PER_PROVIDER = 16;
44
+ export declare const DEFAULT_MAX_QUEUED_JOBS = 128;
45
+ export declare const DEFAULT_QUEUE_TIMEOUT_MS = 120000;
46
+ export declare const DEFAULT_COMPLETED_JOB_MEMORY_TTL_MS: number;
47
+ export declare const DEFAULT_MAX_JOB_OUTPUT_BYTES: number;
48
+ export interface HttpSessionLimitsConfig {
49
+ maxSessions: number;
50
+ sessionIdleTtlMs: number;
51
+ sessionReaperIntervalMs: number;
52
+ }
53
+ export interface JobLimitsConfig {
54
+ maxRunningJobs: number;
55
+ maxRunningJobsPerProvider: number;
56
+ maxQueuedJobs: number;
57
+ queueTimeoutMs: number;
58
+ completedJobMemoryTtlMs: number;
59
+ maxJobOutputBytes: number;
60
+ }
61
+ export interface GatewayLimitsConfig {
62
+ http: HttpSessionLimitsConfig;
63
+ jobs: JobLimitsConfig;
64
+ sources: {
65
+ configFile: string | null;
66
+ };
67
+ }
68
+ export declare const DEFAULT_HTTP_SESSION_LIMITS: HttpSessionLimitsConfig;
69
+ export declare const DEFAULT_JOB_LIMITS: JobLimitsConfig;
70
+ export declare function loadLimitsConfig(logger?: Logger): GatewayLimitsConfig;
39
71
  export declare const ANTHROPIC_TTL_SECONDS_VALUES: readonly [300, 3600];
40
72
  export type AnthropicTtlSeconds = (typeof ANTHROPIC_TTL_SECONDS_VALUES)[number];
41
73
  export declare const DEFAULT_MIN_STABLE_TOKENS_FOR_CACHE_CONTROL: {
@@ -81,6 +113,7 @@ export interface ApiProviderConfig {
81
113
  export interface ApiProviderRuntime {
82
114
  name: string;
83
115
  kind: ApiProviderKind;
116
+ apiKeyEnv: string | null;
84
117
  baseUrl: string;
85
118
  defaultModel: string;
86
119
  models?: string[];
@@ -96,6 +129,7 @@ export interface ProvidersConfig {
96
129
  }
97
130
  export declare function loadProvidersConfig(logger?: Logger): ProvidersConfig;
98
131
  export declare function isApiProviderEnabled(provider: ApiProviderConfig, env?: NodeJS.ProcessEnv): boolean;
132
+ export declare function apiProviderKeyPresent(provider: ApiProviderConfig, env?: NodeJS.ProcessEnv): boolean;
99
133
  export declare function enabledApiProviders(config: ProvidersConfig, env?: NodeJS.ProcessEnv): ApiProviderRuntime[];
100
134
  export declare function isXaiProviderEnabled(config: ProvidersConfig, env?: NodeJS.ProcessEnv): boolean;
101
135
  export declare const ACP_TRANSPORTS: readonly ["cli", "acp"];