@trazum/core 1.82.0 → 1.85.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 (58) hide show
  1. package/README.md +2 -2
  2. package/dist/advisories.d.ts.map +1 -1
  3. package/dist/advisories.js +27 -4
  4. package/dist/advisories.js.map +1 -1
  5. package/dist/band.d.ts.map +1 -1
  6. package/dist/band.js +12 -0
  7. package/dist/band.js.map +1 -1
  8. package/dist/i18n/en.d.ts.map +1 -1
  9. package/dist/i18n/en.js +7 -0
  10. package/dist/i18n/en.js.map +1 -1
  11. package/dist/i18n/es.d.ts.map +1 -1
  12. package/dist/i18n/es.js +8 -0
  13. package/dist/i18n/es.js.map +1 -1
  14. package/dist/i18n/types.d.ts +17 -0
  15. package/dist/i18n/types.d.ts.map +1 -1
  16. package/dist/index.d.ts +6 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/langsmith.d.ts +104 -0
  21. package/dist/langsmith.d.ts.map +1 -0
  22. package/dist/langsmith.js +270 -0
  23. package/dist/langsmith.js.map +1 -0
  24. package/dist/levers.d.ts +1 -1
  25. package/dist/levers.js +2 -2
  26. package/dist/levers.js.map +1 -1
  27. package/dist/optimize.d.ts +2 -2
  28. package/dist/optimize.d.ts.map +1 -1
  29. package/dist/optimize.js +15 -1
  30. package/dist/optimize.js.map +1 -1
  31. package/dist/pricing.d.ts +98 -3
  32. package/dist/pricing.d.ts.map +1 -1
  33. package/dist/pricing.js +393 -35
  34. package/dist/pricing.js.map +1 -1
  35. package/dist/receipt.d.ts +253 -0
  36. package/dist/receipt.d.ts.map +1 -0
  37. package/dist/receipt.js +111 -0
  38. package/dist/receipt.js.map +1 -0
  39. package/dist/tokenizer.d.ts +4 -2
  40. package/dist/tokenizer.d.ts.map +1 -1
  41. package/dist/tokenizer.js +4 -2
  42. package/dist/tokenizer.js.map +1 -1
  43. package/dist/types.d.ts +148 -1
  44. package/dist/types.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/advisories.ts +34 -4
  47. package/src/band.ts +12 -0
  48. package/src/i18n/en.ts +9 -0
  49. package/src/i18n/es.ts +10 -0
  50. package/src/i18n/types.ts +18 -0
  51. package/src/index.ts +14 -1
  52. package/src/langsmith.ts +315 -0
  53. package/src/levers.ts +3 -3
  54. package/src/optimize.ts +16 -0
  55. package/src/pricing.ts +430 -30
  56. package/src/receipt.ts +351 -0
  57. package/src/tokenizer.ts +4 -2
  58. package/src/types.ts +144 -0
@@ -0,0 +1,315 @@
1
+ /**
2
+ * LangSmith's run export, read as a usage log.
3
+ *
4
+ * The fifth converter in the pattern `from-claude-code` started: a pure
5
+ * function turns one tool's export into Trazum's usage-log records, and every
6
+ * door prices it from there. LangSmith is the tracing product most LangChain
7
+ * teams already run, so the export exists before Trazum does.
8
+ *
9
+ * **The format is derived, not guessed.** Every field below is `RunBase` and
10
+ * `Run` in `python/langsmith/schemas.py` in langchain-ai/langsmith-sdk. A
11
+ * converter written from memory of an API is a converter that silently
12
+ * mis-reads somebody's bill.
13
+ *
14
+ * ## A run is not a call, and that is the whole difficulty
15
+ *
16
+ * LangSmith records a **run**, and a trace is a tree of them: chains, tools and
17
+ * retrievers alongside the model calls. Only `run_type: "llm"` carries token
18
+ * counts, and summing the tree would count the same tokens once per level. So
19
+ * everything else is skipped and **counted out loud** — a converter that
20
+ * quietly dropped two thirds of a file would look like one that read it.
21
+ *
22
+ * ## The model, and why it is refused rather than inferred
23
+ *
24
+ * There is no model column. The name lives in `extra.metadata.ls_model_name`,
25
+ * or in the invocation parameters the SDK records beside it, and a run whose
26
+ * metadata carries neither cannot be priced. The obvious guess is the run's
27
+ * own `name` — LangChain names them after the class, `ChatOpenAI`, which is a
28
+ * client and not a model. Pricing a call by the class that made it would be a
29
+ * figure attributed to something it does not describe, which is the fault this
30
+ * repository keeps finding in itself. Such a run is counted in `unnamedModel`
31
+ * and dropped.
32
+ *
33
+ * ## What deliberately does not cross
34
+ *
35
+ * `inputs` and `outputs` are the prompt and the completion, on every single
36
+ * run. **Neither is read.** `extra.metadata` is read for two named keys and
37
+ * nothing else, because it is a free-form bag the operator fills and the next
38
+ * thing they put in it might be a credential. The converter names the fields
39
+ * it takes and takes nothing else, and a fixture plants a marker in each and
40
+ * greps the whole output.
41
+ *
42
+ * ## What it refuses to invent
43
+ *
44
+ * There is no cache-token split. `prompt_cost_details` looks like one and is
45
+ * not: it is LangSmith's own priced breakdown in dollars, computed from
46
+ * LangSmith's price table. It never enters a record, and neither does
47
+ * `total_cost` — `reportedCostUsd` returns it on its own, the way
48
+ * `from-litellm` keeps the gateway's arithmetic apart from Trazum's. Two price
49
+ * tables, two figures; merging them is how a report becomes quietly wrong.
50
+ */
51
+
52
+ /** One converted record, shaped exactly as `parseUsageLine` reads it. */
53
+ export interface LangsmithRecord {
54
+ model: string;
55
+ ts?: string;
56
+ label?: string;
57
+ session?: string;
58
+ usage: {
59
+ input_tokens: number;
60
+ output_tokens: number;
61
+ };
62
+ }
63
+
64
+ export interface LangsmithConversion {
65
+ records: LangsmithRecord[];
66
+ /** Runs that were model calls and converted. */
67
+ rows: number;
68
+ /**
69
+ * Runs that were not model calls: chains, tools, retrievers, prompts.
70
+ *
71
+ * Counted rather than ignored. They are most of a LangSmith export, and a
72
+ * reader who converted a thousand runs into three hundred records is owed
73
+ * the reason.
74
+ */
75
+ notModelCalls: number;
76
+ /** Model calls whose metadata named no model: counted, never guessed. */
77
+ unnamedModel: number;
78
+ /** Model calls carrying no token counts at all. */
79
+ noTokens: number;
80
+ /**
81
+ * LangSmith's own cost total, in dollars, or null when no run reported one.
82
+ *
83
+ * Kept apart from anything Trazum computes and never merged into a record.
84
+ * Null is an absence, not a total of nothing.
85
+ */
86
+ reportedCostUsd: number | null;
87
+ /** Lines or documents that did not parse as JSON at all. */
88
+ unparseable: number;
89
+ }
90
+
91
+ const asObject = (value: unknown): Record<string, unknown> | null =>
92
+ typeof value === 'object' && value !== null && !Array.isArray(value)
93
+ ? (value as Record<string, unknown>)
94
+ : null;
95
+
96
+ const tryParse = (text: string): unknown => {
97
+ try {
98
+ return JSON.parse(text);
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ };
103
+
104
+ const asCount = (value: unknown): number | undefined => {
105
+ const n = typeof value === 'string' ? Number(value) : value;
106
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : undefined;
107
+ };
108
+
109
+ const asName = (value: unknown): string | null =>
110
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
111
+
112
+ /** `extra.metadata`, which is where LangSmith documents everything user-set. */
113
+ function metadataOf(run: Record<string, unknown>): Record<string, unknown> | null {
114
+ const extra = asObject(run.extra);
115
+ return extra === null ? asObject(run.metadata) : (asObject(extra.metadata) ?? asObject(run.metadata));
116
+ }
117
+
118
+ /**
119
+ * The model, from the two named places it is recorded and nowhere else.
120
+ *
121
+ * `ls_model_name` is what the SDK writes; `model` and `model_name` are what a
122
+ * team writes by hand when they set metadata themselves. The run's `name` is
123
+ * deliberately not consulted: LangChain names a run after the client class,
124
+ * and `ChatOpenAI` is not a model anybody can price.
125
+ */
126
+ function modelOf(run: Record<string, unknown>): string | null {
127
+ const metadata = metadataOf(run);
128
+ if (metadata !== null) {
129
+ for (const key of ['ls_model_name', 'model_name', 'model']) {
130
+ const found = asName(metadata[key]);
131
+ if (found !== null) return found;
132
+ }
133
+ // The invocation parameters, where the SDK also records it.
134
+ const params = asObject(metadata.invocation_params);
135
+ if (params !== null) {
136
+ for (const key of ['model', 'model_name']) {
137
+ const found = asName(params[key]);
138
+ if (found !== null) return found;
139
+ }
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * The label, from the tags a team already puts on its traces.
147
+ *
148
+ * `tags` is LangSmith's own list and the first entry wins, because a workload
149
+ * has one name in a bill. Then the two metadata keys a team would plausibly
150
+ * name a workload by. Never joined, never invented.
151
+ */
152
+ function labelOf(run: Record<string, unknown>): string | undefined {
153
+ if (Array.isArray(run.tags)) {
154
+ for (const tag of run.tags) {
155
+ const found = asName(tag);
156
+ if (found !== null) return found;
157
+ }
158
+ }
159
+ const metadata = metadataOf(run);
160
+ if (metadata !== null) {
161
+ for (const key of ['label', 'workload']) {
162
+ const found = asName(metadata[key]);
163
+ if (found !== null) return found;
164
+ }
165
+ }
166
+ return undefined;
167
+ }
168
+
169
+ /** The run's start time as an ISO instant, or undefined. */
170
+ function timeOf(run: Record<string, unknown>): string | undefined {
171
+ const raw = run.start_time ?? run.startTime;
172
+ if (typeof raw !== 'string' && typeof raw !== 'number') return undefined;
173
+ const ms = typeof raw === 'number' ? raw : Date.parse(raw);
174
+ if (!Number.isFinite(ms)) return undefined;
175
+ return new Date(ms).toISOString();
176
+ }
177
+
178
+ /**
179
+ * The token counts, from the two shapes LangSmith has written them in.
180
+ *
181
+ * `prompt_tokens` and `completion_tokens` sit on the run; newer exports also
182
+ * carry a nested `usage_metadata` with `input_tokens` and `output_tokens`.
183
+ * Both are read because both are in the wild, and an export from last year is
184
+ * still somebody's bill.
185
+ */
186
+ function tokensOf(run: Record<string, unknown>): { input?: number; output?: number } {
187
+ const nested = asObject(run.usage_metadata);
188
+ return {
189
+ input: asCount(run.prompt_tokens) ?? asCount(nested?.input_tokens),
190
+ output: asCount(run.completion_tokens) ?? asCount(nested?.output_tokens),
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Convert a LangSmith run export.
196
+ *
197
+ * Accepts a JSON array of runs, a single run, `{ runs: [...] }` as the list
198
+ * endpoint returns it, or newline-delimited runs. Pure over its input.
199
+ */
200
+ export function langsmithRecords(text: string): LangsmithConversion {
201
+ const out: LangsmithConversion = {
202
+ records: [],
203
+ rows: 0,
204
+ notModelCalls: 0,
205
+ unnamedModel: 0,
206
+ noTokens: 0,
207
+ reportedCostUsd: null,
208
+ unparseable: 0,
209
+ };
210
+
211
+ const runs: unknown[] = [];
212
+ const whole = tryParse(text);
213
+ if (whole !== undefined) {
214
+ if (Array.isArray(whole)) runs.push(...whole);
215
+ else {
216
+ const doc = asObject(whole);
217
+ if (doc !== null && Array.isArray(doc.runs)) runs.push(...doc.runs);
218
+ else if (doc !== null) runs.push(doc);
219
+ }
220
+ } else {
221
+ for (const line of text.split('\n')) {
222
+ if (line.trim() === '') continue;
223
+ const parsed = tryParse(line);
224
+ if (parsed === undefined) out.unparseable += 1;
225
+ else if (Array.isArray(parsed)) runs.push(...parsed);
226
+ else runs.push(parsed);
227
+ }
228
+ }
229
+
230
+ let cost = 0;
231
+ let sawCost = false;
232
+
233
+ for (const raw of runs) {
234
+ const run = asObject(raw);
235
+ if (run === null) continue;
236
+
237
+ /*
238
+ A run is only a call when LangSmith says it is one. The tree carries
239
+ chains, tools, retrievers and prompts alongside the model calls, and the
240
+ parent chain of an llm run repeats its child's tokens: summing the tree
241
+ would bill the same tokens once per level. `run_type` is the field that
242
+ settles it, and a run with no run_type at all is not a run.
243
+ */
244
+ const type = asName(run.run_type);
245
+ if (type === null) continue;
246
+ if (type !== 'llm') {
247
+ out.notModelCalls += 1;
248
+ continue;
249
+ }
250
+
251
+ out.rows += 1;
252
+
253
+ const reported = asCount(run.total_cost);
254
+ if (reported !== undefined) {
255
+ cost += reported;
256
+ sawCost = true;
257
+ }
258
+
259
+ const model = modelOf(run);
260
+ if (model === null) {
261
+ out.unnamedModel += 1;
262
+ continue;
263
+ }
264
+
265
+ const { input, output } = tokensOf(run);
266
+ if (input === undefined && output === undefined) {
267
+ out.noTokens += 1;
268
+ continue;
269
+ }
270
+ if (input === 0 && output === 0) out.noTokens += 1;
271
+
272
+ const ts = timeOf(run);
273
+ const label = labelOf(run);
274
+ /*
275
+ The trace is the conversation and the run id is one call. `trace_id`
276
+ spans the calls a single request made, which is the identity Trazum's
277
+ conversation findings need; answering them from `id` would report every
278
+ call as a conversation of one.
279
+ */
280
+ const session = asName(run.trace_id);
281
+
282
+ out.records.push({
283
+ model,
284
+ ...(ts !== undefined ? { ts } : {}),
285
+ ...(label !== undefined ? { label } : {}),
286
+ ...(session !== null ? { session } : {}),
287
+ usage: { input_tokens: input ?? 0, output_tokens: output ?? 0 },
288
+ });
289
+ }
290
+
291
+ // Rounded because these are dollars summed in floating point, and a total
292
+ // printed to eight decimal places reads as a precision nobody has.
293
+ if (sawCost) out.reportedCostUsd = Math.round(cost * 1e6) / 1e6;
294
+
295
+ return out;
296
+ }
297
+
298
+ /**
299
+ * Whether a file looks like a LangSmith run export, by shape.
300
+ *
301
+ * Deliberately narrow: `run_type` beside one of the trace fields only
302
+ * LangSmith writes. A looser test would claim a Helicone export or a LiteLLM
303
+ * spend log — both carry `prompt_tokens` — and refuse it in this file's words
304
+ * instead of letting the code that can read it try.
305
+ */
306
+ export function looksLikeLangsmith(text: string, prefixBytes = 8192): boolean {
307
+ const head = text.slice(0, prefixBytes);
308
+ if (!head.includes('"run_type"')) return false;
309
+ return (
310
+ head.includes('"trace_id"') ||
311
+ head.includes('"dotted_order"') ||
312
+ head.includes('"start_time"') ||
313
+ head.includes('"session_id"')
314
+ );
315
+ }
package/src/levers.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { effectivePricing, multipliersFor } from './pricing.js';
1
+ import { effectivePricing, isOffered, multipliersFor } from './pricing.js';
2
2
  import { UNLABELLED } from './usage.js';
3
3
  import type { PricingCatalogue } from './pricing.js';
4
4
  import type { UsageBreakdown, UsageProfileReport } from './usage.js';
@@ -21,7 +21,7 @@ import type { Capability, ModelPricing } from './types.js';
21
21
  *
22
22
  * | lever | what it moves |
23
23
  * |---|---|
24
- * | which model the call goes to | Opus 5 → Sonnet 5 is 40% off; → Haiku 4.5 is 80% |
24
+ * | which model the call goes to | Opus 5 → Sonnet 5 is 60% off; → Haiku 4.5 is 80% |
25
25
  * | the Batch API | 50% flat, on input and output |
26
26
  * | prompt caching | 3–4x the rules |
27
27
  * | shortening the prompt | ~1% |
@@ -204,7 +204,7 @@ function candidateFor(
204
204
  m.id !== model.id &&
205
205
  m.capability === target &&
206
206
  m.provider === model.provider &&
207
- m.recommendable !== false &&
207
+ isOffered(m) &&
208
208
  m.contextWindow >= avgInput,
209
209
  );
210
210
  if (candidates.length === 0) return null;
package/src/optimize.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  RuleResult,
17
17
  Segment,
18
18
  TokenCounter,
19
+ TokenProvenance,
19
20
  UsageProfile,
20
21
  } from './types.js';
21
22
 
@@ -210,6 +211,14 @@ export function optimize(prompt: string, options: OptimizeOptions = {}): Optimiz
210
211
  usage,
211
212
  locale,
212
213
  tokenSource: options.tokenCounter ? 'external' : 'heuristic',
214
+ /*
215
+ * Null even when a counter was supplied here, and that is not an oversight.
216
+ * `optimize` takes a bare function: it can see that something other than
217
+ * the heuristic ran and cannot see whose it was. Naming one would be an
218
+ * invention, and `withExactTokenCounts` is where a caller that does know
219
+ * says so.
220
+ */
221
+ countedBy: null,
213
222
  pricingSource: {
214
223
  lastReviewed: pricing.lastReviewed,
215
224
  overriddenModels: pricing.overriddenModels,
@@ -230,6 +239,7 @@ export async function withExactTokenCounts(
230
239
  result: OptimizationResult,
231
240
  counter: AsyncTokenCounter,
232
241
  pricing: PricingCatalogue = BUNDLED_CATALOGUE,
242
+ countedBy: TokenProvenance | null = null,
233
243
  ): Promise<OptimizationResult> {
234
244
  // A result priced against an overlay cannot be recomputed against the bundled
235
245
  // catalogue: the token counts would come from one source and the money from
@@ -267,6 +277,12 @@ export async function withExactTokenCounts(
267
277
  savings,
268
278
  advisories,
269
279
  tokenSource: 'external',
280
+ /*
281
+ * Optional and defaulting to null rather than to a guess. A caller that
282
+ * knows whose counter it passed says so; one that does not gets `external`
283
+ * with no claim attached, which is exactly as much as this function knows.
284
+ */
285
+ countedBy,
270
286
  pricingSource: {
271
287
  lastReviewed: pricing.lastReviewed,
272
288
  overriddenModels: pricing.overriddenModels,