#!/usr/bin/env node
import { M as getEnvString, T as safeJsonStringify, _ as sanitizeObject, c as logger, d as REDACTED, f as isSecretField, k as getEnvBool } from "./logger-4ntIRWmf.js";
import { G as HUMAN_ASSERTION_TYPE } from "./fetch-gOBR1GF3.js";
import { t as getCurrentTimestamp } from "./time-CrpgfF3R.js";
import { A as isApiProvider, _ as getDbSignalPath, a as evalResultsTable, g as getDb, j as isProviderOptions, k as isResultFailureReason, w as ResultFailureReason } from "./tables-BD_diFhY.js";
import { n as sha256 } from "./createHash-Ddi0OHLI.js";
import { n as isBlobStorageEnabled, t as extractAndStoreBinaryData } from "./extractor-CnByw92i.js";
import { n as accumulateGradingRequest, o as createEmptyTokenUsage, r as accumulateResponseTokenUsage } from "./tokenUsageUtils-n_A6wlLq.js";
import fs from "fs";
import { isDeepStrictEqual } from "node:util";
import { and, eq, gte, inArray, lt, ne, sql } from "drizzle-orm";
import debounce from "debounce";
import { LRUCache } from "lru-cache";
//#region src/models/prompt.ts
/**
* Generates a unique identifier for a prompt based on its properties.
*
* Priority order:
* 1. If label is truthy, hash the label
* 2. If id is truthy, hash the id
* 3. Otherwise, hash the raw content (stringified if object)
*
* @param prompt - The prompt object to generate an ID for
* @returns A SHA-256 hash string
*/
function generateIdFromPrompt(prompt) {
	if (prompt.label) return sha256(prompt.label);
	if (prompt.id) return sha256(prompt.id);
	return sha256(typeof prompt.raw === "object" ? JSON.stringify(prompt.raw) : prompt.raw);
}
//#endregion
//#region src/prompts/constants.ts
const PROMPT_DELIMITER = getEnvString("PROMPTFOO_PROMPT_SEPARATOR") || "---";
const VALID_FILE_EXTENSIONS = [
	".cjs",
	".cts",
	".j2",
	".js",
	".json",
	".jsonl",
	".md",
	".mjs",
	".mts",
	".py",
	".ts",
	".txt",
	".yml",
	".yaml"
];
//#endregion
//#region src/prompts/utils.ts
/**
* Determines if a string is a valid file path.
* @param str - The string to check.
* @returns True if the string is a valid file path, false otherwise.
*/
function maybeFilePath(str) {
	if (typeof str !== "string") throw new Error(`Invalid input: ${JSON.stringify(str)}`);
	if ([
		"\n",
		"portkey://",
		"langfuse://",
		"helicone://"
	].some((substring) => str.includes(substring))) return false;
	return str.startsWith("file://") || VALID_FILE_EXTENSIONS.some((ext) => {
		const tokens = str.split(":");
		const pathOrFunctionToken = tokens[tokens.length - 1];
		const pathToken = tokens[tokens.length - 2];
		return pathOrFunctionToken?.endsWith(ext) || pathToken?.endsWith(ext);
	}) || str.charAt(str.length - 3) === "." || str.charAt(str.length - 4) === "." || str.includes("*") || str.includes("/") || str.includes("\\");
}
/**
* Normalizes the input prompt to an array of prompts, rejecting invalid and empty inputs.
* @param promptPathOrGlobs - The input prompt.
* @returns The normalized prompts.
* @throws If the input is invalid or empty.
*/
function normalizeInput(promptPathOrGlobs) {
	if (!promptPathOrGlobs || (typeof promptPathOrGlobs === "string" || Array.isArray(promptPathOrGlobs)) && promptPathOrGlobs.length === 0) throw new Error(`Invalid input prompt: ${JSON.stringify(promptPathOrGlobs)}`);
	if (typeof promptPathOrGlobs === "string") return [{ raw: promptPathOrGlobs }];
	if (Array.isArray(promptPathOrGlobs)) return promptPathOrGlobs.map((promptPathOrGlob, _index) => {
		if (typeof promptPathOrGlob === "string") return { raw: promptPathOrGlob };
		return {
			raw: promptPathOrGlob.raw || promptPathOrGlob.id,
			...promptPathOrGlob
		};
	});
	if (typeof promptPathOrGlobs === "object" && Object.keys(promptPathOrGlobs).length) return Object.entries(promptPathOrGlobs).map(([raw, key]) => ({
		label: key,
		raw
	}));
	throw new Error(`Invalid input prompt: ${JSON.stringify(promptPathOrGlobs)}`);
}
/**
* Generates a hash identifier for a prompt.
* This is an alias for generateIdFromPrompt for backward compatibility.
*
* @param prompt - The prompt to hash
* @returns A SHA-256 hash string
*/
function hashPrompt(prompt) {
	return generateIdFromPrompt(prompt);
}
//#endregion
//#region src/database/signal.ts
const SIGNAL_WATCHER_DEBOUNCE_MS = 250;
/**
* The legacy `evalId:timestamp` text format can't be told apart from a stray short token, so
* {@link readLegacySignalEvalId} only recovers eval ids at least this long. Generated ids
* (`eval-<rand>-<iso>`) always clear it; a short caller-provided id (e.g. an imported `demo`) is
* written as JSON instead, which stores the id explicitly and survives the round trip.
*/
const MIN_LEGACY_EVAL_ID_LENGTH = 9;
/** Whether {@link readLegacySignalEvalId} can recover this id from the legacy text format. */
function isLegacyRecoverableEvalId(evalId) {
	return evalId.length >= MIN_LEGACY_EVAL_ID_LENGTH;
}
/** Returns the string entries of an unknown value, or undefined if it is not an array. */
function toStringEvalIds(value) {
	return Array.isArray(value) ? value.filter((evalId) => typeof evalId === "string") : void 0;
}
function writeSignalFile(content) {
	const filePath = getDbSignalPath();
	try {
		fs.writeFileSync(filePath, content);
	} catch (err) {
		logger.warn(`Failed to write database signal file: ${err}`);
	}
}
/**
* Updates the signal file with the current timestamp and optional eval ID.
* This is used to notify clients that there are new data available.
*
* If another signal is still pending in the watcher's debounce window, this folds it into a
* combined JSON payload so no refresh is lost — scoped updates, a pending unscoped "refresh
* latest", and pending deletes all survive coalescing. When nothing is pending it keeps the
* compact legacy `evalId:timestamp` text.
* This read-modify-write is best-effort within a single process; concurrent writes from separate
* processes can still race the unlocked file, in which case a dropped signal self-heals on the
* next one.
* @param evalId - Optional eval ID that triggered the update
*/
function updateSignalFile(evalId) {
	const now = Date.now();
	const nowIso = new Date(now).toISOString();
	const pending = readPendingSignal(now);
	const pendingUpdateIds = pending ? updateEvalIds(pending) : [];
	const pendingHasDelete = pending ? hasDeleteComponent(pending) : false;
	const pendingHasUnscopedUpdate = pending ? hasUnscopedUpdate(pending) : false;
	const updatedEvalIds = [...new Set([...pendingUpdateIds, ...evalId ? [evalId] : []])];
	const refreshLatest = evalId === void 0 || pendingHasUnscopedUpdate;
	const needsCoalesce = pendingHasDelete || pendingUpdateIds.some((id) => id !== evalId) || refreshLatest && updatedEvalIds.length > 0;
	const evalIdNeedsJson = evalId !== void 0 && !isLegacyRecoverableEvalId(evalId);
	if (needsCoalesce || evalIdNeedsJson) {
		writeSignalFile(JSON.stringify({
			type: "update",
			evalId,
			updatedEvalIds: updatedEvalIds.length > 0 ? updatedEvalIds : void 0,
			deletedEvalIds: pendingHasDelete ? pending?.deletedEvalIds ?? [] : void 0,
			refreshLatest: refreshLatest ? true : void 0,
			timestamp: nowIso
		}));
		return;
	}
	writeSignalFile(evalId ? `${evalId}:${nowIso}` : nowIso);
}
/** True when the signal carries an update component (a scoped or unscoped eval refresh). */
function hasUpdateComponent(signal) {
	return signal.type === "update" || signal.evalId !== void 0 || (signal.updatedEvalIds?.length ?? 0) > 0 || signal.refreshLatest === true;
}
/**
* True when the signal asks to refresh the latest eval (an unscoped update component): the
* `refreshLatest` marker, or a bare update with no scoped ids (e.g. a legacy bare timestamp). The
* watcher emits {@link Eval.latest} for these, so a root `/eval` view follows new evals even when a
* coalesced signal also carries scoped updates or a delete.
*/
function hasUnscopedUpdate(signal) {
	return signal.refreshLatest === true || hasUpdateComponent(signal) && updateEvalIds(signal).length === 0;
}
/** True when the signal carries a delete component (specific ids or "all evals deleted"). */
function hasDeleteComponent(signal) {
	return signal.type === "delete" || signal.deletedEvalIds !== void 0;
}
/**
* True when a delete component means "all evals were deleted" rather than a specific id list.
* "All" is encoded as `undefined` by direct delete callers and as `[]` when a pending delete-all
* is folded into a JSON update, so both count. Only meaningful for a signal that actually carries
* a delete component (see {@link hasDeleteComponent}); an update-only signal has no ids either.
*/
function isAllEvalsDeleted(deletedEvalIds) {
	return deletedEvalIds === void 0 || deletedEvalIds.length === 0;
}
/**
* The distinct scoped eval ids an update signal refreshes, newest last. Empty for an unscoped
* update (which means "refresh to the latest eval"). Prefers the accumulated `updatedEvalIds`
* set and falls back to the single `evalId` for legacy/simple signals.
*/
function updateEvalIds(signal) {
	if (signal.updatedEvalIds && signal.updatedEvalIds.length > 0) return signal.updatedEvalIds;
	return signal.evalId ? [signal.evalId] : [];
}
/**
* Parses signal-file content as a combined JSON payload, returning the classified signal and
* its timestamp (used for the debounce-window check), or null if it is not a JSON signal.
*/
function parseSignalJson(content) {
	try {
		const parsed = JSON.parse(content);
		if (parsed !== null && typeof parsed === "object" && "type" in parsed && (parsed.type === "delete" || parsed.type === "update")) {
			const evalId = "evalId" in parsed && typeof parsed.evalId === "string" ? parsed.evalId : void 0;
			const updatedEvalIds = "updatedEvalIds" in parsed ? toStringEvalIds(parsed.updatedEvalIds) : void 0;
			const deletedEvalIds = "deletedEvalIds" in parsed ? toStringEvalIds(parsed.deletedEvalIds) : void 0;
			const timestamp = "timestamp" in parsed && typeof parsed.timestamp === "string" ? parsed.timestamp : void 0;
			const refreshLatest = "refreshLatest" in parsed && parsed.refreshLatest === true ? true : void 0;
			return {
				signal: {
					type: parsed.type,
					evalId,
					updatedEvalIds,
					deletedEvalIds,
					refreshLatest
				},
				timestamp
			};
		}
	} catch {}
	return null;
}
/**
* Reads the signal file only if it was written within the watcher's debounce window, so a
* writer can fold a not-yet-observed signal into its own write instead of clobbering it.
* Returns null when the file is stale (outside the window), unreadable, or unparseable.
*/
function readPendingSignal(now) {
	const withinWindow = (timestamp) => {
		if (timestamp === void 0) return false;
		const elapsed = now - Date.parse(timestamp);
		return Number.isFinite(elapsed) && elapsed >= 0 && elapsed <= SIGNAL_WATCHER_DEBOUNCE_MS;
	};
	try {
		const content = fs.readFileSync(getDbSignalPath(), "utf8").trim();
		const json = parseSignalJson(content);
		if (json) return withinWindow(json.timestamp) ? json.signal : null;
		const legacyTimestamp = content.match(/(\d{4}-\d{2}-\d{2}T[0-9:.]+Z?)$/)?.[1];
		if (!withinWindow(legacyTimestamp)) return null;
		return {
			type: "update",
			evalId: readLegacySignalEvalId(content)
		};
	} catch {
		return null;
	}
}
/**
* Signals that one or more evals were deleted. Deletions use a JSON payload so the watcher can
* tell clients exactly which evals to drop (whereas a plain update keeps the legacy
* `evalId:timestamp` text format unless it has to fold in a pending delete). A delete in the
* debounce window also carries forward a pending update — scoped ids or an unscoped "refresh
* latest" — so neither refresh is lost (same-process best-effort, like {@link updateSignalFile};
* cross-process writes can still race).
* Pre-PR view servers don't understand this JSON and read it back as "no eval id", falling back
* to broadcasting the latest eval — a benign degradation, so the view server and CLI should be
* on matching versions for delete-aware refreshes.
*/
function updateSignalFileForDeletedEvals(deletedEvalIds) {
	const now = Date.now();
	const pending = readPendingSignal(now);
	const pendingDelete = pending && hasDeleteComponent(pending) ? pending : null;
	const mergedDeletedEvalIds = pendingDelete ? isAllEvalsDeleted(pendingDelete.deletedEvalIds) || isAllEvalsDeleted(deletedEvalIds) ? void 0 : [...new Set([...pendingDelete.deletedEvalIds ?? [], ...deletedEvalIds ?? []])] : deletedEvalIds;
	const carriedUpdateIds = pending && hasUpdateComponent(pending) ? updateEvalIds(pending) : [];
	const carriedRefreshLatest = pending ? hasUnscopedUpdate(pending) : false;
	writeSignalFile(JSON.stringify({
		type: "delete",
		deletedEvalIds: mergedDeletedEvalIds,
		evalId: carriedUpdateIds[carriedUpdateIds.length - 1],
		updatedEvalIds: carriedUpdateIds.length > 0 ? carriedUpdateIds : void 0,
		refreshLatest: carriedRefreshLatest ? true : void 0,
		timestamp: new Date(now).toISOString()
	}));
}
function readLegacySignalEvalId(content) {
	if (/^\d{4}-\d{2}-\d{2}T/.test(content)) return;
	const evalId = content.match(/^(.*):\d{4}-\d{2}-\d{2}T/)?.[1];
	return evalId && isLegacyRecoverableEvalId(evalId) ? evalId : void 0;
}
/**
* Reads and classifies the signal file into a {@link DatabaseSignal}. It understands the
* legacy `evalId:timestamp` (or bare timestamp) text written by {@link updateSignalFile} and
* the JSON payload written by the delete/combined writers. A JSON payload may carry BOTH an
* update component (`updatedEvalIds` — possibly several ids when scoped updates coalesced) and a
* delete component (`deletedEvalIds`) when mutations were coalesced inside the watcher's debounce
* window. Malformed/half-written content or a read error degrades to an unscoped `{type:'update'}`.
*/
function readSignalFile() {
	const filePath = getDbSignalPath();
	try {
		const content = fs.readFileSync(filePath, "utf8").trim();
		const json = parseSignalJson(content);
		if (json) return json.signal;
		return {
			type: "update",
			evalId: readLegacySignalEvalId(content)
		};
	} catch {
		return { type: "update" };
	}
}
/**
* Ensures the signal file exists, creating it if necessary.
*/
function ensureSignalFile() {
	const filePath = getDbSignalPath();
	if (!fs.existsSync(filePath)) {
		logger.debug(`Creating signal file at ${filePath}`);
		fs.writeFileSync(filePath, (/* @__PURE__ */ new Date()).toISOString());
	}
}
/**
* Sets up a watcher on the signal file and calls the callback when it changes.
* @param onChange - Callback function that is called when the signal file changes
* @returns The watcher instance
*/
function setupSignalWatcher(onChange) {
	const filePath = getDbSignalPath();
	logger.debug(`Setting up file watcher on ${filePath}`);
	ensureSignalFile();
	try {
		const watcher = fs.watch(filePath);
		watcher.on("change", debounce(onChange, SIGNAL_WATCHER_DEBOUNCE_MS));
		watcher.on("error", (error) => {
			logger.warn(`File watcher error: ${error}`);
		});
		return watcher;
	} catch (error) {
		logger.warn(`Failed to set up file watcher: ${error}`);
		throw error;
	}
}
//#endregion
//#region src/util/standaloneEvalCache.ts
const standaloneEvalCache = new LRUCache({
	ttl: 3600 * 2 * 1e3,
	max: 2e3
});
function getStandaloneEvalCacheKey({ limit = 100, tag, description } = {}) {
	return `standalone_evals_${limit}_${tag?.key}_${tag?.value}_${description}`;
}
function getCachedStandaloneEvals(cacheKey) {
	return standaloneEvalCache.get(cacheKey);
}
function setCachedStandaloneEvals(cacheKey, evals) {
	standaloneEvalCache.set(cacheKey, evals);
}
function clearStandaloneEvalCache() {
	standaloneEvalCache.clear();
}
//#endregion
//#region src/models/evalPerformance.ts
const distinctCountCache = /* @__PURE__ */ new Map();
const totalRowCountCache = /* @__PURE__ */ new Map();
const CACHE_TTL = 300 * 1e3;
/**
* Get the count of distinct test indices for an eval.
* This represents the number of unique test cases (rows in the UI table).
*
* Use getTotalResultRowCount() if you need the total number of result rows
* (which may be higher when there are multiple prompts/providers per test case).
*/
async function getCachedResultsCount(evalId) {
	const cacheKey = `distinct:${evalId}`;
	const cached = distinctCountCache.get(cacheKey);
	if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
		logger.debug(`Using cached distinct count for eval ${evalId}: ${cached.count}`);
		return cached.count;
	}
	const db = await getDb();
	const start = Date.now();
	const result = await db.select({ count: sql`COUNT(DISTINCT test_idx)` }).from(evalResultsTable).where(sql`eval_id = ${evalId}`).all();
	const count = Number(result[0]?.count ?? 0);
	const duration = Date.now() - start;
	logger.debug(`Distinct count query for eval ${evalId}: ${count} in ${duration}ms`);
	distinctCountCache.set(cacheKey, {
		count,
		timestamp: Date.now()
	});
	return count;
}
/**
* Get the total count of all result rows for an eval.
* This counts every result row in the database, including multiple results
* per test case (e.g., when using multiple prompts or providers).
*
* Use this for progress tracking when iterating over all results (e.g., sharing).
*/
async function getTotalResultRowCount(evalId) {
	const cacheKey = `total:${evalId}`;
	const cached = totalRowCountCache.get(cacheKey);
	if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
		logger.debug(`Using cached total row count for eval ${evalId}: ${cached.count}`);
		return cached.count;
	}
	const db = await getDb();
	const start = Date.now();
	const result = await db.select({ count: sql`COUNT(*)` }).from(evalResultsTable).where(sql`eval_id = ${evalId}`).all();
	const count = Number(result[0]?.count ?? 0);
	const duration = Date.now() - start;
	logger.debug(`Total row count query for eval ${evalId}: ${count} in ${duration}ms`);
	totalRowCountCache.set(cacheKey, {
		count,
		timestamp: Date.now()
	});
	return count;
}
function clearCountCache(evalId) {
	if (evalId) {
		distinctCountCache.delete(`distinct:${evalId}`);
		totalRowCountCache.delete(`total:${evalId}`);
	} else {
		distinctCountCache.clear();
		totalRowCountCache.clear();
	}
}
async function queryTestIndicesOptimized(evalId, opts) {
	const db = await getDb();
	const offset = opts.offset ?? 0;
	const limit = opts.limit ?? 50;
	const mode = opts.filterMode ?? "all";
	let baseQuery = sql`eval_id = ${evalId}`;
	if (mode === "errors") baseQuery = sql`${baseQuery} AND failure_reason = ${2}`;
	else if (mode === "failures") baseQuery = sql`${baseQuery} AND success = 0 AND failure_reason != ${2}`;
	else if (mode === "passes") baseQuery = sql`${baseQuery} AND success = 1`;
	else if (mode === "highlights") baseQuery = sql`${baseQuery} AND json_extract(grading_result, '$.comment') LIKE '!highlight%'`;
	else if (mode === "user-rated") baseQuery = sql`${baseQuery} AND EXISTS (
      SELECT 1
      FROM json_each(grading_result, '$.componentResults')
      WHERE json_extract(value, '$.assertion.type') = ${HUMAN_ASSERTION_TYPE}
    )`;
	let searchCondition = sql`1=1`;
	if (opts.searchQuery && opts.searchQuery.trim() !== "" && !opts.filters?.length) searchCondition = sql`response LIKE ${`%${opts.searchQuery}%`}`;
	const whereClause = sql`${baseQuery} AND ${searchCondition}`;
	const countStart = Date.now();
	const countQuery = sql`
    SELECT COUNT(DISTINCT test_idx) as count 
    FROM ${evalResultsTable} 
    WHERE ${whereClause}
  `;
	const countResult = await db.all(countQuery);
	const filteredCount = Number(countResult[0]?.count ?? 0);
	logger.debug(`Optimized count query took ${Date.now() - countStart}ms`);
	const idxStart = Date.now();
	const idxQuery = sql`
    SELECT DISTINCT test_idx 
    FROM ${evalResultsTable} 
    WHERE ${whereClause}
    ORDER BY test_idx 
    LIMIT ${limit} 
    OFFSET ${offset}
  `;
	const testIndices = (await db.all(idxQuery)).map((row) => row.test_idx);
	logger.debug(`Optimized index query took ${Date.now() - idxStart}ms`);
	return {
		testIndices,
		filteredCount
	};
}
//#endregion
//#region src/models/evalMutation.ts
/**
* Drops this process's cached eval aggregates so the next read re-queries the DB.
* Always clears the standalone-eval LRU (keyed by filter combos, no per-eval granularity)
* and the count cache for `evalId` (or every eval's counts when `evalId` is omitted).
* Use this for in-process cache coherence only — it does NOT notify the view server.
*/
function invalidateEvaluationCache(evalId) {
	clearStandaloneEvalCache();
	clearCountCache(evalId);
}
/**
* Like {@link invalidateEvaluationCache} but for several evals at once: clears the standalone-eval
* LRU once (it has no per-eval granularity, so repeating it per id is wasteful) and the count
* cache for each id.
*/
function invalidateEvaluationCaches(evalIds) {
	clearStandaloneEvalCache();
	for (const id of evalIds) clearCountCache(id);
}
/**
* Call after creating or mutating an eval's data (results, prompts, ratings). Invalidates
* this process's caches and writes a scoped update signal so a running `promptfoo view`
* server refreshes clients viewing this eval (and the root route, which follows the latest).
*/
function notifyEvaluationChanged(evalId) {
	invalidateEvaluationCache(evalId);
	updateSignalFile(evalId);
}
/**
* Call after deleting one or more evals. Invalidates this process's caches and writes a
* delete signal so the view server can navigate clients off the removed evals. Pass no
* argument (or omit ids) for "all evals deleted".
*/
function notifyEvaluationsDeleted(evalIds) {
	if (evalIds) invalidateEvaluationCaches(evalIds);
	else invalidateEvaluationCache();
	updateSignalFileForDeletedEvals(evalIds);
}
//#endregion
//#region src/models/evalResult.ts
function sanitizeProviderConfig(config) {
	return sanitizeObject(JSON.parse(safeJsonStringify(config)), {
		context: "provider config",
		maxDepth: Number.POSITIVE_INFINITY
	});
}
function projectProviderResponse(response, options) {
	if (!response) return response;
	if (!options.stripMetadata && !options.stripOutput) return response;
	const projectedResponse = options.stripMetadata ? (({ metadata: _metadata, ...rest }) => rest)(response) : { ...response };
	if (options.stripOutput) projectedResponse.output = "[output stripped]";
	return projectedResponse;
}
function projectPrompt(prompt, stripPromptText) {
	return stripPromptText ? {
		...prompt,
		raw: "[prompt stripped]"
	} : prompt;
}
function projectTestCase(testCase, options) {
	if (!options.stripMetadata && !options.stripVars) return testCase;
	const projectedTestCase = options.stripMetadata ? (({ metadata: _metadata, ...rest }) => rest)(testCase) : { ...testCase };
	if (options.stripVars) projectedTestCase.vars = void 0;
	return projectedTestCase;
}
function sanitizeProvider(provider) {
	try {
		if (isApiProvider(provider)) return {
			id: provider.id(),
			label: provider.label,
			...provider.config && { config: sanitizeProviderConfig(provider.config) }
		};
		if (isProviderOptions(provider)) return {
			id: provider.id,
			label: provider.label,
			...provider.config && { config: sanitizeProviderConfig(provider.config) }
		};
		if (typeof provider === "object" && provider) {
			const providerObj = provider;
			return {
				id: typeof providerObj.id === "function" ? providerObj.id() : providerObj.id,
				label: providerObj.label,
				...providerObj.config && { config: sanitizeProviderConfig(providerObj.config) }
			};
		}
	} catch {}
	return JSON.parse(safeJsonStringify(provider));
}
/**
* Sanitize an object for database storage by removing circular references
* and non-serializable values (functions, Timeout objects, etc.).
* Uses safeJsonStringify which handles circular references gracefully.
*
* This prevents "Converting circular structure to JSON" errors that can occur
* when Node.js Timeout objects or other non-serializable data leaks into results.
* See: https://github.com/promptfoo/promptfoo/issues/7266
*/
function sanitizeForDb(obj) {
	if (obj === null || obj === void 0) return obj;
	try {
		const serialized = safeJsonStringify(obj);
		if (serialized === void 0) {
			logger.debug("sanitizeForDb: Failed to serialize object, using fallback", {
				valueType: typeof obj,
				isArray: Array.isArray(obj)
			});
			return Array.isArray(obj) ? [] : null;
		}
		return JSON.parse(serialized);
	} catch (error) {
		logger.debug("sanitizeForDb: Parse error, using fallback", { error });
		return Array.isArray(obj) ? [] : null;
	}
}
/**
* Sanitize a per-test-case field for persistence: strips circular refs,
* collapses class instances (e.g. live SDK clients that leaked in via
* `defaultTest.options.provider`), and redacts credential fields (`apiKey`,
* `token`, etc.) at any depth. Use this for any slot that can carry a provider
* config — notably `testCase.options.provider` and `prompt.config.provider`,
* where the resolved runtime provider (with its Anthropic / Bedrock SDK
* client) flows in from the evaluator. Without this, credentials configured on
* the judge provider end up in the Eval results both in the DB and in the
* polling response served by `/api/eval/job/:id`.
*/
function sanitizeForDbWithSecrets(obj) {
	if (obj === null || obj === void 0) return obj;
	return sanitizeObject(obj, {
		context: "evalResult field",
		maxDepth: Number.POSITIVE_INFINITY
	});
}
const SENSITIVE_RESPONSE_HEADER_NAMES = new Set([
	"authorization",
	"proxy-authorization",
	"cookie",
	"set-cookie",
	"openai-organization",
	"openai-project",
	"openai-version",
	"x-request-id",
	"x-amzn-requestid",
	"x-amzn-trace-id",
	"x-amz-security-token",
	"x-amz-cf-id",
	"x-azure-ref",
	"x-correlation-id",
	"x-trace-id",
	"cf-ray",
	"cf-cache-status",
	"x-openai-proxy-wasm",
	"via"
]);
const SENSITIVE_RESPONSE_HEADER_PREFIXES = ["x-ratelimit-"];
function isSensitiveResponseHeader(headerName) {
	const normalized = headerName.toLowerCase();
	if (SENSITIVE_RESPONSE_HEADER_NAMES.has(normalized)) return true;
	return SENSITIVE_RESPONSE_HEADER_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
function isSensitiveRequestHeader(headerName) {
	return isSensitiveResponseHeader(headerName) || isSecretField(headerName);
}
function redactSensitiveHeaders(headers, sourceHeaders = headers, isSensitiveHeader = isSensitiveResponseHeader) {
	let mutated = false;
	const next = {};
	for (const [key, value] of Object.entries(headers)) if (Object.prototype.hasOwnProperty.call(sourceHeaders, key) && isDeepStrictEqual(sourceHeaders[key], value) && isSensitiveHeader(key)) {
		next[key] = REDACTED;
		mutated = true;
	} else next[key] = value;
	return mutated ? next : null;
}
function redactHttpHeadersOnMetadata(metadata, options) {
	if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return metadata;
	const m = metadata;
	let nextMetadata;
	const legacyHeaders = m.headers;
	const legacyHeadersSource = options?.redactLegacyHeaders ? legacyHeaders : (options?.legacyHeadersSource)?.headers;
	if (legacyHeaders && typeof legacyHeaders === "object" && !Array.isArray(legacyHeaders) && legacyHeadersSource && typeof legacyHeadersSource === "object" && !Array.isArray(legacyHeadersSource)) {
		const redacted = redactSensitiveHeaders(legacyHeaders, legacyHeadersSource, isSensitiveRequestHeader);
		if (redacted) nextMetadata = {
			...m,
			headers: redacted
		};
	}
	const http = m.http;
	if (!http || typeof http !== "object" || Array.isArray(http)) return nextMetadata ?? metadata;
	const httpRecord = http;
	let nextHttp;
	for (const slot of ["headers", "requestHeaders"]) {
		const slotValue = httpRecord[slot];
		if (slotValue && typeof slotValue === "object" && !Array.isArray(slotValue)) {
			const redacted = slot === "requestHeaders" ? redactSensitiveHeaders(slotValue, slotValue, isSensitiveRequestHeader) : redactSensitiveHeaders(slotValue);
			if (redacted) {
				nextHttp ??= { ...httpRecord };
				nextHttp[slot] = redacted;
			}
		}
	}
	if (!nextHttp) return nextMetadata ?? metadata;
	nextMetadata ??= { ...m };
	nextMetadata.http = nextHttp;
	return nextMetadata;
}
function redactHttpHeadersOnGradingResult(gradingResult) {
	if (!gradingResult || typeof gradingResult !== "object" || Array.isArray(gradingResult)) return gradingResult;
	const gr = gradingResult;
	let mutated = false;
	const next = { ...gr };
	if (gr.metadata !== void 0) {
		const redacted = redactHttpHeadersOnMetadata(gr.metadata);
		if (redacted !== gr.metadata) {
			next.metadata = redacted;
			mutated = true;
		}
	}
	if (Array.isArray(gr.componentResults)) {
		let componentMutated = false;
		const nextComponents = gr.componentResults.map((component) => {
			const redacted = redactHttpHeadersOnGradingResult(component);
			if (redacted !== component) componentMutated = true;
			return redacted;
		});
		if (componentMutated) {
			next.componentResults = nextComponents;
			mutated = true;
		}
	}
	return mutated ? next : gradingResult;
}
function sanitizeResponseForDb(response) {
	if (!response) return response;
	const redactedMetadata = redactHttpHeadersOnMetadata(response.metadata, { redactLegacyHeaders: true });
	if (redactedMetadata === response.metadata) return response;
	return {
		...response,
		metadata: redactedMetadata
	};
}
function sanitizeMetadataForDb(metadata, responseMetadata) {
	return redactHttpHeadersOnMetadata(metadata, { legacyHeadersSource: sanitizeForDb(responseMetadata) });
}
function sanitizeGradingResultForDb(gradingResult) {
	return redactHttpHeadersOnGradingResult(gradingResult);
}
const PROMPTFOO_METADATA_KEY = "__promptfoo";
const TRACE_LINKAGE_KEY = "traceLinkage";
function asRecord(value) {
	return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
}
function persistTraceMetadata(metadata, traceId, evaluationId) {
	if (!traceId && !evaluationId) return stripTraceLinkageFromMetadata(metadata);
	const metadataRecord = metadata ?? {};
	const promptfooMetadata = asRecord(metadataRecord[PROMPTFOO_METADATA_KEY]);
	if (metadataRecord["__promptfoo"] !== void 0 && promptfooMetadata === void 0) logger.warn(`[EvalResult] Overwriting non-object metadata.${PROMPTFOO_METADATA_KEY} with internal trace linkage; the key is reserved for promptfoo internals.`);
	if (promptfooMetadata && TRACE_LINKAGE_KEY in promptfooMetadata) logger.warn(`[EvalResult] Overwriting metadata.${PROMPTFOO_METADATA_KEY}.${TRACE_LINKAGE_KEY} with internal trace linkage; the path is reserved for promptfoo internals.`);
	return {
		...metadataRecord,
		[PROMPTFOO_METADATA_KEY]: {
			...promptfooMetadata ?? {},
			[TRACE_LINKAGE_KEY]: {
				traceId,
				evaluationId
			}
		}
	};
}
function stripTraceLinkageFromMetadata(metadata) {
	const metadataRecord = asRecord(metadata);
	const promptfooMetadata = asRecord(metadataRecord?.[PROMPTFOO_METADATA_KEY]);
	if (!metadataRecord || !promptfooMetadata || !(TRACE_LINKAGE_KEY in promptfooMetadata)) return metadata;
	const { [TRACE_LINKAGE_KEY]: _traceLinkage, ...remainingPromptfooMetadata } = promptfooMetadata;
	const strippedMetadata = { ...metadataRecord };
	delete strippedMetadata[PROMPTFOO_METADATA_KEY];
	if (Object.keys(remainingPromptfooMetadata).length > 0) strippedMetadata[PROMPTFOO_METADATA_KEY] = remainingPromptfooMetadata;
	return strippedMetadata;
}
function surfaceTraceMetadata(metadata) {
	const metadataRecord = metadata ?? {};
	const promptfooMetadata = asRecord(metadataRecord[PROMPTFOO_METADATA_KEY]);
	const traceLinkage = asRecord(promptfooMetadata?.[TRACE_LINKAGE_KEY]);
	const traceId = typeof traceLinkage?.traceId === "string" ? traceLinkage.traceId : void 0;
	const evaluationId = typeof traceLinkage?.evaluationId === "string" ? traceLinkage.evaluationId : void 0;
	if (!(promptfooMetadata != null && TRACE_LINKAGE_KEY in promptfooMetadata)) return {
		traceId,
		evaluationId,
		metadata: metadataRecord
	};
	return {
		traceId,
		evaluationId,
		metadata: stripTraceLinkageFromMetadata(metadataRecord)
	};
}
function redactSensitiveResultFieldsForDb(fields) {
	return {
		response: sanitizeResponseForDb(fields.response),
		gradingResult: sanitizeGradingResultForDb(fields.gradingResult),
		metadata: sanitizeMetadataForDb(fields.metadata, fields.response?.metadata)
	};
}
function getStripFlags() {
	return {
		shouldStripPromptText: getEnvBool("PROMPTFOO_STRIP_PROMPT_TEXT", false),
		shouldStripResponseOutput: getEnvBool("PROMPTFOO_STRIP_RESPONSE_OUTPUT", false),
		shouldStripTestVars: getEnvBool("PROMPTFOO_STRIP_TEST_VARS", false),
		shouldStripGradingResult: getEnvBool("PROMPTFOO_STRIP_GRADING_RESULT", false),
		shouldStripMetadata: getEnvBool("PROMPTFOO_STRIP_METADATA", false)
	};
}
/**
* Sanitize a result before it is serialized into a JSONL output artifact. This is the
* JSONL-boundary equivalent of the database-persistence sanitization and must stay in sync
* with it: it redacts credential-bearing HTTP headers from the response / grading / metadata
* and applies the `PROMPTFOO_STRIP_*` projections (prompt text, response output, test vars,
* grading result, metadata). In-memory rows keep their real values for hooks; only the
* on-disk copy is sanitized.
*/
function sanitizeResultForJsonlArtifact(result) {
	const { shouldStripPromptText, shouldStripResponseOutput, shouldStripTestVars, shouldStripGradingResult, shouldStripMetadata } = getStripFlags();
	const artifactResult = result;
	const redacted = redactSensitiveResultFieldsForDb({
		response: sanitizeForDb(artifactResult.response),
		gradingResult: sanitizeForDb(artifactResult.gradingResult),
		metadata: sanitizeForDb(artifactResult.metadata)
	});
	const response = projectProviderResponse(redacted.response ?? void 0, {
		stripMetadata: shouldStripMetadata,
		stripOutput: shouldStripResponseOutput
	});
	return {
		...result,
		...artifactResult.testCase ? { testCase: projectTestCase(sanitizeForDbWithSecrets(artifactResult.testCase), {
			stripMetadata: shouldStripMetadata,
			stripVars: shouldStripTestVars
		}) } : {},
		...artifactResult.vars === void 0 ? {} : { vars: shouldStripTestVars ? {} : sanitizeForDbWithSecrets(artifactResult.vars) },
		...artifactResult.prompt ? { prompt: projectPrompt(sanitizeForDbWithSecrets(artifactResult.prompt), shouldStripPromptText) } : {},
		...artifactResult.provider ? { provider: sanitizeProvider(artifactResult.provider) } : {},
		response,
		gradingResult: shouldStripGradingResult ? null : redacted.gradingResult,
		namedScores: sanitizeForDb(artifactResult.namedScores),
		metadata: shouldStripMetadata ? {} : redacted.metadata
	};
}
var EvalResult = class EvalResult {
	static async createFromEvaluateResult(evalId, result, opts) {
		const persist = opts?.persist == null ? true : opts.persist;
		const { prompt, error, score, latencyMs, success, provider, gradingResult, namedScores, cost, metadata, failureReason, testCase, traceId, evaluationId } = result;
		const persistedMetadata = persistTraceMetadata(metadata, traceId, evaluationId);
		const preSanitizeTestCase = {
			...testCase,
			...testCase.provider && { provider: sanitizeProvider(testCase.provider) }
		};
		const processedResponse = await extractAndStoreBinaryData(result.response, {
			evalId,
			testIdx: result.testIdx,
			promptIdx: result.promptIdx
		});
		const args = {
			id: crypto.randomUUID(),
			evalId,
			testCase: sanitizeForDbWithSecrets(preSanitizeTestCase),
			promptIdx: result.promptIdx,
			testIdx: result.testIdx,
			prompt: sanitizeForDbWithSecrets(prompt),
			promptId: hashPrompt(prompt),
			error: error?.toString(),
			success,
			score: score == null ? 0 : score,
			response: sanitizeForDb(processedResponse || null),
			gradingResult: sanitizeForDb(gradingResult || null),
			namedScores: sanitizeForDb(namedScores),
			provider: sanitizeProvider(provider),
			latencyMs,
			cost,
			metadata: sanitizeForDb(persistedMetadata),
			failureReason
		};
		if (persist) {
			const db = await getDb();
			const redacted = redactSensitiveResultFieldsForDb({
				response: args.response,
				gradingResult: args.gradingResult,
				metadata: args.metadata
			});
			args.response = redacted.response;
			args.gradingResult = redacted.gradingResult;
			args.metadata = redacted.metadata;
			const dbResult = await db.insert(evalResultsTable).values(args).returning();
			clearCountCache(evalId);
			return new EvalResult({
				...dbResult[0],
				persisted: true
			});
		}
		return new EvalResult(args);
	}
	static async createManyFromEvaluateResult(results, evalId) {
		const db = await getDb();
		const returnResults = [];
		const processedResults = [];
		for (const result of results) {
			const processedResponse = isBlobStorageEnabled() ? await extractAndStoreBinaryData(result.response, {
				evalId,
				testIdx: result.testIdx,
				promptIdx: result.promptIdx
			}) : result.response;
			processedResults.push({
				...result,
				response: processedResponse ?? void 0
			});
		}
		await db.transaction(async (tx) => {
			for (const result of processedResults) {
				const { traceId: _traceId, evaluationId: _evaluationId, ...rest } = result;
				const sanitizedResult = {
					...rest,
					testCase: sanitizeForDbWithSecrets(result.testCase),
					prompt: sanitizeForDbWithSecrets(result.prompt),
					...redactSensitiveResultFieldsForDb({
						response: sanitizeForDb(result.response),
						gradingResult: sanitizeForDb(result.gradingResult),
						metadata: sanitizeForDb(persistTraceMetadata(result.metadata, result.traceId, result.evaluationId))
					}),
					namedScores: sanitizeForDb(result.namedScores),
					provider: result.provider ? sanitizeProvider(result.provider) : result.provider
				};
				const dbResult = await tx.insert(evalResultsTable).values({
					...sanitizedResult,
					evalId,
					id: crypto.randomUUID()
				}).returning().get();
				returnResults.push(new EvalResult({
					...dbResult,
					persisted: true
				}));
			}
		});
		clearCountCache(evalId);
		return returnResults;
	}
	static async findById(id) {
		const result = await (await getDb()).select().from(evalResultsTable).where(eq(evalResultsTable.id, id));
		return result.length > 0 ? new EvalResult({
			...result[0],
			persisted: true
		}) : null;
	}
	static async findManyByEvalId(evalId, opts) {
		return (await (await getDb()).select().from(evalResultsTable).where(and(eq(evalResultsTable.evalId, evalId), opts?.testIdx == null ? void 0 : eq(evalResultsTable.testIdx, opts.testIdx)))).map((result) => new EvalResult({
			...result,
			persisted: true
		}));
	}
	static async findManyByEvalIdAndTestIndices(evalId, testIndices) {
		if (!testIndices.length) return [];
		return (await (await getDb()).select().from(evalResultsTable).where(and(eq(evalResultsTable.evalId, evalId), testIndices.length === 1 ? eq(evalResultsTable.testIdx, testIndices[0]) : inArray(evalResultsTable.testIdx, testIndices)))).map((result) => new EvalResult({
			...result,
			persisted: true
		}));
	}
	/**
	* Returns a set of completed (testIdx,promptIdx) pairs for a given eval.
	* Key format: `${testIdx}:${promptIdx}`
	*
	* @param evalId - The evaluation ID to query
	* @param opts.excludeErrors - If true, excludes results with ERROR failureReason (used in retry mode)
	*/
	static async getCompletedIndexPairs(evalId, opts) {
		const db = await getDb();
		const whereClause = opts?.excludeErrors ? and(eq(evalResultsTable.evalId, evalId), ne(evalResultsTable.failureReason, ResultFailureReason.ERROR)) : eq(evalResultsTable.evalId, evalId);
		const rows = await db.select({
			testIdx: evalResultsTable.testIdx,
			promptIdx: evalResultsTable.promptIdx
		}).from(evalResultsTable).where(whereClause);
		const ret = /* @__PURE__ */ new Set();
		for (const r of rows) ret.add(`${r.testIdx}:${r.promptIdx}`);
		return ret;
	}
	static async *findManyByEvalIdBatched(evalId, opts) {
		const db = await getDb();
		const batchSize = opts?.batchSize || 100;
		let offset = 0;
		while (true) {
			const nextResult = await db.select({ testIdx: evalResultsTable.testIdx }).from(evalResultsTable).where(and(eq(evalResultsTable.evalId, evalId), gte(evalResultsTable.testIdx, offset))).orderBy(evalResultsTable.testIdx).limit(1).get();
			if (!nextResult) break;
			offset = nextResult.testIdx;
			yield (await db.select().from(evalResultsTable).where(and(eq(evalResultsTable.evalId, evalId), gte(evalResultsTable.testIdx, offset), lt(evalResultsTable.testIdx, offset + batchSize))).all()).map((result) => new EvalResult({
				...result,
				persisted: true
			}));
			offset += batchSize;
		}
	}
	id;
	evalId;
	description;
	promptIdx;
	testIdx;
	testCase;
	prompt;
	promptId;
	error;
	success;
	score;
	response;
	gradingResult;
	namedScores;
	provider;
	latencyMs;
	cost;
	metadata;
	traceId;
	evaluationId;
	failureReason;
	persisted;
	pluginId;
	constructor(opts) {
		this.id = opts.id;
		this.evalId = opts.evalId;
		this.promptIdx = opts.promptIdx;
		this.testIdx = opts.testIdx;
		this.testCase = opts.testCase;
		this.prompt = opts.prompt;
		this.promptId = opts.promptId || hashPrompt(opts.prompt);
		this.error = opts.error;
		this.score = opts.score;
		this.success = opts.success;
		this.response = opts.response || void 0;
		this.gradingResult = opts.gradingResult;
		this.namedScores = opts.namedScores || {};
		this.provider = opts.provider;
		this.latencyMs = opts.latencyMs || 0;
		this.cost = opts.cost || 0;
		({metadata: this.metadata, traceId: this.traceId, evaluationId: this.evaluationId} = surfaceTraceMetadata(opts.metadata));
		this.failureReason = isResultFailureReason(opts.failureReason) ? opts.failureReason : ResultFailureReason.NONE;
		this.persisted = opts.persisted || false;
		this.pluginId = opts.testCase.metadata?.pluginId;
	}
	async save() {
		const db = await getDb();
		const { traceId: _traceId, evaluationId: _evaluationId, pluginId: _pluginId, ...rest } = this;
		const persistedValues = {
			...rest,
			metadata: persistTraceMetadata(this.metadata, this.traceId, this.evaluationId)
		};
		if (this.persisted) await db.update(evalResultsTable).set({
			...persistedValues,
			updatedAt: getCurrentTimestamp()
		}).where(eq(evalResultsTable.id, this.id)).run();
		else {
			const result = await db.insert(evalResultsTable).values(persistedValues).returning();
			this.id = result[0].id;
			this.persisted = true;
		}
		invalidateEvaluationCache(this.evalId);
	}
	toEvaluateResult() {
		const { shouldStripPromptText, shouldStripResponseOutput, shouldStripTestVars, shouldStripGradingResult, shouldStripMetadata } = getStripFlags();
		const response = projectProviderResponse(this.response, {
			stripMetadata: shouldStripMetadata,
			stripOutput: shouldStripResponseOutput
		});
		const prompt = projectPrompt(this.prompt, shouldStripPromptText);
		const testCase = projectTestCase(this.testCase, {
			stripMetadata: shouldStripMetadata,
			stripVars: shouldStripTestVars
		});
		const tokenUsage = createEmptyTokenUsage();
		if (this.response) accumulateResponseTokenUsage(tokenUsage, this.response);
		if (this.gradingResult) accumulateGradingRequest(tokenUsage.assertions, this.gradingResult.tokensUsed);
		return {
			cost: this.cost,
			description: this.description || void 0,
			error: this.error || void 0,
			gradingResult: shouldStripGradingResult ? null : this.gradingResult,
			id: this.id,
			latencyMs: this.latencyMs,
			namedScores: this.namedScores,
			prompt,
			promptId: this.promptId,
			promptIdx: this.promptIdx,
			...this.traceId ? { traceId: this.traceId } : {},
			...this.evaluationId ? { evaluationId: this.evaluationId } : {},
			provider: {
				id: this.provider.id,
				label: this.provider.label
			},
			response,
			score: this.score,
			success: this.success,
			testCase,
			testIdx: this.testIdx,
			tokenUsage,
			vars: shouldStripTestVars ? {} : this.testCase.vars || {},
			metadata: shouldStripMetadata ? {} : this.metadata,
			failureReason: this.failureReason
		};
	}
};
/** Normalize an `EvalResult` model instance or a plain `EvaluateResult` to `EvaluateResult`. */
function asEvaluateResult(result) {
	return "toEvaluateResult" in result ? result.toEvaluateResult() : result;
}
/** Canonical `testIdx:promptIdx` key used to dedupe/look up a result across the streaming,
* recovery, and comparison paths. */
function getResultIndexKey(result) {
	return `${result.testIdx}:${result.promptIdx}`;
}
//#endregion
export { setupSignalWatcher as C, normalizeInput as D, maybeFilePath as E, PROMPT_DELIMITER as O, readSignalFile as S, hashPrompt as T, getStandaloneEvalCacheKey as _, persistTraceMetadata as a, hasUnscopedUpdate as b, stripTraceLinkageFromMetadata as c, notifyEvaluationChanged as d, notifyEvaluationsDeleted as f, getCachedStandaloneEvals as g, queryTestIndicesOptimized as h, getResultIndexKey as i, generateIdFromPrompt as k, invalidateEvaluationCache as l, getTotalResultRowCount as m, PROMPTFOO_METADATA_KEY as n, sanitizeProvider as o, getCachedResultsCount as p, asEvaluateResult as r, sanitizeResultForJsonlArtifact as s, EvalResult as t, invalidateEvaluationCaches as u, setCachedStandaloneEvals as v, updateEvalIds as w, isAllEvalsDeleted as x, hasDeleteComponent as y };

//# sourceMappingURL=evalResult-BsFIlxWS.js.map