@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
@@ -0,0 +1,228 @@
1
+ import { buildAdvisories } from './advisories.js';
2
+ import { extractChanges } from './changes.js';
3
+ import { DEFAULT_LOCALE, getMessages } from './i18n/index.js';
4
+ import type { Locale, RuleId } from './i18n/types.js';
5
+ import { BUNDLED_CATALOGUE, DEFAULT_MODEL } from './pricing.js';
6
+ import type { PricingCatalogue } from './pricing.js';
7
+ import { RULES } from './rules.js';
8
+ import { computeSavings } from './savings.js';
9
+ import { join, segment } from './segment.js';
10
+ import { estimateTokens } from './tokenizer.js';
11
+ import type { AsyncTokenCounter } from './tokenizer.js';
12
+ import type {
13
+ OptimizationResult,
14
+ OptimizeOptions,
15
+ RuleResult,
16
+ Segment,
17
+ TokenCounter,
18
+ UsageProfile,
19
+ } from './types.js';
20
+
21
+ const MASK_OPEN = '\uE000';
22
+ const MASK_CLOSE = '\uE001';
23
+ const MASK_BASE = 0xe100;
24
+ /** Private-use-area slots available for masks. */
25
+ const MASK_CAPACITY = 0xf8ff - MASK_BASE;
26
+
27
+ export const DEFAULT_USAGE: UsageProfile = {
28
+ model: DEFAULT_MODEL,
29
+ callsPerMonth: 1000,
30
+ avgOutputTokens: 500,
31
+ cacheHitRate: 0.9,
32
+ batchEligible: false,
33
+ };
34
+
35
+ interface Masked {
36
+ text: string;
37
+ /** Original text of each protected segment, indexed by its mask. */
38
+ vault: string[];
39
+ }
40
+
41
+ /**
42
+ * Replaces every protected segment with a 3-character marker from the Unicode
43
+ * private use area. The rules work on the masked text, so none of them can
44
+ * touch code, URLs or template placeholders.
45
+ */
46
+ function mask(segments: Segment[]): Masked {
47
+ const vault: string[] = [];
48
+ let text = '';
49
+
50
+ for (const seg of segments) {
51
+ if (seg.kind === 'mutable') {
52
+ text += seg.text;
53
+ continue;
54
+ }
55
+ if (vault.length >= MASK_CAPACITY) {
56
+ // A prompt with thousands of protected blocks is left as-is rather than
57
+ // risking a mask collision.
58
+ text += seg.text;
59
+ continue;
60
+ }
61
+ const slot = String.fromCharCode(MASK_BASE + vault.length);
62
+ vault.push(seg.text);
63
+ text += MASK_OPEN + slot + MASK_CLOSE;
64
+ }
65
+
66
+ return { text, vault };
67
+ }
68
+
69
+ /** Puts the protected content back. */
70
+ function unmask(text: string, vault: string[]): string {
71
+ return text.replace(
72
+ new RegExp(`${MASK_OPEN}([\\s\\S])${MASK_CLOSE}`, 'g'),
73
+ (whole, slot: string) => {
74
+ const index = slot.charCodeAt(0) - MASK_BASE;
75
+ const original = vault[index];
76
+ return original === undefined ? whole : original;
77
+ },
78
+ );
79
+ }
80
+
81
+ /** Distinct protected texts that must still be present after optimising. */
82
+ function distinctProtected(segments: Segment[]): string[] {
83
+ return [...new Set(segments.filter((s) => s.kind === 'protected').map((s) => s.text))];
84
+ }
85
+
86
+ /**
87
+ * Optimises a prompt by applying deterministic rules.
88
+ *
89
+ * The result is reproducible: the same input always yields the same output and
90
+ * running it costs nothing. The optional LLM pass lives separately, in
91
+ * `refineWithLlm`.
92
+ */
93
+ export function optimize(prompt: string, options: OptimizeOptions = {}): OptimizationResult {
94
+ const level = options.level ?? 'safe';
95
+ const locale: Locale = options.locale ?? DEFAULT_LOCALE;
96
+ const t = getMessages(locale);
97
+ const disabled = new Set<RuleId>(options.disableRules ?? []);
98
+ const count: TokenCounter = options.tokenCounter ?? estimateTokens;
99
+
100
+ const segments = segment(prompt);
101
+ const mustSurvive = distinctProtected(segments);
102
+ const { text: maskedOriginal, vault } = mask(segments);
103
+
104
+ let current = maskedOriginal;
105
+ const ruleResults: RuleResult[] = [];
106
+
107
+ for (const rule of RULES) {
108
+ if (disabled.has(rule.id)) continue;
109
+ if (rule.level === 'aggressive' && level !== 'aggressive') continue;
110
+
111
+ const currentUnmasked = unmask(current, vault);
112
+ const before = count(currentUnmasked);
113
+ const { text: candidate, hits } = rule.apply(current);
114
+ if (hits === 0 || candidate === current) continue;
115
+
116
+ const candidateUnmasked = unmask(candidate, vault);
117
+
118
+ // Safety net: if a rule made protected content disappear, that rule is
119
+ // dropped rather than returning a broken prompt.
120
+ const lostProtected = mustSurvive.some((text) => !candidateUnmasked.includes(text));
121
+ if (lostProtected) continue;
122
+
123
+ const after = count(candidateUnmasked);
124
+ // Captured against the unmasked text on both sides, so the snippets read
125
+ // as the author wrote them rather than showing private-use markers.
126
+ const changes = extractChanges(currentUnmasked, candidateUnmasked);
127
+ current = candidate;
128
+ const copy = t.rules[rule.id];
129
+ ruleResults.push({
130
+ id: rule.id,
131
+ title: copy.title,
132
+ rationale: copy.rationale,
133
+ level: rule.level,
134
+ hits,
135
+ tokensSaved: Math.max(0, before - after),
136
+ changes,
137
+ });
138
+ }
139
+
140
+ const optimized = unmask(current, vault).trim();
141
+ const tokensBefore = count(prompt);
142
+ const tokensAfter = count(optimized);
143
+
144
+ const usage: UsageProfile = { ...DEFAULT_USAGE, ...options.usage };
145
+ const pricing = options.pricing ?? BUNDLED_CATALOGUE;
146
+ const savings = computeSavings(tokensBefore, tokensAfter, usage, new Date(), pricing);
147
+ const advisories = buildAdvisories(optimized, tokensAfter, usage, { count, locale, pricing });
148
+
149
+ return {
150
+ original: prompt,
151
+ optimized,
152
+ tokensBefore,
153
+ tokensAfter,
154
+ tokensSaved: tokensBefore - tokensAfter,
155
+ reductionPct: tokensBefore > 0 ? ((tokensBefore - tokensAfter) / tokensBefore) * 100 : 0,
156
+ rules: ruleResults,
157
+ advisories,
158
+ savings,
159
+ usage,
160
+ locale,
161
+ tokenSource: options.tokenCounter ? 'external' : 'heuristic',
162
+ pricingSource: {
163
+ lastReviewed: pricing.lastReviewed,
164
+ overriddenModels: pricing.overriddenModels,
165
+ addedModels: pricing.addedModels,
166
+ },
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Recomputes the report with an exact token counter (e.g. the official
172
+ * token-counting endpoint).
173
+ *
174
+ * Only the headline numbers and the saving are recomputed. Per-rule token
175
+ * attribution stays on the heuristic estimate: asking a remote counter once
176
+ * per rule would multiply the calls without changing any decision.
177
+ */
178
+ export async function withExactTokenCounts(
179
+ result: OptimizationResult,
180
+ counter: AsyncTokenCounter,
181
+ pricing: PricingCatalogue = BUNDLED_CATALOGUE,
182
+ ): Promise<OptimizationResult> {
183
+ // A result priced against an overlay cannot be recomputed against the bundled
184
+ // catalogue: the token counts would come from one source and the money from
185
+ // another, and the report would disagree with itself with nothing to show why.
186
+ // Throwing is the only honest option — silently reverting to bundled prices is
187
+ // the exact failure the overlay exists to prevent.
188
+ const overlaid =
189
+ result.pricingSource.overriddenModels.length > 0 ||
190
+ result.pricingSource.addedModels.length > 0;
191
+ if (overlaid && pricing === BUNDLED_CATALOGUE) {
192
+ throw new Error(
193
+ 'This result was priced against a pricing overlay, so withExactTokenCounts ' +
194
+ 'needs the same catalogue passed as its third argument. Recomputing against ' +
195
+ 'the bundled prices would make the token counts and the costs disagree.',
196
+ );
197
+ }
198
+
199
+ const [tokensBefore, tokensAfter] = await Promise.all([
200
+ counter(result.original),
201
+ counter(result.optimized),
202
+ ]);
203
+
204
+ const savings = computeSavings(tokensBefore, tokensAfter, result.usage, new Date(), pricing);
205
+ const advisories = buildAdvisories(result.optimized, tokensAfter, result.usage, {
206
+ locale: result.locale,
207
+ pricing,
208
+ });
209
+
210
+ return {
211
+ ...result,
212
+ tokensBefore,
213
+ tokensAfter,
214
+ tokensSaved: tokensBefore - tokensAfter,
215
+ reductionPct: tokensBefore > 0 ? ((tokensBefore - tokensAfter) / tokensBefore) * 100 : 0,
216
+ savings,
217
+ advisories,
218
+ tokenSource: 'external',
219
+ pricingSource: {
220
+ lastReviewed: pricing.lastReviewed,
221
+ overriddenModels: pricing.overriddenModels,
222
+ addedModels: pricing.addedModels,
223
+ },
224
+ };
225
+ }
226
+
227
+ /** Re-exports of internals useful to anyone extending the library. */
228
+ export { join, mask as maskProtected, segment, unmask as unmaskProtected };
package/src/otlp.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Prompt cost as OpenTelemetry metrics, in the OTLP/HTTP JSON encoding.
3
+ *
4
+ * **Trazum writes the payload; it does not send it.** That is a decision, not a
5
+ * missing feature. Pushing to a collector would mean holding an endpoint and a
6
+ * credential, and this repository has twice shipped an SSRF where a URL reached
7
+ * `fetch` without being the URL that was checked. A command that writes a file
8
+ * has no such failure mode, and `curl --data-binary @metrics.json $OTLP_ENDPOINT`
9
+ * is one line in the pipeline that already has the credential.
10
+ *
11
+ * No dependency on `@opentelemetry/*` either — the JSON encoding is a stable,
12
+ * documented wire format, and this package has no runtime dependencies by design.
13
+ *
14
+ * The two encoding rules that are easy to get wrong and silently produce a
15
+ * payload a collector drops:
16
+ *
17
+ * - **64-bit integers are strings.** `timeUnixNano` and `asInt` are `int64` in
18
+ * the protobuf, and JSON numbers cannot hold one exactly, so the JSON mapping
19
+ * specifies strings. A collector reading `1786000000000000000` as a double
20
+ * loses the last digits of every timestamp.
21
+ * - **A gauge is `asDouble` or `asInt`, never both**, and a money figure is a
22
+ * double. Sending `asInt` for `$41.20` silently reports `41`.
23
+ */
24
+
25
+ /** Attributes as OTLP encodes them: a list of key/value, not an object. */
26
+ export interface OtlpAttribute {
27
+ key: string;
28
+ value: { stringValue: string } | { intValue: string } | { doubleValue: number };
29
+ }
30
+
31
+ export interface OtlpDataPoint {
32
+ attributes: OtlpAttribute[];
33
+ timeUnixNano: string;
34
+ asInt?: string;
35
+ asDouble?: number;
36
+ }
37
+
38
+ export interface OtlpMetric {
39
+ name: string;
40
+ description: string;
41
+ unit: string;
42
+ gauge: { dataPoints: OtlpDataPoint[] };
43
+ }
44
+
45
+ export interface OtlpPayload {
46
+ resourceMetrics: [
47
+ {
48
+ resource: { attributes: OtlpAttribute[] };
49
+ scopeMetrics: [{ scope: { name: string; version: string }; metrics: OtlpMetric[] }];
50
+ },
51
+ ];
52
+ }
53
+
54
+ /** What `doctor` knows, in the shape this module needs. */
55
+ export interface OtlpInput {
56
+ /** Every prompt surveyed, with the count a budget would be compared against. */
57
+ prompts: readonly { path: string; tokens: number; overBudget: boolean; budgeted: boolean }[];
58
+ /** Advisories rolled up by id, as the survey reports them. */
59
+ findings: readonly { id: string; prompts: number; monthlyUsd: number | null }[];
60
+ model: string;
61
+ callsPerMonth: number;
62
+ }
63
+
64
+ const str = (key: string, value: string): OtlpAttribute => ({ key, value: { stringValue: value } });
65
+
66
+ /** Rounded to the nearest nanosecond as an integer string, never a float. */
67
+ function nanos(atMillis: number): string {
68
+ return `${Math.round(atMillis)}000000`;
69
+ }
70
+
71
+ /**
72
+ * `atMillis` is a parameter rather than a call to `Date.now()`.
73
+ *
74
+ * The same reason `computeSavings` takes a `Date`: a function that reads the
75
+ * clock cannot be asserted against a fixed expectation, and every test of it
76
+ * ends up asserting only its shape. Here the timestamp *is* half the payload.
77
+ */
78
+ export function toOtlpMetrics(input: OtlpInput, atMillis: number): OtlpPayload {
79
+ const { prompts, findings, model, callsPerMonth } = input;
80
+ const time = nanos(atMillis);
81
+ const metrics: OtlpMetric[] = [];
82
+
83
+ const gauge = (
84
+ name: string,
85
+ description: string,
86
+ unit: string,
87
+ dataPoints: OtlpDataPoint[],
88
+ ): void => {
89
+ // An empty metric is not the same as a zero, and a collector charting an
90
+ // empty series draws nothing rather than a flat line at zero. Omitted.
91
+ if (dataPoints.length > 0) metrics.push({ name, description, unit, gauge: { dataPoints } });
92
+ };
93
+
94
+ gauge(
95
+ 'trazum.prompt.tokens',
96
+ 'Input tokens in a prompt as written, the figure a budget is compared against.',
97
+ '{token}',
98
+ prompts.map((p) => ({
99
+ attributes: [str('prompt.path', p.path)],
100
+ timeUnixNano: time,
101
+ asInt: `${Math.round(p.tokens)}`,
102
+ })),
103
+ );
104
+
105
+ // Per prompt rather than as one count, so a dashboard can name the offender
106
+ // instead of showing a number that went from 2 to 3.
107
+ gauge(
108
+ 'trazum.prompt.over_budget',
109
+ 'Whether this prompt exceeds the budget that covers it. 1 when it does.',
110
+ '1',
111
+ prompts
112
+ .filter((p) => p.budgeted)
113
+ .map((p) => ({
114
+ attributes: [str('prompt.path', p.path)],
115
+ timeUnixNano: time,
116
+ asInt: p.overBudget ? '1' : '0',
117
+ })),
118
+ );
119
+
120
+ gauge(
121
+ 'trazum.prompts.unbudgeted',
122
+ 'Prompts no budget pattern matches, so nothing is watching them.',
123
+ '{prompt}',
124
+ [
125
+ {
126
+ attributes: [],
127
+ timeUnixNano: time,
128
+ asInt: `${prompts.filter((p) => !p.budgeted).length}`,
129
+ },
130
+ ],
131
+ );
132
+
133
+ // Money is a double. Sending this as asInt would report $41.20 as 41 and the
134
+ // chart would look plausible.
135
+ gauge(
136
+ 'trazum.advisory.monthly_usd',
137
+ 'Estimated monthly USD an advisory would recover, summed across the prompts raising it.',
138
+ 'USD',
139
+ findings
140
+ .filter((f): f is typeof f & { monthlyUsd: number } => f.monthlyUsd !== null)
141
+ .map((f) => ({
142
+ attributes: [str('advisory.id', f.id)],
143
+ timeUnixNano: time,
144
+ asDouble: f.monthlyUsd,
145
+ })),
146
+ );
147
+
148
+ gauge(
149
+ 'trazum.advisory.prompts',
150
+ 'How many prompts raise this advisory.',
151
+ '{prompt}',
152
+ findings.map((f) => ({
153
+ attributes: [str('advisory.id', f.id)],
154
+ timeUnixNano: time,
155
+ asInt: `${f.prompts}`,
156
+ })),
157
+ );
158
+
159
+ return {
160
+ resourceMetrics: [
161
+ {
162
+ // The model and call volume are resource attributes, not metric labels:
163
+ // every figure above was priced through them, and a dollar amount whose
164
+ // scenario is not recorded beside it is a number nobody can check later.
165
+ resource: {
166
+ attributes: [
167
+ str('service.name', 'trazum'),
168
+ str('trazum.model', model),
169
+ {
170
+ key: 'trazum.calls_per_month',
171
+ value: { intValue: `${Math.round(callsPerMonth)}` },
172
+ },
173
+ ],
174
+ },
175
+ scopeMetrics: [{ scope: { name: 'trazum', version: '1.0.0' }, metrics }],
176
+ },
177
+ ],
178
+ };
179
+ }