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.
- package/CHANGELOG.md +29 -0
- package/README.md +132 -5
- package/dist/api-provider.d.ts +1 -0
- package/dist/api-provider.js +13 -0
- package/dist/async-job-manager.d.ts +34 -2
- package/dist/async-job-manager.js +344 -46
- package/dist/config.d.ts +34 -0
- package/dist/config.js +91 -0
- package/dist/doctor.d.ts +30 -2
- package/dist/doctor.js +78 -6
- package/dist/endpoint-exposure.js +15 -3
- package/dist/http-transport.d.ts +3 -0
- package/dist/http-transport.js +138 -8
- package/dist/index.d.ts +15 -0
- package/dist/index.js +221 -74
- package/dist/provider-login-guidance.d.ts +12 -0
- package/dist/provider-login-guidance.js +28 -0
- package/dist/provider-status.d.ts +12 -0
- package/dist/provider-status.js +14 -0
- package/dist/provider-tool-capabilities.d.ts +4 -1
- package/dist/provider-tool-capabilities.js +225 -11
- package/dist/resources.d.ts +6 -2
- package/dist/resources.js +72 -8
- package/dist/validation-normalizer.js +5 -4
- package/npm-shrinkwrap.json +5 -5
- package/package.json +2 -1
- package/setup/status.schema.json +65 -0
|
@@ -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
|
-
|
|
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 >
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
|
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: "
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 >
|
|
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 =
|
|
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",
|
|
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"];
|