@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/config.ts ADDED
@@ -0,0 +1,161 @@
1
+ import { open, stat } from 'node:fs/promises';
2
+ import { dirname, join, resolve, sep } from 'node:path';
3
+
4
+ import {
5
+ CONFIG_FILENAME,
6
+ ConfigError,
7
+ MAX_CONFIG_BYTES,
8
+ MAX_CONFIG_SEARCH_DEPTH,
9
+ parseConfig,
10
+ validateConfigModel,
11
+ } from './config-schema.js';
12
+ import type { TrazumConfig } from './config-schema.js';
13
+ import { BUNDLED_CATALOGUE } from './pricing.js';
14
+ import type { PricingCatalogue } from './pricing.js';
15
+ import { MAX_PRICING_BYTES, PricingOverlayError, applyPricingOverlay, parsePricingOverlay } from './pricing-overlay.js';
16
+
17
+ /**
18
+ * Finding and reading `trazum.config.json`.
19
+ *
20
+ * Split from the schema in `config-schema.ts` because this is the only half
21
+ * that touches the filesystem, and the split is load-bearing: `apps/web`
22
+ * bundles `@trazum/core` for the browser, where a single `node:fs` import
23
+ * anywhere in the graph fails the build. Reachable only through
24
+ * `@trazum/core/node`, which the CLI imports and the web app does not.
25
+ */
26
+
27
+ export interface LoadedConfig {
28
+ config: TrazumConfig;
29
+ /** Absolute path the config came from, or null when none was found. */
30
+ path: string | null;
31
+ /**
32
+ * Prices to work from: the bundled catalogue, with the config's `pricing`
33
+ * overlay applied if it named one. Always present, so a caller never has to
34
+ * decide which of two sources to use.
35
+ */
36
+ pricing: PricingCatalogue;
37
+ /** Absolute path the overlay came from, or null when there was none. */
38
+ pricingPath: string | null;
39
+ }
40
+
41
+ /**
42
+ * Resolves a config's `pricing` overlay, if it names one.
43
+ *
44
+ * The path is relative to **the config file**, not the working directory, so a
45
+ * config found by walking upward still finds its own overlay. Reusing
46
+ * `readIfPresent` means the overlay gets the same one-handle read and the same
47
+ * size limit as the config itself.
48
+ */
49
+ async function resolvePricing(
50
+ config: TrazumConfig,
51
+ configPath: string,
52
+ ): Promise<{ pricing: PricingCatalogue; pricingPath: string | null }> {
53
+ if (!config.pricing) return { pricing: BUNDLED_CATALOGUE, pricingPath: null };
54
+
55
+ const path = resolve(dirname(configPath), config.pricing);
56
+ const raw = await readIfPresent(path, MAX_PRICING_BYTES);
57
+ if (raw === null) {
58
+ // Named and missing is an error, for the same reason `--config` is: somebody
59
+ // who points at a price list is not asking for the bundled one.
60
+ throw new PricingOverlayError('no such pricing overlay', path);
61
+ }
62
+ return {
63
+ pricing: applyPricingOverlay(BUNDLED_CATALOGUE, parsePricingOverlay(raw, path), path),
64
+ pricingPath: path,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Reads a config file if it is there, or returns null.
70
+ *
71
+ * **The handle is opened once and everything is asked of the handle**, not of
72
+ * the path again. Checking the size with `stat(path)` and then reading with
73
+ * `readFile(path)` resolves the name twice, so what gets read is not
74
+ * necessarily what got measured — a symlink swapped in between the two calls
75
+ * defeats the size limit entirely, and on a CI runner checking out a pull
76
+ * request that is not a hypothetical attacker. One `open`, then `fh.stat()` and
77
+ * `fh.readFile()`, refer to the same file object throughout.
78
+ */
79
+ async function readIfPresent(path: string, limit = MAX_CONFIG_BYTES): Promise<string | null> {
80
+ let handle;
81
+ try {
82
+ handle = await open(path, 'r');
83
+ } catch {
84
+ return null;
85
+ }
86
+
87
+ try {
88
+ const info = await handle.stat();
89
+ if (!info.isFile()) return null;
90
+ if (info.size > limit) {
91
+ throw new ConfigError(`is ${info.size} bytes, over the ${limit}-byte limit`, path);
92
+ }
93
+ return await handle.readFile('utf8');
94
+ } finally {
95
+ await handle.close();
96
+ }
97
+ }
98
+
99
+ export interface LoadConfigOptions {
100
+ /** Directory to start the upward search from. Defaults to the process cwd. */
101
+ from?: string;
102
+ /**
103
+ * An explicit path. Given one, no search happens and a missing file is an
104
+ * error: somebody who names a config file is not asking for defaults.
105
+ */
106
+ explicit?: string;
107
+ }
108
+
109
+ /**
110
+ * Finds and reads the nearest config file.
111
+ *
112
+ * The search walks **upward from the working directory**, which is what makes
113
+ * `cd packages/thing && trazum check prompt.txt` pick up the repository's
114
+ * config. It stops at the first hit, at a directory containing `.git`, or after
115
+ * `MAX_CONFIG_SEARCH_DEPTH` levels — a bound rather than an unlimited climb to
116
+ * the filesystem root.
117
+ *
118
+ * Finding nothing is not an error: the tool is useful with no config at all,
119
+ * and returning `{config: {}, path: null}` lets the caller say so.
120
+ */
121
+ export async function loadConfig(options: LoadConfigOptions = {}): Promise<LoadedConfig> {
122
+ if (options.explicit !== undefined) {
123
+ const path = resolve(options.explicit);
124
+ const raw = await readIfPresent(path);
125
+ if (raw === null) throw new ConfigError('no such config file', path);
126
+ return finish(parseConfig(raw, path), path);
127
+ }
128
+
129
+ let directory = resolve(options.from ?? process.cwd());
130
+
131
+ for (let depth = 0; depth < MAX_CONFIG_SEARCH_DEPTH; depth++) {
132
+ const candidate = join(directory, CONFIG_FILENAME);
133
+ const raw = await readIfPresent(candidate);
134
+ if (raw !== null) return finish(parseConfig(raw, candidate), candidate);
135
+
136
+ // A repository root is a deliberate stopping point: reaching past it would
137
+ // read a config belonging to whatever happens to be above the checkout,
138
+ // which on a CI runner is not the project's business.
139
+ const gitDirectory = await stat(join(directory, '.git')).catch(() => null);
140
+ if (gitDirectory) break;
141
+
142
+ const parent = dirname(directory);
143
+ if (parent === directory || parent === sep) break;
144
+ directory = parent;
145
+ }
146
+
147
+ return { config: {}, path: null, pricing: BUNDLED_CATALOGUE, pricingPath: null };
148
+ }
149
+
150
+ /**
151
+ * Resolves the overlay and checks the model, in that order.
152
+ *
153
+ * The order is the point: `usage.model` may name a model the overlay introduces,
154
+ * so validating it before the overlay is read would reject a config that is
155
+ * correct.
156
+ */
157
+ async function finish(config: TrazumConfig, path: string): Promise<LoadedConfig> {
158
+ const { pricing, pricingPath } = await resolvePricing(config, path);
159
+ validateConfigModel(config, pricing, path);
160
+ return { config, path, pricing, pricingPath };
161
+ }
package/src/detect.ts ADDED
@@ -0,0 +1,312 @@
1
+ import type { ModelPricing } from './types.js';
2
+
3
+ /**
4
+ * Where a prompt is actually going.
5
+ *
6
+ * Trazum priced one vendor, so defaulting to Claude cost nothing. Pricing seven
7
+ * made that default a **wrong number**: `trazum check src/prompts.ts` billed a
8
+ * file that calls OpenAI against Claude Opus 5 and said so with a straight face.
9
+ * The fix is not a better default — it is reading what the code already says.
10
+ *
11
+ * Everything here is **evidence-first**. A detection that cannot name what it
12
+ * saw is a guess wearing a result's clothes, and this feeds a command used as a
13
+ * CI gate. Every answer carries the line it came from, so a wrong one is
14
+ * arguable rather than mysterious.
15
+ *
16
+ * **It declines when the file points two ways.** A module importing both
17
+ * `openai` and `@anthropic-ai/sdk` is a module Trazum cannot price without
18
+ * picking a side, and picking silently is how somebody budgets against the wrong
19
+ * provider for a month. The conflict is reported and the caller falls back to
20
+ * whatever they configured.
21
+ *
22
+ * Detection sits **between config and defaults** in the usual layering: a flag
23
+ * beats config, config beats detection, detection beats the built-in default.
24
+ * Reading the code is better than assuming, and worse than being told.
25
+ */
26
+
27
+ export type EvidenceKind =
28
+ /** `model: 'gpt-5'` — names the model outright, so nothing beats it. */
29
+ | 'model-literal'
30
+ /** `from 'openai'` — names the provider but not which model. */
31
+ | 'sdk-import'
32
+ /** `https://api.deepseek.com` — a base URL pinned in the source. */
33
+ | 'base-url'
34
+ /** `// trazum:prompt name model=gpt-5` — the author said so directly. */
35
+ | 'marker';
36
+
37
+ export interface Evidence {
38
+ kind: EvidenceKind;
39
+ /** The text that produced it, so the reader can go and look. */
40
+ detail: string;
41
+ /** 1-based line, when it came from a specific place in the file. */
42
+ line?: number;
43
+ provider?: string;
44
+ model?: string;
45
+ }
46
+
47
+ export interface Detection {
48
+ /** The provider, or null when nothing was found or the file disagreed. */
49
+ provider: string | null;
50
+ /** The exact model, when something named one. */
51
+ model: string | null;
52
+ /** What was found, strongest first. Empty when nothing was. */
53
+ evidence: Evidence[];
54
+ /**
55
+ * Evidence pointing somewhere other than the answer.
56
+ *
57
+ * Non-empty means the file names more than one provider, and `provider` is
58
+ * null: two answers is not a weaker version of one answer.
59
+ */
60
+ conflicts: Evidence[];
61
+ }
62
+
63
+ /**
64
+ * How each provider announces itself in source.
65
+ *
66
+ * Data rather than branches, so adding a provider is a row. Matched as plain
67
+ * substrings on a lowercased copy — no regex, because this runs over files a
68
+ * contributor supplies and the pattern-goes-quadratic lesson has been paid for
69
+ * three times this week already.
70
+ *
71
+ * A quoted package name will also match the same string appearing in a prompt's
72
+ * own text. That is accepted rather than fixed: an import is the *weakest* kind
73
+ * of evidence here, so a model literal or a base URL overrides it, and the
74
+ * alternative — enumerating every import spelling in two languages — is how the
75
+ * single-quoted `require` form came to be missing.
76
+ */
77
+ const SIGNATURES: Array<{ provider: string; imports: string[]; urls: string[] }> = [
78
+ {
79
+ provider: 'anthropic',
80
+ imports: ['@anthropic-ai/sdk', 'anthropic-sdk', 'from anthropic', 'import anthropic'],
81
+ urls: ['api.anthropic.com'],
82
+ },
83
+ {
84
+ provider: 'openai',
85
+ // The quoted package name covers `from 'openai'`, `require("openai")` and
86
+ // every other JS spelling without enumerating them — which is how the
87
+ // single-quoted `require` form got missed. The bare Python forms need their
88
+ // own entries because Python does not quote its imports.
89
+ imports: ["'openai'", '"openai"', 'from openai', 'import openai'],
90
+ urls: ['api.openai.com'],
91
+ },
92
+ {
93
+ provider: 'google',
94
+ imports: [
95
+ '@google/genai',
96
+ '@google/generative-ai',
97
+ 'google.generativeai',
98
+ 'from google import genai',
99
+ ],
100
+ urls: ['generativelanguage.googleapis.com', 'aiplatform.googleapis.com'],
101
+ },
102
+ {
103
+ provider: 'moonshot',
104
+ imports: ['moonshot'],
105
+ urls: ['api.moonshot.cn', 'api.moonshot.ai'],
106
+ },
107
+ {
108
+ provider: 'deepseek',
109
+ imports: ['deepseek'],
110
+ urls: ['api.deepseek.com'],
111
+ },
112
+ { provider: 'xai', imports: ['@ai-sdk/xai', 'xai-sdk'], urls: ['api.x.ai'] },
113
+ { provider: 'mistral', imports: ['@mistralai/mistralai', 'from mistralai'], urls: ['api.mistral.ai'] },
114
+ ];
115
+
116
+ /**
117
+ * 1-based line lookup, built once per file.
118
+ *
119
+ * The obvious version counts newlines from zero on every call, which is
120
+ * quadratic in the number of matches — 36 seconds on a file repeating a model
121
+ * id. That is the third time this week the same shape has appeared, and the
122
+ * second time I have written it after fixing it in `extract.ts`; the difference
123
+ * here is that matches arrive out of order across the signature loop, so a
124
+ * forward-only counter does not work and this is a binary search instead.
125
+ */
126
+ function lineIndex(source: string): (offset: number) => number {
127
+ const newlines: number[] = [];
128
+ for (let i = 0; i < source.length; i++) if (source[i] === '\n') newlines.push(i);
129
+
130
+ return (offset) => {
131
+ let low = 0;
132
+ let high = newlines.length;
133
+ while (low < high) {
134
+ const mid = (low + high) >> 1;
135
+ if (newlines[mid]! < offset) low = mid + 1;
136
+ else high = mid;
137
+ }
138
+ return low + 1;
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Model names written as string literals next to a `model` key.
144
+ *
145
+ * Bounded deliberately: `model` then up to a few characters of punctuation and
146
+ * whitespace, then a quoted value. A looser search would match the word "model"
147
+ * in a prompt's own text and price the prompt against something it merely
148
+ * mentions.
149
+ */
150
+ function modelLiterals(
151
+ source: string,
152
+ known: ModelPricing[],
153
+ lineAt: (offset: number) => number,
154
+ ): Evidence[] {
155
+ const found: Evidence[] = [];
156
+ const haystack = source.toLowerCase();
157
+
158
+ for (const model of known) {
159
+ let from = 0;
160
+ for (;;) {
161
+ const at = haystack.indexOf(model.id.toLowerCase(), from);
162
+ if (at === -1) break;
163
+ from = at + model.id.length;
164
+
165
+ // It has to be a quoted value: a model id in prose is a mention, not a
166
+ // call. Checking the character either side is enough and costs nothing.
167
+ const before = source[at - 1];
168
+ const after = source[at + model.id.length];
169
+ const quoted =
170
+ (before === "'" || before === '"' || before === '`') &&
171
+ (after === "'" || after === '"' || after === '`');
172
+ if (!quoted) continue;
173
+
174
+ found.push({
175
+ kind: 'model-literal',
176
+ detail: model.id,
177
+ line: lineAt(at),
178
+ provider: model.provider,
179
+ model: model.id,
180
+ });
181
+ }
182
+ }
183
+ return found;
184
+ }
185
+
186
+ /** `model=gpt-5` on a `trazum:prompt` marker line. */
187
+ function markerModels(
188
+ source: string,
189
+ known: ModelPricing[],
190
+ lineAt: (offset: number) => number,
191
+ ): Evidence[] {
192
+ const found: Evidence[] = [];
193
+ let from = 0;
194
+ for (;;) {
195
+ const at = source.indexOf('trazum:prompt', from);
196
+ if (at === -1) break;
197
+ from = at + 13;
198
+
199
+ const lineEnd = source.indexOf('\n', at);
200
+ const rest = source.slice(from, lineEnd === -1 ? source.length : lineEnd);
201
+ const key = rest.indexOf('model=');
202
+ if (key === -1) continue;
203
+
204
+ const value = rest.slice(key + 6).trim().split(/[\s>]/)[0] ?? '';
205
+ const model = known.find((m) => m.id === value);
206
+ if (!model) continue;
207
+
208
+ found.push({
209
+ kind: 'marker',
210
+ detail: `model=${value}`,
211
+ line: lineAt(at),
212
+ provider: model.provider,
213
+ model: model.id,
214
+ });
215
+ }
216
+ return found;
217
+ }
218
+
219
+ export interface DetectOptions {
220
+ /** The catalogue to recognise model names from. */
221
+ models?: ModelPricing[];
222
+ }
223
+
224
+ /**
225
+ * Works out which provider a source file talks to, and says why.
226
+ *
227
+ * Returns `provider: null` both when nothing was found and when the file named
228
+ * more than one — the caller cannot act on either, and `conflicts` distinguishes
229
+ * them for the reader.
230
+ */
231
+ export function detectFromSource(source: string, options: DetectOptions = {}): Detection {
232
+ const known = options.models ?? [];
233
+ const haystack = source.toLowerCase();
234
+ const lineAt = lineIndex(source);
235
+
236
+ const evidence: Evidence[] = [
237
+ // Strongest first: the author naming a model beats the code naming one,
238
+ // which beats the code naming only a provider.
239
+ ...markerModels(source, known, lineAt),
240
+ ...modelLiterals(source, known, lineAt),
241
+ ];
242
+
243
+ for (const signature of SIGNATURES) {
244
+ for (const needle of signature.imports) {
245
+ const at = haystack.indexOf(needle.toLowerCase());
246
+ if (at === -1) continue;
247
+ evidence.push({
248
+ kind: 'sdk-import',
249
+ detail: needle,
250
+ line: lineAt(at),
251
+ provider: signature.provider,
252
+ });
253
+ break;
254
+ }
255
+ for (const url of signature.urls) {
256
+ const at = haystack.indexOf(url);
257
+ if (at === -1) continue;
258
+ evidence.push({
259
+ kind: 'base-url',
260
+ detail: url,
261
+ line: lineAt(at),
262
+ provider: signature.provider,
263
+ });
264
+ break;
265
+ }
266
+ }
267
+
268
+ if (evidence.length === 0) {
269
+ return { provider: null, model: null, evidence: [], conflicts: [] };
270
+ }
271
+
272
+ // A stronger kind overrides a weaker one; only equals can disagree.
273
+ //
274
+ // Getting this wrong the first time broke the most common non-OpenAI setup
275
+ // there is. Moonshot, DeepSeek, xAI, Groq and Together all ship an
276
+ // OpenAI-compatible endpoint, so their documented usage is the OpenAI SDK
277
+ // pointed at a different `base_url`. Treating that as a contradiction —
278
+ // "this file says both openai and deepseek" — would refuse to price a
279
+ // perfectly ordinary DeepSeek client. The base URL is the specific fact and
280
+ // the SDK import is the generic one, so the URL wins rather than ties.
281
+ //
282
+ // Likewise a `model=` on the marker is the author stating the answer, which
283
+ // cannot be contradicted by an import they wrote for another reason.
284
+ const RANK: Record<EvidenceKind, number> = {
285
+ marker: 3,
286
+ 'model-literal': 2,
287
+ 'base-url': 1,
288
+ 'sdk-import': 0,
289
+ };
290
+ const ranked = [...evidence].sort((a, b) => RANK[b.kind] - RANK[a.kind]);
291
+ const best = ranked[0]!;
292
+ const topRank = RANK[best.kind];
293
+
294
+ // Only evidence of the same strength can conflict. Anything weaker is
295
+ // context, not contradiction.
296
+ const conflicts = ranked.filter(
297
+ (e) => RANK[e.kind] === topRank && e.provider !== undefined && e.provider !== best.provider,
298
+ );
299
+
300
+ if (conflicts.length > 0) {
301
+ return { provider: null, model: null, evidence: ranked, conflicts };
302
+ }
303
+
304
+ return {
305
+ provider: best.provider ?? null,
306
+ // A model only comes from something that named one, and only from the
307
+ // winning provider: an import tells us who, never which of their models.
308
+ model: ranked.find((e) => e.model !== undefined && e.provider === best.provider)?.model ?? null,
309
+ evidence: ranked,
310
+ conflicts: [],
311
+ };
312
+ }
@@ -0,0 +1,188 @@
1
+ import { segment } from './segment.js';
2
+ import { jaccard, normalizeForCompare } from './similarity.js';
3
+ import type { LlmProvider } from './types.js';
4
+
5
+ /**
6
+ * Golden-set evaluation.
7
+ *
8
+ * Everything else Trazum reports is arithmetic: tokens, prices, multiplication.
9
+ * This is the one question arithmetic cannot answer — does the shorter prompt
10
+ * still do the job? — and the README has been answering it with a caveat
11
+ * ("the aggressive level can change nuance; read the diff") because a rules
12
+ * engine genuinely cannot know.
13
+ *
14
+ * The trap here is comparing the two prompts' outputs and calling the
15
+ * difference a regression. A model asked the same question twice does not
16
+ * answer identically, so "the optimised prompt diverged on 3 of 10 cases" is
17
+ * meaningless on its own — it might be better than the original manages
18
+ * against itself.
19
+ *
20
+ * So the original is run twice per case first, and that self-agreement is the
21
+ * yardstick. The optimised prompt is judged against the model's own variance,
22
+ * not against an imaginary determinism it never had. It costs a third call per
23
+ * case and it is the only reason the number means anything.
24
+ */
25
+
26
+ export interface EvalCase {
27
+ /** The input this case feeds the prompt. */
28
+ input: string;
29
+ /** The original prompt's two answers, used to measure its own variance. */
30
+ baseline: [string, string];
31
+ /** The optimised prompt's answer. */
32
+ optimized: string;
33
+ /** How closely the original agreed with itself (0-1). */
34
+ selfSimilarity: number;
35
+ /** How closely the optimised answer matched the original's first (0-1). */
36
+ crossSimilarity: number;
37
+ }
38
+
39
+ export type EvalVerdict = 'indistinguishable' | 'within-noise' | 'diverges' | 'inconclusive';
40
+
41
+ export interface EvalReport {
42
+ provider: string;
43
+ model: string;
44
+ cases: EvalCase[];
45
+ /** Mean agreement of the original prompt with itself. The yardstick. */
46
+ selfAgreement: number;
47
+ /** Mean agreement between the original and the optimised prompt. */
48
+ crossAgreement: number;
49
+ verdict: EvalVerdict;
50
+ /** Total provider calls made, so the cost is never a surprise. */
51
+ callsMade: number;
52
+ }
53
+
54
+ export interface EvaluateOptions {
55
+ /**
56
+ * How many cases to run at once. Kept low by default: this hammers someone
57
+ * else's endpoint, and a rate limit tripped halfway through wastes every
58
+ * call already paid for.
59
+ */
60
+ concurrency?: number;
61
+ }
62
+
63
+ /**
64
+ * Builds the prompt for one case.
65
+ *
66
+ * A template gets its first placeholder filled; anything else gets the input
67
+ * appended. Substituting is the honest reading of a prompt written with
68
+ * `{{query}}` — appending would test a prompt nobody runs.
69
+ */
70
+ export function fillPrompt(prompt: string, input: string): string {
71
+ const placeholder = segment(prompt).find(
72
+ (s) => s.kind === 'protected' && s.protection === 'placeholder',
73
+ );
74
+ if (!placeholder) return `${prompt.trimEnd()}\n\n${input}`;
75
+ return prompt.replace(placeholder.text, input);
76
+ }
77
+
78
+ /** Agreement between two answers, 0-1. */
79
+ /**
80
+ * How closely two answers agree, 0 to 1.
81
+ *
82
+ * Exported because `prune.ts` measures the same thing and must measure it the
83
+ * same way. It was a private function here first, and the copy that appeared in
84
+ * `prune.ts` was a bag-of-words F1 while this is Jaccard over normalised text —
85
+ * two different numbers under one name, with a comment in the copy claiming they
86
+ * were the same measure. Sharing the function is what makes that comment true.
87
+ */
88
+ export function agreement(a: string, b: string): number {
89
+ const left = normalizeForCompare(a);
90
+ const right = normalizeForCompare(b);
91
+ if (left === right) return 1;
92
+ return jaccard(left, right);
93
+ }
94
+
95
+ const mean = (values: number[]): number =>
96
+ values.length === 0 ? 0 : values.reduce((sum, v) => sum + v, 0) / values.length;
97
+
98
+ /**
99
+ * Turns the two agreement figures into a verdict.
100
+ *
101
+ * The comparison is always relative. An optimised prompt agreeing with the
102
+ * original 0.85 of the time looks alarming until you see the original agrees
103
+ * with itself 0.86 — at which point the optimisation changed nothing the model
104
+ * was not already doing on its own.
105
+ *
106
+ * `inconclusive` exists because a model that is wildly inconsistent with itself
107
+ * cannot be used to judge anything. Reporting a confident verdict off that
108
+ * would be worse than admitting the test does not work here.
109
+ */
110
+ export function verdictFor(selfAgreement: number, crossAgreement: number): EvalVerdict {
111
+ if (crossAgreement >= 0.999) return 'indistinguishable';
112
+ if (selfAgreement < 0.5) return 'inconclusive';
113
+ // Within a small margin of the model's own noise floor, the difference is
114
+ // not attributable to the prompt.
115
+ if (crossAgreement >= selfAgreement - 0.05) return 'within-noise';
116
+ return 'diverges';
117
+ }
118
+
119
+ /** Runs `tasks` with a bounded number in flight, preserving order. */
120
+ export async function pooled<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
121
+ const results = new Array<T>(tasks.length);
122
+ let next = 0;
123
+
124
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
125
+ for (;;) {
126
+ const index = next++;
127
+ if (index >= tasks.length) return;
128
+ results[index] = await tasks[index]!();
129
+ }
130
+ });
131
+
132
+ await Promise.all(workers);
133
+ return results;
134
+ }
135
+
136
+ /**
137
+ * Runs both prompt versions over a set of inputs and reports whether the
138
+ * optimisation changed the answers.
139
+ *
140
+ * Costs **three provider calls per case**: the original twice, the optimised
141
+ * once. The doubled original is what makes the result interpretable, and
142
+ * `callsMade` reports the total so the bill is never a surprise.
143
+ */
144
+ export async function evaluate(
145
+ originalPrompt: string,
146
+ optimizedPrompt: string,
147
+ inputs: readonly string[],
148
+ provider: LlmProvider,
149
+ options: EvaluateOptions = {},
150
+ ): Promise<EvalReport> {
151
+ const concurrency = Math.max(1, options.concurrency ?? 3);
152
+
153
+ const run = (prompt: string, input: string): Promise<string> =>
154
+ provider.complete({ system: fillPrompt(prompt, input), user: input });
155
+
156
+ const cases = await pooled(
157
+ inputs.map((input) => async (): Promise<EvalCase> => {
158
+ // Sequential within a case: the two baseline runs exist to measure the
159
+ // model's variance, and issuing them together invites a provider to
160
+ // serve one from a cache and report a variance of zero.
161
+ const baselineA = await run(originalPrompt, input);
162
+ const baselineB = await run(originalPrompt, input);
163
+ const optimized = await run(optimizedPrompt, input);
164
+
165
+ return {
166
+ input,
167
+ baseline: [baselineA, baselineB],
168
+ optimized,
169
+ selfSimilarity: agreement(baselineA, baselineB),
170
+ crossSimilarity: agreement(baselineA, optimized),
171
+ };
172
+ }),
173
+ concurrency,
174
+ );
175
+
176
+ const selfAgreement = mean(cases.map((c) => c.selfSimilarity));
177
+ const crossAgreement = mean(cases.map((c) => c.crossSimilarity));
178
+
179
+ return {
180
+ provider: provider.name,
181
+ model: provider.model,
182
+ cases,
183
+ selfAgreement,
184
+ crossAgreement,
185
+ verdict: verdictFor(selfAgreement, crossAgreement),
186
+ callsMade: cases.length * 3,
187
+ };
188
+ }