Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions web/packages/studio/src/api/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export const isValidationErrorArray = (detail: unknown): detail is ValidationErr
);
};

/** The entity-store's optimistic-lock rejection: `expected_db_version` no longer matches. */
export const isVersionConflictError = (error: unknown): boolean =>
error instanceof AxiosError && error.response?.status === 409;

/**
* Extracts a user-friendly error message from an error object.
* Handles both ValidationError arrays and simple string errors from the backend.
Expand Down
165 changes: 165 additions & 0 deletions web/packages/studio/src/api/guardrail-checks/guardrailChecks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { RailsConfigOutput } from '@nemo/sdk/generated/platform/schema';
import {
resolveConfigModel,
runGuardrailCheck,
runGuardrailChecks,
updateGuardrailCheck,
} from '@studio/api/guardrail-checks/guardrailChecks';
import {
GUARDRAIL_CHECKS_ENTITY_TYPE,
type GuardrailCheckEntity,
} from '@studio/api/guardrail-checks/types';
import { PLATFORM_BASE_URL } from '@studio/constants/environment';
import {
getMockGuardrailCheck,
recordedCheckRequests,
resetGuardrailMocks,
} from '@studio/mocks/handlers/guardrails';
import { server } from '@studio/mocks/node';
import { http, HttpResponse } from 'msw';

const WORKSPACE = 'default';
const CONFIG_ID = 'cfg-1';

const CHECK_BY_NAME_URL = `${PLATFORM_BASE_URL}/apis/entities/v2/workspaces/:workspace/entities/${GUARDRAIL_CHECKS_ENTITY_TYPE}/:name`;

beforeEach(() => {
resetGuardrailMocks();
});

/** The seeded check, as a caller would have snapshotted it from the list query. */
const snapshot = (name: string): GuardrailCheckEntity => {
const check = getMockGuardrailCheck(name);
if (!check) throw new Error(`missing fixture: ${name}`);
return structuredClone(check);
};

describe('resolveConfigModel', () => {
it('prefers the model marked type "main"', () => {
const config: RailsConfigOutput = {
models: [
{ type: 'embeddings', engine: 'openai', model: 'text-embedding-ada-002' },
{ type: 'main', engine: 'openai', model: 'gpt-4' },
],
};
expect(resolveConfigModel(config, 'pii-filter')).toBe('gpt-4');
});

it('falls back to the first model that declares a reference', () => {
const config: RailsConfigOutput = {
models: [{ type: 'embeddings', engine: 'openai', model: 'text-embedding-ada-002' }],
};
expect(resolveConfigModel(config, 'pii-filter')).toBe('text-embedding-ada-002');
});

it.each([
['no models', { models: [] } satisfies RailsConfigOutput],
['models without a reference', { models: [{ type: 'main', engine: 'openai' }] }],
['an absent config', undefined],
])('throws a named error for %s', (_label, config) => {
expect(() => resolveConfigModel(config as RailsConfigOutput | undefined, 'pii-filter')).toThrow(
"Guardrail config 'pii-filter' has no usable model to run checks against."
);
});
});

describe('runGuardrailCheck', () => {
it('sends the check messages against the parent config model and records the run', async () => {
const check = snapshot('benign-greeting');

const { run } = await runGuardrailCheck(WORKSPACE, check);

expect(recordedCheckRequests).toEqual([
{
model: 'gpt-4',
messages: [{ role: 'user', content: 'Hello there' }],
guardrails: { config_ids: ['pii-filter'] },
},
]);
expect(run.status).toBe('success');
expect(run.config_version).toBe(1);

const persisted = getMockGuardrailCheck('benign-greeting');
expect(persisted?.data.runs).toEqual([run]);
expect(persisted?.db_version).toBe(2);
});

it('appends to existing run history rather than replacing it', async () => {
const { run } = await runGuardrailCheck(WORKSPACE, snapshot('leaks-ssn'));

const persisted = getMockGuardrailCheck('leaks-ssn');
expect(persisted?.data.runs).toHaveLength(2);
expect(persisted?.data.runs.at(-1)).toEqual(run);
expect(run.status).toBe('blocked');
});

// Regression: a concurrent edit bumps db_version between the snapshot and this write-back.
// /checks already ran, so the record must be re-applied to fresh state, not discarded.
it('re-reads and retries the write-back when a concurrent edit bumps the version', async () => {
const stale = snapshot('benign-greeting');

await updateGuardrailCheck(WORKSPACE, 'benign-greeting', {
data: { ...stale.data, messages: [{ role: 'user', content: 'edited elsewhere' }] },
expected_db_version: stale.db_version,
parent: CONFIG_ID,
});

const { run } = await runGuardrailCheck(WORKSPACE, stale);

const persisted = getMockGuardrailCheck('benign-greeting');
expect(persisted?.data.runs).toEqual([run]);
// The concurrent edit survives: the retry re-applied the run onto the fresh entity.
expect(persisted?.data.messages).toEqual([{ role: 'user', content: 'edited elsewhere' }]);
});

it('surfaces a conflict that persists across the retry', async () => {
server.use(
http.put(CHECK_BY_NAME_URL, () =>
HttpResponse.json({ detail: 'still conflicting' }, { status: 409 })
)
);

await expect(runGuardrailCheck(WORKSPACE, snapshot('benign-greeting'))).rejects.toThrow();
});

it('rejects a check with no parent config before calling /checks', async () => {
const orphan: GuardrailCheckEntity = { ...snapshot('benign-greeting'), parent: undefined };

await expect(runGuardrailCheck(WORKSPACE, orphan)).rejects.toThrow(
'has no parent config to resolve a model from'
);
expect(recordedCheckRequests).toHaveLength(0);
});
});

describe('runGuardrailChecks', () => {
it('captures per-check failures without rejecting the batch', async () => {
server.use(
http.post(`${PLATFORM_BASE_URL}/apis/guardrails/v2/workspaces/:workspace/checks`, () =>
HttpResponse.json({ detail: 'rails unavailable' }, { status: 503 })
)
);

const results = await runGuardrailChecks(WORKSPACE, [
snapshot('leaks-ssn'),
snapshot('benign-greeting'),
]);

expect(results).toHaveLength(2);
expect(results.every((result) => 'error' in result)).toBe(true);
expect(results.map((result) => result.name)).toEqual(['leaks-ssn', 'benign-greeting']);
});

it('reports a mix of successes and failures', async () => {
const results = await runGuardrailChecks(WORKSPACE, [
snapshot('benign-greeting'),
{ ...snapshot('leaks-ssn'), parent: undefined },
]);

expect(results[0]).toMatchObject({ name: 'benign-greeting', run: { status: 'success' } });
expect(results[1]).toHaveProperty('error');
});
});
30 changes: 25 additions & 5 deletions web/packages/studio/src/api/guardrail-checks/guardrailChecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
GuardrailCheckResponse,
RailsConfigOutput,
} from '@nemo/sdk/generated/platform/schema';
import { isVersionConflictError } from '@studio/api/common/utils';
import {
GUARDRAIL_CHECKS_ENTITY_TYPE,
type GuardrailCheckData,
Expand Down Expand Up @@ -243,15 +244,34 @@ export async function runGuardrailCheck(
const response = await executeGuardrailCheck(workspace, request);
const run = responseToRunRecord(response, new Date().toISOString(), configEntity.db_version);

const entity = await updateGuardrailCheck(workspace, check.name, {
data: { ...check.data, runs: [...check.data.runs, run] },
expected_db_version: check.db_version,
parent: check.parent,
});
const entity = await persistRun(workspace, check, run);

return { entity, run };
}

// A 409 only means `data.runs` came from a stale snapshot, so re-read and re-append rather
// than discard a run that already cost an LLM round trip.
async function persistRun(
workspace: string,
check: GuardrailCheckEntity,
run: RunRecord
): Promise<GuardrailCheckEntity> {
const write = (base: GuardrailCheckEntity) =>
updateGuardrailCheck(workspace, base.name, {
data: { ...base.data, runs: [...base.data.runs, run] },
expected_db_version: base.db_version,
parent: base.parent,
});

try {
return await write(check);
} catch (error) {
if (!isVersionConflictError(error) || !check.parent) throw error;
const latest = await getGuardrailCheck(workspace, check.name, check.parent);
return write(latest);
}
}

/** Batch execution — backs the "Re-run N Tests" action. Failures are captured per check. */
export function runGuardrailChecks(
workspace: string,
Expand Down
Loading
Loading