@trazum/core 1.8.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/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/advisories.d.ts +23 -0
- package/dist/advisories.d.ts.map +1 -0
- package/dist/advisories.js +376 -0
- package/dist/advisories.js.map +1 -0
- package/dist/aws-sigv4.d.ts +88 -0
- package/dist/aws-sigv4.d.ts.map +1 -0
- package/dist/aws-sigv4.js +117 -0
- package/dist/aws-sigv4.js.map +1 -0
- package/dist/baseline.d.ts +171 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +273 -0
- package/dist/baseline.js.map +1 -0
- package/dist/cache.d.ts +26 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +28 -0
- package/dist/cache.js.map +1 -0
- package/dist/changes.d.ts +29 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/changes.js +142 -0
- package/dist/changes.js.map +1 -0
- package/dist/compare.d.ts +65 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/compare.js +58 -0
- package/dist/compare.js.map +1 -0
- package/dist/config-schema.d.ts +118 -0
- package/dist/config-schema.d.ts.map +1 -0
- package/dist/config-schema.js +315 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +112 -0
- package/dist/config.js.map +1 -0
- package/dist/detect.d.ts +70 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +228 -0
- package/dist/detect.js.map +1 -0
- package/dist/evaluate.d.ts +98 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +110 -0
- package/dist/evaluate.js.map +1 -0
- package/dist/extract.d.ts +81 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +280 -0
- package/dist/extract.js.map +1 -0
- package/dist/gcp-auth.d.ts +58 -0
- package/dist/gcp-auth.d.ts.map +1 -0
- package/dist/gcp-auth.js +113 -0
- package/dist/gcp-auth.js.map +1 -0
- package/dist/glob.d.ts +49 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +154 -0
- package/dist/glob.js.map +1 -0
- package/dist/host.d.ts +30 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +69 -0
- package/dist/host.js.map +1 -0
- package/dist/i18n/en.d.ts +4 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +168 -0
- package/dist/i18n/en.js.map +1 -0
- package/dist/i18n/es.d.ts +4 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +168 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/index.d.ts +36 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +50 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/types.d.ts +180 -0
- package/dist/i18n/types.d.ts.map +1 -0
- package/dist/i18n/types.js +11 -0
- package/dist/i18n/types.js.map +1 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/llm.d.ts +226 -0
- package/dist/llm.d.ts.map +1 -0
- package/dist/llm.js +485 -0
- package/dist/llm.js.map +1 -0
- package/dist/nearest.d.ts +20 -0
- package/dist/nearest.d.ts.map +1 -0
- package/dist/nearest.js +54 -0
- package/dist/nearest.js.map +1 -0
- package/dist/net.d.ts +90 -0
- package/dist/net.d.ts.map +1 -0
- package/dist/net.js +203 -0
- package/dist/net.js.map +1 -0
- package/dist/node.d.ts +32 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +33 -0
- package/dist/node.js.map +1 -0
- package/dist/openrouter.d.ts +25 -0
- package/dist/openrouter.d.ts.map +1 -0
- package/dist/openrouter.js +72 -0
- package/dist/openrouter.js.map +1 -0
- package/dist/optimize.d.ts +38 -0
- package/dist/optimize.d.ts.map +1 -0
- package/dist/optimize.js +183 -0
- package/dist/optimize.js.map +1 -0
- package/dist/otlp.d.ts +91 -0
- package/dist/otlp.d.ts.map +1 -0
- package/dist/otlp.js +102 -0
- package/dist/otlp.js.map +1 -0
- package/dist/phrases.d.ts +169 -0
- package/dist/phrases.d.ts.map +1 -0
- package/dist/phrases.js +939 -0
- package/dist/phrases.js.map +1 -0
- package/dist/pricing-overlay.d.ts +55 -0
- package/dist/pricing-overlay.d.ts.map +1 -0
- package/dist/pricing-overlay.js +241 -0
- package/dist/pricing-overlay.js.map +1 -0
- package/dist/pricing.d.ts +115 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +400 -0
- package/dist/pricing.js.map +1 -0
- package/dist/profile.d.ts +71 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +55 -0
- package/dist/profile.js.map +1 -0
- package/dist/promptfoo.d.ts +58 -0
- package/dist/promptfoo.d.ts.map +1 -0
- package/dist/promptfoo.js +149 -0
- package/dist/promptfoo.js.map +1 -0
- package/dist/prune.d.ts +91 -0
- package/dist/prune.d.ts.map +1 -0
- package/dist/prune.js +110 -0
- package/dist/prune.js.map +1 -0
- package/dist/reorder.d.ts +82 -0
- package/dist/reorder.d.ts.map +1 -0
- package/dist/reorder.js +215 -0
- package/dist/reorder.js.map +1 -0
- package/dist/review.d.ts +54 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +131 -0
- package/dist/review.js.map +1 -0
- package/dist/rules.d.ts +5 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +279 -0
- package/dist/rules.js.map +1 -0
- package/dist/savings.d.ts +36 -0
- package/dist/savings.d.ts.map +1 -0
- package/dist/savings.js +83 -0
- package/dist/savings.js.map +1 -0
- package/dist/segment.d.ts +8 -0
- package/dist/segment.d.ts.map +1 -0
- package/dist/segment.js +74 -0
- package/dist/segment.js.map +1 -0
- package/dist/shared-prefix.d.ts +63 -0
- package/dist/shared-prefix.d.ts.map +1 -0
- package/dist/shared-prefix.js +151 -0
- package/dist/shared-prefix.js.map +1 -0
- package/dist/similarity.d.ts +13 -0
- package/dist/similarity.d.ts.map +1 -0
- package/dist/similarity.js +30 -0
- package/dist/similarity.js.map +1 -0
- package/dist/structure.d.ts +144 -0
- package/dist/structure.d.ts.map +1 -0
- package/dist/structure.js +455 -0
- package/dist/structure.js.map +1 -0
- package/dist/suggest.d.ts +100 -0
- package/dist/suggest.d.ts.map +1 -0
- package/dist/suggest.js +151 -0
- package/dist/suggest.js.map +1 -0
- package/dist/tokenizer.d.ts +57 -0
- package/dist/tokenizer.d.ts.map +1 -0
- package/dist/tokenizer.js +157 -0
- package/dist/tokenizer.js.map +1 -0
- package/dist/types.d.ts +296 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/walk.d.ts +40 -0
- package/dist/walk.d.ts.map +1 -0
- package/dist/walk.js +95 -0
- package/dist/walk.js.map +1 -0
- package/package.json +56 -0
- package/src/advisories.ts +431 -0
- package/src/aws-sigv4.ts +174 -0
- package/src/baseline.ts +390 -0
- package/src/cache.ts +54 -0
- package/src/changes.ts +158 -0
- package/src/compare.ts +131 -0
- package/src/config-schema.ts +451 -0
- package/src/config.ts +161 -0
- package/src/detect.ts +312 -0
- package/src/evaluate.ts +188 -0
- package/src/extract.ts +336 -0
- package/src/gcp-auth.ts +166 -0
- package/src/glob.ts +160 -0
- package/src/host.ts +90 -0
- package/src/i18n/en.ts +236 -0
- package/src/i18n/es.ts +236 -0
- package/src/i18n/index.ts +68 -0
- package/src/i18n/types.ts +230 -0
- package/src/index.ts +228 -0
- package/src/llm.ts +708 -0
- package/src/nearest.ts +61 -0
- package/src/net.ts +233 -0
- package/src/node.ts +63 -0
- package/src/openrouter.ts +125 -0
- package/src/optimize.ts +228 -0
- package/src/otlp.ts +179 -0
- package/src/phrases.ts +1047 -0
- package/src/pricing-overlay.ts +319 -0
- package/src/pricing.ts +468 -0
- package/src/profile.ts +124 -0
- package/src/promptfoo.ts +213 -0
- package/src/prune.ts +211 -0
- package/src/reorder.ts +307 -0
- package/src/review.ts +180 -0
- package/src/rules.ts +324 -0
- package/src/savings.ts +121 -0
- package/src/segment.ts +106 -0
- package/src/shared-prefix.ts +198 -0
- package/src/similarity.ts +28 -0
- package/src/structure.ts +652 -0
- package/src/suggest.ts +254 -0
- package/src/tokenizer.ts +190 -0
- package/src/types.ts +323 -0
- package/src/walk.ts +117 -0
package/src/nearest.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggesting what somebody probably meant.
|
|
3
|
+
*
|
|
4
|
+
* Used for an unrecognised CLI flag and an unrecognised config key. Both are
|
|
5
|
+
* the same failure: a name that looks right, is silently not the name the tool
|
|
6
|
+
* reads, and leaves a limit unenforced while the author believes it is set.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Edit distance, with an early exit.
|
|
11
|
+
*
|
|
12
|
+
* Bounded by construction: the table is two rows of `b.length + 1`, and pairs
|
|
13
|
+
* whose lengths differ by more than `MAX_DISTANCE` return immediately without
|
|
14
|
+
* building anything. Only ever used to rank candidates, so a large distance
|
|
15
|
+
* needs no precision — `SENTINEL` is simply "further than we care about".
|
|
16
|
+
*/
|
|
17
|
+
const MAX_DISTANCE = 3;
|
|
18
|
+
const SENTINEL = 99;
|
|
19
|
+
|
|
20
|
+
export function editDistance(a: string, b: string): number {
|
|
21
|
+
if (Math.abs(a.length - b.length) > MAX_DISTANCE) return SENTINEL;
|
|
22
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
23
|
+
for (let i = 1; i <= a.length; i++) {
|
|
24
|
+
const row = [i];
|
|
25
|
+
for (let j = 1; j <= b.length; j++) {
|
|
26
|
+
row[j] = Math.min(
|
|
27
|
+
prev[j]! + 1,
|
|
28
|
+
row[j - 1]! + 1,
|
|
29
|
+
prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
prev = row;
|
|
33
|
+
}
|
|
34
|
+
return prev[b.length]!;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The closest candidate, or null when nothing is close enough to name.
|
|
39
|
+
*
|
|
40
|
+
* **The tolerance scales with the length of what was typed**, and that is the
|
|
41
|
+
* whole reason this function exists rather than a fixed threshold. Three edits
|
|
42
|
+
* is a plausible typo on `max-tokens` and a completely different word on
|
|
43
|
+
* `llm` — which is how a fixed budget of three came to answer "did you mean
|
|
44
|
+
* --help?" for `--llm`. A wrong guess is worse than no guess: it sends the
|
|
45
|
+
* reader off to check something that was never the answer.
|
|
46
|
+
*/
|
|
47
|
+
export function nearestName(typed: string, candidates: readonly string[]): string | null {
|
|
48
|
+
let best: string | null = null;
|
|
49
|
+
let bestDistance = Infinity;
|
|
50
|
+
|
|
51
|
+
for (const candidate of candidates) {
|
|
52
|
+
const distance = editDistance(typed, candidate);
|
|
53
|
+
if (distance < bestDistance) {
|
|
54
|
+
best = candidate;
|
|
55
|
+
bestDistance = distance;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tolerance = Math.min(MAX_DISTANCE, Math.max(1, Math.floor(typed.length / 3)));
|
|
60
|
+
return best !== null && bestDistance <= tolerance ? best : null;
|
|
61
|
+
}
|
package/src/net.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint validation for the pluggable LLM layer.
|
|
3
|
+
*
|
|
4
|
+
* Trazum lets the caller choose the URL its optional LLM pass talks to. On a
|
|
5
|
+
* deployed server that is a server-side request forgery primitive: without
|
|
6
|
+
* this check, anyone who can reach the web app can make it fetch the cloud
|
|
7
|
+
* metadata service, an internal admin panel, or any host behind the firewall,
|
|
8
|
+
* and read the response through the error message.
|
|
9
|
+
*
|
|
10
|
+
* This lives in the core rather than in the web route on purpose. It is the
|
|
11
|
+
* most security-sensitive code in the project, so it belongs where it can be
|
|
12
|
+
* unit-tested and where every caller — the API route today, anything else
|
|
13
|
+
* later — gets the same answer.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hostnames that must never be reachable from a server-side fetch.
|
|
18
|
+
*
|
|
19
|
+
* Written against the *hostname string* rather than a resolved address: DNS
|
|
20
|
+
* resolution would be more thorough but introduces a TOCTOU window (the name
|
|
21
|
+
* can resolve differently between the check and the request) and a network
|
|
22
|
+
* call in a validation path. This blocks the literal forms an attacker
|
|
23
|
+
* actually types; defence in depth against DNS rebinding belongs at the egress
|
|
24
|
+
* layer, and is called out in SECURITY.md rather than pretended to here.
|
|
25
|
+
*/
|
|
26
|
+
const PRIVATE_HOST_PATTERNS: readonly RegExp[] = [
|
|
27
|
+
/^localhost$/i,
|
|
28
|
+
/^127\./,
|
|
29
|
+
/^0\./,
|
|
30
|
+
/^10\./,
|
|
31
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
32
|
+
/^192\.168\./,
|
|
33
|
+
/^169\.254\./, // cloud metadata (AWS/GCP/Azure) and link-local
|
|
34
|
+
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // carrier-grade NAT
|
|
35
|
+
/^\[?::1\]?$/,
|
|
36
|
+
/^\[?::\]?$/,
|
|
37
|
+
/^\[?f[cd][0-9a-f]{2}:/i, // IPv6 unique local
|
|
38
|
+
/^\[?fe80:/i, // IPv6 link-local
|
|
39
|
+
/^\[?::ffff:/i, // IPv4-mapped IPv6, e.g. ::ffff:169.254.169.254
|
|
40
|
+
/\.internal$/i,
|
|
41
|
+
/\.local$/i,
|
|
42
|
+
/\.localhost$/i,
|
|
43
|
+
/^metadata\./i,
|
|
44
|
+
/^metadata$/i,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** Whether a hostname points somewhere a public service must not fetch. */
|
|
48
|
+
export function isPrivateHost(hostname: string): boolean {
|
|
49
|
+
const host = hostname.trim().toLowerCase().replace(/\.$/, '');
|
|
50
|
+
if (!host) return true;
|
|
51
|
+
return PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(host));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type EndpointRejection =
|
|
55
|
+
| 'invalid-url'
|
|
56
|
+
| 'insecure-scheme'
|
|
57
|
+
| 'private-host'
|
|
58
|
+
| 'credentials-in-url';
|
|
59
|
+
|
|
60
|
+
export interface ValidateEndpointOptions {
|
|
61
|
+
/**
|
|
62
|
+
* Allow `http:` and private hosts. For local development only — it disables
|
|
63
|
+
* the entire protection, so it must never be derived from request input.
|
|
64
|
+
*/
|
|
65
|
+
allowInsecure?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validates an LLM endpoint URL. Returns `null` when it is safe to fetch, or a
|
|
70
|
+
* machine-readable reason when it is not.
|
|
71
|
+
*
|
|
72
|
+
* A reason code rather than a message so the caller renders it in the reader's
|
|
73
|
+
* locale, and so a test asserts on the decision rather than on wording.
|
|
74
|
+
*/
|
|
75
|
+
export function validateLlmEndpoint(
|
|
76
|
+
raw: string,
|
|
77
|
+
options: ValidateEndpointOptions = {},
|
|
78
|
+
): EndpointRejection | null {
|
|
79
|
+
let url: URL;
|
|
80
|
+
try {
|
|
81
|
+
url = new URL(raw);
|
|
82
|
+
} catch {
|
|
83
|
+
return 'invalid-url';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { allowInsecure = false } = options;
|
|
87
|
+
|
|
88
|
+
if (url.protocol !== 'https:' && !(allowInsecure && url.protocol === 'http:')) {
|
|
89
|
+
return 'insecure-scheme';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Credentials in the URL would be forwarded to whatever the host turns out
|
|
93
|
+
// to be, and would land in any log line that records the endpoint.
|
|
94
|
+
if (url.username || url.password) {
|
|
95
|
+
return 'credentials-in-url';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!allowInsecure && isPrivateHost(url.hostname)) {
|
|
99
|
+
return 'private-host';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validates an endpoint and returns the value to fetch, with no trailing slash.
|
|
107
|
+
*
|
|
108
|
+
* Returning it rather than approving it is the point. The first version of this
|
|
109
|
+
* validated `baseUrl` and then fetched
|
|
110
|
+
* `` `${baseUrl.replace(/\/$/, '')}/chat/completions` `` — two different
|
|
111
|
+
* expressions, so the thing checked was never the thing used, and a later edit
|
|
112
|
+
* could have moved the check without anything noticing.
|
|
113
|
+
*
|
|
114
|
+
* Re-parsing normalises it too: `https://host/v1/../../admin` passes validation
|
|
115
|
+
* as a string and resolves somewhere else on the wire.
|
|
116
|
+
*
|
|
117
|
+
* Every caller in this package that sends a key to a caller-named host goes
|
|
118
|
+
* through here — both providers and the exact token counter.
|
|
119
|
+
*/
|
|
120
|
+
export function checkedEndpoint(
|
|
121
|
+
baseUrl: string,
|
|
122
|
+
{ allowInsecure = false, name }: ValidateEndpointOptions & { name: string },
|
|
123
|
+
): string {
|
|
124
|
+
const rejection = validateLlmEndpoint(baseUrl, { allowInsecure });
|
|
125
|
+
if (rejection !== null) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Provider "${name}" cannot use ${baseUrl}: ${rejection}. ` +
|
|
128
|
+
'Pass allowInsecure only for an endpoint you configured yourself.',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return new URL(baseUrl).toString().replace(/\/$/, '');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `fetch` options that every server-side call in this package must carry.
|
|
136
|
+
*
|
|
137
|
+
* `redirect: 'error'` is the one that matters, and it was missing. Everything
|
|
138
|
+
* above validates the URL the caller named — and then `fetch` followed
|
|
139
|
+
* redirects by default, so an endpoint that passed every check could answer
|
|
140
|
+
* `302 Location: http://169.254.169.254/latest/meta-data/` and the request went
|
|
141
|
+
* there anyway, carrying the `authorization` header. The entire host filter was
|
|
142
|
+
* one HTTP response away from being bypassed, for the CLI as much as for the
|
|
143
|
+
* deployed app.
|
|
144
|
+
*
|
|
145
|
+
* A refused redirect is a thrown `TypeError`, which is the right outcome: a
|
|
146
|
+
* legitimate LLM endpoint does not redirect its completions API, and one that
|
|
147
|
+
* suddenly does is exactly the case worth failing on.
|
|
148
|
+
*/
|
|
149
|
+
export const SAFE_FETCH_INIT = {
|
|
150
|
+
redirect: 'error',
|
|
151
|
+
// No cookies or TLS client certs on a cross-origin call the caller named.
|
|
152
|
+
credentials: 'omit',
|
|
153
|
+
referrerPolicy: 'no-referrer',
|
|
154
|
+
} as const satisfies RequestInit;
|
|
155
|
+
|
|
156
|
+
// --------------------------------------------------------------------------
|
|
157
|
+
// What a deployment is willing to be pointed at
|
|
158
|
+
// --------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Which LLM endpoints a server-side caller is allowed to reach.
|
|
162
|
+
*
|
|
163
|
+
* The web route used to take `baseUrl` out of the request body, validate the
|
|
164
|
+
* string and hand it to a provider. That is server-side request forgery by
|
|
165
|
+
* construction, and validating harder does not fix it:
|
|
166
|
+
*
|
|
167
|
+
* - Every check above reads the *name*. `https://totally-fine.example.com` that
|
|
168
|
+
* resolves to `169.254.169.254` passes all of them, and the fix for that is
|
|
169
|
+
* pinning the resolved address at the socket, which `fetch` does not offer.
|
|
170
|
+
* - Even with the redirect hop closed, an anonymous caller could still aim the
|
|
171
|
+
* server at any public host and read the reply through the error body.
|
|
172
|
+
* "Public" is not the same as "fine to fetch on somebody else's behalf".
|
|
173
|
+
*
|
|
174
|
+
* So the request body no longer *names* an endpoint. It **selects** one the
|
|
175
|
+
* operator listed, and the value that reaches the provider is the entry from
|
|
176
|
+
* the list — never the string that arrived over HTTP. The default is an empty
|
|
177
|
+
* list, so a deployment that has not thought about this cannot be pointed
|
|
178
|
+
* anywhere at all.
|
|
179
|
+
*
|
|
180
|
+
* Nobody loses the capability: an operator running Trazum for themselves puts
|
|
181
|
+
* the endpoint in `TRAZUM_LLM_BASE_URL` and it is used directly, exactly as the
|
|
182
|
+
* CLI has always worked. This is only about who is allowed to choose.
|
|
183
|
+
*
|
|
184
|
+
* `env` is a parameter rather than a read of `process.env` because this module
|
|
185
|
+
* is on the browser-safe path.
|
|
186
|
+
*/
|
|
187
|
+
const ALLOWLIST_VAR = 'TRAZUM_ALLOWED_LLM_ENDPOINTS';
|
|
188
|
+
|
|
189
|
+
/** Normalised, so a trailing slash or a capitalised host is not a mismatch. */
|
|
190
|
+
function normaliseEndpoint(raw: string): string | null {
|
|
191
|
+
try {
|
|
192
|
+
return new URL(raw.trim()).toString().replace(/\/$/, '');
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The endpoints this server will call, from `TRAZUM_ALLOWED_LLM_ENDPOINTS`
|
|
200
|
+
* (comma-separated).
|
|
201
|
+
*
|
|
202
|
+
* An entry that would fail `validateLlmEndpoint` is dropped rather than
|
|
203
|
+
* honoured. The operator is trusted to choose, not to be immune from pasting
|
|
204
|
+
* `http://169.254.169.254` into a list that then serves every anonymous caller.
|
|
205
|
+
* `allowInsecure` is deliberately not offered: an endpoint only reachable with
|
|
206
|
+
* the protection off has no business being selectable over HTTP.
|
|
207
|
+
*/
|
|
208
|
+
export function allowedEndpoints(env: Record<string, string | undefined>): readonly string[] {
|
|
209
|
+
const raw = env[ALLOWLIST_VAR];
|
|
210
|
+
if (!raw) return [];
|
|
211
|
+
|
|
212
|
+
const listed = new Set<string>();
|
|
213
|
+
for (const part of raw.split(',')) {
|
|
214
|
+
const url = normaliseEndpoint(part);
|
|
215
|
+
if (url === null) continue;
|
|
216
|
+
if (validateLlmEndpoint(url) !== null) continue;
|
|
217
|
+
listed.add(url);
|
|
218
|
+
}
|
|
219
|
+
return [...listed];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolves what a caller asked for to an entry on the list.
|
|
224
|
+
*
|
|
225
|
+
* Returns the **listed** value, not the requested one. That distinction is the
|
|
226
|
+
* entire function: the string from the request is compared and then discarded,
|
|
227
|
+
* so nothing derived from it reaches `fetch`.
|
|
228
|
+
*/
|
|
229
|
+
export function resolveEndpoint(requested: string, allowed: readonly string[]): string | null {
|
|
230
|
+
const wanted = normaliseEndpoint(requested);
|
|
231
|
+
if (wanted === null) return null;
|
|
232
|
+
return allowed.find((entry) => entry.toLowerCase() === wanted.toLowerCase()) ?? null;
|
|
233
|
+
}
|
package/src/node.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of the core that read the filesystem.
|
|
3
|
+
*
|
|
4
|
+
* Kept off the main entry point on purpose, and the reason is a build failure
|
|
5
|
+
* rather than a preference: `apps/web` bundles `@trazum/core` for the browser,
|
|
6
|
+
* and a single `node:fs/promises` import anywhere in that graph fails the build
|
|
7
|
+
* outright — "the chunking context does not support external modules".
|
|
8
|
+
*
|
|
9
|
+
* That failure is the friendly version of the real risk. The web app hands
|
|
10
|
+
* `optimize()` a prompt straight from a request body; a file read reachable
|
|
11
|
+
* from that entry point is path traversal available to anyone who can reach the
|
|
12
|
+
* API. Two entry points make the split structural: the browser cannot import
|
|
13
|
+
* what is not in its graph, whatever a future refactor does.
|
|
14
|
+
*
|
|
15
|
+
* Only the CLI imports this.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export { loadConfig } from './config.js';
|
|
19
|
+
export type { LoadConfigOptions, LoadedConfig } from './config.js';
|
|
20
|
+
|
|
21
|
+
// Re-exported for convenience, so the CLI has one import for everything
|
|
22
|
+
// config-shaped. These halves are pure and also live on the main entry point.
|
|
23
|
+
export {
|
|
24
|
+
CONFIG_FILENAME,
|
|
25
|
+
CONFIG_KEYS,
|
|
26
|
+
CONFIG_USAGE_KEYS,
|
|
27
|
+
ConfigError,
|
|
28
|
+
DEFAULT_EXTENSIONS,
|
|
29
|
+
MAX_CONFIG_BYTES,
|
|
30
|
+
MAX_CONFIG_SEARCH_DEPTH,
|
|
31
|
+
budgetFor,
|
|
32
|
+
parseConfig,
|
|
33
|
+
validateConfigModel,
|
|
34
|
+
} from './config-schema.js';
|
|
35
|
+
export type { ResolvedBudget, TrazumConfig } from './config-schema.js';
|
|
36
|
+
|
|
37
|
+
// Local price corrections. Pure, so also on the main entry point; re-exported
|
|
38
|
+
// here so the CLI has one import for everything it needs to resolve a run.
|
|
39
|
+
export {
|
|
40
|
+
MAX_PRICING_BYTES,
|
|
41
|
+
PricingOverlayError,
|
|
42
|
+
applyPricingOverlay,
|
|
43
|
+
catalogueFromOverlay,
|
|
44
|
+
parsePricingOverlay,
|
|
45
|
+
} from './pricing-overlay.js';
|
|
46
|
+
export type { PricingOverlay } from './pricing-overlay.js';
|
|
47
|
+
|
|
48
|
+
// Turns a live price feed into an overlay. Pure — the fetch is the CLI's — and
|
|
49
|
+
// re-exported here for the same reason as the overlay itself: one import.
|
|
50
|
+
export { openrouterOverlay } from './openrouter.js';
|
|
51
|
+
export type { OpenRouterResult } from './openrouter.js';
|
|
52
|
+
|
|
53
|
+
// The endpoint gate every outbound call in this project goes through.
|
|
54
|
+
export { SAFE_FETCH_INIT, checkedEndpoint } from './net.js';
|
|
55
|
+
export { BUNDLED_CATALOGUE } from './pricing.js';
|
|
56
|
+
export type { PricingCatalogue } from './pricing.js';
|
|
57
|
+
|
|
58
|
+
// Reads the process environment, so it cannot live on the browser-safe entry.
|
|
59
|
+
export { detectHost } from './host.js';
|
|
60
|
+
export type { HostEnvironment } from './host.js';
|
|
61
|
+
|
|
62
|
+
export { MAX_WALK_DEPTH, MAX_WALK_FILES, walkPrompts } from './walk.js';
|
|
63
|
+
export type { WalkOptions, WalkResult } from './walk.js';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { PricingOverlay } from './pricing-overlay.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A pricing overlay built from OpenRouter's model catalogue.
|
|
5
|
+
*
|
|
6
|
+
* **Why a live source at all.** Trazum's bundled prices are a table somebody
|
|
7
|
+
* typed, and prices change on someone else's schedule. Worse, the table only
|
|
8
|
+
* covers the providers whoever typed it had reached for — so a user on Groq or
|
|
9
|
+
* Together got no figure at all, from a tool whose entire output is figures.
|
|
10
|
+
* OpenRouter publishes price and context window for hundreds of models across
|
|
11
|
+
* dozens of providers, as data, at a URL. That is a better source than my
|
|
12
|
+
* memory, and it is current by construction.
|
|
13
|
+
*
|
|
14
|
+
* **What it deliberately does not do.** OpenRouter publishes what a model costs
|
|
15
|
+
* and how much context it takes. It does not publish whether the model has
|
|
16
|
+
* prompt caching, or the minimum prefix it caches at. Both feed the caching
|
|
17
|
+
* advisory, which is the largest saving Trazum reports — an order of magnitude
|
|
18
|
+
* above what the trimming rules recover.
|
|
19
|
+
*
|
|
20
|
+
* So a model that arrives from here carries `caching: 'unknown'` and
|
|
21
|
+
* `cacheMinTokens: null`, and the advisory declines. The two available lies are
|
|
22
|
+
* symmetrical and both are worse: claim caching works and Trazum offers a
|
|
23
|
+
* saving that cannot be bought at any price; claim it does not and Trazum hides
|
|
24
|
+
* the biggest saving there is.
|
|
25
|
+
*
|
|
26
|
+
* **Pure, and in the core, so it is testable without a network.** The fetch
|
|
27
|
+
* lives in the CLI. Everything here is a transformation of a document somebody
|
|
28
|
+
* already has, which is the same split as `LlmProvider` taking a `fetchImpl`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The subset of OpenRouter's payload this reads. Everything else is ignored. */
|
|
32
|
+
interface OpenRouterModel {
|
|
33
|
+
id?: unknown;
|
|
34
|
+
name?: unknown;
|
|
35
|
+
context_length?: unknown;
|
|
36
|
+
pricing?: { prompt?: unknown; completion?: unknown };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OpenRouterResult {
|
|
40
|
+
overlay: PricingOverlay;
|
|
41
|
+
/** Ids that were skipped, and the reason, so nothing disappears silently. */
|
|
42
|
+
skipped: Array<{ id: string; reason: string }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* OpenRouter quotes USD **per token**, as a decimal string.
|
|
47
|
+
*
|
|
48
|
+
* `"0.000003"` is three dollars per million. Parsed rather than multiplied
|
|
49
|
+
* blindly: a free model quotes `"0"`, and a model with no price for one half
|
|
50
|
+
* quotes `"-1"` on occasion. Neither is a price Trazum can put in a budget.
|
|
51
|
+
*/
|
|
52
|
+
function perMillion(raw: unknown): number | null {
|
|
53
|
+
if (typeof raw !== 'string' && typeof raw !== 'number') return null;
|
|
54
|
+
const value = Number(raw);
|
|
55
|
+
if (!Number.isFinite(value) || value <= 0) return null;
|
|
56
|
+
return value * 1_000_000;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Turns OpenRouter's catalogue into an overlay.
|
|
61
|
+
*
|
|
62
|
+
* `knownIds` is the set the bundled catalogue already has. For those, only the
|
|
63
|
+
* three things OpenRouter actually knows are overridden — price in, price out,
|
|
64
|
+
* context window. `cacheMinTokens`, `caching`, `capability` and `tier` are left
|
|
65
|
+
* alone, because the bundled entry was written by somebody who looked them up
|
|
66
|
+
* and this feed has nothing to say about them. Overwriting a researched fact
|
|
67
|
+
* with a blank is not a refresh.
|
|
68
|
+
*/
|
|
69
|
+
export function openrouterOverlay(
|
|
70
|
+
payload: unknown,
|
|
71
|
+
options: { knownIds: ReadonlySet<string>; lastReviewed: string; idFor?: (id: string) => string },
|
|
72
|
+
): OpenRouterResult {
|
|
73
|
+
const { knownIds, lastReviewed, idFor = (id) => id } = options;
|
|
74
|
+
|
|
75
|
+
const data = (payload as { data?: unknown })?.data;
|
|
76
|
+
if (!Array.isArray(data)) {
|
|
77
|
+
throw new Error('OpenRouter payload has no "data" array — is this the models endpoint?');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const models: PricingOverlay['models'] = {};
|
|
81
|
+
const skipped: Array<{ id: string; reason: string }> = [];
|
|
82
|
+
|
|
83
|
+
for (const entry of data as OpenRouterModel[]) {
|
|
84
|
+
const rawId = typeof entry?.id === 'string' ? entry.id : '';
|
|
85
|
+
if (!rawId) continue;
|
|
86
|
+
|
|
87
|
+
const id = idFor(rawId);
|
|
88
|
+
const input = perMillion(entry.pricing?.prompt);
|
|
89
|
+
const output = perMillion(entry.pricing?.completion);
|
|
90
|
+
|
|
91
|
+
if (input === null || output === null) {
|
|
92
|
+
// Free models and half-priced entries. Skipped rather than recorded at
|
|
93
|
+
// zero: a zero price makes every saving Trazum computes zero too, and a
|
|
94
|
+
// report full of $0.00 reads as "nothing to gain here" rather than as
|
|
95
|
+
// "this catalogue has no price for that".
|
|
96
|
+
skipped.push({ id: rawId, reason: 'no usable price' });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const context = Number(entry.context_length);
|
|
101
|
+
if (!Number.isInteger(context) || context <= 0) {
|
|
102
|
+
skipped.push({ id: rawId, reason: 'no context window' });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (knownIds.has(id)) {
|
|
107
|
+
models[id] = { inputPerMTok: input, outputPerMTok: output, contextWindow: context };
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
models[id] = {
|
|
112
|
+
displayName: typeof entry.name === 'string' && entry.name ? entry.name : rawId,
|
|
113
|
+
inputPerMTok: input,
|
|
114
|
+
outputPerMTok: output,
|
|
115
|
+
contextWindow: context,
|
|
116
|
+
// The two the feed cannot answer. See the note at the top of this file.
|
|
117
|
+
cacheMinTokens: null,
|
|
118
|
+
caching: 'unknown',
|
|
119
|
+
capability: 'unknown',
|
|
120
|
+
tier: 'unknown',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { overlay: { lastReviewed, models }, skipped };
|
|
125
|
+
}
|