distil-llm 1.41.0 → 1.42.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/README.md +63 -0
- package/index.d.ts +84 -7
- package/index.js +26 -18
- package/lib/aisdk.js +132 -0
- package/lib/baseurl.js +23 -0
- package/lib/compress.js +98 -0
- package/lib/tier0.js +323 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -60,3 +60,66 @@ const openai = new OpenAI({ baseURL: distilOpenAIBaseURL() }); // adds /v1
|
|
|
60
60
|
publishes real, auditable numbers.
|
|
61
61
|
|
|
62
62
|
Full docs: <https://dshakes.github.io/distil/> · License: Apache-2.0
|
|
63
|
+
|
|
64
|
+
## In-process compression (lossless tier)
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import { compress } from "distil-llm";
|
|
68
|
+
|
|
69
|
+
const r = compress(messages); // never mutates the input
|
|
70
|
+
console.log(`${r.savedPct.toFixed(1)}% smaller`);
|
|
71
|
+
const response = await client.messages.create({ model, messages: r.messages });
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Tool and user text get lossless transforms; **the model's own turns and
|
|
75
|
+
non-string content (image blocks, tool_use) are never rewritten**. Unchanged
|
|
76
|
+
messages are returned by identity.
|
|
77
|
+
|
|
78
|
+
This runs entirely in Node — no daemon, no network — and is **byte-identical to
|
|
79
|
+
the Python engine**, enforced by a conformance suite (`tests/test_ts_conformance.py`)
|
|
80
|
+
that runs both implementations over a shared corpus and fails on any divergence.
|
|
81
|
+
Where JS cannot reproduce Python's bytes (Python renders an integral float as
|
|
82
|
+
`1.0`, JS as `1`; JS objects hoist integer-like keys) the transform **declines**
|
|
83
|
+
rather than emitting output the certificate does not cover.
|
|
84
|
+
|
|
85
|
+
### Getting the reversible digest tier
|
|
86
|
+
|
|
87
|
+
`compress()` is lossless-only, on purpose. The digest tier mints restore handles
|
|
88
|
+
whose originals must live in one store shared with the proxy and the MCP server,
|
|
89
|
+
and it is the tier the decision-equivalence certificate measures — so there is
|
|
90
|
+
deliberately no second implementation of it here.
|
|
91
|
+
|
|
92
|
+
Route through the proxy instead, which is the same engine and the same store:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
96
|
+
import { distilBaseURL } from "distil-llm";
|
|
97
|
+
|
|
98
|
+
const client = new Anthropic({ baseURL: distilBaseURL() });
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx distil-llm proxy # or: distil default --always-on
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Vercel AI SDK middleware
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
import { wrapLanguageModel } from "ai";
|
|
109
|
+
import { distilMiddleware } from "distil-llm";
|
|
110
|
+
|
|
111
|
+
const model = wrapLanguageModel({
|
|
112
|
+
model: gateway("anthropic/claude-sonnet-5"),
|
|
113
|
+
middleware: distilMiddleware({
|
|
114
|
+
onSavings: (s) => console.log(`${s.savedPct.toFixed(1)}% smaller`),
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Implements `transformParams` only — compression happens on the way **in**, and
|
|
120
|
+
wrapping the response would mean rewriting the model's own output, which distil
|
|
121
|
+
does not do. Tool results (both the v4 `result` and v5 `output.value` shapes),
|
|
122
|
+
user text parts, and system strings are compressed; assistant messages, files,
|
|
123
|
+
and tool calls are passed through untouched.
|
|
124
|
+
|
|
125
|
+
Lossless tier, like `compress()`. Route through the proxy for the digest tier.
|
package/index.d.ts
CHANGED
|
@@ -1,15 +1,92 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Type definitions for distil-llm.
|
|
2
|
+
|
|
3
|
+
export interface Message {
|
|
4
|
+
role?: string;
|
|
5
|
+
type?: string;
|
|
6
|
+
content?: unknown;
|
|
7
|
+
[k: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CompressionResult {
|
|
11
|
+
/** New array. Unchanged messages are returned by identity. */
|
|
12
|
+
messages: Message[];
|
|
13
|
+
tokensBefore: number;
|
|
14
|
+
tokensAfter: number;
|
|
15
|
+
tokensSaved: number;
|
|
16
|
+
/**
|
|
17
|
+
* Percent smaller. Legitimately near 0 on small payloads — savings come from
|
|
18
|
+
* LARGE tool output, and a request that is mostly system prompt has nothing
|
|
19
|
+
* to fold.
|
|
20
|
+
*/
|
|
21
|
+
savedPct: number;
|
|
22
|
+
tier: "lossless";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BaseURLOptions {
|
|
3
26
|
host?: string;
|
|
4
|
-
/** Proxy port. Default 8788 (or DISTIL_PORT). */
|
|
5
27
|
port?: number;
|
|
6
28
|
}
|
|
7
29
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Compress a message list in-process, lossless tier only.
|
|
32
|
+
*
|
|
33
|
+
* Byte-identical to the Python engine (enforced by a conformance suite). Tool
|
|
34
|
+
* and user text get lossless transforms; the model's own turns and non-string
|
|
35
|
+
* content are never rewritten; the input array is never mutated.
|
|
36
|
+
*
|
|
37
|
+
* For the reversible DIGEST tier, route your SDK through a distil proxy with
|
|
38
|
+
* `distilBaseURL()` — that path mints restore handles into the shared store and
|
|
39
|
+
* is the path the decision-equivalence certificate covers.
|
|
40
|
+
*/
|
|
41
|
+
export function compress(messages: Message[]): CompressionResult;
|
|
10
42
|
|
|
11
|
-
/**
|
|
12
|
-
export function
|
|
43
|
+
/** Base URL of a local distil proxy (honours DISTIL_BASE_URL / DISTIL_PORT). */
|
|
44
|
+
export function distilBaseURL(opts?: BaseURLOptions): string;
|
|
45
|
+
/** Same, with the `/v1` segment OpenAI-style clients expect. */
|
|
46
|
+
export function distilOpenAIBaseURL(opts?: BaseURLOptions): string;
|
|
13
47
|
|
|
14
48
|
export const DEFAULT_PORT: number;
|
|
15
49
|
export const DEFAULT_HOST: string;
|
|
50
|
+
|
|
51
|
+
/** Full Tier-0 pipeline: JSON minify, then run-collapse if it does not inflate. */
|
|
52
|
+
export function applyTier0(text: string): string;
|
|
53
|
+
/** Run-length-collapse consecutive identical lines to `L\n<<xN>>`. */
|
|
54
|
+
export function collapseRuns(text: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Textual inverse of `collapseRuns`.
|
|
57
|
+
* PRECONDITION: the original text contained no line matching /^<<x\d+>>$/ —
|
|
58
|
+
* a generated marker and content that looks like one are indistinguishable.
|
|
59
|
+
* distil's real recovery path is the restore store, not this function.
|
|
60
|
+
*/
|
|
61
|
+
export function expandRuns(text: string): string;
|
|
62
|
+
/** Minify JSON, or null when a byte-identical round-trip is not provable. */
|
|
63
|
+
export function minifyJson(text: string): string | null;
|
|
64
|
+
/** Heuristic token count, matching the Python HeuristicTokenizer. */
|
|
65
|
+
export function countTokens(text: string): number;
|
|
66
|
+
|
|
67
|
+
export interface SavingsInfo {
|
|
68
|
+
tokensBefore: number;
|
|
69
|
+
tokensAfter: number;
|
|
70
|
+
tokensSaved: number;
|
|
71
|
+
savedPct: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface DistilMiddlewareOptions {
|
|
75
|
+
/** Called once per request with the measured delta. */
|
|
76
|
+
onSavings?: (info: SavingsInfo) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Vercel AI SDK middleware that losslessly compresses the prompt in-process.
|
|
81
|
+
*
|
|
82
|
+
* Implements `transformParams` only — compression happens on the way IN, and
|
|
83
|
+
* wrapping the response would mean rewriting the model's own output, which
|
|
84
|
+
* distil does not do.
|
|
85
|
+
*
|
|
86
|
+
* const model = wrapLanguageModel({ model: ..., middleware: distilMiddleware() });
|
|
87
|
+
*
|
|
88
|
+
* For the reversible digest tier, route through a distil proxy instead.
|
|
89
|
+
*/
|
|
90
|
+
export function distilMiddleware(
|
|
91
|
+
opts?: DistilMiddlewareOptions
|
|
92
|
+
): { transformParams: (arg: { params: unknown }) => Promise<unknown> };
|
package/index.js
CHANGED
|
@@ -17,22 +17,30 @@
|
|
|
17
17
|
|
|
18
18
|
"use strict";
|
|
19
19
|
|
|
20
|
-
const
|
|
21
|
-
|
|
20
|
+
const {
|
|
21
|
+
distilBaseURL,
|
|
22
|
+
distilOpenAIBaseURL,
|
|
23
|
+
DEFAULT_PORT,
|
|
24
|
+
DEFAULT_HOST,
|
|
25
|
+
} = require("./lib/baseurl");
|
|
26
|
+
const { compress } = require("./lib/compress");
|
|
27
|
+
const { distilMiddleware } = require("./lib/aisdk");
|
|
28
|
+
const { applyTier0, collapseRuns, expandRuns, minifyJson, countTokens } = require("./lib/tier0");
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
module.exports = {
|
|
31
|
+
// in-process lossless compression (byte-identical to the Python engine)
|
|
32
|
+
compress,
|
|
33
|
+
// Vercel AI SDK middleware for wrapLanguageModel()
|
|
34
|
+
distilMiddleware,
|
|
35
|
+
// proxy routing helpers
|
|
36
|
+
distilBaseURL,
|
|
37
|
+
distilOpenAIBaseURL,
|
|
38
|
+
DEFAULT_PORT,
|
|
39
|
+
DEFAULT_HOST,
|
|
40
|
+
// lower-level transforms, exposed for verification and testing
|
|
41
|
+
applyTier0,
|
|
42
|
+
collapseRuns,
|
|
43
|
+
expandRuns,
|
|
44
|
+
minifyJson,
|
|
45
|
+
countTokens,
|
|
46
|
+
};
|
package/lib/aisdk.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Vercel AI SDK middleware.
|
|
2
|
+
//
|
|
3
|
+
// The proxy route (`createAnthropic({ baseURL: distilBaseURL() })`) is still the
|
|
4
|
+
// way to get the reversible digest tier. This is for the other case: you want
|
|
5
|
+
// lossless savings in-process, with no daemon to keep alive — a serverless
|
|
6
|
+
// function, an edge worker, a CI job that runs once.
|
|
7
|
+
//
|
|
8
|
+
// import { wrapLanguageModel } from "ai";
|
|
9
|
+
// import { distilMiddleware } from "distil-llm";
|
|
10
|
+
//
|
|
11
|
+
// const model = wrapLanguageModel({
|
|
12
|
+
// model: gateway("anthropic/claude-sonnet-5"),
|
|
13
|
+
// middleware: distilMiddleware(),
|
|
14
|
+
// });
|
|
15
|
+
//
|
|
16
|
+
// Implements `transformParams` only. `wrapGenerate`/`wrapStream` are not
|
|
17
|
+
// implemented because compression happens on the way IN — wrapping the response
|
|
18
|
+
// would let us rewrite the model's own output, which distil does not do.
|
|
19
|
+
|
|
20
|
+
"use strict";
|
|
21
|
+
|
|
22
|
+
const { applyTier0, countTokens } = require("./tier0");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A `LanguageModelV4Prompt` message carries `content` that is a plain STRING for
|
|
26
|
+
* `system`, and an ARRAY OF PARTS for `user`, `assistant`, and `tool`. Both are
|
|
27
|
+
* handled; anything unrecognised is passed through untouched, because a
|
|
28
|
+
* compressor that mangles a shape it does not understand is worse than one that
|
|
29
|
+
* saves nothing.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Compress a text-bearing part, preserving every sibling key. */
|
|
33
|
+
function compressPart(part) {
|
|
34
|
+
if (!part || typeof part !== "object") return part;
|
|
35
|
+
|
|
36
|
+
// { type: "text", text: "..." } — user and tool text.
|
|
37
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
38
|
+
const next = applyTier0(part.text);
|
|
39
|
+
return next === part.text ? part : { ...part, text: next };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Tool results carry the payload under different keys across SDK versions:
|
|
43
|
+
// v5: { type: "tool-result", output: { type: "text", value: "..." } }
|
|
44
|
+
// v4: { type: "tool-result", result: "..." }
|
|
45
|
+
// Both are handled rather than pinning one, so an SDK bump does not silently
|
|
46
|
+
// stop compressing the single largest thing in an agent's context.
|
|
47
|
+
if (part.type === "tool-result") {
|
|
48
|
+
if (part.output && typeof part.output.value === "string") {
|
|
49
|
+
const next = applyTier0(part.output.value);
|
|
50
|
+
return next === part.output.value
|
|
51
|
+
? part
|
|
52
|
+
: { ...part, output: { ...part.output, value: next } };
|
|
53
|
+
}
|
|
54
|
+
if (typeof part.result === "string") {
|
|
55
|
+
const next = applyTier0(part.result);
|
|
56
|
+
return next === part.result ? part : { ...part, result: next };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return part; // file / image / tool-call parts are never rewritten
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function compressMessage(message) {
|
|
63
|
+
if (!message || typeof message !== "object") return message;
|
|
64
|
+
|
|
65
|
+
// The model's own words are not distil's to edit.
|
|
66
|
+
if (message.role === "assistant") return message;
|
|
67
|
+
|
|
68
|
+
if (typeof message.content === "string") {
|
|
69
|
+
const next = applyTier0(message.content);
|
|
70
|
+
return next === message.content ? message : { ...message, content: next };
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(message.content)) {
|
|
73
|
+
const parts = message.content.map(compressPart);
|
|
74
|
+
const changed = parts.some((p, i) => p !== message.content[i]);
|
|
75
|
+
return changed ? { ...message, content: parts } : message;
|
|
76
|
+
}
|
|
77
|
+
return message;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function textOf(message) {
|
|
81
|
+
if (typeof message?.content === "string") return message.content;
|
|
82
|
+
if (Array.isArray(message?.content)) {
|
|
83
|
+
return message.content
|
|
84
|
+
.map((p) =>
|
|
85
|
+
typeof p?.text === "string"
|
|
86
|
+
? p.text
|
|
87
|
+
: typeof p?.output?.value === "string"
|
|
88
|
+
? p.output.value
|
|
89
|
+
: typeof p?.result === "string"
|
|
90
|
+
? p.result
|
|
91
|
+
: ""
|
|
92
|
+
)
|
|
93
|
+
.join("");
|
|
94
|
+
}
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build AI SDK middleware that losslessly compresses the prompt before it is sent.
|
|
100
|
+
*
|
|
101
|
+
* @param {{onSavings?: (info: {tokensBefore: number, tokensAfter: number,
|
|
102
|
+
* tokensSaved: number, savedPct: number}) => void}} [opts]
|
|
103
|
+
* `onSavings` is called once per request with the measured delta. Provided
|
|
104
|
+
* because in-process compression has no proxy to report through, and savings
|
|
105
|
+
* you cannot see are savings you will not trust.
|
|
106
|
+
* @returns {{transformParams: (arg: {params: object}) => Promise<object>}}
|
|
107
|
+
*/
|
|
108
|
+
function distilMiddleware(opts) {
|
|
109
|
+
const onSavings = opts && typeof opts.onSavings === "function" ? opts.onSavings : null;
|
|
110
|
+
return {
|
|
111
|
+
transformParams: async ({ params }) => {
|
|
112
|
+
if (!params || !Array.isArray(params.prompt)) return params;
|
|
113
|
+
|
|
114
|
+
const before = onSavings ? params.prompt.reduce((n, m) => n + countTokens(textOf(m)), 0) : 0;
|
|
115
|
+
const prompt = params.prompt.map(compressMessage);
|
|
116
|
+
const changed = prompt.some((m, i) => m !== params.prompt[i]);
|
|
117
|
+
|
|
118
|
+
if (onSavings) {
|
|
119
|
+
const after = prompt.reduce((n, m) => n + countTokens(textOf(m)), 0);
|
|
120
|
+
onSavings({
|
|
121
|
+
tokensBefore: before,
|
|
122
|
+
tokensAfter: after,
|
|
123
|
+
tokensSaved: before - after,
|
|
124
|
+
savedPct: before > 0 ? (100 * (before - after)) / before : 0,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return changed ? { ...params, prompt } : params;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { distilMiddleware };
|
package/lib/baseurl.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Base-URL helpers, factored out of index.js so lib/ modules can use them
|
|
2
|
+
// without importing the package entry point (which imports them back).
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PORT = 8788;
|
|
6
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
7
|
+
|
|
8
|
+
/** Base URL of a local distil proxy. Reads DISTIL_BASE_URL if set, else builds
|
|
9
|
+
* http://<host>:<port> (defaults 127.0.0.1:8788). */
|
|
10
|
+
function distilBaseURL(opts) {
|
|
11
|
+
opts = opts || {};
|
|
12
|
+
if (process.env.DISTIL_BASE_URL) return process.env.DISTIL_BASE_URL;
|
|
13
|
+
const host = opts.host || DEFAULT_HOST;
|
|
14
|
+
const port = opts.port || Number(process.env.DISTIL_PORT) || DEFAULT_PORT;
|
|
15
|
+
return `http://${host}:${port}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** OpenAI-style clients expect the base URL to include the API version segment. */
|
|
19
|
+
function distilOpenAIBaseURL(opts) {
|
|
20
|
+
return distilBaseURL(opts).replace(/\/+$/, "") + "/v1";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { distilBaseURL, distilOpenAIBaseURL, DEFAULT_PORT, DEFAULT_HOST };
|
package/lib/compress.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Public TypeScript/JavaScript compression API.
|
|
2
|
+
//
|
|
3
|
+
// Two tiers, two very different trust stories, and the difference is the whole
|
|
4
|
+
// design of this file:
|
|
5
|
+
//
|
|
6
|
+
// Tier-0 (lossless) — implemented natively in lib/tier0.js and proven
|
|
7
|
+
// BYTE-IDENTICAL to the Python engine by
|
|
8
|
+
// tests/test_ts_conformance.py. Safe to run in-process:
|
|
9
|
+
// no daemon, no network, no handles to keep alive.
|
|
10
|
+
//
|
|
11
|
+
// Tier-1 (digest) — NOT implemented here. It mints restore handles whose
|
|
12
|
+
// originals must live in ONE store shared with the proxy
|
|
13
|
+
// and the MCP server, or `expand` silently fails to
|
|
14
|
+
// resolve a handle the model can see. It is also the tier
|
|
15
|
+
// the decision-equivalence certificate actually measures.
|
|
16
|
+
// A second implementation would be a second thing to
|
|
17
|
+
// certify, and nobody would notice when they drifted.
|
|
18
|
+
//
|
|
19
|
+
// So `compress()` is lossless-only and always available in-process. The digest
|
|
20
|
+
// tier comes from routing your client through the proxy (see the note at the
|
|
21
|
+
// bottom of this file), which is the same engine, store, and certificate.
|
|
22
|
+
|
|
23
|
+
"use strict";
|
|
24
|
+
|
|
25
|
+
const { applyTier0, countTokens } = require("./tier0");
|
|
26
|
+
|
|
27
|
+
/** Roles whose content distil never rewrites. */
|
|
28
|
+
const NEVER_REWRITE = new Set(["assistant", "ai"]);
|
|
29
|
+
|
|
30
|
+
function roleOf(m) {
|
|
31
|
+
if (!m || typeof m !== "object") return "";
|
|
32
|
+
return String(m.role || m.type || "").toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compress a message list in-process, lossless tier only.
|
|
37
|
+
*
|
|
38
|
+
* Mirrors the Python `distil.compress_messages` contract: the input is never
|
|
39
|
+
* mutated, unchanged messages pass through by identity, non-string content
|
|
40
|
+
* (image blocks, tool_use structures) is untouched, and the model's own turns
|
|
41
|
+
* are never rewritten.
|
|
42
|
+
*
|
|
43
|
+
* @param {Array<object>} messages OpenAI/Anthropic-style message objects
|
|
44
|
+
* @returns {{messages: Array<object>, tokensBefore: number, tokensAfter: number,
|
|
45
|
+
* tokensSaved: number, savedPct: number, tier: "lossless"}}
|
|
46
|
+
*/
|
|
47
|
+
function compress(messages) {
|
|
48
|
+
if (!Array.isArray(messages)) {
|
|
49
|
+
throw new TypeError("compress(messages): expected an array of messages");
|
|
50
|
+
}
|
|
51
|
+
let before = 0;
|
|
52
|
+
let after = 0;
|
|
53
|
+
const out = messages.map((m) => {
|
|
54
|
+
const content = m && typeof m === "object" ? m.content : undefined;
|
|
55
|
+
if (typeof content !== "string") return m;
|
|
56
|
+
before += countTokens(content);
|
|
57
|
+
if (NEVER_REWRITE.has(roleOf(m))) {
|
|
58
|
+
after += countTokens(content);
|
|
59
|
+
return m;
|
|
60
|
+
}
|
|
61
|
+
const next = applyTier0(content);
|
|
62
|
+
after += countTokens(next);
|
|
63
|
+
return next === content ? m : Object.assign({}, m, { content: next });
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
messages: out,
|
|
67
|
+
tokensBefore: before,
|
|
68
|
+
tokensAfter: after,
|
|
69
|
+
tokensSaved: before - after,
|
|
70
|
+
// Can legitimately be ~0: savings come from LARGE tool output, and a request
|
|
71
|
+
// that is mostly system prompt has nothing to fold.
|
|
72
|
+
savedPct: before > 0 ? (100 * (before - after)) / before : 0,
|
|
73
|
+
tier: "lossless",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How to get the reversible DIGEST tier from JS.
|
|
79
|
+
*
|
|
80
|
+
* There is deliberately no `compressViaProxy()` here. The distil proxy has no
|
|
81
|
+
* "compress this payload and hand it back" endpoint, and inventing one would add
|
|
82
|
+
* a new public API surface — accepting arbitrary content, needing its own
|
|
83
|
+
* authentication story — to get something the existing design already provides.
|
|
84
|
+
*
|
|
85
|
+
* The proxy's contract is transparent interception: point your SDK's baseURL at
|
|
86
|
+
* it and every request is compressed on the way through, by the same engine the
|
|
87
|
+
* decision-equivalence certificate was measured against, minting handles into the
|
|
88
|
+
* one restore store that `distil expand` and the MCP server read.
|
|
89
|
+
*
|
|
90
|
+
* import Anthropic from "@anthropic-ai/sdk";
|
|
91
|
+
* import { distilBaseURL } from "distil-llm";
|
|
92
|
+
* const client = new Anthropic({ baseURL: distilBaseURL() });
|
|
93
|
+
*
|
|
94
|
+
* Use `compress()` above when you hold the message list and want lossless
|
|
95
|
+
* savings with no daemon; route through the proxy when you want the digest.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
module.exports = { compress };
|
package/lib/tier0.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// Tier-0 lossless transforms — a faithful port of distil/compress/tier0.py.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS A PORT AND NOT A REIMPLEMENTATION
|
|
4
|
+
// ---------------------------------------------
|
|
5
|
+
// distil's whole claim is a decision-equivalence certificate, and that
|
|
6
|
+
// certificate is measured against the PYTHON compressor. A JS implementation
|
|
7
|
+
// that merely "compresses similarly" would sit outside it — users would get a
|
|
8
|
+
// guarantee that does not describe the code they are running.
|
|
9
|
+
//
|
|
10
|
+
// So this file targets BYTE-IDENTICAL output with tier0.py, and
|
|
11
|
+
// tests/test_ts_conformance.py runs both against a shared fixture corpus and
|
|
12
|
+
// fails on any divergence. Where byte-identity is not achievable (see
|
|
13
|
+
// `minifyJson`), the transform DECLINES rather than producing output the
|
|
14
|
+
// certificate does not cover. Declining costs a little compression; diverging
|
|
15
|
+
// silently would cost the guarantee.
|
|
16
|
+
//
|
|
17
|
+
// Tier-1 (the reversible digest) is deliberately NOT ported: it allocates
|
|
18
|
+
// restore handles and must share one store with the proxy and the MCP server.
|
|
19
|
+
// Use `compressViaProxy` in compress.js for that.
|
|
20
|
+
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
// Matches tier0.py's _RUN_MARKER: a generated marker, so a run of markers is
|
|
24
|
+
// left alone (collapsing it would make the new marker indistinguishable from
|
|
25
|
+
// original content when reversing).
|
|
26
|
+
const RUN_MARKER = /^<<x\d+>>$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run-length-encode consecutive identical lines, reversibly.
|
|
30
|
+
* Two or more identical lines `L` collapse to `L\n<<xN>>\n`, where N is the
|
|
31
|
+
* exact original count, so the input is fully recoverable.
|
|
32
|
+
*
|
|
33
|
+
* Pure string manipulation, so this is exactly portable — no locale, no number
|
|
34
|
+
* formatting, no object ordering. It is the bulk of Tier-0's real-world win
|
|
35
|
+
* (repeated log lines, duplicated stack frames).
|
|
36
|
+
*
|
|
37
|
+
* @param {string} text
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function collapseRuns(text) {
|
|
41
|
+
if (typeof text !== "string" || text.indexOf("\n") === -1) return text;
|
|
42
|
+
|
|
43
|
+
const lines = text.split("\n");
|
|
44
|
+
const out = [];
|
|
45
|
+
let i = 0;
|
|
46
|
+
// The Python side uses the regex /^(.*)\n(?:\1\n)+/m, which only ever matches
|
|
47
|
+
// runs that are newline-TERMINATED. A trailing final line with no newline
|
|
48
|
+
// after it therefore cannot participate in a run; splitting keeps that
|
|
49
|
+
// property because the last element has no terminator to consume.
|
|
50
|
+
const lastIndex = lines.length - 1;
|
|
51
|
+
while (i < lines.length) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
let j = i;
|
|
54
|
+
// Count how many consecutive newline-terminated copies follow.
|
|
55
|
+
while (j < lastIndex && lines[j] === line) j++;
|
|
56
|
+
const runLen = j - i;
|
|
57
|
+
if (runLen >= 2 && !RUN_MARKER.test(line)) {
|
|
58
|
+
out.push(line, `<<x${runLen}>>`);
|
|
59
|
+
i = j;
|
|
60
|
+
} else {
|
|
61
|
+
out.push(line);
|
|
62
|
+
i += 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Textual inverse of `collapseRuns`.
|
|
70
|
+
*
|
|
71
|
+
* PRECONDITION: the ORIGINAL text contained no line matching /^<<x\d+>>$/.
|
|
72
|
+
* A generated marker and original content that looks like one are
|
|
73
|
+
* indistinguishable after the fact, so no textual inverse can separate them.
|
|
74
|
+
* `collapseRuns` refuses to collapse such runs precisely so nothing is lost —
|
|
75
|
+
* but expanding text that already contained markers will invent lines.
|
|
76
|
+
*
|
|
77
|
+
* This is why distil's real reversibility guarantee comes from the restore
|
|
78
|
+
* store (the exact original bytes, keyed by handle) rather than from inverting
|
|
79
|
+
* the transform. Use this for local verification of ordinary text, not as the
|
|
80
|
+
* recovery path.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} text
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function expandRuns(text) {
|
|
86
|
+
if (typeof text !== "string") return text;
|
|
87
|
+
const lines = text.split("\n");
|
|
88
|
+
const out = [];
|
|
89
|
+
for (let i = 0; i < lines.length; i++) {
|
|
90
|
+
const m = /^<<x(\d+)>>$/.exec(lines[i]);
|
|
91
|
+
if (m && out.length > 0) {
|
|
92
|
+
const prev = out[out.length - 1];
|
|
93
|
+
const n = parseInt(m[1], 10);
|
|
94
|
+
for (let k = 1; k < n; k++) out.push(prev);
|
|
95
|
+
} else {
|
|
96
|
+
out.push(lines[i]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out.join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- JSON ------------------------------------------------------------------
|
|
103
|
+
//
|
|
104
|
+
// Python's json.dumps and JS's JSON.stringify disagree on two things that
|
|
105
|
+
// matter for byte-identity:
|
|
106
|
+
//
|
|
107
|
+
// 1. NUMBER FORMATTING. Python renders a float that happens to be integral as
|
|
108
|
+
// "1.0"; JS renders it "1". Nothing in JS can recover the distinction,
|
|
109
|
+
// because JSON.parse has already collapsed 1.0 and 1 to the same double.
|
|
110
|
+
// 2. KEY ORDER. Python dicts preserve insertion order for every key. JS
|
|
111
|
+
// objects hoist integer-like keys ("2" before "a", regardless of input).
|
|
112
|
+
//
|
|
113
|
+
// Both are detected below and cause the transform to DECLINE (return null), so
|
|
114
|
+
// the caller keeps the text byte-exact. That is the same posture tier0.py takes
|
|
115
|
+
// for duplicate keys and non-finite numbers: when the round trip is not
|
|
116
|
+
// provably value-preserving, do nothing.
|
|
117
|
+
|
|
118
|
+
/** True when a key would be reordered by a JS object (array-index-like). */
|
|
119
|
+
function isIntegerLikeKey(k) {
|
|
120
|
+
return /^(0|[1-9]\d*)$/.test(k) && Number(k) <= 4294967294;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Scan the raw JSON source for constructs we cannot round-trip byte-identically
|
|
125
|
+
* with Python. Returns a reason string, or null when it is safe.
|
|
126
|
+
*/
|
|
127
|
+
function unsafeJsonReason(src) {
|
|
128
|
+
// Strip string contents FIRST. Scanning raw source treats `"line1"` as
|
|
129
|
+
// exponent notation ("e1") and `"v1.0"` as an integral float, which made this
|
|
130
|
+
// decline JSON that Python happily minifies — a conformance divergence in the
|
|
131
|
+
// safe direction, but a divergence.
|
|
132
|
+
const bare = stripJsonStrings(src);
|
|
133
|
+
|
|
134
|
+
// Python renders an integral float as "1.0"; JS renders it "1". After parsing
|
|
135
|
+
// the distinction is gone, so it has to be decided on the literal.
|
|
136
|
+
const nums = bare.match(/-?\d+\.\d+/g) || [];
|
|
137
|
+
for (const n of nums) {
|
|
138
|
+
if (Number.isInteger(Number(n))) {
|
|
139
|
+
return "integral float renders differently in Python and JS";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (/\d[eE][+-]?\d+/.test(bare)) return "exponent notation renders differently";
|
|
143
|
+
if (/\b(Infinity|-Infinity|NaN)\b/.test(bare)) return "non-finite constant";
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Replace every JSON string literal with "" so scans see structure only. */
|
|
148
|
+
function stripJsonStrings(src) {
|
|
149
|
+
let out = "";
|
|
150
|
+
let inStr = false;
|
|
151
|
+
let esc = false;
|
|
152
|
+
for (let i = 0; i < src.length; i++) {
|
|
153
|
+
const c = src[i];
|
|
154
|
+
if (inStr) {
|
|
155
|
+
if (esc) esc = false;
|
|
156
|
+
else if (c === "\\") esc = true;
|
|
157
|
+
else if (c === '"') {
|
|
158
|
+
inStr = false;
|
|
159
|
+
out += '""';
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (c === '"') inStr = true;
|
|
164
|
+
else out += c;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Depth-first check for duplicate or reorder-prone keys, on the raw source. */
|
|
170
|
+
function objectKeysAreSafe(obj, seenPath) {
|
|
171
|
+
if (Array.isArray(obj)) return obj.every((v) => objectKeysAreSafe(v, seenPath));
|
|
172
|
+
if (obj && typeof obj === "object") {
|
|
173
|
+
for (const k of Object.keys(obj)) {
|
|
174
|
+
if (isIntegerLikeKey(k)) return false; // JS would hoist it
|
|
175
|
+
if (!objectKeysAreSafe(obj[k], seenPath)) return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Re-encode JSON with no incidental whitespace, or null when that would not be
|
|
183
|
+
* provably value-preserving *and* byte-identical to the Python implementation.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} text
|
|
186
|
+
* @returns {string|null}
|
|
187
|
+
*/
|
|
188
|
+
function minifyJson(text) {
|
|
189
|
+
if (typeof text !== "string") return null;
|
|
190
|
+
const s = text.trim();
|
|
191
|
+
const first = s.charAt(0);
|
|
192
|
+
const last = s.charAt(s.length - 1);
|
|
193
|
+
// Same gate as tier0.py: only whole objects/arrays.
|
|
194
|
+
if (!((first === "{" || first === "[") && (last === "}" || last === "]"))) return null;
|
|
195
|
+
|
|
196
|
+
if (unsafeJsonReason(s) !== null) return null;
|
|
197
|
+
|
|
198
|
+
let obj;
|
|
199
|
+
try {
|
|
200
|
+
obj = JSON.parse(s);
|
|
201
|
+
} catch (_e) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
if (!objectKeysAreSafe(obj)) return null;
|
|
205
|
+
|
|
206
|
+
// Duplicate keys: JSON.parse silently keeps the last. tier0.py rejects such
|
|
207
|
+
// input outright, so detect it by counting keys in the source.
|
|
208
|
+
if (hasDuplicateKeys(s)) return null;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
return JSON.stringify(obj);
|
|
212
|
+
} catch (_e) {
|
|
213
|
+
return null; // circular refs cannot occur from JSON.parse, but never throw
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Cheap duplicate-key detector over the raw source (string-aware). */
|
|
218
|
+
function hasDuplicateKeys(src) {
|
|
219
|
+
const stack = [];
|
|
220
|
+
let inStr = false;
|
|
221
|
+
let esc = false;
|
|
222
|
+
let cur = null;
|
|
223
|
+
let pendingKey = null;
|
|
224
|
+
for (let i = 0; i < src.length; i++) {
|
|
225
|
+
const c = src[i];
|
|
226
|
+
if (inStr) {
|
|
227
|
+
if (esc) {
|
|
228
|
+
esc = false;
|
|
229
|
+
} else if (c === "\\") {
|
|
230
|
+
esc = true;
|
|
231
|
+
} else if (c === '"') {
|
|
232
|
+
inStr = false;
|
|
233
|
+
pendingKey = src.slice(cur + 1, i);
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (c === '"') {
|
|
238
|
+
inStr = true;
|
|
239
|
+
cur = i;
|
|
240
|
+
} else if (c === "{") {
|
|
241
|
+
stack.push(new Set());
|
|
242
|
+
pendingKey = null;
|
|
243
|
+
} else if (c === "}") {
|
|
244
|
+
stack.pop();
|
|
245
|
+
pendingKey = null;
|
|
246
|
+
} else if (c === ":" && stack.length && pendingKey !== null) {
|
|
247
|
+
const top = stack[stack.length - 1];
|
|
248
|
+
if (top.has(pendingKey)) return true;
|
|
249
|
+
top.add(pendingKey);
|
|
250
|
+
pendingKey = null;
|
|
251
|
+
} else if (c === ",") {
|
|
252
|
+
pendingKey = null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- heuristic tokenizer ----------------------------------------------------
|
|
259
|
+
// Port of distil/tokenizer.py:HeuristicTokenizer. Needed because the production
|
|
260
|
+
// Tier-0 path is reject-if-bigger *by tokens*, not by characters: collapsing a
|
|
261
|
+
// run of near-free blank lines into a "<<xN>>" marker can cost MORE tokens than
|
|
262
|
+
// it removes, and Tier-0 must never inflate.
|
|
263
|
+
|
|
264
|
+
// Python: re.compile(r"\w+|[^\w\s]", re.UNICODE). The `u` flag plus explicit
|
|
265
|
+
// Unicode property escapes reproduce Python's \w/\s semantics closely enough
|
|
266
|
+
// for counting; the conformance suite is what proves it on real inputs.
|
|
267
|
+
const PIECE = /[\p{L}\p{N}_]+|[^\p{L}\p{N}_\s]/gu;
|
|
268
|
+
const SUBWORD_FACTOR = 1.33;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Approximate token count, matching HeuristicTokenizer.count byte-for-byte.
|
|
272
|
+
* @param {string} text
|
|
273
|
+
* @returns {number}
|
|
274
|
+
*/
|
|
275
|
+
function countTokens(text) {
|
|
276
|
+
if (!text) return 0;
|
|
277
|
+
const pieces = text.match(PIECE);
|
|
278
|
+
const n = pieces ? pieces.length : 0;
|
|
279
|
+
return Math.max(1, bankersRound(n * SUBWORD_FACTOR));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Python's round() is round-half-to-EVEN; JS Math.round is round-half-UP.
|
|
284
|
+
* They differ on exact .5, which n * 1.33 can land on, so the naive version
|
|
285
|
+
* would silently diverge from Python on specific input lengths.
|
|
286
|
+
*/
|
|
287
|
+
function bankersRound(x) {
|
|
288
|
+
const floor = Math.floor(x);
|
|
289
|
+
const diff = x - floor;
|
|
290
|
+
if (diff > 0.5) return floor + 1;
|
|
291
|
+
if (diff < 0.5) return floor;
|
|
292
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Apply every Tier-0 transform in the same order as the production path
|
|
297
|
+
* (distil/adapters/anthropic.py:_apply_tier0): JSON minification first, then
|
|
298
|
+
* run-collapse on the RESULT, keeping the collapse only when it does not
|
|
299
|
+
* increase the token count.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} text
|
|
302
|
+
* @returns {string}
|
|
303
|
+
*/
|
|
304
|
+
function applyTier0(text) {
|
|
305
|
+
if (typeof text !== "string" || text.length === 0) return text;
|
|
306
|
+
const mj = minifyJson(text);
|
|
307
|
+
const base = mj !== null ? mj : text;
|
|
308
|
+
const collapsed = collapseRuns(base);
|
|
309
|
+
if (collapsed !== base && countTokens(collapsed) <= countTokens(base)) {
|
|
310
|
+
return collapsed;
|
|
311
|
+
}
|
|
312
|
+
return base;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
module.exports = {
|
|
316
|
+
applyTier0,
|
|
317
|
+
countTokens,
|
|
318
|
+
collapseRuns,
|
|
319
|
+
expandRuns,
|
|
320
|
+
minifyJson,
|
|
321
|
+
// exported for the conformance harness
|
|
322
|
+
_internals: { hasDuplicateKeys, unsafeJsonReason, isIntegerLikeKey },
|
|
323
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "distil-llm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.0",
|
|
4
4
|
"description": "Compress your AI agent's context and prove its decisions don't change \u2014 cache-aware, reversible, certified. JS/TS bridge to the distil CLI + proxy helpers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
|
@@ -38,6 +38,6 @@
|
|
|
38
38
|
"node": ">=18"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
|
-
"test": "node --test test
|
|
41
|
+
"test": "node --test test/*.test.mjs"
|
|
42
42
|
}
|
|
43
43
|
}
|