openmerit 0.3.11 → 0.4.1

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.mts +152 -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,141 @@
1
+ // HTTP transport for the Langfuse public API. It uses only global fetch, so it runs on
2
+ // Node.js 22.19+ against Langfuse Cloud or any self-hosted Langfuse.
3
+ //
4
+ // Secrets are kept out of errors: a message names the method, the path and the status, plus at
5
+ // most 300 characters of Langfuse's own error body, and never the Authorization header.
6
+ export class LangfuseError extends Error {
7
+ status;
8
+ constructor(message, status = null) {
9
+ super(message);
10
+ this.name = "LangfuseError";
11
+ this.status = status;
12
+ }
13
+ }
14
+ const DEFAULT_BASE_URL = "https://cloud.langfuse.com";
15
+ const MAX_BODY_BYTES = 8_000_000;
16
+ /**
17
+ * Reads LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_BASE_URL (or LANGFUSE_HOST, the
18
+ * Langfuse SDK's name). Without a base URL it uses Langfuse Cloud EU, like the Langfuse SDKs.
19
+ */
20
+ export function connectionFromEnv(env = process.env) {
21
+ const publicKey = env.LANGFUSE_PUBLIC_KEY?.trim();
22
+ const secretKey = env.LANGFUSE_SECRET_KEY?.trim();
23
+ const missing = [!publicKey && "LANGFUSE_PUBLIC_KEY", !secretKey && "LANGFUSE_SECRET_KEY"].filter(Boolean);
24
+ if (missing.length)
25
+ throw new LangfuseError(`Set ${missing.join(" and ")} to connect Langfuse (and LANGFUSE_BASE_URL for a self-hosted or US instance).`);
26
+ return { baseUrl: normalizeBaseUrl(env.LANGFUSE_BASE_URL?.trim() || env.LANGFUSE_HOST?.trim() || DEFAULT_BASE_URL), publicKey: publicKey, secretKey: secretKey };
27
+ }
28
+ export function normalizeBaseUrl(value) {
29
+ let url;
30
+ try {
31
+ url = new URL(value);
32
+ }
33
+ catch {
34
+ throw new LangfuseError(`LANGFUSE_BASE_URL is not a URL: "${value.slice(0, 200)}".`);
35
+ }
36
+ if (url.protocol !== "https:" && url.protocol !== "http:")
37
+ throw new LangfuseError("LANGFUSE_BASE_URL must start with https:// or http://.");
38
+ if (url.username || url.password || url.search || url.hash)
39
+ throw new LangfuseError("LANGFUSE_BASE_URL must not contain credentials, a query or a fragment.");
40
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
41
+ }
42
+ export function createTransport(connection, options = {}) {
43
+ const baseUrl = normalizeBaseUrl(connection.baseUrl);
44
+ const fetcher = options.fetch ?? fetch;
45
+ const timeoutMs = options.timeoutMs ?? 30_000;
46
+ const retries = options.retries ?? 5;
47
+ const maxDelay = options.maxRetryDelayMs ?? 120_000;
48
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
49
+ const authorization = `Basic ${Buffer.from(`${connection.publicKey}:${connection.secretKey}`).toString("base64")}`;
50
+ return {
51
+ baseUrl,
52
+ async request(method, path, request = {}) {
53
+ const url = new URL(`${baseUrl}${path}`);
54
+ for (const [key, value] of Object.entries(request.query ?? {}))
55
+ if (value !== undefined && value !== null && value !== "")
56
+ url.searchParams.set(key, String(value));
57
+ const label = `Langfuse ${method} ${path}`;
58
+ for (let attempt = 0;; attempt += 1) {
59
+ let response;
60
+ try {
61
+ response = await fetcher(url, {
62
+ method,
63
+ // Never follow a redirect with the key pair attached; a redirect means LANGFUSE_BASE_URL is wrong.
64
+ redirect: "manual",
65
+ signal: AbortSignal.timeout(timeoutMs),
66
+ headers: { authorization, accept: "application/json", ...(request.body === undefined ? {} : { "content-type": "application/json" }), ...request.headers },
67
+ ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
68
+ });
69
+ }
70
+ catch (error) {
71
+ const reason = error instanceof Error ? (error.name === "TimeoutError" ? `timed out after ${timeoutMs} ms` : error.message) : String(error);
72
+ if (attempt < retries) {
73
+ await sleep(Math.min(maxDelay, 1000 * 2 ** attempt));
74
+ continue;
75
+ }
76
+ throw new LangfuseError(`${label} failed: ${reason}. Check LANGFUSE_BASE_URL (${baseUrl}).`);
77
+ }
78
+ const status = response.status;
79
+ if ((status === 429 || status === 502 || status === 503 || status === 504) && attempt < retries) {
80
+ await response.body?.cancel();
81
+ const retryAfter = Number(response.headers.get("retry-after"));
82
+ await sleep(Math.min(maxDelay, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** attempt));
83
+ continue;
84
+ }
85
+ if (status >= 300 && status < 400) {
86
+ await response.body?.cancel();
87
+ throw new LangfuseError(`${label} was redirected (HTTP ${status}). Set LANGFUSE_BASE_URL to the final Langfuse URL; credentials are not forwarded across redirects.`, status);
88
+ }
89
+ if (status === 404 && request.allowNotFound) {
90
+ await response.body?.cancel();
91
+ return { status, data: null };
92
+ }
93
+ const text = await readBounded(response, label);
94
+ if (!response.ok) {
95
+ const hint = status === 401 ? " Check LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and that LANGFUSE_BASE_URL is the instance or region that issued them." : status === 429 ? " Langfuse kept rate limiting after retries; try again later." : "";
96
+ throw new LangfuseError(`${label} failed (HTTP ${status}): ${excerpt(text).replace(/[.\s]*$/, ".")}${hint}`, status);
97
+ }
98
+ if (!text)
99
+ return { status, data: null };
100
+ try {
101
+ return { status, data: JSON.parse(text) };
102
+ }
103
+ catch {
104
+ throw new LangfuseError(`${label} returned invalid JSON (HTTP ${status}). Is LANGFUSE_BASE_URL a Langfuse instance?`, status);
105
+ }
106
+ }
107
+ },
108
+ };
109
+ }
110
+ async function readBounded(response, label) {
111
+ const reader = response.body?.getReader();
112
+ if (!reader)
113
+ return "";
114
+ const decoder = new TextDecoder();
115
+ let bytes = 0;
116
+ let text = "";
117
+ for (;;) {
118
+ const { done, value } = await reader.read();
119
+ if (done)
120
+ break;
121
+ bytes += value.length;
122
+ if (bytes > MAX_BODY_BYTES) {
123
+ await reader.cancel();
124
+ throw new LangfuseError(`${label} returned more than ${MAX_BODY_BYTES} bytes.`);
125
+ }
126
+ text += decoder.decode(value, { stream: true });
127
+ }
128
+ return text + decoder.decode();
129
+ }
130
+ function excerpt(text) {
131
+ let message = text;
132
+ try {
133
+ const parsed = JSON.parse(text);
134
+ if (parsed && typeof parsed === "object" && "message" in parsed && typeof parsed.message === "string")
135
+ message = parsed.message;
136
+ }
137
+ catch {
138
+ // Not JSON; use the raw text.
139
+ }
140
+ return message.replace(/\s+/g, " ").trim().slice(0, 300) || "(empty body)";
141
+ }
@@ -0,0 +1,163 @@
1
+ import { type LangfuseConnection, type TransportOptions } from "./client.js";
2
+ export { connectionFromEnv, type LangfuseConnection, LangfuseError, normalizeBaseUrl, type TransportOptions } from "./client.js";
3
+ export type ScoreValue = number | boolean | string;
4
+ export type Metadata = Record<string, string | number | boolean>;
5
+ export interface EvalItem {
6
+ id: string;
7
+ input: unknown;
8
+ expectedOutput: unknown;
9
+ metadata: Record<string, unknown>;
10
+ }
11
+ export interface DatasetSummary {
12
+ id: string;
13
+ name: string;
14
+ }
15
+ export interface ExperimentSummary {
16
+ id: string;
17
+ name: string;
18
+ datasetId: string | null;
19
+ itemCount: number | null;
20
+ startTime: string | null;
21
+ metadata: Record<string, unknown>;
22
+ /** Experiment metadata `model_<role>` entries, e.g. { enrollment: "openrouter/anthropic/claude-sonnet-5.5" }. */
23
+ models: Record<string, string>;
24
+ }
25
+ /**
26
+ * Decides whether one item passed from one score. With no condition, a BOOLEAN score must be true
27
+ * and a NUMERIC score must be 1. `equals` compares exactly; `min`/`max` are inclusive numeric bounds.
28
+ */
29
+ export interface PassRule {
30
+ score: string;
31
+ equals?: ScoreValue;
32
+ min?: number;
33
+ max?: number;
34
+ }
35
+ export interface ItemResult {
36
+ itemId: string;
37
+ traceId: string;
38
+ passed: boolean;
39
+ /** The pass score was absent: the item failed to run or was not scored. It never counts as a pass. */
40
+ missingScore: boolean;
41
+ scores: Record<string, ScoreValue>;
42
+ costUsd: number | null;
43
+ }
44
+ export interface ExperimentResult {
45
+ experimentId: string;
46
+ experimentName: string;
47
+ datasetId: string | null;
48
+ items: ItemResult[];
49
+ passes: number;
50
+ total: number;
51
+ /** Requested item IDs with no experiment item in Langfuse. */
52
+ missing: string[];
53
+ /** Item IDs present without the pass score. */
54
+ missingScores: string[];
55
+ /** Sum of generation costs Langfuse recorded for the items; null when not requested or none recorded. */
56
+ costUsd: number | null;
57
+ }
58
+ export interface ExperimentQuery {
59
+ experimentId?: string;
60
+ /** Names are not unique (the playground gives its claims and enrollments runs one name); an ambiguous name throws. */
61
+ experimentName?: string;
62
+ pass: PassRule;
63
+ /** Only these items count, e.g. the five screening samples; absent ones are listed in `missing`. */
64
+ itemIds?: string[];
65
+ includeCost?: boolean;
66
+ /** Lower bound for Langfuse's required start-time filter. Default: 30 days ago. */
67
+ since?: Date | string;
68
+ }
69
+ export interface RecordedScore {
70
+ name: string;
71
+ value: ScoreValue;
72
+ /** Inferred from the value when absent: boolean → BOOLEAN, number → NUMERIC, string → CATEGORICAL. */
73
+ dataType?: "NUMERIC" | "BOOLEAN" | "CATEGORICAL";
74
+ comment?: string;
75
+ }
76
+ export interface RecordedItem {
77
+ /** Dataset item ID; it must exist in the experiment's dataset. */
78
+ itemId: string;
79
+ /** Model that produced the output, e.g. "openrouter/deepseek/deepseek-v4-pro". */
80
+ model: string;
81
+ input: unknown;
82
+ output: unknown;
83
+ startMs: number;
84
+ endMs: number;
85
+ usage?: {
86
+ input?: number;
87
+ output?: number;
88
+ total?: number;
89
+ };
90
+ costUsd?: number;
91
+ /** Set when the run failed; the root observation gets level ERROR. */
92
+ error?: string;
93
+ /** Per-item trace metadata, e.g. { stage: "screen", eci: 142.1 }. */
94
+ metadata?: Metadata;
95
+ scores: RecordedScore[];
96
+ }
97
+ export interface ExperimentRecording {
98
+ datasetId: string;
99
+ experimentId: string;
100
+ experimentName: string;
101
+ /** Experiment metadata. Put the candidate model under `model_<role>` so listExperiments reports it. */
102
+ metadata?: Metadata;
103
+ /** Trace and root observation name. Default "openmerit-eval". */
104
+ traceName?: string;
105
+ items: RecordedItem[];
106
+ }
107
+ export interface RecordedExperiment {
108
+ experimentId: string;
109
+ traceIds: Record<string, string>;
110
+ url: string;
111
+ }
112
+ export interface DatasetInput {
113
+ name: string;
114
+ description?: string;
115
+ metadata?: Metadata;
116
+ /** Without an ID, an item gets one derived from the dataset name and its input, so re-pushing a file upserts. */
117
+ items: {
118
+ id?: string;
119
+ input: unknown;
120
+ expectedOutput: unknown;
121
+ metadata?: Record<string, unknown>;
122
+ }[];
123
+ }
124
+ export interface LangfuseConnector {
125
+ readonly baseUrl: string;
126
+ check(): Promise<{
127
+ baseUrl: string;
128
+ projectId: string;
129
+ projectName: string;
130
+ }>;
131
+ listDatasets(): Promise<DatasetSummary[]>;
132
+ getDataset(name: string): Promise<DatasetSummary>;
133
+ getDatasetItems(datasetName: string): Promise<EvalItem[]>;
134
+ ensureDataset(input: DatasetInput): Promise<{
135
+ datasetId: string;
136
+ itemIds: string[];
137
+ }>;
138
+ listExperiments(query?: {
139
+ datasetName?: string;
140
+ datasetId?: string;
141
+ names?: string[];
142
+ ids?: string[];
143
+ since?: Date | string;
144
+ }): Promise<ExperimentSummary[]>;
145
+ experimentResults(query: ExperimentQuery): Promise<ExperimentResult>;
146
+ waitForExperiment(query: ExperimentQuery, options: {
147
+ expectItems: number;
148
+ timeoutMs?: number;
149
+ intervalMs?: number;
150
+ }): Promise<ExperimentResult>;
151
+ recordExperimentItems(recording: ExperimentRecording): Promise<RecordedExperiment>;
152
+ experimentUrl(datasetId: string, experimentId: string): Promise<string>;
153
+ }
154
+ export interface ConnectorOptions extends TransportOptions {
155
+ /** Langfuse environment for recorded traces and scores. Default "openmerit". */
156
+ environment?: string;
157
+ }
158
+ /** Version written into recorded traces' scope. */
159
+ export declare const CONNECTOR_VERSION = "0.1.0";
160
+ export declare function evaluatePass(rule: PassRule, value: ScoreValue | undefined): boolean;
161
+ /** Trace ID of one recorded item: the first 32 hex characters of SHA-256 over its experiment and item. */
162
+ export declare const recordedTraceId: (experimentId: string, itemId: string) => string;
163
+ export declare function createLangfuseConnector(connection: LangfuseConnection, options?: ConnectorOptions): LangfuseConnector;