feat(api): routing/4985 — configurable response-body validation + failover

A combo can declare a per-combo responseValidation predicate. When an upstream returns
200 OK but the parsed body fails the predicate, the combo fails over to the next target
through the exact same path the built-in empty-content guard uses (executeTarget returns
null + 502). No new failover wiring.

- open-sse/services/combo/responseValidation.ts: pure declarative evaluator —
  forbiddenSubstrings / requiredSubstrings / minContentLength / jsonPathPredicates with a
  bounded dot-path resolver (no eval, no regex/ReDoS).
- Zod responseValidationSchema in shared/validation/schemas/combo.ts (per-combo, in
  comboRuntimeConfigSchema); threaded to validateResponseQuality (new 4th param) at all
  three call-sites (combo.ts x2 + runtimeUnits.ts); default key in comboConfig.ts.
- Evaluated on non-streaming responses; streaming keeps its existing empty-content guard.
- UI: extracted ResponseValidationEditor.tsx (keeps the combos god-component lean) wired
  into the combo form; i18n keys + getI18nOrFallback fallbacks.

TDD: evaluator (9), failover integration mirroring #5085 (2), editor UI (4). Existing
quality + combo-config + combo UI suites green; typecheck:core 0, ESLint 0, cycles clean.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-30 13:27:17 -03:00
parent 871dc792d4
commit 50b2c84ee1
12 changed files with 786 additions and 4 deletions

View File

@@ -1971,7 +1971,12 @@ export async function handleComboChat({
undefined;
const effectiveConnectionId = selectedConnectionId || target.connectionId || "";
const quality = await validateResponseQuality(result, clientRequestedStream, log);
const quality = await validateResponseQuality(
result,
clientRequestedStream,
log,
config.responseValidation
);
if (!quality.valid) {
log.warn(
"COMBO",
@@ -3016,7 +3021,12 @@ async function handleRoundRobinCombo({
// Success — validate response quality before returning
if (result.ok) {
const quality = await validateResponseQuality(result, clientRequestedStream, log);
const quality = await validateResponseQuality(
result,
clientRequestedStream,
log,
config.responseValidation
);
if (!quality.valid) {
log.warn(
"COMBO-RR",

View File

@@ -0,0 +1,206 @@
/**
* Feature 4985 — configurable response-body validation for combo routing.
*
* A combo can declare a `responseValidation` predicate. When an upstream returns 200 OK
* but the parsed body fails the predicate, `validateResponseQuality` reports it as
* invalid, which the combo orchestrator already treats exactly like an HTTP error
* (skip this target → fail over to the next). All checks are declarative and safe:
* substring matching (no regex / no ReDoS) and a bounded dot-path resolver (no eval).
*/
export type JsonPathCondition = "exists" | "nonEmpty" | "equals" | "notEquals";
export interface JsonPathPredicate {
path: string;
condition: JsonPathCondition;
value?: string | number | boolean;
}
export interface ResponseValidationConfig {
/** The assistant content must NOT contain any of these substrings. */
forbiddenSubstrings?: string[];
/** The assistant content must contain ALL of these substrings. */
requiredSubstrings?: string[];
/** The trimmed assistant content must be at least this many characters. */
minContentLength?: number;
/** Structural/value checks against the parsed JSON body (shape validation). */
jsonPathPredicates?: JsonPathPredicate[];
}
export interface ResponseValidationResult {
valid: boolean;
reason?: string;
}
const MAX_REASON_SNIPPET = 60;
function snippet(value: string): string {
return value.length > MAX_REASON_SNIPPET ? `${value.slice(0, MAX_REASON_SNIPPET)}` : value;
}
/**
* Parse a dot/bracket path (e.g. `choices[0].message.content`) into tokens with a
* single bounded left-to-right scan — no regex, no backtracking, no eval.
*/
export function parseJsonPath(path: string): Array<string | number> {
const tokens: Array<string | number> = [];
let buf = "";
const flush = () => {
if (buf) {
tokens.push(buf);
buf = "";
}
};
for (let i = 0; i < path.length; i++) {
const ch = path[i];
if (ch === ".") {
flush();
} else if (ch === "[") {
flush();
let inner = "";
i++;
while (i < path.length && path[i] !== "]") {
inner += path[i];
i++;
}
const trimmed = inner.trim();
const n = Number(trimmed);
tokens.push(trimmed !== "" && Number.isInteger(n) ? n : trimmed);
} else {
buf += ch;
}
}
flush();
return tokens;
}
/** Resolve a dot-path against a parsed JSON value. Returns `undefined` if any hop misses. */
export function resolveJsonPath(root: unknown, path: string): unknown {
let current: unknown = root;
for (const token of parseJsonPath(path)) {
if (current === null || current === undefined) return undefined;
if (typeof token === "number") {
if (!Array.isArray(current)) return undefined;
current = current[token];
} else {
if (typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[token];
}
}
return current;
}
function isNonEmpty(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "object") return Object.keys(value as object).length > 0;
return Boolean(value);
}
function checkCondition(value: unknown, condition: JsonPathCondition, expected: unknown): boolean {
switch (condition) {
case "exists":
return value !== undefined && value !== null;
case "nonEmpty":
return isNonEmpty(value);
case "equals":
return value === expected;
case "notEquals":
return value !== expected;
}
}
/** Best-effort extraction of the assistant's text content from a chat/Responses body. */
export function extractContentText(json: unknown): string {
if (!json || typeof json !== "object") return "";
const obj = json as Record<string, unknown>;
// Chat Completions: choices[].message.content (string or array of parts).
const choices = obj.choices;
if (Array.isArray(choices)) {
const parts: string[] = [];
for (const choice of choices) {
const message = (choice as Record<string, unknown>)?.message as
| Record<string, unknown>
| undefined;
const content = message?.content;
if (typeof content === "string") parts.push(content);
else if (Array.isArray(content)) {
for (const part of content) {
const text = (part as Record<string, unknown>)?.text;
if (typeof text === "string") parts.push(text);
}
}
}
if (parts.length) return parts.join("");
}
// Responses API: output[].content[].text
const output = obj.output;
if (Array.isArray(output)) {
const parts: string[] = [];
for (const item of output) {
const content = (item as Record<string, unknown>)?.content;
if (Array.isArray(content)) {
for (const part of content) {
const text = (part as Record<string, unknown>)?.text;
if (typeof text === "string") parts.push(text);
}
}
}
if (parts.length) return parts.join("");
}
return "";
}
/**
* Evaluate the configured predicate against a parsed JSON response body.
* Returns `{ valid: true }` when there is no config or all checks pass.
*/
export function evaluateResponseValidation(
json: unknown,
config: ResponseValidationConfig | undefined | null
): ResponseValidationResult {
if (!config || typeof config !== "object") return { valid: true };
const content = extractContentText(json);
for (const sub of config.forbiddenSubstrings ?? []) {
if (typeof sub === "string" && sub.length > 0 && content.includes(sub)) {
return { valid: false, reason: `response contains forbidden substring "${snippet(sub)}"` };
}
}
for (const sub of config.requiredSubstrings ?? []) {
if (typeof sub === "string" && sub.length > 0 && !content.includes(sub)) {
return { valid: false, reason: `response missing required substring "${snippet(sub)}"` };
}
}
if (
typeof config.minContentLength === "number" &&
Number.isFinite(config.minContentLength) &&
config.minContentLength > 0 &&
content.trim().length < config.minContentLength
) {
return {
valid: false,
reason: `response content shorter than ${config.minContentLength} chars`,
};
}
for (const predicate of config.jsonPathPredicates ?? []) {
if (!predicate || typeof predicate.path !== "string" || !predicate.path) continue;
const resolved = resolveJsonPath(json, predicate.path);
if (!checkCondition(resolved, predicate.condition, predicate.value)) {
return {
valid: false,
reason: `jsonpath check failed: "${snippet(predicate.path)}" ${predicate.condition}`,
};
}
}
return { valid: true };
}

View File

@@ -3,6 +3,7 @@ import { errorResponse } from "../../utils/error.ts";
import { recordComboRequest } from "../comboMetrics.ts";
import { resolveDelayMs } from "./comboPredicates.ts";
import { validateResponseQuality } from "./validateQuality.ts";
import type { ResponseValidationConfig } from "./responseValidation.ts";
import type {
ComboCollectionLike,
ComboLike,
@@ -227,7 +228,12 @@ export async function executeRuntimeUnitCombo(args: {
});
return { response, unit };
}
const quality = await validateResponseQuality(response, clientRequestedStream, args.log);
const quality = await validateResponseQuality(
response,
clientRequestedStream,
args.log,
args.config.responseValidation as ResponseValidationConfig | undefined
);
if (quality.valid) {
recordComboRequest(args.combo.name, unit.modelStr, {
success: true,

View File

@@ -10,6 +10,10 @@ import {
createSSEDataLineNormalizer,
isKnownNonClaudeStreamPayload,
} from "../../utils/streamHelpers.ts";
import {
evaluateResponseValidation,
type ResponseValidationConfig,
} from "./responseValidation.ts";
import { getReasoningTokens } from "../../../src/lib/usage/tokenAccounting.ts";
import type { ComboRetryAfter } from "./types.ts";
@@ -57,7 +61,8 @@ function responsesApiOutputHasContent(output: unknown): boolean {
export async function validateResponseQuality(
response: Response,
isStreaming: boolean,
log: { warn?: (...args: unknown[]) => void }
log: { warn?: (...args: unknown[]) => void },
responseValidation?: ResponseValidationConfig | null
): Promise<{ valid: boolean; reason?: string; clonedResponse?: Response }> {
// Issue #3685: For Claude SSE streaming responses, use a BOUNDED PEEK to
// detect the empty-content-block pattern (content_filter stop_reason with
@@ -292,6 +297,15 @@ export async function validateResponseQuality(
return { valid: false, reason: "response is not valid JSON" };
}
// Feature 4985: apply the combo's configured response-body predicate. A failure here
// fails over to the next target via the same path as the built-in empty-content checks.
if (responseValidation) {
const verdict = evaluateResponseValidation(json, responseValidation);
if (!verdict.valid) {
return { valid: false, reason: verdict.reason };
}
}
const choices = json?.choices;
if (json?.object === "response") {
if (!responsesApiOutputHasContent(json.output)) return { valid: false, reason: "empty_choices" };

View File

@@ -6,6 +6,7 @@
*/
import { MAX_TIMER_TIMEOUT_MS } from "../../src/shared/utils/runtimeTimeouts.ts";
import type { ResponseValidationConfig } from "./combo/responseValidation.ts";
/**
* Maximum number of concurrent pre-screen checks (provider profile + availability)
@@ -63,6 +64,9 @@ const DEFAULT_COMBO_CONFIG = {
resetAwareTieBandPercent: 5,
resetAwareExhaustionGuardPercent: 10,
failoverBeforeRetry: true,
// Feature 4985: configurable response-body validation predicate (per-combo). When set,
// a 200 OK whose body fails the predicate fails over to the next target.
responseValidation: undefined as ResponseValidationConfig | undefined,
maxSetRetries: 0,
setRetryDelayMs: 2000,
// Zero-latency optimizations are opt-in because some modes can race targets or

View File

@@ -0,0 +1,199 @@
"use client";
import React from "react";
// Feature 4985 — per-combo response-body validation editor. Extracted into its own file
// so the combos god-component (page.tsx) stays lean. Emits the same declarative shape the
// Zod `responseValidationSchema` validates; backend evaluates it in validateResponseQuality.
export type JsonPathCondition = "exists" | "nonEmpty" | "equals" | "notEquals";
export interface ResponseValidationValue {
forbiddenSubstrings?: string[];
requiredSubstrings?: string[];
minContentLength?: number;
jsonPathPredicates?: Array<{
path: string;
condition: JsonPathCondition;
value?: string | number | boolean;
}>;
}
function tr(t: ((key: string) => string) | undefined, key: string, fallback: string): string {
if (!t) return fallback;
try {
const v = t(key);
return v && v !== key ? v : fallback;
} catch {
return fallback;
}
}
const linesToArray = (text: string): string[] =>
text
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const arrayToLines = (arr?: string[]): string => (arr ?? []).join("\n");
const INPUT_CLASS =
"w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none";
const LABEL_CLASS = "text-[11px] font-medium text-text-muted block mb-0.5";
const CONDITIONS: JsonPathCondition[] = ["exists", "nonEmpty", "equals", "notEquals"];
export function ResponseValidationEditor({
value,
onChange,
t,
}: {
value?: ResponseValidationValue | null;
onChange: (next: ResponseValidationValue | undefined) => void;
t?: (key: string) => string;
}) {
const v: ResponseValidationValue = value && typeof value === "object" ? value : {};
const emit = (draft: ResponseValidationValue) => {
const cleaned: ResponseValidationValue = {};
if (draft.forbiddenSubstrings && draft.forbiddenSubstrings.length)
cleaned.forbiddenSubstrings = draft.forbiddenSubstrings;
if (draft.requiredSubstrings && draft.requiredSubstrings.length)
cleaned.requiredSubstrings = draft.requiredSubstrings;
if (typeof draft.minContentLength === "number" && draft.minContentLength > 0)
cleaned.minContentLength = draft.minContentLength;
if (draft.jsonPathPredicates && draft.jsonPathPredicates.length)
cleaned.jsonPathPredicates = draft.jsonPathPredicates;
onChange(Object.keys(cleaned).length ? cleaned : undefined);
};
const predicates = v.jsonPathPredicates ?? [];
const updatePredicate = (index: number, patch: Partial<(typeof predicates)[number]>) => {
const next = predicates.map((p, i) => (i === index ? { ...p, ...patch } : p));
emit({ ...v, jsonPathPredicates: next });
};
return (
<div className="flex flex-col gap-2" data-testid="response-validation-editor">
<p className="text-[10px] text-text-muted">
{tr(
t,
"responseValidationHelp",
"Fail over to the next target when a 200 OK body fails these checks (assistant content)."
)}
</p>
<div>
<label className={LABEL_CLASS}>
{tr(t, "responseValidationForbidden", "Forbidden substrings (one per line)")}
</label>
<textarea
rows={2}
value={arrayToLines(v.forbiddenSubstrings)}
onChange={(e) => emit({ ...v, forbiddenSubstrings: linesToArray(e.target.value) })}
placeholder={"I cannot help\nas an AI"}
data-testid="rv-forbidden"
className={INPUT_CLASS + " font-mono"}
/>
</div>
<div>
<label className={LABEL_CLASS}>
{tr(t, "responseValidationRequired", "Required substrings (one per line)")}
</label>
<textarea
rows={2}
value={arrayToLines(v.requiredSubstrings)}
onChange={(e) => emit({ ...v, requiredSubstrings: linesToArray(e.target.value) })}
data-testid="rv-required"
className={INPUT_CLASS + " font-mono"}
/>
</div>
<div>
<label className={LABEL_CLASS}>
{tr(t, "responseValidationMinLength", "Minimum content length (chars)")}
</label>
<input
type="number"
min={0}
value={typeof v.minContentLength === "number" ? v.minContentLength : ""}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
emit({ ...v, minContentLength: Number.isFinite(n) && n > 0 ? n : undefined });
}}
data-testid="rv-min-length"
className={INPUT_CLASS}
/>
</div>
<div>
<label className={LABEL_CLASS}>
{tr(t, "responseValidationJsonPaths", "JSON-path checks")}
</label>
<div className="flex flex-col gap-1.5">
{predicates.map((predicate, index) => (
<div key={index} className="flex flex-wrap items-center gap-1.5" data-testid="rv-predicate-row">
<input
type="text"
value={predicate.path}
onChange={(e) => updatePredicate(index, { path: e.target.value })}
placeholder="choices[0].message.content"
data-testid="rv-predicate-path"
className={INPUT_CLASS + " font-mono flex-1 min-w-[140px]"}
/>
<select
value={predicate.condition}
onChange={(e) =>
updatePredicate(index, { condition: e.target.value as JsonPathCondition })
}
data-testid="rv-predicate-condition"
className={INPUT_CLASS + " w-auto"}
>
{CONDITIONS.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
{(predicate.condition === "equals" || predicate.condition === "notEquals") && (
<input
type="text"
value={predicate.value === undefined ? "" : String(predicate.value)}
onChange={(e) => updatePredicate(index, { value: e.target.value })}
placeholder="value"
data-testid="rv-predicate-value"
className={INPUT_CLASS + " w-auto"}
/>
)}
<button
type="button"
onClick={() =>
emit({ ...v, jsonPathPredicates: predicates.filter((_, i) => i !== index) })
}
data-testid="rv-predicate-remove"
className="text-[10px] px-1.5 py-1 rounded border border-black/10 dark:border-white/10 text-text-muted hover:text-red-500"
>
</button>
</div>
))}
<button
type="button"
onClick={() =>
emit({
...v,
jsonPathPredicates: [...predicates, { path: "", condition: "exists" }],
})
}
data-testid="rv-predicate-add"
className="self-start text-[10px] px-2 py-1 rounded border border-black/10 dark:border-white/10 text-text-muted hover:text-primary"
>
{tr(t, "responseValidationAddCheck", "+ Add check")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import Toggle from "@/shared/components/Toggle";
import Tooltip from "@/shared/components/Tooltip";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { FieldLabelWithHelp, WeightTotalBar } from "./parts";
import { ResponseValidationEditor, type ResponseValidationValue } from "./ResponseValidationEditor";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
@@ -4176,6 +4177,23 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
</>
)}
{/* Response Validation (4985) */}
{showStrategySection && (
<div className="flex flex-col gap-2 p-3 bg-black/[0.02] dark:bg-white/[0.02] rounded-lg border border-black/5 dark:border-white/5">
<div className="flex items-center gap-1.5 mb-1">
<span className="material-symbols-outlined text-[14px] text-primary">rule</span>
<p className="text-xs font-medium">
{getI18nOrFallback(t, "responseValidationTitle", "Response validation")}
</p>
</div>
<ResponseValidationEditor
value={config.responseValidation as ResponseValidationValue | undefined}
onChange={(next) => setConfig({ ...config, responseValidation: next })}
t={t}
/>
</div>
)}
{/* Agent Features (#399 / #401 / #454) */}
{showStrategySection && (
<div className="flex flex-col gap-2 p-3 bg-black/[0.02] dark:bg-white/[0.02] rounded-lg border border-black/5 dark:border-white/5">

View File

@@ -2802,6 +2802,13 @@
"browseLegacyCatalog": "Browse legacy combo catalog",
"agentFeaturesTitle": "Agent Features",
"agentFeaturesDescription": "Enable advanced features for agents using this combo",
"responseValidationTitle": "Response validation",
"responseValidationHelp": "Fail over to the next target when a 200 OK body fails these checks (assistant content).",
"responseValidationForbidden": "Forbidden substrings (one per line)",
"responseValidationRequired": "Required substrings (one per line)",
"responseValidationMinLength": "Minimum content length (chars)",
"responseValidationJsonPaths": "JSON-path checks",
"responseValidationAddCheck": "+ Add check",
"agentFeaturesSystemMessageOverride": "Override system message",
"agentFeaturesSystemMessagePlaceholder": "You are an expert assistant...",
"agentFeaturesSystemMessageHint": "System message override for agents",

View File

@@ -122,8 +122,29 @@ export const slaRoutingPolicySchema = z
})
.strict();
// Feature 4985 — configurable response-body validation for combo routing. A 200 OK whose
// body fails this predicate fails over to the next target (same path as an HTTP error).
export const responseValidationSchema = z
.object({
forbiddenSubstrings: z.array(z.string().min(1).max(500)).max(50).optional(),
requiredSubstrings: z.array(z.string().min(1).max(500)).max(50).optional(),
minContentLength: z.coerce.number().int().min(0).max(1_000_000).optional(),
jsonPathPredicates: z
.array(
z.object({
path: z.string().trim().min(1).max(300),
condition: z.enum(["exists", "nonEmpty", "equals", "notEquals"]),
value: z.union([z.string().max(1000), z.number(), z.boolean()]).optional(),
})
)
.max(20)
.optional(),
})
.strict();
export const comboRuntimeConfigSchema = z
.object({
responseValidation: responseValidationSchema.optional(),
strategy: comboStrategySchema.optional(),
maxRetries: z.coerce.number().int().min(0).max(10).optional(),
retryDelayMs: z.coerce.number().int().min(0).max(60000).optional(),

View File

@@ -0,0 +1,96 @@
/**
* Feature 4985 — a combo with a configured `responseValidation` predicate must fail over
* when a leg returns a 200 OK whose body fails the predicate, exactly like an HTTP error.
* Mirrors the #5085 empty-content failover harness (2 legs, different providers).
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-4985-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-4985-test-secret";
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
const noop = () => {};
const log = { info: noop, warn: noop, debug: noop, error: noop };
function content200(model: string, text: string) {
return new Response(
JSON.stringify({
id: "ok",
object: "chat.completion",
model,
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
function makeCombo(models: string[], responseValidation: Record<string, unknown>) {
return {
name: "test-combo-4985",
strategy: "priority",
models: models.map((m) => ({ model: m })),
config: { responseValidation },
};
}
test("4985 fails over when leg 1's 200 body trips a forbidden substring", async () => {
const modelsCalled: string[] = [];
const handleSingleModel = async (_body: unknown, modelStr: string) => {
modelsCalled.push(modelStr);
if (modelsCalled.length === 1) return content200(modelStr, "Sorry, I cannot help with that.");
return content200(modelStr, "Here is the answer you asked for.");
};
const result = await handleComboChat({
body: { model: "test", messages: [{ role: "user", content: "hi" }] },
combo: makeCombo(["nvidia/minimaxai/minimax-m3", "openai/gpt-4o-mini"], {
forbiddenSubstrings: ["I cannot help"],
}),
handleSingleModel,
log,
settings: {},
allCombos: [],
});
assert.equal(
modelsCalled.length,
2,
`predicate failure on leg 1 must advance to leg 2, tried: ${modelsCalled.join(", ")}`
);
assert.equal(result.status, 200, "the combo must surface the healthy second leg's 200");
const body = JSON.parse(await result.text());
assert.match(
String(body?.choices?.[0]?.message?.content ?? ""),
/answer you asked for/,
"surfaced content must come from the leg that passed the predicate"
);
});
test("4985 does NOT fail over when the configured predicate passes", async () => {
const modelsCalled: string[] = [];
const handleSingleModel = async (_body: unknown, modelStr: string) => {
modelsCalled.push(modelStr);
return content200(modelStr, "A perfectly good answer.");
};
const result = await handleComboChat({
body: { model: "test", messages: [{ role: "user", content: "hi" }] },
combo: makeCombo(["nvidia/minimaxai/minimax-m3", "openai/gpt-4o-mini"], {
forbiddenSubstrings: ["I cannot help"],
minContentLength: 5,
}),
handleSingleModel,
log,
settings: {},
allCombos: [],
});
assert.equal(modelsCalled.length, 1, "a passing predicate must not trigger a needless failover");
assert.equal(result.status, 200);
});

View File

@@ -0,0 +1,117 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
evaluateResponseValidation,
resolveJsonPath,
parseJsonPath,
extractContentText,
type ResponseValidationConfig,
} from "../../open-sse/services/combo/responseValidation.ts";
// Feature 4985 — configurable response-body validation predicate.
const chat = (content: string | null, extra: Record<string, unknown> = {}) => ({
choices: [{ message: { content, ...extra } }],
});
test("no config → always valid", () => {
assert.deepEqual(evaluateResponseValidation(chat("anything"), undefined), { valid: true });
assert.deepEqual(evaluateResponseValidation(chat("anything"), null), { valid: true });
assert.deepEqual(evaluateResponseValidation(chat("anything"), {}), { valid: true });
});
test("forbiddenSubstrings: fails when the content contains one", () => {
const cfg: ResponseValidationConfig = { forbiddenSubstrings: ["I cannot help", "as an AI"] };
assert.equal(evaluateResponseValidation(chat("Sure, here you go"), cfg).valid, true);
const bad = evaluateResponseValidation(chat("Sorry, I cannot help with that"), cfg);
assert.equal(bad.valid, false);
assert.match(bad.reason ?? "", /forbidden substring/);
});
test("requiredSubstrings: fails when a required substring is missing", () => {
const cfg: ResponseValidationConfig = { requiredSubstrings: ["```"] };
assert.equal(evaluateResponseValidation(chat("```js\ncode\n```"), cfg).valid, true);
assert.equal(evaluateResponseValidation(chat("no fence here"), cfg).valid, false);
});
test("minContentLength: fails on near-empty content", () => {
const cfg: ResponseValidationConfig = { minContentLength: 10 };
assert.equal(evaluateResponseValidation(chat("plenty of characters here"), cfg).valid, true);
assert.equal(evaluateResponseValidation(chat(" hi "), cfg).valid, false);
});
test("jsonPathPredicates: exists / nonEmpty / equals / notEquals", () => {
const body = chat("hello", { tool_calls: [] });
(body as Record<string, unknown>).usage = { total_tokens: 0 };
(body.choices[0] as Record<string, unknown>).finish_reason = "stop";
assert.equal(
evaluateResponseValidation(body, {
jsonPathPredicates: [{ path: "choices[0].message.content", condition: "nonEmpty" }],
}).valid,
true
);
assert.equal(
evaluateResponseValidation(body, {
jsonPathPredicates: [{ path: "choices[0].message.refusal", condition: "exists" }],
}).valid,
false
);
assert.equal(
evaluateResponseValidation(body, {
jsonPathPredicates: [{ path: "choices[0].finish_reason", condition: "equals", value: "stop" }],
}).valid,
true
);
assert.equal(
evaluateResponseValidation(body, {
jsonPathPredicates: [
{ path: "choices[0].finish_reason", condition: "notEquals", value: "content_filter" },
],
}).valid,
true
);
assert.equal(
evaluateResponseValidation(body, {
jsonPathPredicates: [{ path: "choices[0].finish_reason", condition: "equals", value: "length" }],
}).valid,
false
);
});
test("the first failing check wins; otherwise valid", () => {
const cfg: ResponseValidationConfig = {
forbiddenSubstrings: ["BAD"],
requiredSubstrings: ["GOOD"],
minContentLength: 3,
};
assert.equal(evaluateResponseValidation(chat("this is GOOD enough"), cfg).valid, true);
assert.equal(evaluateResponseValidation(chat("this is BAD and GOOD"), cfg).valid, false);
});
test("parseJsonPath tokenizes dot + bracket paths without regex", () => {
assert.deepEqual(parseJsonPath("choices[0].message.content"), ["choices", 0, "message", "content"]);
assert.deepEqual(parseJsonPath("a[1][2].b"), ["a", 1, 2, "b"]);
assert.deepEqual(parseJsonPath("plain"), ["plain"]);
});
test("resolveJsonPath returns undefined for missing hops (no throw)", () => {
const obj = { choices: [{ message: { content: "x" } }] };
assert.equal(resolveJsonPath(obj, "choices[0].message.content"), "x");
assert.equal(resolveJsonPath(obj, "choices[5].message.content"), undefined);
assert.equal(resolveJsonPath(obj, "a.b.c"), undefined);
assert.equal(resolveJsonPath(null, "a.b"), undefined);
});
test("extractContentText handles string, array parts, and Responses API output", () => {
assert.equal(extractContentText(chat("hello")), "hello");
assert.equal(
extractContentText({ choices: [{ message: { content: [{ text: "a" }, { text: "b" }] } }] }),
"ab"
);
assert.equal(
extractContentText({ output: [{ content: [{ text: "resp" }] }] }),
"resp"
);
assert.equal(extractContentText({}), "");
});

View File

@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ResponseValidationEditor,
type ResponseValidationValue,
} from "@/app/(dashboard)/dashboard/combos/ResponseValidationEditor";
// Feature 4985 — the per-combo response-validation editor emits the declarative shape.
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | undefined;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
act(() => root?.unmount());
root = undefined;
container.remove();
vi.restoreAllMocks();
});
function render(value: ResponseValidationValue | undefined, onChange: (v: unknown) => void) {
act(() => {
root = createRoot(container);
root.render(<ResponseValidationEditor value={value} onChange={onChange} />);
});
}
function setTextarea(testid: string, text: string) {
const el = container.querySelector<HTMLTextAreaElement>(`[data-testid="${testid}"]`);
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value"
)!.set!;
act(() => {
setter.call(el, text);
el!.dispatchEvent(new Event("input", { bubbles: true }));
});
}
describe("ResponseValidationEditor (4985)", () => {
it("turns forbidden-substring lines into an array (trimmed, no blanks)", () => {
const onChange = vi.fn();
render(undefined, onChange);
setTextarea("rv-forbidden", "I cannot help\n\n as an AI \n");
expect(onChange).toHaveBeenLastCalledWith({
forbiddenSubstrings: ["I cannot help", "as an AI"],
});
});
it("clears the whole config back to undefined when every field is emptied", () => {
const onChange = vi.fn();
render({ forbiddenSubstrings: ["x"] }, onChange);
setTextarea("rv-forbidden", "");
expect(onChange).toHaveBeenLastCalledWith(undefined);
});
it("adds a json-path predicate row with sane defaults", () => {
const onChange = vi.fn();
render(undefined, onChange);
const addBtn = container.querySelector<HTMLButtonElement>('[data-testid="rv-predicate-add"]');
act(() => addBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onChange).toHaveBeenLastCalledWith({
jsonPathPredicates: [{ path: "", condition: "exists" }],
});
});
it("renders existing predicate rows from the value", () => {
render(
{ jsonPathPredicates: [{ path: "choices[0].message.content", condition: "nonEmpty" }] },
vi.fn()
);
const rows = container.querySelectorAll('[data-testid="rv-predicate-row"]');
expect(rows.length).toBe(1);
const pathInput = container.querySelector<HTMLInputElement>('[data-testid="rv-predicate-path"]');
expect(pathInput?.value).toBe("choices[0].message.content");
});
});