@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/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
|
+
}
|
package/src/evaluate.ts
ADDED
|
@@ -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
|
+
}
|