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.
- package/README.md +239 -58
- package/data/catalogue.json +6778 -0
- package/dist/catalogue/catalogue.d.ts +41 -0
- package/dist/catalogue/catalogue.js +33 -0
- package/dist/cli/prompt.d.ts +26 -0
- package/dist/cli/prompt.js +114 -0
- package/dist/cli/render.d.ts +12 -0
- package/dist/cli/render.js +104 -0
- package/dist/cli.js +245 -158
- package/dist/connect.d.ts +34 -0
- package/dist/connect.js +158 -0
- package/dist/connectors/langfuse/index.d.ts +37 -110
- package/dist/connectors/langfuse/index.js +95 -246
- package/dist/daemon/daemon.d.ts +81 -0
- package/dist/daemon/daemon.js +408 -0
- package/dist/daemon/registry.d.ts +111 -0
- package/dist/daemon/registry.js +109 -0
- package/dist/eval/proxy.d.ts +54 -0
- package/dist/eval/proxy.js +174 -0
- package/dist/eval/runner.d.ts +29 -0
- package/dist/eval/runner.js +80 -0
- package/dist/harness/claude-bridge.d.ts +21 -0
- package/dist/harness/claude-bridge.js +65 -0
- package/dist/harness/harness.d.ts +60 -0
- package/dist/harness/harness.js +221 -0
- package/dist/index.d.ts +9 -10
- package/dist/index.js +9 -10
- package/dist/local.d.ts +12 -0
- package/dist/local.js +40 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +155 -0
- package/dist/scout/pool.d.ts +17 -0
- package/dist/scout/pool.js +56 -0
- package/dist/scout/scan.d.ts +42 -0
- package/dist/scout/scan.js +169 -0
- package/dist/scout/search.d.ts +25 -98
- package/dist/scout/search.js +56 -156
- package/dist/scout/workflow.d.ts +221 -0
- package/dist/scout/workflow.js +941 -0
- package/dist/setup.d.ts +44 -0
- package/dist/setup.js +211 -0
- package/dist/state/config.d.ts +116 -0
- package/dist/state/config.js +60 -0
- package/dist/state/db.d.ts +159 -0
- package/dist/state/db.js +260 -0
- package/dist/state/paths.d.ts +12 -0
- package/dist/state/paths.js +23 -0
- package/dist/update.d.ts +22 -0
- package/dist/update.js +58 -0
- package/package.json +12 -17
- package/dist/connectors/langfuse/otlp.d.ts +0 -60
- package/dist/connectors/langfuse/otlp.js +0 -40
- package/dist/pi/extension.d.ts +0 -13
- package/dist/pi/extension.js +0 -148
- package/dist/pi/harness.d.ts +0 -20
- package/dist/pi/harness.js +0 -49
- package/dist/pi/instructions.d.ts +0 -2
- package/dist/pi/instructions.js +0 -12
- package/dist/scout/evaluators/module.d.ts +0 -24
- package/dist/scout/evaluators/module.js +0 -19
- package/dist/scout/evaluators/pi.d.ts +0 -53
- package/dist/scout/evaluators/pi.js +0 -117
- package/dist/scout/pipeline.d.ts +0 -111
- package/dist/scout/pipeline.js +0 -157
- package/dist/scout/report.d.ts +0 -3
- package/dist/scout/report.js +0 -42
- package/dist/scout/service.d.ts +0 -148
- package/dist/scout/service.js +0 -143
- package/examples/health-declaration-codes/dataset.jsonl +0 -14
- package/examples/sahra-enrollment/evaluator.mts +0 -152
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
// Langfuse
|
|
2
|
-
//
|
|
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
|
-
//
|
|
5
|
-
//
|
|
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
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
166
|
-
throw new LangfuseError("Pass
|
|
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,
|
|
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
|
|
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
|
-
|
|
122
|
+
let best;
|
|
197
123
|
for (const score of Array.isArray(raw.scores) ? raw.scores : []) {
|
|
198
|
-
if (!isObject(score) ||
|
|
124
|
+
if (!isObject(score) || score.name !== query.pass.score)
|
|
199
125
|
continue;
|
|
200
|
-
const value = scoreValue(score);
|
|
201
|
-
|
|
202
|
-
|
|
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,
|
|
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(
|
|
133
|
+
const unscored = items.filter(item => item.score === undefined);
|
|
210
134
|
if (unscored.length) {
|
|
211
|
-
const found = await scoresByTrace(unscored.map(
|
|
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.
|
|
139
|
+
item.score = value;
|
|
216
140
|
}
|
|
217
141
|
}
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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:
|
|
152
|
+
baseUrl: transport.baseUrl,
|
|
249
153
|
async check() {
|
|
250
|
-
const
|
|
251
|
-
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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 =
|
|
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}
|
|
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
|
|
305
|
-
const {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
const
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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 {};
|