Skip to content

Commit 721b471

Browse files
authored
fix(providers): pin transport policy and lift the 60s cap on Groq and Cerebras (#6306)
* fix(providers): pin transport policy and lift the 60s cap on Groq and Cerebras * fix(providers): cover the option payload, drop the unused discovery constant * fix(guardrails): forward the caller's abort signal to hallucination scoring * fix(guardrails): surface a cancelled scoring run as cancellation, not a failed guardrail * fix(guardrails): return 499 on a cancelled validation instead of a failed verdict
1 parent c530d27 commit 721b471

25 files changed

Lines changed: 255 additions & 6 deletions

File tree

apps/sim/app/api/guardrails/validate/route.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,4 +435,31 @@ describe('POST /api/guardrails/validate', () => {
435435
await expect(res.json()).resolves.toEqual({ error: 'Failed to resolve billing attribution' })
436436
expect(mockValidateHallucination).not.toHaveBeenCalled()
437437
})
438+
439+
/**
440+
* The signal now reaches the scoring model, so cancellation is reachable here.
441+
* `passed: false` would read to a consumer as the guardrail rejecting the content,
442+
* blocking a run that was abandoned rather than judged.
443+
*/
444+
it('reports a cancelled run as cancellation rather than a failed guardrail', async () => {
445+
mockAuthorizeCredentialUse.mockResolvedValue({ ok: true })
446+
mockValidateHallucination.mockRejectedValueOnce(
447+
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
448+
)
449+
450+
const res = await POST(
451+
createMockRequest('POST', {
452+
validationType: 'hallucination',
453+
input: 'test input',
454+
knowledgeBaseId: 'kb-1',
455+
model: 'openai/gpt-4o',
456+
workflowId: 'wf-1',
457+
})
458+
)
459+
460+
expect(res.status).toBe(499)
461+
const json = await res.json()
462+
expect(json.success).toBe(false)
463+
expect(json.output?.passed).toBeUndefined()
464+
})
438465
})

apps/sim/app/api/guardrails/validate/route.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
ProviderNotAllowedError,
2929
} from '@/ee/access-control/utils/permission-check'
3030
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
31+
import { isAbortError } from '@/providers/streaming-tool-loop-shared'
3132
import { getProviderFromModel } from '@/providers/utils'
3233

3334
const logger = createLogger('GuardrailsValidateAPI')
@@ -316,7 +317,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
316317
auth.userId,
317318
billingAttribution,
318319
requestId,
319-
resolvedSecretTraceRegistry
320+
resolvedSecretTraceRegistry,
321+
request.signal
320322
)
321323

322324
/**
@@ -371,6 +373,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
371373
},
372374
})
373375
} catch (error: any) {
376+
/**
377+
* A cancelled run must not be reshaped into a verdict. `passed: false` here reads
378+
* to a consumer as the guardrail rejecting the content, so an abandoned run would
379+
* block content that was never actually judged. 499 matches the convention the
380+
* workflow execute route already uses for a client-cancelled request.
381+
*/
382+
if (isAbortError(error)) {
383+
logger.info(`[${requestId}] Guardrails validation cancelled by client`)
384+
return NextResponse.json(
385+
{ success: false, error: 'Client cancelled request' },
386+
{ status: 499 }
387+
)
388+
}
374389
logger.error(`[${requestId}] Guardrails validation failed`, { error })
375390
return NextResponse.json({
376391
success: true,
@@ -431,7 +446,8 @@ async function executeValidation(
431446
actorUserId: string,
432447
billingAttribution: BillingAttributionSnapshot | undefined,
433448
requestId: string,
434-
resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined
449+
resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined,
450+
abortSignal: AbortSignal | undefined
435451
): Promise<{
436452
passed: boolean
437453
error?: string
@@ -488,6 +504,7 @@ async function executeValidation(
488504
billingAttribution,
489505
requestId,
490506
resolvedSecretTraceRegistry,
507+
abortSignal,
491508
})
492509
}
493510
if (validationType === 'pii') {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/text/text.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function Text({ blockId, subBlockId, content, className }: TextProps) {
3333
className={`rounded-md border bg-[var(--surface-2)] p-4 shadow-sm ${className || ''}`}
3434
>
3535
<div
36-
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)]'
36+
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)]'
3737
dangerouslySetInnerHTML={{ __html: content }}
3838
/>
3939
</div>

apps/sim/lib/guardrails/validate_hallucination.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,25 @@ describe('validateHallucination', () => {
182182
expect(mockExecuteProviderRequest).not.toHaveBeenCalled()
183183
expect(registry.isComplete()).toBe(true)
184184
})
185+
186+
/**
187+
* Forwarding the caller's signal means the scoring model can now be aborted. A
188+
* cancelled run must not be reported as a guardrail verdict — `passed: false` would
189+
* block content on a run the caller abandoned, which is indistinguishable to a
190+
* consumer from the model actually hallucinating.
191+
*/
192+
it('surfaces a cancelled run as cancellation, not as a failed guardrail', async () => {
193+
const registry = new ResolvedSecretTraceRegistry()
194+
const fetchMock = vi.fn(async () =>
195+
createPrivateKnowledgeResponse({ data: { results: [{ content: 'reference' }] } })
196+
)
197+
vi.stubGlobal('fetch', fetchMock)
198+
199+
const abort = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
200+
mockExecuteProviderRequest.mockRejectedValueOnce(abort)
201+
202+
await expect(validateHallucination(createInput(registry))).rejects.toMatchObject({
203+
name: 'AbortError',
204+
})
205+
})
185206
})

apps/sim/lib/guardrails/validate_hallucination.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
2424
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
2525
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
2626
import { executeProviderRequest } from '@/providers'
27+
import { isAbortError } from '@/providers/streaming-tool-loop-shared'
2728
import { getProviderFromModel } from '@/providers/utils'
2829

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

7380
/**
@@ -176,7 +183,8 @@ async function scoreHallucinationWithLLM(
176183
providerCredentials: HallucinationValidationInput['providerCredentials'],
177184
workspaceId: string | undefined,
178185
requestId: string,
179-
resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry
186+
resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry,
187+
abortSignal: AbortSignal | undefined
180188
): Promise<{ score: number; reasoning: string; cost: number }> {
181189
try {
182190
const contextText = ragContext.join('\n\n---\n\n')
@@ -251,6 +259,7 @@ Evaluate the consistency and provide your score and reasoning in JSON format.`
251259
bedrockSecretKey: providerCredentials?.bedrockSecretKey,
252260
bedrockRegion: providerCredentials?.bedrockRegion,
253261
workspaceId,
262+
abortSignal,
254263
},
255264
{ resolvedSecretTraceRegistry }
256265
)
@@ -290,6 +299,11 @@ Evaluate the consistency and provide your score and reasoning in JSON format.`
290299
cost,
291300
}
292301
} catch (error: any) {
302+
/**
303+
* A cancelled run is not a scoring failure. Rewrapping it would erase the
304+
* `AbortError` name the outer handler classifies on, so it propagates as-is.
305+
*/
306+
if (isAbortError(error)) throw error
293307
logger.error(`[${requestId}] Error scoring with LLM`, {
294308
error: error.message,
295309
})
@@ -317,6 +331,7 @@ export async function validateHallucination(
317331
billingAttribution,
318332
requestId,
319333
resolvedSecretTraceRegistry,
334+
abortSignal,
320335
} = input
321336

322337
try {
@@ -371,7 +386,8 @@ export async function validateHallucination(
371386
providerCredentials,
372387
workspaceId,
373388
requestId,
374-
providerRegistry
389+
providerRegistry,
390+
abortSignal
375391
)
376392

377393
logger.info(`[${requestId}] Confidence score: ${score}`, {
@@ -392,6 +408,12 @@ export async function validateHallucination(
392408
: `Low confidence: score ${score}/10 is below threshold ${threshold}`,
393409
}
394410
} catch (error: any) {
411+
/**
412+
* Cancellation is surfaced as cancellation, not as a guardrail verdict. Returning
413+
* `passed: false` here would fail content on a run the caller abandoned, which is
414+
* indistinguishable to a consumer from the model actually hallucinating.
415+
*/
416+
if (isAbortError(error)) throw error
395417
logger.error(`[${requestId}] Hallucination validation error`, {
396418
error: error.message,
397419
})

apps/sim/providers/baseten/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
1919
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
2020
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
2121
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
22+
import { openAICompatTransport } from '@/providers/transport'
2223
import type {
2324
FunctionCallResponse,
2425
Message,
@@ -86,6 +87,7 @@ export const basetenProvider: ProviderConfig = {
8687
}
8788

8889
const client = new OpenAI({
90+
...openAICompatTransport(),
8991
apiKey: request.apiKey,
9092
baseURL: 'https://inference.baseten.co/v1',
9193
})

apps/sim/providers/cerebras/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
1515
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
1616
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
1717
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
18+
import { openAICompatTransport } from '@/providers/transport'
1819
import type {
1920
ProviderConfig,
2021
ProviderRequest,
@@ -54,6 +55,7 @@ export const cerebrasProvider: ProviderConfig = {
5455
try {
5556
const client = new Cerebras({
5657
apiKey: request.apiKey,
58+
...openAICompatTransport(),
5759
})
5860

5961
const allMessages = []

apps/sim/providers/deepseek/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
1313
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
1414
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
1515
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
16+
import { openAICompatTransport } from '@/providers/transport'
1617
import type {
1718
ProviderConfig,
1819
ProviderRequest,
@@ -50,6 +51,7 @@ export const deepseekProvider: ProviderConfig = {
5051

5152
try {
5253
const deepseek = new OpenAI({
54+
...openAICompatTransport(),
5355
apiKey: request.apiKey,
5456
baseURL: 'https://api.deepseek.com',
5557
})

apps/sim/providers/fireworks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
2020
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
2121
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
2222
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
23+
import { openAICompatTransport } from '@/providers/transport'
2324
import type {
2425
FunctionCallResponse,
2526
Message,
@@ -87,6 +88,7 @@ export const fireworksProvider: ProviderConfig = {
8788
}
8889

8990
const client = new OpenAI({
91+
...openAICompatTransport(),
9092
apiKey: request.apiKey,
9193
baseURL: 'https://api.fireworks.ai/inference/v1',
9294
})

apps/sim/providers/groq/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
1818
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
1919
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
2020
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
21+
import { openAICompatTransport } from '@/providers/transport'
2122
import type {
2223
ProviderConfig,
2324
ProviderRequest,
@@ -50,7 +51,7 @@ export const groqProvider: ProviderConfig = {
5051
throw new Error('API key is required for Groq')
5152
}
5253

53-
const groq = new Groq({ apiKey: request.apiKey })
54+
const groq = new Groq({ apiKey: request.apiKey, ...openAICompatTransport() })
5455

5556
const allMessages = []
5657

0 commit comments

Comments
 (0)