@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.
Files changed (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -0
  3. package/dist/advisories.d.ts +23 -0
  4. package/dist/advisories.d.ts.map +1 -0
  5. package/dist/advisories.js +376 -0
  6. package/dist/advisories.js.map +1 -0
  7. package/dist/aws-sigv4.d.ts +88 -0
  8. package/dist/aws-sigv4.d.ts.map +1 -0
  9. package/dist/aws-sigv4.js +117 -0
  10. package/dist/aws-sigv4.js.map +1 -0
  11. package/dist/baseline.d.ts +171 -0
  12. package/dist/baseline.d.ts.map +1 -0
  13. package/dist/baseline.js +273 -0
  14. package/dist/baseline.js.map +1 -0
  15. package/dist/cache.d.ts +26 -0
  16. package/dist/cache.d.ts.map +1 -0
  17. package/dist/cache.js +28 -0
  18. package/dist/cache.js.map +1 -0
  19. package/dist/changes.d.ts +29 -0
  20. package/dist/changes.d.ts.map +1 -0
  21. package/dist/changes.js +142 -0
  22. package/dist/changes.js.map +1 -0
  23. package/dist/compare.d.ts +65 -0
  24. package/dist/compare.d.ts.map +1 -0
  25. package/dist/compare.js +58 -0
  26. package/dist/compare.js.map +1 -0
  27. package/dist/config-schema.d.ts +118 -0
  28. package/dist/config-schema.d.ts.map +1 -0
  29. package/dist/config-schema.js +315 -0
  30. package/dist/config-schema.js.map +1 -0
  31. package/dist/config.d.ts +47 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +112 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/detect.d.ts +70 -0
  36. package/dist/detect.d.ts.map +1 -0
  37. package/dist/detect.js +228 -0
  38. package/dist/detect.js.map +1 -0
  39. package/dist/evaluate.d.ts +98 -0
  40. package/dist/evaluate.d.ts.map +1 -0
  41. package/dist/evaluate.js +110 -0
  42. package/dist/evaluate.js.map +1 -0
  43. package/dist/extract.d.ts +81 -0
  44. package/dist/extract.d.ts.map +1 -0
  45. package/dist/extract.js +280 -0
  46. package/dist/extract.js.map +1 -0
  47. package/dist/gcp-auth.d.ts +58 -0
  48. package/dist/gcp-auth.d.ts.map +1 -0
  49. package/dist/gcp-auth.js +113 -0
  50. package/dist/gcp-auth.js.map +1 -0
  51. package/dist/glob.d.ts +49 -0
  52. package/dist/glob.d.ts.map +1 -0
  53. package/dist/glob.js +154 -0
  54. package/dist/glob.js.map +1 -0
  55. package/dist/host.d.ts +30 -0
  56. package/dist/host.d.ts.map +1 -0
  57. package/dist/host.js +69 -0
  58. package/dist/host.js.map +1 -0
  59. package/dist/i18n/en.d.ts +4 -0
  60. package/dist/i18n/en.d.ts.map +1 -0
  61. package/dist/i18n/en.js +168 -0
  62. package/dist/i18n/en.js.map +1 -0
  63. package/dist/i18n/es.d.ts +4 -0
  64. package/dist/i18n/es.d.ts.map +1 -0
  65. package/dist/i18n/es.js +168 -0
  66. package/dist/i18n/es.js.map +1 -0
  67. package/dist/i18n/index.d.ts +36 -0
  68. package/dist/i18n/index.d.ts.map +1 -0
  69. package/dist/i18n/index.js +50 -0
  70. package/dist/i18n/index.js.map +1 -0
  71. package/dist/i18n/types.d.ts +180 -0
  72. package/dist/i18n/types.d.ts.map +1 -0
  73. package/dist/i18n/types.js +11 -0
  74. package/dist/i18n/types.js.map +1 -0
  75. package/dist/index.d.ts +66 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +69 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/llm.d.ts +226 -0
  80. package/dist/llm.d.ts.map +1 -0
  81. package/dist/llm.js +485 -0
  82. package/dist/llm.js.map +1 -0
  83. package/dist/nearest.d.ts +20 -0
  84. package/dist/nearest.d.ts.map +1 -0
  85. package/dist/nearest.js +54 -0
  86. package/dist/nearest.js.map +1 -0
  87. package/dist/net.d.ts +90 -0
  88. package/dist/net.d.ts.map +1 -0
  89. package/dist/net.js +203 -0
  90. package/dist/net.js.map +1 -0
  91. package/dist/node.d.ts +32 -0
  92. package/dist/node.d.ts.map +1 -0
  93. package/dist/node.js +33 -0
  94. package/dist/node.js.map +1 -0
  95. package/dist/openrouter.d.ts +25 -0
  96. package/dist/openrouter.d.ts.map +1 -0
  97. package/dist/openrouter.js +72 -0
  98. package/dist/openrouter.js.map +1 -0
  99. package/dist/optimize.d.ts +38 -0
  100. package/dist/optimize.d.ts.map +1 -0
  101. package/dist/optimize.js +183 -0
  102. package/dist/optimize.js.map +1 -0
  103. package/dist/otlp.d.ts +91 -0
  104. package/dist/otlp.d.ts.map +1 -0
  105. package/dist/otlp.js +102 -0
  106. package/dist/otlp.js.map +1 -0
  107. package/dist/phrases.d.ts +169 -0
  108. package/dist/phrases.d.ts.map +1 -0
  109. package/dist/phrases.js +939 -0
  110. package/dist/phrases.js.map +1 -0
  111. package/dist/pricing-overlay.d.ts +55 -0
  112. package/dist/pricing-overlay.d.ts.map +1 -0
  113. package/dist/pricing-overlay.js +241 -0
  114. package/dist/pricing-overlay.js.map +1 -0
  115. package/dist/pricing.d.ts +115 -0
  116. package/dist/pricing.d.ts.map +1 -0
  117. package/dist/pricing.js +400 -0
  118. package/dist/pricing.js.map +1 -0
  119. package/dist/profile.d.ts +71 -0
  120. package/dist/profile.d.ts.map +1 -0
  121. package/dist/profile.js +55 -0
  122. package/dist/profile.js.map +1 -0
  123. package/dist/promptfoo.d.ts +58 -0
  124. package/dist/promptfoo.d.ts.map +1 -0
  125. package/dist/promptfoo.js +149 -0
  126. package/dist/promptfoo.js.map +1 -0
  127. package/dist/prune.d.ts +91 -0
  128. package/dist/prune.d.ts.map +1 -0
  129. package/dist/prune.js +110 -0
  130. package/dist/prune.js.map +1 -0
  131. package/dist/reorder.d.ts +82 -0
  132. package/dist/reorder.d.ts.map +1 -0
  133. package/dist/reorder.js +215 -0
  134. package/dist/reorder.js.map +1 -0
  135. package/dist/review.d.ts +54 -0
  136. package/dist/review.d.ts.map +1 -0
  137. package/dist/review.js +131 -0
  138. package/dist/review.js.map +1 -0
  139. package/dist/rules.d.ts +5 -0
  140. package/dist/rules.d.ts.map +1 -0
  141. package/dist/rules.js +279 -0
  142. package/dist/rules.js.map +1 -0
  143. package/dist/savings.d.ts +36 -0
  144. package/dist/savings.d.ts.map +1 -0
  145. package/dist/savings.js +83 -0
  146. package/dist/savings.js.map +1 -0
  147. package/dist/segment.d.ts +8 -0
  148. package/dist/segment.d.ts.map +1 -0
  149. package/dist/segment.js +74 -0
  150. package/dist/segment.js.map +1 -0
  151. package/dist/shared-prefix.d.ts +63 -0
  152. package/dist/shared-prefix.d.ts.map +1 -0
  153. package/dist/shared-prefix.js +151 -0
  154. package/dist/shared-prefix.js.map +1 -0
  155. package/dist/similarity.d.ts +13 -0
  156. package/dist/similarity.d.ts.map +1 -0
  157. package/dist/similarity.js +30 -0
  158. package/dist/similarity.js.map +1 -0
  159. package/dist/structure.d.ts +144 -0
  160. package/dist/structure.d.ts.map +1 -0
  161. package/dist/structure.js +455 -0
  162. package/dist/structure.js.map +1 -0
  163. package/dist/suggest.d.ts +100 -0
  164. package/dist/suggest.d.ts.map +1 -0
  165. package/dist/suggest.js +151 -0
  166. package/dist/suggest.js.map +1 -0
  167. package/dist/tokenizer.d.ts +57 -0
  168. package/dist/tokenizer.d.ts.map +1 -0
  169. package/dist/tokenizer.js +157 -0
  170. package/dist/tokenizer.js.map +1 -0
  171. package/dist/types.d.ts +296 -0
  172. package/dist/types.d.ts.map +1 -0
  173. package/dist/types.js +3 -0
  174. package/dist/types.js.map +1 -0
  175. package/dist/walk.d.ts +40 -0
  176. package/dist/walk.d.ts.map +1 -0
  177. package/dist/walk.js +95 -0
  178. package/dist/walk.js.map +1 -0
  179. package/package.json +56 -0
  180. package/src/advisories.ts +431 -0
  181. package/src/aws-sigv4.ts +174 -0
  182. package/src/baseline.ts +390 -0
  183. package/src/cache.ts +54 -0
  184. package/src/changes.ts +158 -0
  185. package/src/compare.ts +131 -0
  186. package/src/config-schema.ts +451 -0
  187. package/src/config.ts +161 -0
  188. package/src/detect.ts +312 -0
  189. package/src/evaluate.ts +188 -0
  190. package/src/extract.ts +336 -0
  191. package/src/gcp-auth.ts +166 -0
  192. package/src/glob.ts +160 -0
  193. package/src/host.ts +90 -0
  194. package/src/i18n/en.ts +236 -0
  195. package/src/i18n/es.ts +236 -0
  196. package/src/i18n/index.ts +68 -0
  197. package/src/i18n/types.ts +230 -0
  198. package/src/index.ts +228 -0
  199. package/src/llm.ts +708 -0
  200. package/src/nearest.ts +61 -0
  201. package/src/net.ts +233 -0
  202. package/src/node.ts +63 -0
  203. package/src/openrouter.ts +125 -0
  204. package/src/optimize.ts +228 -0
  205. package/src/otlp.ts +179 -0
  206. package/src/phrases.ts +1047 -0
  207. package/src/pricing-overlay.ts +319 -0
  208. package/src/pricing.ts +468 -0
  209. package/src/profile.ts +124 -0
  210. package/src/promptfoo.ts +213 -0
  211. package/src/prune.ts +211 -0
  212. package/src/reorder.ts +307 -0
  213. package/src/review.ts +180 -0
  214. package/src/rules.ts +324 -0
  215. package/src/savings.ts +121 -0
  216. package/src/segment.ts +106 -0
  217. package/src/shared-prefix.ts +198 -0
  218. package/src/similarity.ts +28 -0
  219. package/src/structure.ts +652 -0
  220. package/src/suggest.ts +254 -0
  221. package/src/tokenizer.ts +190 -0
  222. package/src/types.ts +323 -0
  223. 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
+ }