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
27 changes: 27 additions & 0 deletions apps/sim/app/api/guardrails/validate/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,4 +435,31 @@ describe('POST /api/guardrails/validate', () => {
await expect(res.json()).resolves.toEqual({ error: 'Failed to resolve billing attribution' })
expect(mockValidateHallucination).not.toHaveBeenCalled()
})

/**
* The signal now reaches the scoring model, so cancellation is reachable here.
* `passed: false` would read to a consumer as the guardrail rejecting the content,
* blocking a run that was abandoned rather than judged.
*/
it('reports a cancelled run as cancellation rather than a failed guardrail', async () => {
mockAuthorizeCredentialUse.mockResolvedValue({ ok: true })
mockValidateHallucination.mockRejectedValueOnce(
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
)

const res = await POST(
createMockRequest('POST', {
validationType: 'hallucination',
input: 'test input',
knowledgeBaseId: 'kb-1',
model: 'openai/gpt-4o',
workflowId: 'wf-1',
})
)

expect(res.status).toBe(499)
const json = await res.json()
expect(json.success).toBe(false)
expect(json.output?.passed).toBeUndefined()
})
})
21 changes: 19 additions & 2 deletions apps/sim/app/api/guardrails/validate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
ProviderNotAllowedError,
} from '@/ee/access-control/utils/permission-check'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import { isAbortError } from '@/providers/streaming-tool-loop-shared'
import { getProviderFromModel } from '@/providers/utils'

const logger = createLogger('GuardrailsValidateAPI')
Expand Down Expand Up @@ -316,7 +317,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
auth.userId,
billingAttribution,
requestId,
resolvedSecretTraceRegistry
resolvedSecretTraceRegistry,
request.signal
)

/**
Expand Down Expand Up @@ -371,6 +373,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
},
})
} catch (error: any) {
/**
* A cancelled run must not be reshaped into a verdict. `passed: false` here reads
* to a consumer as the guardrail rejecting the content, so an abandoned run would
* block content that was never actually judged. 499 matches the convention the
* workflow execute route already uses for a client-cancelled request.
*/
if (isAbortError(error)) {
logger.info(`[${requestId}] Guardrails validation cancelled by client`)
return NextResponse.json(
{ success: false, error: 'Client cancelled request' },
{ status: 499 }
)
}
logger.error(`[${requestId}] Guardrails validation failed`, { error })
return NextResponse.json({
success: true,
Expand Down Expand Up @@ -431,7 +446,8 @@ async function executeValidation(
actorUserId: string,
billingAttribution: BillingAttributionSnapshot | undefined,
requestId: string,
resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined
Comment thread
waleedlatif1 marked this conversation as resolved.
resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined,
abortSignal: AbortSignal | undefined
): Promise<{
passed: boolean
error?: string
Expand Down Expand Up @@ -488,6 +504,7 @@ async function executeValidation(
billingAttribution,
requestId,
resolvedSecretTraceRegistry,
abortSignal,
})
}
if (validationType === 'pii') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function Text({ blockId, subBlockId, content, className }: TextProps) {
className={`rounded-md border bg-[var(--surface-2)] p-4 shadow-sm ${className || ''}`}
>
<div
className='max-w-none break-words text-[var(--text-secondary)] text-sm [&_a]:text-[var(--brand-secondary)] [&_a]:underline [&_a]:underline-offset-2 [&_a]:hover-hover:brightness-110 [&_code]:rounded [&_code]:bg-[var(--surface-5)] [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[var(--text-tertiary)] [&_code]:text-xs [&_ul]:ml-5 [&_ul]:list-disc [&_ul]:marker:text-[var(--text-muted)] [&_strong]:font-medium [&_strong]:text-[var(--text-primary)]'
className='max-w-none break-words text-[var(--text-secondary)] text-sm [&_a]:text-[var(--brand-secondary)] [&_a]:underline [&_a]:underline-offset-2 [&_a]:hover-hover:brightness-110 [&_code]:rounded [&_code]:bg-[var(--surface-5)] [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-[var(--text-tertiary)] [&_code]:text-xs [&_strong]:font-medium [&_strong]:text-[var(--text-primary)] [&_ul]:ml-5 [&_ul]:list-disc [&_ul]:marker:text-[var(--text-muted)]'
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/lib/guardrails/validate_hallucination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,25 @@ describe('validateHallucination', () => {
expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
expect(registry.isComplete()).toBe(true)
})

/**
* Forwarding the caller's signal means the scoring model can now be aborted. A
* cancelled run must not be reported as a guardrail verdict — `passed: false` would
* block content on a run the caller abandoned, which is indistinguishable to a
* consumer from the model actually hallucinating.
*/
it('surfaces a cancelled run as cancellation, not as a failed guardrail', async () => {
const registry = new ResolvedSecretTraceRegistry()
const fetchMock = vi.fn(async () =>
createPrivateKnowledgeResponse({ data: { results: [{ content: 'reference' }] } })
)
vi.stubGlobal('fetch', fetchMock)

const abort = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
mockExecuteProviderRequest.mockRejectedValueOnce(abort)

await expect(validateHallucination(createInput(registry))).rejects.toMatchObject({
name: 'AbortError',
})
})
})
26 changes: 24 additions & 2 deletions apps/sim/lib/guardrails/validate_hallucination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import { executeProviderRequest } from '@/providers'
import { isAbortError } from '@/providers/streaming-tool-loop-shared'
import { getProviderFromModel } from '@/providers/utils'

const logger = createLogger('HallucinationValidator')
Expand Down Expand Up @@ -68,6 +69,12 @@ export interface HallucinationValidationInput {
billingAttribution: BillingAttributionSnapshot
requestId: string
resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry
/**
* The caller's cancellation signal, forwarded to the scoring model exactly as the
* agent handler forwards `ctx.abortSignal`. Without it the scoring request outlives
* a cancelled request and keeps burning a provider slot until the transport gives up.
*/
abortSignal?: AbortSignal
}

/**
Expand Down Expand Up @@ -176,7 +183,8 @@ async function scoreHallucinationWithLLM(
providerCredentials: HallucinationValidationInput['providerCredentials'],
workspaceId: string | undefined,
requestId: string,
resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry
resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry,
abortSignal: AbortSignal | undefined
): Promise<{ score: number; reasoning: string; cost: number }> {
try {
const contextText = ragContext.join('\n\n---\n\n')
Expand Down Expand Up @@ -251,6 +259,7 @@ Evaluate the consistency and provide your score and reasoning in JSON format.`
bedrockSecretKey: providerCredentials?.bedrockSecretKey,
bedrockRegion: providerCredentials?.bedrockRegion,
workspaceId,
abortSignal,
},
{ resolvedSecretTraceRegistry }
)
Expand Down Expand Up @@ -290,6 +299,11 @@ Evaluate the consistency and provide your score and reasoning in JSON format.`
cost,
}
} catch (error: any) {
/**
* A cancelled run is not a scoring failure. Rewrapping it would erase the
* `AbortError` name the outer handler classifies on, so it propagates as-is.
*/
if (isAbortError(error)) throw error
logger.error(`[${requestId}] Error scoring with LLM`, {
error: error.message,
})
Expand Down Expand Up @@ -317,6 +331,7 @@ export async function validateHallucination(
billingAttribution,
requestId,
resolvedSecretTraceRegistry,
abortSignal,
} = input

try {
Expand Down Expand Up @@ -371,7 +386,8 @@ export async function validateHallucination(
providerCredentials,
workspaceId,
requestId,
providerRegistry
providerRegistry,
abortSignal
Comment thread
waleedlatif1 marked this conversation as resolved.
)

logger.info(`[${requestId}] Confidence score: ${score}`, {
Expand All @@ -392,6 +408,12 @@ export async function validateHallucination(
: `Low confidence: score ${score}/10 is below threshold ${threshold}`,
}
} catch (error: any) {
/**
* Cancellation is surfaced as cancellation, not as a guardrail verdict. Returning
* `passed: false` here would fail content on a run the caller abandoned, which is
* indistinguishable to a consumer from the model actually hallucinating.
*/
if (isAbortError(error)) throw error
logger.error(`[${requestId}] Hallucination validation error`, {
error: error.message,
})
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/providers/baseten/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import { openAICompatTransport } from '@/providers/transport'
import type {
FunctionCallResponse,
Message,
Expand Down Expand Up @@ -86,6 +87,7 @@ export const basetenProvider: ProviderConfig = {
}

const client = new OpenAI({
...openAICompatTransport(),
apiKey: request.apiKey,
baseURL: 'https://inference.baseten.co/v1',
})
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/providers/cerebras/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import { openAICompatTransport } from '@/providers/transport'
import type {
ProviderConfig,
ProviderRequest,
Expand Down Expand Up @@ -54,6 +55,7 @@ export const cerebrasProvider: ProviderConfig = {
try {
const client = new Cerebras({
apiKey: request.apiKey,
...openAICompatTransport(),
})

const allMessages = []
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/providers/deepseek/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import { openAICompatTransport } from '@/providers/transport'
import type {
ProviderConfig,
ProviderRequest,
Expand Down Expand Up @@ -50,6 +51,7 @@ export const deepseekProvider: ProviderConfig = {

try {
const deepseek = new OpenAI({
...openAICompatTransport(),
apiKey: request.apiKey,
baseURL: 'https://api.deepseek.com',
})
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/providers/fireworks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import { openAICompatTransport } from '@/providers/transport'
import type {
FunctionCallResponse,
Message,
Expand Down Expand Up @@ -87,6 +88,7 @@ export const fireworksProvider: ProviderConfig = {
}

const client = new OpenAI({
...openAICompatTransport(),
apiKey: request.apiKey,
baseURL: 'https://api.fireworks.ai/inference/v1',
})
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/providers/groq/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import { openAICompatTransport } from '@/providers/transport'
import type {
ProviderConfig,
ProviderRequest,
Expand Down Expand Up @@ -50,7 +51,7 @@ export const groqProvider: ProviderConfig = {
throw new Error('API key is required for Groq')
}

const groq = new Groq({ apiKey: request.apiKey })
const groq = new Groq({ apiKey: request.apiKey, ...openAICompatTransport() })

const allMessages = []

Expand Down
2 changes: 2 additions & 0 deletions apps/sim/providers/kimi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import { openAICompatTransport } from '@/providers/transport'
import type {
ProviderConfig,
ProviderRequest,
Expand Down Expand Up @@ -100,6 +101,7 @@ export const kimiProvider: ProviderConfig = {

try {
const kimi = new OpenAI({
...openAICompatTransport(),
apiKey: request.apiKey,
baseURL: KIMI_BASE_URL,
})
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/providers/litellm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import { openAICompatTransport } from '@/providers/transport'
import type {
Message,
ProviderConfig,
Expand Down Expand Up @@ -110,6 +111,7 @@ export const litellmProvider: ProviderConfig = {

const apiKey = request.apiKey || env.LITELLM_API_KEY || 'empty'
const litellm = new OpenAI({
...openAICompatTransport(),
apiKey,
baseURL: `${baseUrl}/v1`,
})
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/providers/meta/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import { openAICompatTransport } from '@/providers/transport'
import type {
ProviderConfig,
ProviderRequest,
Expand Down Expand Up @@ -53,6 +54,7 @@ export const metaProvider: ProviderConfig = {

try {
const meta = new OpenAI({
...openAICompatTransport(),
apiKey: request.apiKey,
baseURL: META_BASE_URL,
})
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/providers/mistral/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import { openAICompatTransport } from '@/providers/transport'
import type {
ProviderConfig,
ProviderRequest,
Expand Down Expand Up @@ -61,6 +62,7 @@ export const mistralProvider: ProviderConfig = {
}

const mistral = new OpenAI({
...openAICompatTransport(),
apiKey: request.apiKey,
baseURL: 'https://api.mistral.ai/v1',
})
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/providers/nvidia/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import { openAICompatTransport } from '@/providers/transport'
import type {
ProviderConfig,
ProviderRequest,
Expand Down Expand Up @@ -60,6 +61,7 @@ export const nvidiaProvider: ProviderConfig = {

try {
const nvidia = new OpenAI({
...openAICompatTransport(),
apiKey: request.apiKey,
baseURL: NVIDIA_BASE_URL,
})
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/providers/ollama-cloud/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { StreamingExecution } from '@/executor/types'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import { executeOllamaProviderRequest } from '@/providers/ollama/core'
import { createReadableStreamFromOllamaCloudStream } from '@/providers/ollama-cloud/utils'
import { openAICompatTransport } from '@/providers/transport'
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'

const logger = createLogger('OllamaCloudProvider')
Expand Down Expand Up @@ -36,6 +37,7 @@ export const ollamaCloudProvider: ProviderConfig = {
providerLabel: 'Ollama Cloud',
createClient: () =>
new OpenAI({
...openAICompatTransport(),
apiKey,
baseURL: OLLAMA_CLOUD_BASE_URL,
}),
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/providers/ollama/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { StreamingExecution } from '@/executor/types'
import { executeOllamaProviderRequest } from '@/providers/ollama/core'
import type { ModelsObject } from '@/providers/ollama/types'
import { createReadableStreamFromOllamaStream } from '@/providers/ollama/utils'
import { openAICompatTransport } from '@/providers/transport'
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'
import { useProvidersStore } from '@/stores/providers'

Expand Down Expand Up @@ -52,6 +53,7 @@ export const ollamaProvider: ProviderConfig = {
providerLabel: 'Ollama',
createClient: () =>
new OpenAI({
...openAICompatTransport(),
apiKey: 'empty',
baseURL: `${OLLAMA_HOST}/v1`,
}),
Expand Down
Loading
Loading