llm-cli-gateway 2.15.0 → 2.17.0
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/.agents/skills/least-cost-routing/SKILL.md +123 -0
- package/CHANGELOG.md +78 -1
- package/dist/acp/client.js +5 -0
- package/dist/acp/flight-redaction.d.ts +2 -0
- package/dist/acp/flight-redaction.js +2 -0
- package/dist/acp/process-manager.js +2 -1
- package/dist/acp/provider-registry.js +8 -8
- package/dist/acp/runtime.d.ts +1 -0
- package/dist/acp/runtime.js +20 -0
- package/dist/acp/types.d.ts +52 -0
- package/dist/acp/types.js +8 -0
- package/dist/api-provider.d.ts +1 -0
- package/dist/api-provider.js +1 -0
- package/dist/async-job-manager.d.ts +20 -1
- package/dist/async-job-manager.js +85 -28
- package/dist/claude-mcp-config.js +1 -1
- package/dist/compressor/estimate.d.ts +5 -0
- package/dist/compressor/estimate.js +21 -0
- package/dist/compressor/index.d.ts +23 -0
- package/dist/compressor/index.js +77 -0
- package/dist/compressor/router.d.ts +2 -0
- package/dist/compressor/router.js +57 -0
- package/dist/compressor/transforms/ansi.d.ts +3 -0
- package/dist/compressor/transforms/ansi.js +99 -0
- package/dist/compressor/transforms/json.d.ts +1 -0
- package/dist/compressor/transforms/json.js +156 -0
- package/dist/compressor/transforms/log.d.ts +12 -0
- package/dist/compressor/transforms/log.js +55 -0
- package/dist/compressor/transforms/whitespace.d.ts +8 -0
- package/dist/compressor/transforms/whitespace.js +79 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +166 -6
- package/dist/db.js +4 -4
- package/dist/doctor.d.ts +34 -1
- package/dist/doctor.js +85 -3
- package/dist/executor.d.ts +6 -0
- package/dist/executor.js +16 -3
- package/dist/flight-recorder.d.ts +19 -0
- package/dist/flight-recorder.js +110 -2
- package/dist/http-transport.js +19 -21
- package/dist/index.d.ts +58 -2
- package/dist/index.js +929 -94
- package/dist/job-store.d.ts +14 -2
- package/dist/job-store.js +170 -43
- package/dist/lcr-priors.d.ts +60 -0
- package/dist/lcr-priors.js +190 -0
- package/dist/lcr-router-env.d.ts +20 -0
- package/dist/lcr-router-env.js +133 -0
- package/dist/lcr-telemetry.d.ts +2 -0
- package/dist/lcr-telemetry.js +17 -0
- package/dist/least-cost-router.d.ts +86 -0
- package/dist/least-cost-router.js +296 -0
- package/dist/least-cost-types.d.ts +34 -0
- package/dist/least-cost-types.js +1 -0
- package/dist/migrate-sessions.js +1 -1
- package/dist/migrate.js +1 -1
- package/dist/model-registry.js +1 -1
- package/dist/postgres-job-store-worker.js +56 -13
- package/dist/pricing.d.ts +17 -0
- package/dist/pricing.js +167 -0
- package/dist/provider-definitions.d.ts +5 -0
- package/dist/provider-definitions.js +56 -10
- package/dist/provider-tool-capabilities.js +3 -3
- package/dist/request-helpers.d.ts +2 -2
- package/dist/request-helpers.js +1 -1
- package/dist/resources.d.ts +37 -2
- package/dist/resources.js +96 -1
- package/dist/retry.d.ts +1 -0
- package/dist/retry.js +1 -1
- package/dist/spawn-env-isolation.d.ts +10 -0
- package/dist/spawn-env-isolation.js +55 -0
- package/dist/token-estimator.d.ts +6 -0
- package/dist/token-estimator.js +59 -0
- package/dist/upstream-contracts.js +48 -28
- package/dist/validation-receipt.js +1 -1
- package/dist/validation-tools.d.ts +6 -0
- package/dist/validation-tools.js +174 -54
- package/npm-shrinkwrap.json +2 -2
- package/package.json +15 -6
- package/setup/status.schema.json +68 -0
|
@@ -20,6 +20,7 @@ const DEFAULT_LEASE_RUNTIME_CONFIG = {
|
|
|
20
20
|
role: "gateway",
|
|
21
21
|
};
|
|
22
22
|
const MAX_CONSECUTIVE_HEARTBEAT_FAILURES = 3;
|
|
23
|
+
const MIN_CONSECUTIVE_HEARTBEAT_SUCCESSES_TO_RECOVER = 3;
|
|
23
24
|
export function extractApiHttpStatus(error) {
|
|
24
25
|
for (const candidate of [error, error?.cause]) {
|
|
25
26
|
if (candidate instanceof ApiHttpError && typeof candidate.status === "number") {
|
|
@@ -72,6 +73,9 @@ export class JobSaturationError extends Error {
|
|
|
72
73
|
this.name = "JobSaturationError";
|
|
73
74
|
}
|
|
74
75
|
}
|
|
76
|
+
export function providerAtCapacity(snapshot, provider) {
|
|
77
|
+
return (snapshot.runningByProvider[provider] ?? 0) >= snapshot.maxRunningPerProvider;
|
|
78
|
+
}
|
|
75
79
|
class JobLimiter {
|
|
76
80
|
cfg;
|
|
77
81
|
logger;
|
|
@@ -258,6 +262,10 @@ export class AsyncJobManager {
|
|
|
258
262
|
sweepTimer = null;
|
|
259
263
|
durableAdmission;
|
|
260
264
|
consecutiveHeartbeatFailures = 0;
|
|
265
|
+
consecutiveHeartbeatSuccesses = 0;
|
|
266
|
+
lastHeartbeatFailureAt = null;
|
|
267
|
+
lastHeartbeatRecoveryAt = null;
|
|
268
|
+
lastHeartbeatErrorName = null;
|
|
261
269
|
skipSweepThisCycle = false;
|
|
262
270
|
nextHeartbeatExpectedAt = 0;
|
|
263
271
|
disposed = false;
|
|
@@ -278,25 +286,11 @@ export class AsyncJobManager {
|
|
|
278
286
|
maxQueuedJobs: limits.maxQueuedJobs,
|
|
279
287
|
queueTimeoutMs: limits.queueTimeoutMs,
|
|
280
288
|
}, logger);
|
|
281
|
-
this.durableAdmission =
|
|
289
|
+
this.durableAdmission = false;
|
|
282
290
|
if (this.store) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
role: this.lease.role ?? "gateway",
|
|
287
|
-
hostname: this.hostname,
|
|
288
|
-
pid: this.instancePid,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
catch (err) {
|
|
292
|
-
this.durableAdmission = false;
|
|
293
|
-
this.logger.error("#139 registerInstance failed; durable async admission is disabled for this instance", err);
|
|
294
|
-
}
|
|
295
|
-
if (this.durableAdmission) {
|
|
296
|
-
this.runOrphanSweep();
|
|
297
|
-
this.startHeartbeat();
|
|
298
|
-
this.startReaper();
|
|
299
|
-
}
|
|
291
|
+
this.restoreDurableAdmission("startup");
|
|
292
|
+
this.startHeartbeat();
|
|
293
|
+
this.startReaper();
|
|
300
294
|
}
|
|
301
295
|
this.evictionTimer = setInterval(() => this.evictCompletedJobs(), EVICTION_INTERVAL_MS);
|
|
302
296
|
if (this.evictionTimer.unref) {
|
|
@@ -310,6 +304,17 @@ export class AsyncJobManager {
|
|
|
310
304
|
canAdmitDurableJobs() {
|
|
311
305
|
return this.store !== null && this.durableAdmission;
|
|
312
306
|
}
|
|
307
|
+
getDurableAdmissionHealth() {
|
|
308
|
+
return {
|
|
309
|
+
storeAttached: this.store !== null,
|
|
310
|
+
admitting: this.canAdmitDurableJobs(),
|
|
311
|
+
consecutiveHeartbeatFailures: this.consecutiveHeartbeatFailures,
|
|
312
|
+
consecutiveHeartbeatSuccesses: this.consecutiveHeartbeatSuccesses,
|
|
313
|
+
lastHeartbeatFailureAt: this.lastHeartbeatFailureAt,
|
|
314
|
+
lastHeartbeatRecoveryAt: this.lastHeartbeatRecoveryAt,
|
|
315
|
+
lastHeartbeatErrorName: this.lastHeartbeatErrorName,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
313
318
|
assertDurableAdmission(provider) {
|
|
314
319
|
if (this.store && !this.durableAdmission) {
|
|
315
320
|
throw new Error(`Durable async admission is disabled for ${provider}: this gateway instance could not register or lost its heartbeat lease. Retry after the gateway recovers.`);
|
|
@@ -394,13 +399,23 @@ export class AsyncJobManager {
|
|
|
394
399
|
try {
|
|
395
400
|
this.store.heartbeat(this.instanceId);
|
|
396
401
|
this.consecutiveHeartbeatFailures = 0;
|
|
402
|
+
if (!this.durableAdmission) {
|
|
403
|
+
this.consecutiveHeartbeatSuccesses++;
|
|
404
|
+
if (this.consecutiveHeartbeatSuccesses >= MIN_CONSECUTIVE_HEARTBEAT_SUCCESSES_TO_RECOVER) {
|
|
405
|
+
this.restoreDurableAdmission("heartbeat recovery");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
397
408
|
}
|
|
398
409
|
catch (err) {
|
|
399
410
|
this.consecutiveHeartbeatFailures++;
|
|
411
|
+
this.consecutiveHeartbeatSuccesses = 0;
|
|
412
|
+
this.lastHeartbeatFailureAt = new Date().toISOString();
|
|
413
|
+
this.lastHeartbeatErrorName = err instanceof Error ? err.name : typeof err;
|
|
400
414
|
this.logger.error(`#139 heartbeat failed (${this.consecutiveHeartbeatFailures}/${MAX_CONSECUTIVE_HEARTBEAT_FAILURES})`, err);
|
|
401
415
|
if (this.consecutiveHeartbeatFailures >= MAX_CONSECUTIVE_HEARTBEAT_FAILURES) {
|
|
402
416
|
if (this.durableAdmission) {
|
|
403
417
|
this.durableAdmission = false;
|
|
418
|
+
this.consecutiveHeartbeatSuccesses = 0;
|
|
404
419
|
this.logger.error("#139 sustained heartbeat failure; disabling durable admission and orphan sweeping on this instance");
|
|
405
420
|
}
|
|
406
421
|
}
|
|
@@ -412,8 +427,10 @@ export class AsyncJobManager {
|
|
|
412
427
|
startReaper() {
|
|
413
428
|
this.sweepTimer = setInterval(() => {
|
|
414
429
|
this.runOrphanSweep();
|
|
430
|
+
if (!this.store || this.disposed || !this.durableAdmission)
|
|
431
|
+
return;
|
|
415
432
|
try {
|
|
416
|
-
this.store
|
|
433
|
+
this.store.gcInstances(this.lease.instanceGcMs);
|
|
417
434
|
}
|
|
418
435
|
catch (err) {
|
|
419
436
|
this.logger.error("#139 gateway_instances GC failed", err);
|
|
@@ -422,6 +439,35 @@ export class AsyncJobManager {
|
|
|
422
439
|
if (this.sweepTimer.unref)
|
|
423
440
|
this.sweepTimer.unref();
|
|
424
441
|
}
|
|
442
|
+
restoreDurableAdmission(reason) {
|
|
443
|
+
if (!this.store || this.disposed)
|
|
444
|
+
return;
|
|
445
|
+
try {
|
|
446
|
+
this.store.registerInstance({
|
|
447
|
+
instanceId: this.instanceId,
|
|
448
|
+
role: this.lease.role ?? "gateway",
|
|
449
|
+
hostname: this.hostname,
|
|
450
|
+
pid: this.instancePid,
|
|
451
|
+
});
|
|
452
|
+
this.store.heartbeat(this.instanceId);
|
|
453
|
+
const wasDisabled = !this.durableAdmission;
|
|
454
|
+
this.durableAdmission = true;
|
|
455
|
+
this.consecutiveHeartbeatFailures = 0;
|
|
456
|
+
this.consecutiveHeartbeatSuccesses = 0;
|
|
457
|
+
this.lastHeartbeatRecoveryAt = new Date().toISOString();
|
|
458
|
+
if (wasDisabled && reason === "heartbeat recovery") {
|
|
459
|
+
this.logger.info("#139 durable heartbeat recovered; re-enabled async admission and orphan sweeping");
|
|
460
|
+
}
|
|
461
|
+
this.runOrphanSweep();
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
this.durableAdmission = false;
|
|
465
|
+
this.consecutiveHeartbeatSuccesses = 0;
|
|
466
|
+
this.lastHeartbeatFailureAt = new Date().toISOString();
|
|
467
|
+
this.lastHeartbeatErrorName = err instanceof Error ? err.name : typeof err;
|
|
468
|
+
this.logger.error(`#139 ${reason} register/heartbeat failed; durable async admission remains disabled`, err);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
425
471
|
runOrphanSweep() {
|
|
426
472
|
if (!this.store || this.disposed || !this.durableAdmission)
|
|
427
473
|
return;
|
|
@@ -493,13 +539,13 @@ export class AsyncJobManager {
|
|
|
493
539
|
acq.cancel();
|
|
494
540
|
this.jobs.delete(job.id);
|
|
495
541
|
this.logger.error(`#139 durable recordStart failed for job ${job.id}; failing the request (fail-closed)`, err);
|
|
496
|
-
throw new Error(`Durable job admission failed for ${job.cli}: the job store rejected recordStart
|
|
542
|
+
throw new Error(`Durable job admission failed for ${job.cli}: the job store rejected recordStart`, { cause: err });
|
|
497
543
|
}
|
|
498
544
|
}
|
|
499
545
|
markRunningDurable(job, pid, failClosed = false) {
|
|
500
546
|
if (!this.store)
|
|
501
547
|
return;
|
|
502
|
-
let transitioned
|
|
548
|
+
let transitioned;
|
|
503
549
|
try {
|
|
504
550
|
transitioned = this.store.markRunning(job.id, { pid });
|
|
505
551
|
}
|
|
@@ -645,7 +691,7 @@ export class AsyncJobManager {
|
|
|
645
691
|
if (evicted > 0) {
|
|
646
692
|
this.logger.debug(`Evicted ${evicted} completed jobs from memory (durable store retains them)`);
|
|
647
693
|
}
|
|
648
|
-
if (this.store) {
|
|
694
|
+
if (this.store && this.durableAdmission) {
|
|
649
695
|
try {
|
|
650
696
|
const removed = this.store.evictExpired();
|
|
651
697
|
if (removed > 0) {
|
|
@@ -657,11 +703,12 @@ export class AsyncJobManager {
|
|
|
657
703
|
}
|
|
658
704
|
}
|
|
659
705
|
}
|
|
660
|
-
buildRequestKey(cli, args, env, stdin, cwd, outputFormat) {
|
|
706
|
+
buildRequestKey(cli, args, env, stdin, cwd, outputFormat, compressResponse) {
|
|
661
707
|
const extraEnv = canonicaliseEnvForKey(env);
|
|
662
708
|
const withStdin = stdin === undefined ? extraEnv : `${extraEnv}|stdin:${stdin}`;
|
|
663
709
|
const withCwd = cwd === undefined ? withStdin : `${withStdin}|cwd:${cwd}`;
|
|
664
|
-
const
|
|
710
|
+
const withFmt = cli === "codex" ? `${withCwd}|fmt:${outputFormat ?? "text"}` : withCwd;
|
|
711
|
+
const extra = compressResponse ? `${withFmt}|compress:1` : withFmt;
|
|
665
712
|
const principal = resolveOwnerPrincipal(getRequestContext());
|
|
666
713
|
const scoped = principal === "local" ? extra : `${extra}|principal:${principal}`;
|
|
667
714
|
return computeRequestKey(cli, args, scoped);
|
|
@@ -927,7 +974,7 @@ export class AsyncJobManager {
|
|
|
927
974
|
durationMs,
|
|
928
975
|
retryCount: 0,
|
|
929
976
|
circuitBreakerState: "closed",
|
|
930
|
-
optimizationApplied: false,
|
|
977
|
+
optimizationApplied: job.flightRecorderEntry.optimizationApplied ?? false,
|
|
931
978
|
exitCode,
|
|
932
979
|
httpStatus: job.transport === "http" ? (job.httpStatus ?? undefined) : undefined,
|
|
933
980
|
errorMessage,
|
|
@@ -937,6 +984,7 @@ export class AsyncJobManager {
|
|
|
937
984
|
cacheReadTokens: usage.cacheReadTokens,
|
|
938
985
|
cacheCreationTokens: usage.cacheCreationTokens,
|
|
939
986
|
costUsd: usage.costUsd,
|
|
987
|
+
costBasis: usage.costBasis,
|
|
940
988
|
providerSessionId: providerMeta?.sessionId,
|
|
941
989
|
stopReason: providerMeta?.stopReason,
|
|
942
990
|
});
|
|
@@ -965,6 +1013,7 @@ export class AsyncJobManager {
|
|
|
965
1013
|
inputTokens: u.inputTokens,
|
|
966
1014
|
outputTokens: u.outputTokens,
|
|
967
1015
|
cacheReadTokens: u.cacheReadTokens,
|
|
1016
|
+
cacheCreationTokens: u.cacheCreationTokens,
|
|
968
1017
|
costUsd: u.costUsd,
|
|
969
1018
|
};
|
|
970
1019
|
}
|
|
@@ -1068,6 +1117,7 @@ export class AsyncJobManager {
|
|
|
1068
1117
|
exited: row.status !== "running" && row.status !== "queued",
|
|
1069
1118
|
metricsRecorded: true,
|
|
1070
1119
|
outputFormat: row.outputFormat ?? undefined,
|
|
1120
|
+
compressResponse: row.compressResponse ?? null,
|
|
1071
1121
|
ownerPrincipal: row.ownerPrincipal,
|
|
1072
1122
|
outputDirty: false,
|
|
1073
1123
|
lastOutputFlushAt: Date.now(),
|
|
@@ -1081,7 +1131,7 @@ export class AsyncJobManager {
|
|
|
1081
1131
|
job = this.hydrateFromStore(jobId) ?? undefined;
|
|
1082
1132
|
return job?.ownerPrincipal;
|
|
1083
1133
|
}
|
|
1084
|
-
startJob(cli, args, correlationId, cwd, idleTimeoutMs, outputFormat, forceRefresh, env, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, stdin) {
|
|
1134
|
+
startJob(cli, args, correlationId, cwd, idleTimeoutMs, outputFormat, forceRefresh, env, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, stdin, compressResponse) {
|
|
1085
1135
|
return this.startJobWithDedup(cli, args, correlationId, {
|
|
1086
1136
|
cwd,
|
|
1087
1137
|
idleTimeoutMs,
|
|
@@ -1093,11 +1143,12 @@ export class AsyncJobManager {
|
|
|
1093
1143
|
flightRecorderEntry,
|
|
1094
1144
|
extractUsage,
|
|
1095
1145
|
writeFlightStart,
|
|
1146
|
+
compressResponse,
|
|
1096
1147
|
}).snapshot;
|
|
1097
1148
|
}
|
|
1098
1149
|
startJobWithDedup(cli, args, correlationId, opts = {}) {
|
|
1099
|
-
const { cwd, idleTimeoutMs, outputFormat, forceRefresh, env: extraEnv, stdin, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, } = opts;
|
|
1100
|
-
const requestKey = this.buildRequestKey(cli, args, extraEnv, stdin, cwd, outputFormat);
|
|
1150
|
+
const { cwd, idleTimeoutMs, outputFormat, forceRefresh, env: extraEnv, stdin, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, compressResponse, } = opts;
|
|
1151
|
+
const requestKey = this.buildRequestKey(cli, args, extraEnv, stdin, cwd, outputFormat, compressResponse);
|
|
1101
1152
|
if (!forceRefresh) {
|
|
1102
1153
|
const reused = this.tryReuseDedupedJob(requestKey, correlationId, cli, onComplete);
|
|
1103
1154
|
if (reused)
|
|
@@ -1130,6 +1181,7 @@ export class AsyncJobManager {
|
|
|
1130
1181
|
exited: false,
|
|
1131
1182
|
metricsRecorded: false,
|
|
1132
1183
|
outputFormat,
|
|
1184
|
+
compressResponse: compressResponse ?? null,
|
|
1133
1185
|
onComplete,
|
|
1134
1186
|
onCompleteFired: false,
|
|
1135
1187
|
outputDirty: false,
|
|
@@ -1178,6 +1230,7 @@ export class AsyncJobManager {
|
|
|
1178
1230
|
cli,
|
|
1179
1231
|
args: [...args],
|
|
1180
1232
|
outputFormat,
|
|
1233
|
+
compressResponse,
|
|
1181
1234
|
startedAt,
|
|
1182
1235
|
pid: null,
|
|
1183
1236
|
ownerPrincipal,
|
|
@@ -1222,6 +1275,7 @@ export class AsyncJobManager {
|
|
|
1222
1275
|
cwd,
|
|
1223
1276
|
stdio: stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
|
|
1224
1277
|
env: { ...baseEnv, ...(extraEnv ?? {}) },
|
|
1278
|
+
logger: this.logger,
|
|
1225
1279
|
});
|
|
1226
1280
|
job.process = child;
|
|
1227
1281
|
try {
|
|
@@ -1531,6 +1585,9 @@ export class AsyncJobManager {
|
|
|
1531
1585
|
getJobOutputFormat(jobId) {
|
|
1532
1586
|
return this.jobs.get(jobId)?.outputFormat;
|
|
1533
1587
|
}
|
|
1588
|
+
getJobCompressResponse(jobId) {
|
|
1589
|
+
return this.jobs.get(jobId)?.compressResponse === true;
|
|
1590
|
+
}
|
|
1534
1591
|
getJobCli(jobId) {
|
|
1535
1592
|
return this.jobs.get(jobId)?.cli;
|
|
1536
1593
|
}
|
|
@@ -147,7 +147,7 @@ export function buildClaudeMcpConfig(servers) {
|
|
|
147
147
|
}
|
|
148
148
|
catch (error) {
|
|
149
149
|
const message = error instanceof Error ? error.message : String(error);
|
|
150
|
-
throw new Error(`Failed to write Claude MCP config: ${message}
|
|
150
|
+
throw new Error(`Failed to write Claude MCP config: ${message}`, { cause: error });
|
|
151
151
|
}
|
|
152
152
|
return { path: configPath, enabled, missing };
|
|
153
153
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ContentRoute } from "./router.js";
|
|
2
|
+
export type DevTokenizer = (text: string) => number;
|
|
3
|
+
export declare function setDevTokenizer(tokenizer: DevTokenizer | null): void;
|
|
4
|
+
export declare function estimateTokensForRoute(text: string, route: ContentRoute): number;
|
|
5
|
+
export declare function estimateTokensSaved(originalText: string, compressedText: string, route: ContentRoute): number;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const DIVISORS = {
|
|
2
|
+
json: 3.0,
|
|
3
|
+
log: 3.4,
|
|
4
|
+
"ansi-text": 3.4,
|
|
5
|
+
plain: 3.6,
|
|
6
|
+
identity: 3.6,
|
|
7
|
+
};
|
|
8
|
+
let devTokenizer = null;
|
|
9
|
+
export function setDevTokenizer(tokenizer) {
|
|
10
|
+
devTokenizer = tokenizer;
|
|
11
|
+
}
|
|
12
|
+
export function estimateTokensForRoute(text, route) {
|
|
13
|
+
if (devTokenizer)
|
|
14
|
+
return devTokenizer(text);
|
|
15
|
+
if (text.length === 0)
|
|
16
|
+
return 0;
|
|
17
|
+
return Math.ceil(text.length / DIVISORS[route]);
|
|
18
|
+
}
|
|
19
|
+
export function estimateTokensSaved(originalText, compressedText, route) {
|
|
20
|
+
return Math.max(0, estimateTokensForRoute(originalText, route) - estimateTokensForRoute(compressedText, route));
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ContentRoute } from "./router.js";
|
|
2
|
+
export interface CompressCtx {
|
|
3
|
+
provider: string;
|
|
4
|
+
direction: "outbound";
|
|
5
|
+
outputFormat?: string;
|
|
6
|
+
outputSchemaDeclared: boolean;
|
|
7
|
+
lossless: true;
|
|
8
|
+
}
|
|
9
|
+
export interface CompressResult {
|
|
10
|
+
text: string;
|
|
11
|
+
originalChars: number;
|
|
12
|
+
compressedChars: number;
|
|
13
|
+
route: ContentRoute;
|
|
14
|
+
transforms: string[];
|
|
15
|
+
estimatedTokensSaved: number;
|
|
16
|
+
}
|
|
17
|
+
export interface Compressor {
|
|
18
|
+
compact(text: string, ctx: CompressCtx): CompressResult;
|
|
19
|
+
}
|
|
20
|
+
export declare class NativeCompressor implements Compressor {
|
|
21
|
+
compact(text: string, ctx: CompressCtx): CompressResult;
|
|
22
|
+
}
|
|
23
|
+
export declare function compressDisplayText(text: string, ctx: CompressCtx): CompressResult;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { classify } from "./router.js";
|
|
2
|
+
import { minifyJson } from "./transforms/json.js";
|
|
3
|
+
import { dedupAdjacentLines, escapeSentinelLikeLines, noteLine, } from "./transforms/log.js";
|
|
4
|
+
import { stripAnsi } from "./transforms/ansi.js";
|
|
5
|
+
import { normalizeWhitespace } from "./transforms/whitespace.js";
|
|
6
|
+
import { estimateTokensSaved } from "./estimate.js";
|
|
7
|
+
function identityResult(text, route = "identity") {
|
|
8
|
+
return {
|
|
9
|
+
text,
|
|
10
|
+
originalChars: text.length,
|
|
11
|
+
compressedChars: text.length,
|
|
12
|
+
route,
|
|
13
|
+
transforms: [],
|
|
14
|
+
estimatedTokensSaved: 0,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export class NativeCompressor {
|
|
18
|
+
compact(text, ctx) {
|
|
19
|
+
if (ctx.outputFormat === "json" || ctx.outputSchemaDeclared) {
|
|
20
|
+
return identityResult(text);
|
|
21
|
+
}
|
|
22
|
+
const route = classify(text);
|
|
23
|
+
if (route === "identity")
|
|
24
|
+
return identityResult(text);
|
|
25
|
+
if (route === "json") {
|
|
26
|
+
const minified = minifyJson(text.trim());
|
|
27
|
+
if (minified === null || minified.length >= text.length) {
|
|
28
|
+
return identityResult(text);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
text: minified,
|
|
32
|
+
originalChars: text.length,
|
|
33
|
+
compressedChars: minified.length,
|
|
34
|
+
route,
|
|
35
|
+
transforms: ["json-minify"],
|
|
36
|
+
estimatedTokensSaved: estimateTokensSaved(text, minified, route),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const counts = { folded: 0, escaped: 0 };
|
|
40
|
+
const transforms = [];
|
|
41
|
+
let current = text;
|
|
42
|
+
const apply = (name, fn) => {
|
|
43
|
+
const next = fn(current);
|
|
44
|
+
if (next !== current)
|
|
45
|
+
transforms.push(name);
|
|
46
|
+
current = next;
|
|
47
|
+
};
|
|
48
|
+
apply("lit-escape", input => escapeSentinelLikeLines(input, counts));
|
|
49
|
+
if (route === "ansi-text") {
|
|
50
|
+
apply("ansi-strip", input => stripAnsi(input, counts));
|
|
51
|
+
apply("dedup", input => dedupAdjacentLines(input, counts));
|
|
52
|
+
}
|
|
53
|
+
if (route === "log") {
|
|
54
|
+
apply("dedup", input => dedupAdjacentLines(input, counts));
|
|
55
|
+
}
|
|
56
|
+
apply("whitespace", normalizeWhitespace);
|
|
57
|
+
if (counts.folded + counts.escaped > 0) {
|
|
58
|
+
current = `${noteLine(counts.folded, counts.escaped)}\n${current}`;
|
|
59
|
+
transforms.push("leading-note");
|
|
60
|
+
}
|
|
61
|
+
if (current.length >= text.length) {
|
|
62
|
+
return identityResult(text, route);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
text: current,
|
|
66
|
+
originalChars: text.length,
|
|
67
|
+
compressedChars: current.length,
|
|
68
|
+
route,
|
|
69
|
+
transforms,
|
|
70
|
+
estimatedTokensSaved: estimateTokensSaved(text, current, route),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const defaultCompressor = new NativeCompressor();
|
|
75
|
+
export function compressDisplayText(text, ctx) {
|
|
76
|
+
return defaultCompressor.compact(text, ctx);
|
|
77
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { hasDangerousSequences } from "./transforms/ansi.js";
|
|
2
|
+
import { minifyJson } from "./transforms/json.js";
|
|
3
|
+
import { splitFences } from "./transforms/whitespace.js";
|
|
4
|
+
import { MIN_RUN } from "./transforms/log.js";
|
|
5
|
+
const HAS_ESCAPE = /\x1b/;
|
|
6
|
+
const HAS_INTERNAL_CR = /\r(?!\n|$)/;
|
|
7
|
+
const HAS_FENCE = /^ {0,3}(?:`{3,}|~{3,})/m;
|
|
8
|
+
function hasFence(text) {
|
|
9
|
+
return HAS_FENCE.test(text);
|
|
10
|
+
}
|
|
11
|
+
function hasRepeatedRuns(text) {
|
|
12
|
+
for (const segment of splitFences(text)) {
|
|
13
|
+
if (segment.fenced)
|
|
14
|
+
continue;
|
|
15
|
+
const lines = segment.text.split("\n");
|
|
16
|
+
let run = 1;
|
|
17
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
18
|
+
if (lines[i] === lines[i - 1] && lines[i].trim() !== "") {
|
|
19
|
+
run += 1;
|
|
20
|
+
if (run >= MIN_RUN)
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
run = 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function isStructuralJson(text) {
|
|
31
|
+
const trimmed = text.trim();
|
|
32
|
+
if (trimmed.length < 2)
|
|
33
|
+
return false;
|
|
34
|
+
const first = trimmed[0];
|
|
35
|
+
const last = trimmed[trimmed.length - 1];
|
|
36
|
+
const structural = (first === "{" && last === "}") || (first === "[" && last === "]");
|
|
37
|
+
if (!structural)
|
|
38
|
+
return false;
|
|
39
|
+
return minifyJson(trimmed) !== null;
|
|
40
|
+
}
|
|
41
|
+
export function classify(text) {
|
|
42
|
+
if (text.length === 0)
|
|
43
|
+
return "identity";
|
|
44
|
+
const escapes = HAS_ESCAPE.test(text);
|
|
45
|
+
const internalCr = HAS_INTERNAL_CR.test(text);
|
|
46
|
+
if (escapes || internalCr) {
|
|
47
|
+
if (hasDangerousSequences(text))
|
|
48
|
+
return "identity";
|
|
49
|
+
if (!hasFence(text))
|
|
50
|
+
return "ansi-text";
|
|
51
|
+
}
|
|
52
|
+
if (isStructuralJson(text))
|
|
53
|
+
return "json";
|
|
54
|
+
if (hasRepeatedRuns(text))
|
|
55
|
+
return "log";
|
|
56
|
+
return "plain";
|
|
57
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mapUnfenced } from "./whitespace.js";
|
|
2
|
+
import { crMarker } from "./log.js";
|
|
3
|
+
const CSI = /\x1b\[([0-9:;<=>?]*)[ -/]*([@-~])/g;
|
|
4
|
+
const OSC = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
|
|
5
|
+
const DCS_APC_PM_SOS = /\x1b[PX^_][\s\S]*?\x1b\\/g;
|
|
6
|
+
const ESC_TWO_BYTE = /\x1b[@-Z\\-_]/g;
|
|
7
|
+
const STRAY_C0 = /[\x00-\x07\x0b\x0c\x0e-\x1f]/g;
|
|
8
|
+
const DANGEROUS_CSI_FINALS = new Set([
|
|
9
|
+
"A",
|
|
10
|
+
"B",
|
|
11
|
+
"C",
|
|
12
|
+
"D",
|
|
13
|
+
"E",
|
|
14
|
+
"F",
|
|
15
|
+
"G",
|
|
16
|
+
"H",
|
|
17
|
+
"f",
|
|
18
|
+
"S",
|
|
19
|
+
"T",
|
|
20
|
+
"J",
|
|
21
|
+
"s",
|
|
22
|
+
"u",
|
|
23
|
+
]);
|
|
24
|
+
export function hasDangerousSequences(text) {
|
|
25
|
+
if (text.includes("\b"))
|
|
26
|
+
return true;
|
|
27
|
+
CSI.lastIndex = 0;
|
|
28
|
+
let match;
|
|
29
|
+
while ((match = CSI.exec(text)) !== null) {
|
|
30
|
+
const params = match[1];
|
|
31
|
+
const final = match[2];
|
|
32
|
+
if (DANGEROUS_CSI_FINALS.has(final))
|
|
33
|
+
return true;
|
|
34
|
+
if ((final === "h" || final === "l") && params.includes("?")) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
function stripEscapes(text) {
|
|
41
|
+
return text
|
|
42
|
+
.replace(OSC, "")
|
|
43
|
+
.replace(DCS_APC_PM_SOS, "")
|
|
44
|
+
.replace(CSI, "")
|
|
45
|
+
.replace(ESC_TWO_BYTE, "")
|
|
46
|
+
.replace(STRAY_C0, "");
|
|
47
|
+
}
|
|
48
|
+
function isSingleColumnAsciiFrame(text) {
|
|
49
|
+
for (const character of text) {
|
|
50
|
+
const codePoint = character.codePointAt(0);
|
|
51
|
+
if (codePoint !== 0x09 &&
|
|
52
|
+
codePoint !== 0x0d &&
|
|
53
|
+
(codePoint === undefined || codePoint < 0x20 || codePoint > 0x7e)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
function overlayFrames(frames) {
|
|
60
|
+
const cells = [];
|
|
61
|
+
for (const frame of frames) {
|
|
62
|
+
for (let i = 0; i < frame.length; i += 1)
|
|
63
|
+
cells[i] = frame[i];
|
|
64
|
+
}
|
|
65
|
+
return cells.join("");
|
|
66
|
+
}
|
|
67
|
+
function collapseLine(line, out, counts) {
|
|
68
|
+
const crlf = line.endsWith("\r");
|
|
69
|
+
const body = crlf ? line.slice(0, -1) : line;
|
|
70
|
+
if (!body.includes("\r")) {
|
|
71
|
+
out.push(line);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!isSingleColumnAsciiFrame(body)) {
|
|
75
|
+
out.push(line);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const frames = body.split("\r");
|
|
79
|
+
const visible = overlayFrames(frames);
|
|
80
|
+
out.push(crlf ? `${visible}\r` : visible);
|
|
81
|
+
out.push(crMarker(frames.length - 1));
|
|
82
|
+
counts.folded += 1;
|
|
83
|
+
}
|
|
84
|
+
export function stripAnsi(text, counts) {
|
|
85
|
+
if (hasDangerousSequences(text))
|
|
86
|
+
return text;
|
|
87
|
+
return mapUnfenced(text, segment => {
|
|
88
|
+
const stripped = stripEscapes(segment);
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const line of stripped.split("\n")) {
|
|
91
|
+
if (line.includes("`")) {
|
|
92
|
+
out.push(line);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
collapseLine(line, out, counts);
|
|
96
|
+
}
|
|
97
|
+
return out.join("\n");
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function minifyJson(text: string): string | null;
|