openmerit 0.3.11 → 0.4.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 (69) hide show
  1. package/README.md +74 -22
  2. package/data/eci-estimates.csv +179 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +180 -0
  5. package/dist/connectors/langfuse/client.d.ts +42 -0
  6. package/dist/connectors/langfuse/client.js +141 -0
  7. package/dist/connectors/langfuse/index.d.ts +163 -0
  8. package/dist/connectors/langfuse/index.js +378 -0
  9. package/dist/connectors/langfuse/otlp.d.ts +60 -0
  10. package/dist/connectors/langfuse/otlp.js +40 -0
  11. package/dist/index.d.ts +11 -0
  12. package/dist/index.js +11 -0
  13. package/dist/pi/extension.d.ts +13 -0
  14. package/dist/pi/extension.js +148 -0
  15. package/dist/pi/harness.d.ts +20 -0
  16. package/dist/pi/harness.js +49 -0
  17. package/dist/pi/instructions.d.ts +2 -0
  18. package/dist/pi/instructions.js +12 -0
  19. package/dist/scout/candidates.d.ts +62 -0
  20. package/dist/scout/candidates.js +90 -0
  21. package/dist/scout/eci.d.ts +42 -0
  22. package/dist/scout/eci.js +84 -0
  23. package/dist/scout/evaluators/module.d.ts +24 -0
  24. package/dist/scout/evaluators/module.js +19 -0
  25. package/dist/scout/evaluators/pi.d.ts +53 -0
  26. package/dist/scout/evaluators/pi.js +117 -0
  27. package/dist/scout/pipeline.d.ts +111 -0
  28. package/dist/scout/pipeline.js +157 -0
  29. package/dist/scout/report.d.ts +3 -0
  30. package/dist/scout/report.js +42 -0
  31. package/dist/scout/search.d.ts +110 -0
  32. package/dist/scout/search.js +205 -0
  33. package/dist/scout/service.d.ts +148 -0
  34. package/dist/scout/service.js +143 -0
  35. package/examples/health-declaration-codes/dataset.jsonl +14 -0
  36. package/examples/sahra-enrollment/evaluator.ts +151 -0
  37. package/package.json +34 -78
  38. package/CHANGELOG.md +0 -255
  39. package/dist/pi/scout-extension.mjs +0 -287
  40. package/dist/pi/src/index.d.ts +0 -6
  41. package/dist/pi/src/index.js +0 -56
  42. package/dist/terminal/scout-update.json +0 -1
  43. package/dist/terminal/src/browser.d.mts +0 -9
  44. package/dist/terminal/src/browser.mjs +0 -28
  45. package/dist/terminal/src/cli.mjs +0 -28
  46. package/dist/terminal/src/connections.js +0 -151
  47. package/dist/terminal/src/install.d.mts +0 -2
  48. package/dist/terminal/src/install.mjs +0 -524
  49. package/dist/terminal/src/scout-claude.js +0 -25
  50. package/dist/terminal/src/scout-credentials.js +0 -41
  51. package/dist/terminal/src/scout-instructions.js +0 -213
  52. package/dist/terminal/src/scout-pi.js +0 -84
  53. package/dist/terminal/src/scout.js +0 -229
  54. package/dist/terminal/src/telemetry.d.mts +0 -30
  55. package/dist/terminal/src/telemetry.mjs +0 -138
  56. package/dist/terminal/src/updates.js +0 -397
  57. package/docs/agent-evaluations.md +0 -15
  58. package/docs/commands.md +0 -9
  59. package/docs/getting-started.md +0 -37
  60. package/docs/index.md +0 -25
  61. package/docs/inference-terminal.md +0 -25
  62. package/docs/pi-extension.md +0 -58
  63. package/docs/roadmap.md +0 -16
  64. package/docs/scout-close-loop.md +0 -371
  65. package/docs/scout-data-policy.md +0 -87
  66. package/docs/scout.md +0 -237
  67. package/docs/security.md +0 -107
  68. package/docs/testing.md +0 -114
  69. package/docs/troubleshooting.md +0 -50
@@ -0,0 +1,378 @@
1
+ // Langfuse eval connector: reads labelled datasets and experiment results, and records the
2
+ // evaluations Scout runs as Langfuse experiments, on Langfuse Cloud or a self-hosted instance.
3
+ //
4
+ // Read paths: /api/public/projects, /api/public/v2/datasets, /api/public/dataset-items,
5
+ // /api/public/experiments, /api/public/experiment-items (fields=scores), /api/public/v3/scores
6
+ // (trace-level scores that experiment items omit) and /api/public/v2/observations (cost).
7
+ // Write paths: /api/public/v2/datasets and /api/public/dataset-items (ensureDataset), OTLP
8
+ // /api/public/otel/v1/traces with langfuse.experiment.* attributes, and /api/public/scores.
9
+ // The deprecated dataset-run-items API is not used.
10
+ import { createTransport, LangfuseError } from "./client.js";
11
+ import { asText, attribute, exportRequest, hexId, span } from "./otlp.js";
12
+ export { connectionFromEnv, LangfuseError, normalizeBaseUrl } from "./client.js";
13
+ /** Version written into recorded traces' scope. */
14
+ export const CONNECTOR_VERSION = "0.1.0";
15
+ const DAY_MS = 86_400_000;
16
+ const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
17
+ const str = (value) => (typeof value === "string" ? value : null);
18
+ export function evaluatePass(rule, value) {
19
+ if (value === undefined)
20
+ return false;
21
+ if (rule.equals !== undefined)
22
+ return value === rule.equals || (typeof rule.equals === "boolean" && value === (rule.equals ? 1 : 0));
23
+ if (rule.min !== undefined || rule.max !== undefined) {
24
+ if (typeof value !== "number")
25
+ return false;
26
+ return (rule.min === undefined || value >= rule.min) && (rule.max === undefined || value <= rule.max);
27
+ }
28
+ return value === true || value === 1;
29
+ }
30
+ function scoreValue(score) {
31
+ const value = score.value;
32
+ if (score.dataType === "BOOLEAN") {
33
+ if (typeof value === "boolean")
34
+ return value;
35
+ if (value === 1 || value === "1" || value === "true" || value === "True")
36
+ return true;
37
+ if (value === 0 || value === "0" || value === "false" || value === "False")
38
+ return false;
39
+ return undefined;
40
+ }
41
+ if (typeof value === "number" && Number.isFinite(value))
42
+ return value;
43
+ if (typeof value === "string" || typeof value === "boolean")
44
+ return value;
45
+ return undefined;
46
+ }
47
+ const iso = (since, now) => (since === undefined ? new Date(now - 30 * DAY_MS).toISOString() : new Date(since).toISOString());
48
+ function stable(value) {
49
+ if (Array.isArray(value))
50
+ return `[${value.map(stable).join(",")}]`;
51
+ if (isObject(value))
52
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`;
53
+ return JSON.stringify(value) ?? "null";
54
+ }
55
+ /** Trace ID of one recorded item: the first 32 hex characters of SHA-256 over its experiment and item. */
56
+ export const recordedTraceId = (experimentId, itemId) => hexId(`openmerit:experiment:${experimentId}:item:${itemId}`, 32);
57
+ export function createLangfuseConnector(connection, options = {}) {
58
+ const http = createTransport(connection, options);
59
+ const environment = options.environment ?? "openmerit";
60
+ if (!/^(?!langfuse)[a-z0-9_-]{1,40}$/.test(environment))
61
+ throw new LangfuseError("The Langfuse environment must be lowercase letters, digits, - or _, and must not start with 'langfuse'.");
62
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
63
+ let project;
64
+ const get = async (path, query, allowNotFound = false) => (await http.request("GET", path, { ...(query ? { query } : {}), allowNotFound })).data;
65
+ async function* cursorPages(path, query) {
66
+ const seen = new Set();
67
+ let cursor;
68
+ do {
69
+ const page = await get(path, { ...query, cursor });
70
+ if (!page || !Array.isArray(page.data))
71
+ throw new LangfuseError(`Langfuse GET ${path} returned no data array.`);
72
+ yield page.data;
73
+ cursor = str(page.meta?.cursor) ?? undefined;
74
+ if (cursor && seen.has(cursor))
75
+ throw new LangfuseError(`Langfuse GET ${path} repeated a pagination cursor.`);
76
+ if (cursor)
77
+ seen.add(cursor);
78
+ } while (cursor);
79
+ }
80
+ async function* numberedPages(path, query) {
81
+ for (let page = 1;; page += 1) {
82
+ const body = await get(path, { ...query, page, limit: 100 });
83
+ if (!body || !Array.isArray(body.data))
84
+ throw new LangfuseError(`Langfuse GET ${path} returned no data array.`);
85
+ yield body.data;
86
+ const total = typeof body.meta?.totalPages === "number" ? body.meta.totalPages : 0;
87
+ if (page >= total || body.data.length === 0)
88
+ return;
89
+ }
90
+ }
91
+ function getProject() {
92
+ project ??= (async () => {
93
+ const body = await get("/api/public/projects");
94
+ const first = Array.isArray(body?.data) ? body.data[0] : undefined;
95
+ if (!isObject(first) || !str(first.id))
96
+ throw new LangfuseError("Langfuse returned no project for this key pair. Use a project-scoped API key.");
97
+ return { id: String(first.id), name: str(first.name) ?? "" };
98
+ })();
99
+ project.catch(() => (project = undefined));
100
+ return project;
101
+ }
102
+ async function getDataset(name) {
103
+ const body = await get(`/api/public/v2/datasets/${encodeURIComponent(name)}`, undefined, true);
104
+ if (!body || !str(body.id))
105
+ throw new LangfuseError(`Langfuse has no dataset named "${name}" in this project.`, 404);
106
+ return { id: String(body.id), name: str(body.name) ?? name };
107
+ }
108
+ async function listExperiments(query = {}) {
109
+ const datasetId = query.datasetId ?? (query.datasetName ? (await getDataset(query.datasetName)).id : undefined);
110
+ const experiments = [];
111
+ for await (const page of cursorPages("/api/public/experiments", { fields: "core,metadata", limit: 100, fromStartTime: iso(query.since, Date.now()), datasetId, name: query.names?.join(","), id: query.ids?.join(",") })) {
112
+ for (const raw of page) {
113
+ if (!isObject(raw) || !str(raw.id))
114
+ continue;
115
+ const metadata = isObject(raw.metadata) ? raw.metadata : {};
116
+ const models = {};
117
+ for (const [key, value] of Object.entries(metadata))
118
+ if (key.startsWith("model_") && typeof value === "string")
119
+ models[key.slice("model_".length)] = value;
120
+ experiments.push({ id: String(raw.id), name: str(raw.name) ?? "", datasetId: str(raw.datasetId), itemCount: typeof raw.itemCount === "number" ? raw.itemCount : null, startTime: str(raw.startTime), metadata, models });
121
+ }
122
+ }
123
+ return experiments;
124
+ }
125
+ /** Latest score per name for each trace, from /api/public/v3/scores (trace- and observation-level). */
126
+ async function scoresByTrace(traceIds, name, since) {
127
+ const latest = new Map();
128
+ for (let i = 0; i < traceIds.length; i += 40) {
129
+ for await (const page of cursorPages("/api/public/v3/scores", { traceId: traceIds.slice(i, i + 40).join(","), name, fields: "subject", limit: 100, fromTimestamp: since })) {
130
+ for (const raw of page) {
131
+ if (!isObject(raw) || !isObject(raw.subject))
132
+ continue;
133
+ const traceId = raw.subject.kind === "trace" ? str(raw.subject.id) : str(raw.subject.traceId);
134
+ const value = scoreValue(raw);
135
+ const at = str(raw.timestamp) ?? "";
136
+ if (!traceId || value === undefined)
137
+ continue;
138
+ const old = latest.get(traceId);
139
+ if (!old || at > old.at)
140
+ latest.set(traceId, { at, value });
141
+ }
142
+ }
143
+ }
144
+ return new Map([...latest].map(([traceId, { value }]) => [traceId, value]));
145
+ }
146
+ async function costsByTrace(traceIds, since) {
147
+ const costs = new Map();
148
+ for (let i = 0; i < traceIds.length; i += 40) {
149
+ const filter = JSON.stringify([
150
+ { type: "stringOptions", column: "traceId", operator: "any of", value: traceIds.slice(i, i + 40) },
151
+ { type: "string", column: "type", operator: "=", value: "GENERATION" },
152
+ { type: "datetime", column: "startTime", operator: ">=", value: since },
153
+ ]);
154
+ for await (const page of cursorPages("/api/public/v2/observations", { fields: "core,usage", limit: 100, filter })) {
155
+ for (const raw of page) {
156
+ if (!isObject(raw) || !str(raw.traceId) || typeof raw.totalCost !== "number" || !Number.isFinite(raw.totalCost))
157
+ continue;
158
+ costs.set(String(raw.traceId), (costs.get(String(raw.traceId)) ?? 0) + raw.totalCost);
159
+ }
160
+ }
161
+ }
162
+ return costs;
163
+ }
164
+ async function experimentResults(query) {
165
+ if (!query.experimentId === !query.experimentName)
166
+ throw new LangfuseError("Pass exactly one of experimentId or experimentName.");
167
+ if (!query.pass?.score)
168
+ throw new LangfuseError("Pass a pass rule naming the score that decides each item.");
169
+ const since = iso(query.since, Date.now());
170
+ const latest = new Map();
171
+ const experimentIds = new Set();
172
+ let experimentName = query.experimentName ?? "";
173
+ let datasetId = null;
174
+ for await (const page of cursorPages("/api/public/experiment-items", { fields: "core,dataset,scores", limit: 100, scoreLimit: 50, fromStartTime: since, experimentId: query.experimentId, experimentName: query.experimentName })) {
175
+ for (const raw of page) {
176
+ if (!isObject(raw) || !str(raw.experimentItemId) || !str(raw.traceId))
177
+ continue;
178
+ experimentIds.add(str(raw.experimentId) ?? "");
179
+ experimentName = str(raw.experimentName) ?? experimentName;
180
+ datasetId = str(raw.experimentDatasetId) ?? datasetId;
181
+ const itemId = String(raw.experimentItemId);
182
+ const at = str(raw.startTime) ?? "";
183
+ const old = latest.get(itemId);
184
+ // A retried item has several experiment items; its latest run counts.
185
+ if (!old || at > old.at)
186
+ latest.set(itemId, { raw, at });
187
+ }
188
+ }
189
+ if (experimentIds.size > 1)
190
+ throw new LangfuseError(`Experiment name "${query.experimentName}" matches ${experimentIds.size} experiments (${[...experimentIds].join(", ")}). Query by experimentId.`);
191
+ const wanted = query.itemIds ? new Set(query.itemIds) : null;
192
+ const items = [];
193
+ for (const [itemId, { raw }] of latest) {
194
+ if (wanted && !wanted.has(itemId))
195
+ continue;
196
+ const scores = {};
197
+ for (const score of Array.isArray(raw.scores) ? raw.scores : []) {
198
+ if (!isObject(score) || !str(score.name))
199
+ continue;
200
+ const value = scoreValue(score);
201
+ const at = str(score.timestamp) ?? "";
202
+ const old = scores[String(score.name)];
203
+ if (value !== undefined && (!old || at > old.at))
204
+ scores[String(score.name)] = { at, value };
205
+ }
206
+ items.push({ itemId, traceId: String(raw.traceId), passed: false, missingScore: false, scores: Object.fromEntries(Object.entries(scores).map(([name, { value }]) => [name, value])), costUsd: null });
207
+ }
208
+ // Experiment items list observation-level scores; scores posted on the trace come from the scores API.
209
+ const unscored = items.filter((item) => item.scores[query.pass.score] === undefined);
210
+ if (unscored.length) {
211
+ const found = await scoresByTrace(unscored.map((item) => item.traceId), query.pass.score, since);
212
+ for (const item of unscored) {
213
+ const value = found.get(item.traceId);
214
+ if (value !== undefined)
215
+ item.scores[query.pass.score] = value;
216
+ }
217
+ }
218
+ for (const item of items) {
219
+ const value = item.scores[query.pass.score];
220
+ item.missingScore = value === undefined;
221
+ item.passed = evaluatePass(query.pass, value);
222
+ }
223
+ let costUsd = null;
224
+ if (query.includeCost && items.length) {
225
+ const costs = await costsByTrace(items.map((item) => item.traceId), since);
226
+ for (const item of items)
227
+ item.costUsd = costs.get(item.traceId) ?? null;
228
+ costUsd = costs.size ? [...costs.values()].reduce((sum, cost) => sum + cost, 0) : null;
229
+ }
230
+ items.sort((a, b) => a.itemId.localeCompare(b.itemId));
231
+ return {
232
+ experimentId: query.experimentId ?? [...experimentIds][0] ?? "",
233
+ experimentName,
234
+ datasetId,
235
+ items,
236
+ passes: items.filter((item) => item.passed).length,
237
+ total: items.length,
238
+ missing: query.itemIds ? query.itemIds.filter((id) => !latest.has(id)) : [],
239
+ missingScores: items.filter((item) => item.missingScore).map((item) => item.itemId),
240
+ costUsd,
241
+ };
242
+ }
243
+ async function experimentUrl(datasetId, experimentId) {
244
+ const { id } = await getProject();
245
+ return `${http.baseUrl}/project/${encodeURIComponent(id)}/datasets/${encodeURIComponent(datasetId)}/runs/${encodeURIComponent(experimentId)}`;
246
+ }
247
+ return {
248
+ baseUrl: http.baseUrl,
249
+ async check() {
250
+ const { id, name } = await getProject();
251
+ return { baseUrl: http.baseUrl, projectId: id, projectName: name };
252
+ },
253
+ async listDatasets() {
254
+ const datasets = [];
255
+ for await (const page of numberedPages("/api/public/v2/datasets", {}))
256
+ for (const raw of page)
257
+ if (isObject(raw) && str(raw.id) && str(raw.name))
258
+ datasets.push({ id: String(raw.id), name: String(raw.name) });
259
+ return datasets;
260
+ },
261
+ getDataset,
262
+ async getDatasetItems(datasetName) {
263
+ const items = [];
264
+ for await (const page of numberedPages("/api/public/dataset-items", { datasetName })) {
265
+ for (const raw of page) {
266
+ if (!isObject(raw) || !str(raw.id) || raw.status === "ARCHIVED")
267
+ continue;
268
+ items.push({ id: String(raw.id), input: raw.input ?? null, expectedOutput: raw.expectedOutput ?? null, metadata: isObject(raw.metadata) ? raw.metadata : {} });
269
+ }
270
+ }
271
+ return items;
272
+ },
273
+ async ensureDataset(input) {
274
+ if (!input.name?.trim())
275
+ throw new LangfuseError("A dataset needs a name.");
276
+ const existing = await http.request("GET", `/api/public/v2/datasets/${encodeURIComponent(input.name)}`, { allowNotFound: true });
277
+ const dataset = existing.data ?? (await http.request("POST", "/api/public/v2/datasets", { body: { name: input.name, description: input.description, metadata: input.metadata } })).data;
278
+ if (!isObject(dataset) || !str(dataset.id))
279
+ throw new LangfuseError(`Langfuse returned no ID for dataset "${input.name}".`);
280
+ const itemIds = [];
281
+ for (const item of input.items) {
282
+ const id = item.id ?? `om-${hexId(`${input.name}\n${stable(item.input)}`, 16)}`;
283
+ // Upserts by ID. Langfuse item IDs are unique per project, so reuse an ID in one dataset only.
284
+ await http.request("POST", "/api/public/dataset-items", { body: { datasetName: input.name, id, input: item.input, expectedOutput: item.expectedOutput, metadata: item.metadata } });
285
+ itemIds.push(id);
286
+ }
287
+ return { datasetId: String(dataset.id), itemIds };
288
+ },
289
+ listExperiments,
290
+ experimentResults,
291
+ async waitForExperiment(query, { expectItems, timeoutMs = 600_000, intervalMs = 15_000 }) {
292
+ const deadline = Date.now() + timeoutMs;
293
+ for (;;) {
294
+ const result = await experimentResults(query);
295
+ const scored = result.total - result.missingScores.length;
296
+ if (scored >= expectItems)
297
+ return result;
298
+ if (Date.now() + intervalMs > deadline) {
299
+ throw new LangfuseError(`Langfuse shows ${scored}/${expectItems} scored items for experiment ${query.experimentId ?? query.experimentName} after ${Math.round(timeoutMs / 1000)} s (missing items: ${result.missing.join(", ") || "none"}; unscored: ${result.missingScores.join(", ") || "none"}).`);
300
+ }
301
+ await sleep(intervalMs);
302
+ }
303
+ },
304
+ async recordExperimentItems(recording) {
305
+ const { datasetId, experimentId, experimentName } = recording;
306
+ if (![datasetId, experimentId, experimentName].every((value) => typeof value === "string" && value.trim()))
307
+ throw new LangfuseError("A recording needs datasetId, experimentId and experimentName.");
308
+ if (!recording.items.length)
309
+ throw new LangfuseError("A recording needs at least one item.");
310
+ const traceName = recording.traceName ?? "openmerit-eval";
311
+ const traceIds = {};
312
+ const spans = [];
313
+ const scores = [];
314
+ for (const item of recording.items) {
315
+ if (traceIds[item.itemId])
316
+ throw new LangfuseError(`Item ${item.itemId} appears twice in one recording.`);
317
+ const traceId = recordedTraceId(experimentId, item.itemId);
318
+ const rootId = hexId(`${traceId}:root`, 16);
319
+ traceIds[item.itemId] = traceId;
320
+ const common = [
321
+ attribute("langfuse.trace.name", traceName),
322
+ attribute("langfuse.trace.tags", ["openmerit", `experiment:${experimentId}`]),
323
+ attribute("langfuse.environment", environment),
324
+ attribute("langfuse.release", `openmerit-langfuse-connector@${CONNECTOR_VERSION}`),
325
+ attribute("langfuse.trace.metadata.model", item.model),
326
+ ...Object.entries(item.metadata ?? {}).map(([key, value]) => attribute(`langfuse.trace.metadata.${key}`, String(value))),
327
+ ];
328
+ spans.push(span({
329
+ traceId, spanId: rootId, name: traceName, startMs: item.startMs, endMs: item.endMs, ...(item.error ? { error: item.error } : {}),
330
+ attributes: [
331
+ ...common,
332
+ attribute("langfuse.observation.type", "span"),
333
+ attribute("langfuse.observation.input", asText(item.input)),
334
+ attribute("langfuse.observation.output", asText(item.output ?? null)),
335
+ ...(item.error ? [attribute("langfuse.observation.level", "ERROR"), attribute("langfuse.observation.status_message", item.error.slice(0, 500))] : []),
336
+ attribute("langfuse.experiment.id", experimentId),
337
+ attribute("langfuse.experiment.name", experimentName),
338
+ attribute("langfuse.experiment.dataset.id", datasetId),
339
+ attribute("langfuse.experiment.item.id", item.itemId),
340
+ attribute("langfuse.experiment.item.root_observation_id", rootId),
341
+ ...Object.entries(recording.metadata ?? {}).map(([key, value]) => attribute(`langfuse.experiment.metadata.${key}`, String(value))),
342
+ ],
343
+ }), span({
344
+ traceId, spanId: hexId(`${traceId}:generation`, 16), parentSpanId: rootId, name: `${traceName}-model`, startMs: item.startMs, endMs: item.endMs, ...(item.error ? { error: item.error } : {}),
345
+ attributes: [
346
+ ...common,
347
+ attribute("langfuse.observation.type", "generation"),
348
+ attribute("langfuse.observation.model.name", item.model),
349
+ ...(item.usage ? [attribute("langfuse.observation.usage_details", JSON.stringify(item.usage))] : []),
350
+ ...(item.costUsd !== undefined ? [attribute("langfuse.observation.cost_details", JSON.stringify({ total: item.costUsd }))] : []),
351
+ ],
352
+ }));
353
+ for (const score of item.scores) {
354
+ const dataType = score.dataType ?? (typeof score.value === "boolean" ? "BOOLEAN" : typeof score.value === "number" ? "NUMERIC" : "CATEGORICAL");
355
+ const value = typeof score.value === "boolean" ? (score.value ? 1 : 0) : score.value;
356
+ if (dataType === "CATEGORICAL" ? typeof value !== "string" : typeof value !== "number" || !Number.isFinite(value))
357
+ throw new LangfuseError(`Score ${score.name} on item ${item.itemId} has a ${typeof score.value} value for data type ${dataType}.`);
358
+ // A deterministic ID makes a retried recording update the score instead of adding a second one.
359
+ scores.push({ id: hexId(`openmerit:score:${traceId}:${score.name}`, 32), traceId, observationId: rootId, name: score.name, value, dataType, environment, ...(score.comment ? { comment: score.comment.slice(0, 1000) } : {}), metadata: { source: "openmerit", experiment: experimentId } });
360
+ }
361
+ }
362
+ for (let i = 0; i < spans.length; i += 200) {
363
+ const response = await http.request("POST", "/api/public/otel/v1/traces", { body: exportRequest(spans.slice(i, i + 200), CONNECTOR_VERSION), headers: { "x-langfuse-ingestion-version": "4" } });
364
+ const rejected = Number(response.data?.partialSuccess?.rejectedSpans ?? 0);
365
+ if (rejected > 0)
366
+ throw new LangfuseError(`Langfuse rejected ${rejected} spans: ${String(response.data?.partialSuccess?.errorMessage ?? "").slice(0, 300)}`);
367
+ }
368
+ for (let i = 0; i < scores.length; i += 100) {
369
+ const response = await http.request("POST", "/api/public/scores", { body: scores.slice(i, i + 100) });
370
+ // 207 reports rejected scores with a 2xx status; Langfuse asks clients not to retry the batch.
371
+ if (response.status === 207)
372
+ throw new LangfuseError(`Langfuse rejected some scores (HTTP 207): ${JSON.stringify(response.data?.errors ?? response.data).slice(0, 300)}`, 207);
373
+ }
374
+ return { experimentId, traceIds, url: await experimentUrl(datasetId, experimentId) };
375
+ },
376
+ experimentUrl,
377
+ };
378
+ }
@@ -0,0 +1,60 @@
1
+ /** Deterministic hex IDs, so a retried recording updates the same trace, spans and scores. */
2
+ export declare const hexId: (value: string, length: 16 | 32) => string;
3
+ type AnyValue = {
4
+ stringValue: string;
5
+ } | {
6
+ boolValue: boolean;
7
+ } | {
8
+ intValue: string;
9
+ } | {
10
+ doubleValue: number;
11
+ } | {
12
+ arrayValue: {
13
+ values: AnyValue[];
14
+ };
15
+ };
16
+ export interface Attribute {
17
+ key: string;
18
+ value: AnyValue;
19
+ }
20
+ export declare function attribute(key: string, value: string | number | boolean | string[]): Attribute;
21
+ export declare const asText: (value: unknown) => string;
22
+ export interface OtlpSpan {
23
+ traceId: string;
24
+ spanId: string;
25
+ parentSpanId?: string;
26
+ name: string;
27
+ kind: number;
28
+ startTimeUnixNano: string;
29
+ endTimeUnixNano: string;
30
+ attributes: Attribute[];
31
+ status: {
32
+ code: number;
33
+ message?: string;
34
+ };
35
+ }
36
+ export declare function span(input: {
37
+ traceId: string;
38
+ spanId: string;
39
+ parentSpanId?: string;
40
+ name: string;
41
+ startMs: number;
42
+ endMs: number;
43
+ attributes: Attribute[];
44
+ error?: string;
45
+ }): OtlpSpan;
46
+ export declare function exportRequest(spans: OtlpSpan[], version: string): {
47
+ resourceSpans: {
48
+ resource: {
49
+ attributes: Attribute[];
50
+ };
51
+ scopeSpans: {
52
+ scope: {
53
+ name: string;
54
+ version: string;
55
+ };
56
+ spans: OtlpSpan[];
57
+ }[];
58
+ }[];
59
+ };
60
+ export {};
@@ -0,0 +1,40 @@
1
+ // Builds OTLP/HTTP JSON spans with Langfuse's attribute names (ingestion format v4), so a
2
+ // recorded evaluation is a Langfuse experiment item without the deprecated dataset-run-items API.
3
+ // https://langfuse.com/integrations/native/opentelemetry
4
+ import { createHash } from "node:crypto";
5
+ /** Deterministic hex IDs, so a retried recording updates the same trace, spans and scores. */
6
+ export const hexId = (value, length) => createHash("sha256").update(value).digest("hex").slice(0, length);
7
+ export function attribute(key, value) {
8
+ if (Array.isArray(value))
9
+ return { key, value: { arrayValue: { values: value.map((item) => ({ stringValue: item })) } } };
10
+ if (typeof value === "boolean")
11
+ return { key, value: { boolValue: value } };
12
+ if (typeof value === "number")
13
+ return { key, value: Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value } };
14
+ return { key, value: { stringValue: value } };
15
+ }
16
+ export const asText = (value) => (typeof value === "string" ? value : JSON.stringify(value) ?? "null");
17
+ const nanos = (ms) => `${BigInt(Math.round(ms)) * 1000000n}`;
18
+ export function span(input) {
19
+ return {
20
+ traceId: input.traceId,
21
+ spanId: input.spanId,
22
+ ...(input.parentSpanId ? { parentSpanId: input.parentSpanId } : {}),
23
+ name: input.name,
24
+ kind: 1,
25
+ startTimeUnixNano: nanos(input.startMs),
26
+ endTimeUnixNano: nanos(Math.max(input.endMs, input.startMs)),
27
+ attributes: input.attributes,
28
+ status: input.error ? { code: 2, message: input.error.slice(0, 500) } : { code: 1 },
29
+ };
30
+ }
31
+ export function exportRequest(spans, version) {
32
+ return {
33
+ resourceSpans: [
34
+ {
35
+ resource: { attributes: [attribute("service.name", "openmerit")] },
36
+ scopeSpans: [{ scope: { name: "openmerit-langfuse-connector", version }, spans }],
37
+ },
38
+ ],
39
+ };
40
+ }
@@ -0,0 +1,11 @@
1
+ export { rankCandidates, rank, openRouterToolSupport, type Candidate, type CandidateFilter, type RunnableModel } from "./scout/candidates.ts";
2
+ export { loadEci, parseEciScores, ECI_ATTRIBUTION, type EciRow, type EciSource } from "./scout/eci.ts";
3
+ export { scoutSearch, CandidateUnavailable, outward, compareFinals, meanCost, SAMPLE_SIZE, FINALISTS, type EvalItem, type Evaluate, type Outcome, type SearchOptions, type SearchResult, type Stage, type Trial } from "./scout/search.ts";
4
+ export { runScout, type ScoutReport, type ScoutRunOptions, type FinalVerdict, type DatasetSource } from "./scout/pipeline.ts";
5
+ export { reportMarkdown } from "./scout/report.ts";
6
+ export { piEvaluator, jsonMatches, extractJson, type Grader, type ModelCaller, type PiTaskSpec } from "./scout/evaluators/pi.ts";
7
+ export { loadEvaluatorPlugin, type EvaluatorPlugin } from "./scout/evaluators/module.ts";
8
+ export * as scout from "./scout/service.ts";
9
+ export { createScoutExtension, type ScoutExtensionOptions } from "./pi/extension.ts";
10
+ export { configureHarness } from "./pi/harness.ts";
11
+ export * as langfuse from "./connectors/langfuse/index.ts";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export { rankCandidates, rank, openRouterToolSupport } from "./scout/candidates.js";
2
+ export { loadEci, parseEciScores, ECI_ATTRIBUTION } from "./scout/eci.js";
3
+ export { scoutSearch, CandidateUnavailable, outward, compareFinals, meanCost, SAMPLE_SIZE, FINALISTS } from "./scout/search.js";
4
+ export { runScout } from "./scout/pipeline.js";
5
+ export { reportMarkdown } from "./scout/report.js";
6
+ export { piEvaluator, jsonMatches, extractJson } from "./scout/evaluators/pi.js";
7
+ export { loadEvaluatorPlugin } from "./scout/evaluators/module.js";
8
+ export * as scout from "./scout/service.js";
9
+ export { createScoutExtension } from "./pi/extension.js";
10
+ export { configureHarness } from "./pi/harness.js";
11
+ export * as langfuse from "./connectors/langfuse/index.js";
@@ -0,0 +1,13 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ export interface ScoutExtensionOptions {
3
+ /** Whether `scout_run` may spend money: true (headless `--execute`), false (plan only) or "ask" (confirm in the UI). */
4
+ allowSpend?: boolean | "ask";
5
+ /** Progress lines, for example to stderr in headless runs. */
6
+ log?: (line: string) => void;
7
+ /** The `provider/id` Scout runs on, for the judge grader. Defaults to the session's model. */
8
+ scoutModel?: string;
9
+ }
10
+ /** OpenMerit Scout inside Pi: model-recommendation tools, a `/scout` command and Scout's instructions. */
11
+ export declare function createScoutExtension(options?: ScoutExtensionOptions): ExtensionFactory;
12
+ declare const _default: ExtensionFactory;
13
+ export default _default;