openmerit 0.4.1 → 0.4.2

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 (70) hide show
  1. package/README.md +239 -58
  2. package/data/catalogue.json +6778 -0
  3. package/dist/catalogue/catalogue.d.ts +41 -0
  4. package/dist/catalogue/catalogue.js +33 -0
  5. package/dist/cli/prompt.d.ts +26 -0
  6. package/dist/cli/prompt.js +114 -0
  7. package/dist/cli/render.d.ts +12 -0
  8. package/dist/cli/render.js +104 -0
  9. package/dist/cli.js +245 -158
  10. package/dist/connect.d.ts +34 -0
  11. package/dist/connect.js +158 -0
  12. package/dist/connectors/langfuse/index.d.ts +37 -110
  13. package/dist/connectors/langfuse/index.js +95 -246
  14. package/dist/daemon/daemon.d.ts +81 -0
  15. package/dist/daemon/daemon.js +408 -0
  16. package/dist/daemon/registry.d.ts +111 -0
  17. package/dist/daemon/registry.js +109 -0
  18. package/dist/eval/proxy.d.ts +54 -0
  19. package/dist/eval/proxy.js +174 -0
  20. package/dist/eval/runner.d.ts +29 -0
  21. package/dist/eval/runner.js +80 -0
  22. package/dist/harness/claude-bridge.d.ts +21 -0
  23. package/dist/harness/claude-bridge.js +65 -0
  24. package/dist/harness/harness.d.ts +60 -0
  25. package/dist/harness/harness.js +221 -0
  26. package/dist/index.d.ts +9 -10
  27. package/dist/index.js +9 -10
  28. package/dist/local.d.ts +12 -0
  29. package/dist/local.js +40 -0
  30. package/dist/mcp.d.ts +1 -0
  31. package/dist/mcp.js +155 -0
  32. package/dist/scout/pool.d.ts +17 -0
  33. package/dist/scout/pool.js +56 -0
  34. package/dist/scout/scan.d.ts +42 -0
  35. package/dist/scout/scan.js +169 -0
  36. package/dist/scout/search.d.ts +25 -98
  37. package/dist/scout/search.js +56 -156
  38. package/dist/scout/workflow.d.ts +221 -0
  39. package/dist/scout/workflow.js +941 -0
  40. package/dist/setup.d.ts +44 -0
  41. package/dist/setup.js +211 -0
  42. package/dist/state/config.d.ts +116 -0
  43. package/dist/state/config.js +60 -0
  44. package/dist/state/db.d.ts +159 -0
  45. package/dist/state/db.js +260 -0
  46. package/dist/state/paths.d.ts +12 -0
  47. package/dist/state/paths.js +23 -0
  48. package/dist/update.d.ts +22 -0
  49. package/dist/update.js +58 -0
  50. package/package.json +12 -17
  51. package/dist/connectors/langfuse/otlp.d.ts +0 -60
  52. package/dist/connectors/langfuse/otlp.js +0 -40
  53. package/dist/pi/extension.d.ts +0 -13
  54. package/dist/pi/extension.js +0 -148
  55. package/dist/pi/harness.d.ts +0 -20
  56. package/dist/pi/harness.js +0 -49
  57. package/dist/pi/instructions.d.ts +0 -2
  58. package/dist/pi/instructions.js +0 -12
  59. package/dist/scout/evaluators/module.d.ts +0 -24
  60. package/dist/scout/evaluators/module.js +0 -19
  61. package/dist/scout/evaluators/pi.d.ts +0 -53
  62. package/dist/scout/evaluators/pi.js +0 -117
  63. package/dist/scout/pipeline.d.ts +0 -111
  64. package/dist/scout/pipeline.js +0 -157
  65. package/dist/scout/report.d.ts +0 -3
  66. package/dist/scout/report.js +0 -42
  67. package/dist/scout/service.d.ts +0 -148
  68. package/dist/scout/service.js +0 -143
  69. package/examples/health-declaration-codes/dataset.jsonl +0 -14
  70. package/examples/sahra-enrollment/evaluator.mts +0 -152
@@ -1,20 +1,18 @@
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.
1
+ // Langfuse connector, read-only, for Langfuse Cloud or a self-hosted instance. It reads:
2
+ // - per-item pass/fail of experiments the application's own eval recorded (experiment items and
3
+ // their scores), which is how Scout measures a model without writing anything itself;
4
+ // - dataset item IDs (never their contents), to know which items an eval run must cover;
5
+ // - aggregate production evidence: generation counts, cost and latency by model, score averages.
6
+ // It never creates datasets, experiments, traces or scores, and it never reads inputs, outputs or comments.
3
7
  //
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.
8
+ // The only non-GET request is the Viewer check: a deliberately invalid dataset creation ({} has no
9
+ // name). A Viewer key gets 403; any other answer means the key can write, so setup refuses it.
10
10
  import { createTransport, LangfuseError } from "./client.js";
11
- import { asText, attribute, exportRequest, hexId, span } from "./otlp.js";
12
11
  export { connectionFromEnv, LangfuseError, normalizeBaseUrl } from "./client.js";
13
- /** Version written into recorded traces' scope. */
14
- export const CONNECTOR_VERSION = "0.1.0";
15
12
  const DAY_MS = 86_400_000;
16
13
  const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
17
14
  const str = (value) => (typeof value === "string" ? value : null);
15
+ const num = (value) => (typeof value === "number" && Number.isFinite(value) ? value : null);
18
16
  export function evaluatePass(rule, value) {
19
17
  if (value === undefined)
20
18
  return false;
@@ -44,24 +42,11 @@ function scoreValue(score) {
44
42
  return value;
45
43
  return undefined;
46
44
  }
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
45
  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;
46
+ const transport = createTransport(connection, options);
47
+ const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
48
+ const get = async (path, query) => (await transport.request("GET", path, query ? { query } : {})).data;
49
+ const window = (days) => ({ from: new Date(Date.now() - days * DAY_MS).toISOString(), to: new Date().toISOString() });
65
50
  async function* cursorPages(path, query) {
66
51
  const seen = new Set();
67
52
  let cursor;
@@ -77,52 +62,14 @@ export function createLangfuseConnector(connection, options = {}) {
77
62
  seen.add(cursor);
78
63
  } while (cursor);
79
64
  }
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). */
65
+ const metrics = async (query) => {
66
+ const data = await get("/api/public/v2/metrics", { query: JSON.stringify(query) });
67
+ if (!data || !Array.isArray(data.data))
68
+ throw new LangfuseError("Langfuse returned invalid aggregate metrics.");
69
+ return data.data.filter(isObject);
70
+ };
71
+ const generation = { column: "type", operator: "=", value: "GENERATION", type: "string" };
72
+ /** Latest score of one name per trace, from /api/public/v3/scores (trace- and observation-level). */
126
73
  async function scoresByTrace(traceIds, name, since) {
127
74
  const latest = new Map();
128
75
  for (let i = 0; i < traceIds.length; i += 40) {
@@ -143,40 +90,19 @@ export function createLangfuseConnector(connection, options = {}) {
143
90
  }
144
91
  return new Map([...latest].map(([traceId, { value }]) => [traceId, value]));
145
92
  }
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
93
  async function experimentResults(query) {
165
- if (!query.experimentId === !query.experimentName)
166
- throw new LangfuseError("Pass exactly one of experimentId or experimentName.");
94
+ if (!query.experimentName)
95
+ throw new LangfuseError("Pass the experiment name to read.");
167
96
  if (!query.pass?.score)
168
97
  throw new LangfuseError("Pass a pass rule naming the score that decides each item.");
169
- const since = iso(query.since, Date.now());
170
98
  const latest = new Map();
171
99
  const experimentIds = new Set();
172
- let experimentName = query.experimentName ?? "";
173
100
  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 })) {
101
+ for await (const page of cursorPages("/api/public/experiment-items", { fields: "core,scores", limit: 100, scoreLimit: 50, fromStartTime: query.since, experimentName: query.experimentName })) {
175
102
  for (const raw of page) {
176
103
  if (!isObject(raw) || !str(raw.experimentItemId) || !str(raw.traceId))
177
104
  continue;
178
105
  experimentIds.add(str(raw.experimentId) ?? "");
179
- experimentName = str(raw.experimentName) ?? experimentName;
180
106
  datasetId = str(raw.experimentDatasetId) ?? datasetId;
181
107
  const itemId = String(raw.experimentItemId);
182
108
  const at = str(raw.startTime) ?? "";
@@ -187,108 +113,81 @@ export function createLangfuseConnector(connection, options = {}) {
187
113
  }
188
114
  }
189
115
  if (experimentIds.size > 1)
190
- throw new LangfuseError(`Experiment name "${query.experimentName}" matches ${experimentIds.size} experiments (${[...experimentIds].join(", ")}). Query by experimentId.`);
116
+ throw new LangfuseError(`Experiment name "${query.experimentName}" matches ${experimentIds.size} experiments; the eval must use a fresh name per run.`);
191
117
  const wanted = query.itemIds ? new Set(query.itemIds) : null;
192
118
  const items = [];
193
119
  for (const [itemId, { raw }] of latest) {
194
120
  if (wanted && !wanted.has(itemId))
195
121
  continue;
196
- const scores = {};
122
+ let best;
197
123
  for (const score of Array.isArray(raw.scores) ? raw.scores : []) {
198
- if (!isObject(score) || !str(score.name))
124
+ if (!isObject(score) || score.name !== query.pass.score)
199
125
  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 };
126
+ const value = scoreValue(score), at = str(score.timestamp) ?? "";
127
+ if (value !== undefined && (!best || at > best.at))
128
+ best = { at, value };
205
129
  }
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 });
130
+ items.push({ itemId, traceId: String(raw.traceId), passed: false, missingScore: false, ...(best ? { score: best.value } : {}) });
207
131
  }
208
132
  // 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);
133
+ const unscored = items.filter(item => item.score === undefined);
210
134
  if (unscored.length) {
211
- const found = await scoresByTrace(unscored.map((item) => item.traceId), query.pass.score, since);
135
+ const found = await scoresByTrace(unscored.map(item => item.traceId), query.pass.score, query.since);
212
136
  for (const item of unscored) {
213
137
  const value = found.get(item.traceId);
214
138
  if (value !== undefined)
215
- item.scores[query.pass.score] = value;
139
+ item.score = value;
216
140
  }
217
141
  }
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));
142
+ const results = items.map(({ score, ...item }) => ({ ...item, missingScore: score === undefined, passed: evaluatePass(query.pass, score) }));
143
+ results.sort((a, b) => a.itemId.localeCompare(b.itemId));
231
144
  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,
145
+ experimentName: query.experimentName, experimentId: [...experimentIds][0] ?? "", datasetId, items: results,
146
+ passes: results.filter(item => item.passed).length, total: results.length,
147
+ missing: query.itemIds ? query.itemIds.filter(id => !latest.has(id)) : [],
148
+ missingScores: results.filter(item => item.missingScore).map(item => item.itemId),
241
149
  };
242
150
  }
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
151
  return {
248
- baseUrl: http.baseUrl,
152
+ baseUrl: transport.baseUrl,
249
153
  async check() {
250
- const { id, name } = await getProject();
251
- return { baseUrl: http.baseUrl, projectId: id, projectName: name };
154
+ const data = await get("/api/public/projects");
155
+ const project = data?.data?.[0];
156
+ if (!project?.id)
157
+ throw new LangfuseError("Langfuse returned no project for this key pair.");
158
+ return { baseUrl: transport.baseUrl, projectId: project.id, projectName: project.name ?? project.id };
252
159
  },
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
- }
160
+ async verifyViewer() {
161
+ let status;
162
+ try {
163
+ status = (await transport.request("POST", "/api/public/v2/datasets", { body: {} })).status;
270
164
  }
271
- return items;
165
+ catch (error) {
166
+ if (!(error instanceof LangfuseError) || error.status === null)
167
+ throw error;
168
+ status = error.status;
169
+ }
170
+ if (status !== 403)
171
+ throw new LangfuseError("This key pair can write to Langfuse. Create a separate Viewer key in Langfuse Project Settings → API Keys; OpenMerit only reads and keeps your tracing keys untouched.");
272
172
  },
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);
173
+ async datasetItemIds(name) {
174
+ const ids = [];
175
+ for (let page = 1;; page += 1) {
176
+ const body = await get("/api/public/dataset-items", { datasetName: name, page, limit: 100 });
177
+ if (!body || !Array.isArray(body.data))
178
+ throw new LangfuseError("Langfuse GET /api/public/dataset-items returned no data array.");
179
+ for (const raw of body.data)
180
+ if (isObject(raw) && str(raw.id) && raw.status !== "ARCHIVED")
181
+ ids.push(String(raw.id));
182
+ if (page >= (num(body.meta?.totalPages) ?? 0) || body.data.length === 0)
183
+ break;
286
184
  }
287
- return { datasetId: String(dataset.id), itemIds };
185
+ if (!ids.length)
186
+ throw new LangfuseError(`Langfuse dataset "${name}" has no active items (or does not exist in this project).`);
187
+ return ids;
288
188
  },
289
- listExperiments,
290
189
  experimentResults,
291
- async waitForExperiment(query, { expectItems, timeoutMs = 600_000, intervalMs = 15_000 }) {
190
+ async waitForExperiment(query, { expectItems, timeoutMs = 300_000, intervalMs = 15_000 }) {
292
191
  const deadline = Date.now() + timeoutMs;
293
192
  for (;;) {
294
193
  const result = await experimentResults(query);
@@ -296,83 +195,33 @@ export function createLangfuseConnector(connection, options = {}) {
296
195
  if (scored >= expectItems)
297
196
  return result;
298
197
  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"}).`);
198
+ throw new LangfuseError(`Langfuse shows ${scored}/${expectItems} items scored with "${query.pass.score}" in experiment "${query.experimentName}" after ${Math.round(timeoutMs / 1000)} s (missing items: ${result.missing.slice(0, 10).join(", ") || "none"}; unscored: ${result.missingScores.slice(0, 10).join(", ") || "none"}). The eval must record every requested item under OPENMERIT_EXPERIMENT.`);
300
199
  }
301
200
  await sleep(intervalMs);
302
201
  }
303
202
  },
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) };
203
+ async traceNames(days = 30) {
204
+ const { from, to } = window(days);
205
+ const rows = await metrics({ view: "observations", dimensions: [{ field: "traceName" }], metrics: [{ measure: "count", aggregation: "count" }], filters: [generation], fromTimestamp: from, toTimestamp: to, config: { row_limit: 100 } });
206
+ return rows.filter(row => typeof row.traceName === "string").map(row => ({ traceName: row.traceName, generations: num(row.count_count) ?? 0 })).sort((a, b) => b.generations - a.generations);
207
+ },
208
+ async taskEvidence(mapping, days = 30) {
209
+ const { from, to } = window(days);
210
+ const byTrace = { column: "traceName", operator: "=", value: mapping.traceName, type: "string" };
211
+ const usage = await metrics({
212
+ view: "observations", dimensions: [{ field: "providedModelName" }],
213
+ metrics: [{ measure: "count", aggregation: "count" }, { measure: "totalCost", aggregation: "sum" }, { measure: "latency", aggregation: "p50" }],
214
+ filters: [generation, byTrace], fromTimestamp: from, toTimestamp: to, config: { row_limit: 50 },
215
+ });
216
+ const scores = mapping.scores.length ? await metrics({
217
+ view: "scores-numeric", dimensions: [{ field: "name" }], metrics: [{ measure: "value", aggregation: "avg" }, { measure: "count", aggregation: "count" }],
218
+ filters: [byTrace, { column: "name", operator: "any of", value: mapping.scores, type: "stringOptions" }], fromTimestamp: from, toTimestamp: to, config: { row_limit: mapping.scores.length },
219
+ }) : [];
220
+ return {
221
+ traceName: mapping.traceName, from, to,
222
+ models: usage.filter(row => typeof row.providedModelName === "string").map(row => ({ model: row.providedModelName, generations: num(row.count_count) ?? 0, costUsd: num(row.sum_totalCost), p50LatencyMs: num(row.p50_latency) })),
223
+ scores: scores.filter(row => typeof row.name === "string" && num(row.avg_value) !== null).map(row => ({ name: row.name, average: num(row.avg_value), count: num(row.count_count) ?? 0 })),
224
+ };
375
225
  },
376
- experimentUrl,
377
226
  };
378
227
  }
@@ -0,0 +1,81 @@
1
+ import type { Answers, Step } from "../scout/workflow.js";
2
+ export declare const DEFAULT_PORT = 6720;
3
+ export interface DaemonInfo {
4
+ pid: number;
5
+ port: number;
6
+ token: string;
7
+ version: string;
8
+ startedAt: string;
9
+ }
10
+ type Env = Record<string, string | undefined>;
11
+ export declare function readDaemonInfo(env?: Env): DaemonInfo | null;
12
+ /** Whether the daemon of `info` runs, has a recent heartbeat and its engine answers. `openmerit daemon status` uses it too. */
13
+ export declare function healthy(info: DaemonInfo, env?: Env): Promise<boolean>;
14
+ /** Runs the daemon in this process until it receives SIGINT or SIGTERM. `openmerit daemon` calls it. */
15
+ export declare function serveDaemon(env?: Env): Promise<void>;
16
+ /** Stops the daemon, if one runs. Its runs stop as interrupted and continue with `openmerit scout --resume`. */
17
+ export declare function stopDaemon(env?: Env): Promise<boolean>;
18
+ /** The daemon could not start. Unlike a lost connection, trying again does not help until its cause is fixed. */
19
+ export declare class DaemonStartError extends Error {
20
+ }
21
+ /** The running daemon of this package version, started in the background when there is none. Throws DaemonStartError when it does not start. */
22
+ export declare function ensureDaemon(env?: Env): Promise<DaemonInfo>;
23
+ export declare function daemonClient(info: DaemonInfo): import("rivetkit/client").Client<import("rivetkit").Registry<{
24
+ scoutRun: import("rivetkit/client").ActorDefinition<{
25
+ inFlight: string | null;
26
+ outcome: import("./registry.js").Outcome | null;
27
+ }, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
28
+ advance: import("./registry.js").AdvanceQueue;
29
+ }, {
30
+ attach: (c: import("rivetkit").ActionContext<{
31
+ inFlight: string | null;
32
+ outcome: import("./registry.js").Outcome | null;
33
+ }, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
34
+ advance: import("./registry.js").AdvanceQueue;
35
+ }>, input: {
36
+ cwd: string;
37
+ env: Record<string, string | undefined>;
38
+ }) => {
39
+ inFlight: string | null;
40
+ since: number;
41
+ };
42
+ poll: (c: import("rivetkit").ActionContext<{
43
+ inFlight: string | null;
44
+ outcome: import("./registry.js").Outcome | null;
45
+ }, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
46
+ advance: import("./registry.js").AdvanceQueue;
47
+ }>, input: {
48
+ requestId: string;
49
+ since: number;
50
+ }) => {
51
+ outcome: import("./registry.js").Outcome | null;
52
+ lines: string[];
53
+ next: number;
54
+ inFlight: string | null;
55
+ };
56
+ stop: (c: import("rivetkit").ActionContext<{
57
+ inFlight: string | null;
58
+ outcome: import("./registry.js").Outcome | null;
59
+ }, undefined, undefined, undefined, undefined, undefined, Record<never, never>, {
60
+ advance: import("./registry.js").AdvanceQueue;
61
+ }>) => void;
62
+ }>;
63
+ }>>;
64
+ /** Reconnect attempts after a lost connection, before the client gives up. */
65
+ export declare const RECONNECTS = 3;
66
+ /**
67
+ * Advances a Scout run in the daemon: attaches this client's directory and environment, sends the
68
+ * answers, and waits for the step's outcome while relaying its progress lines. Aborting `signal` stops
69
+ * the step, which saves the run as interrupted. When the daemon stops answering, the client reconnects
70
+ * (starting a new daemon if needed) up to RECONNECTS times, then gives up with the resume command. A
71
+ * daemon that cannot start is not a lost connection: that error ends the call at once, with the resume command.
72
+ */
73
+ export declare function advanceInDaemon(input: {
74
+ runId: string;
75
+ cwd: string;
76
+ env: Env;
77
+ answers: Answers;
78
+ log: (line: string) => void;
79
+ signal?: AbortSignal;
80
+ }): Promise<Step>;
81
+ export {};