Skip to content

Commit 3748b04

Browse files
committed
fix(jev): harden evaluation transport and output discovery
1 parent 041a480 commit 3748b04

9 files changed

Lines changed: 225 additions & 29 deletions

File tree

‎apps/sim/blocks/agent-evaluation.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import { describe, expect, it, vi } from 'vitest'
2+
import {
3+
getEffectiveBlockOutputPaths,
4+
getEffectiveBlockOutputs,
5+
getEffectiveBlockOutputType,
6+
} from '@/lib/workflows/blocks/block-outputs'
27
import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility'
38
import { AgentBlock } from '@/blocks/blocks/agent'
49
import { getAgentModelOptions, getModelOptions } from '@/blocks/utils'
@@ -29,6 +34,37 @@ describe('Agent evaluation configuration', () => {
2934
}
3035
})
3136

37+
it.each(['jev-1.13.0', '<start.model>', '{{MODEL_ID}}'])(
38+
'exposes answers for %s in downstream selectors',
39+
(model) => {
40+
const values = { model: { value: model } }
41+
expect(getEffectiveBlockOutputs('agent', values)).toHaveProperty('answers')
42+
expect(getEffectiveBlockOutputPaths('agent', values)).toContain('answers')
43+
expect(getEffectiveBlockOutputType('agent', 'answers', values)).toBe('json')
44+
}
45+
)
46+
47+
it('does not expose evaluation answers for a known chat model', () => {
48+
expect(getEffectiveBlockOutputs('agent', { model: { value: 'gpt-4o' } })).not.toHaveProperty(
49+
'answers'
50+
)
51+
})
52+
53+
it.each(['jev-1.13.0', '<start.model>'])(
54+
'keeps answers accessible with a saved chat schema for %s',
55+
(model) => {
56+
const outputs = getEffectiveBlockOutputs('agent', {
57+
model: { value: model },
58+
responseFormat: {
59+
value: { schema: { type: 'object', properties: { title: { type: 'string' } } } },
60+
},
61+
})
62+
expect(outputs).toHaveProperty('answers')
63+
if (model === 'jev-1.13.0') expect(outputs).not.toHaveProperty('title')
64+
else expect(outputs).toHaveProperty('title')
65+
}
66+
)
67+
3268
it('shows Jev only in the model picker that supports evaluation inputs', () => {
3369
useProvidersStore.getState().setProviderModels('base', Object.keys(getBaseModelProviders()))
3470
expect(getAgentModelOptions().map((option) => option.id)).toContain('jev-1.13.0')

‎apps/sim/blocks/blocks/agent.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,7 @@ Return ONLY the JSON array.`,
650650
type: 'json',
651651
description:
652652
'Evaluation answers keyed by question ID: choice, score, or noul, with probabilities and confidence where applicable',
653-
condition: { field: 'model', value: EVALUATION_MODELS },
653+
condition: { field: 'model', value: EVALUATION_MODELS, allowReference: true },
654654
},
655655
content: { type: 'string', description: 'Generated response content' },
656656
model: { type: 'string', description: 'Model used for generation' },

‎apps/sim/ee/organization-usage/components/usage-consumers.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
OpenRouterIcon,
2424
SakanaIcon,
2525
TogetherIcon,
26+
TypeSafeIcon,
2627
VertexIcon,
2728
VllmIcon,
2829
xAIIcon,
@@ -74,6 +75,7 @@ const PROVIDER_ICONS: Readonly<Record<string, ComponentType<{ className?: string
7475
openrouter: OpenRouterIcon,
7576
sakana: SakanaIcon,
7677
together: TogetherIcon,
78+
typesafe: TypeSafeIcon,
7779
vertex: VertexIcon,
7880
vllm: VllmIcon,
7981
xai: xAIIcon,

‎apps/sim/lib/workflows/blocks/block-outputs.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
parseResponseFormatSafely,
55
} from '@/lib/core/utils/response-format'
66
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
7+
import { containsReference } from '@/lib/workflows/sanitization/references'
78
import {
89
classifyStartBlockType,
910
StartBlockPath,
@@ -23,6 +24,7 @@ import {
2324
type OutputFieldDefinition,
2425
} from '@/blocks/types'
2526
import { isHumanInTheLoopBlock } from '@/executor/constants'
27+
import { isEvaluationModel } from '@/providers/models'
2628
import { getToolOutputsMetadata } from '@/tools/metadata-outputs'
2729
import { getTrigger, isTriggerValid } from '@/triggers'
2830

@@ -61,15 +63,19 @@ function evaluateOutputCondition(
6163

6264
const fieldValue = subBlocks[condition.field]?.value
6365

66+
const deferred =
67+
condition.allowReference && typeof fieldValue === 'string' && containsReference(fieldValue)
6468
let matches: boolean
65-
if (Array.isArray(condition.value)) {
69+
if (deferred) {
70+
matches = true
71+
} else if (Array.isArray(condition.value)) {
6672
// For array conditions, check if fieldValue is a valid primitive and included
6773
matches = isConditionPrimitive(fieldValue) && condition.value.includes(fieldValue)
6874
} else {
6975
matches = fieldValue === condition.value
7076
}
7177

72-
if (condition.not) {
78+
if (condition.not && !deferred) {
7379
matches = !matches
7480
}
7581

@@ -428,8 +434,19 @@ export function getEffectiveBlockOutputs(
428434
const includeHidden = options?.includeHidden ?? false
429435

430436
if (blockType === 'agent') {
431-
const responseFormatOutputs = getResponseFormatOutputs(subBlocks, 'agent')
432-
if (responseFormatOutputs) return responseFormatOutputs
437+
const model = subBlocks?.model?.value
438+
if (typeof model !== 'string' || !isEvaluationModel(model)) {
439+
const responseFormatOutputs = getResponseFormatOutputs(subBlocks, 'agent')
440+
if (responseFormatOutputs) {
441+
/** A referenced model may select either evaluation or chat at execution time. */
442+
return typeof model === 'string' && containsReference(model)
443+
? {
444+
...getBlockOutputs('agent', subBlocks, false, { includeHidden }),
445+
...responseFormatOutputs,
446+
}
447+
: responseFormatOutputs
448+
}
449+
}
433450
}
434451

435452
let baseOutputs: OutputDefinition

‎apps/sim/providers/typesafe/index.ts‎

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
import { consumeOrCancelBody, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
21
import type { StreamingExecution } from '@/executor/types'
32
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
43
import { createSettledAgentEventStream } from '@/providers/stream-events'
54
import { createStreamingExecution } from '@/providers/streaming-execution'
6-
import { PROVIDER_HEADERS_TIMEOUT_MS } from '@/providers/transport'
75
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'
86
import { buildJevBody, parseJevResponse } from '@/providers/typesafe/schema'
7+
import { requestJevEvaluation } from '@/providers/typesafe/transport'
98
import { calculateCost } from '@/providers/utils'
109

11-
const MAX_EVALUATION_RESPONSE_BYTES = 10 * 1024 * 1024
12-
1310
export const typesafeProvider: ProviderConfig = {
1411
id: 'typesafe',
1512
name: 'TypeSafe',
@@ -40,26 +37,8 @@ export const typesafeProvider: ProviderConfig = {
4037
)
4138
const start = Date.now()
4239
const startTime = new Date(start).toISOString()
43-
const timeout = AbortSignal.timeout(PROVIDER_HEADERS_TIMEOUT_MS)
44-
const signal = request.abortSignal ? AbortSignal.any([request.abortSignal, timeout]) : timeout
45-
signal.throwIfAborted()
46-
const response = await fetch('https://api.typesafe.ai/v1/systemone', {
47-
method: 'POST',
48-
headers: { Authorization: `Bearer ${request.apiKey}`, 'Content-Type': 'application/json' },
49-
body: JSON.stringify(body),
50-
signal,
51-
redirect: 'error',
52-
})
53-
if (!response.ok) {
54-
await consumeOrCancelBody(response)
55-
throw new Error(`TypeSafe evaluation failed (HTTP ${response.status})`)
56-
}
5740
const result = parseJevResponse(
58-
await readResponseJsonWithLimit(response, {
59-
maxBytes: MAX_EVALUATION_RESPONSE_BYTES,
60-
label: 'TypeSafe evaluation response',
61-
signal,
62-
}),
41+
await requestJevEvaluation(body, request.apiKey, request.abortSignal),
6342
body.questions
6443
)
6544
const content = JSON.stringify(result.answers)

‎apps/sim/providers/typesafe/schema.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,5 +113,15 @@ export function parseJevResponse(
113113
'TypeSafe returned Jev answers that do not match the requested question IDs and types'
114114
)
115115
}
116+
for (const [id, question] of Object.entries(questions)) {
117+
const answer = answers[id]
118+
if (
119+
question.type === 'choice' &&
120+
answer.type === 'choice' &&
121+
!Object.hasOwn(question.criteria, answer.choice)
122+
) {
123+
throw new Error('TypeSafe returned a Jev Choice answer outside the requested options')
124+
}
125+
}
116126
return result.data
117127
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { interruptibleSleep } from '@sim/utils/helpers'
2+
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
3+
import { consumeOrCancelBody, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
4+
import { PROVIDER_HEADERS_TIMEOUT_MS, PROVIDER_MAX_RETRIES } from '@/providers/transport'
5+
import type { buildJevBody } from '@/providers/typesafe/schema'
6+
7+
const MAX_EVALUATION_RESPONSE_BYTES = 10 * 1024 * 1024
8+
9+
class TypeSafeHttpError extends Error {
10+
constructor(
11+
readonly status: number,
12+
readonly retryAfterMs: number | null
13+
) {
14+
super(`TypeSafe evaluation failed (HTTP ${status})`)
15+
this.name = 'TypeSafeHttpError'
16+
}
17+
}
18+
19+
export async function requestJevEvaluation(
20+
body: ReturnType<typeof buildJevBody>,
21+
apiKey: string,
22+
abortSignal?: AbortSignal
23+
): Promise<unknown> {
24+
const payload = JSON.stringify(body)
25+
for (let attempt = 0; ; attempt++) {
26+
abortSignal?.throwIfAborted()
27+
const timeout = AbortSignal.timeout(PROVIDER_HEADERS_TIMEOUT_MS)
28+
const signal = abortSignal ? AbortSignal.any([abortSignal, timeout]) : timeout
29+
let response: Response | undefined
30+
try {
31+
signal.throwIfAborted()
32+
response = await fetch('https://api.typesafe.ai/v1/systemone', {
33+
method: 'POST',
34+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
35+
body: payload,
36+
signal,
37+
redirect: 'error',
38+
})
39+
if (!response.ok) {
40+
await consumeOrCancelBody(response)
41+
throw new TypeSafeHttpError(
42+
response.status,
43+
parseRetryAfter(response.headers.get('retry-after'))
44+
)
45+
}
46+
return await readResponseJsonWithLimit(response, {
47+
maxBytes: MAX_EVALUATION_RESPONSE_BYTES,
48+
label: 'TypeSafe evaluation response',
49+
signal,
50+
})
51+
} catch (error) {
52+
abortSignal?.throwIfAborted()
53+
const retryable =
54+
error instanceof TypeSafeHttpError
55+
? error.status === 408 || error.status === 429 || error.status >= 500
56+
: !response || timeout.aborted || error instanceof TypeError
57+
if (!retryable || attempt >= PROVIDER_MAX_RETRIES) throw error
58+
const retryAfterMs = error instanceof TypeSafeHttpError ? error.retryAfterMs : null
59+
await interruptibleSleep(backoffWithJitter(attempt + 1, retryAfterMs), abortSignal)
60+
}
61+
}
62+
}

‎apps/sim/providers/typesafe/typesafe.test.ts‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** @vitest-environment node */
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33
import { getHostedModels, getModelCapabilities, getProviderIcon } from '@/providers/models'
4+
import { PROVIDER_MAX_RETRIES } from '@/providers/transport'
45
import type { ProviderRequest } from '@/providers/types'
56
import { typesafeProvider } from '@/providers/typesafe'
67
import { buildJevBody, parseJevResponse } from '@/providers/typesafe/schema'
@@ -52,7 +53,11 @@ describe('TypeSafe provider', () => {
5253
fetchMock.mockReset().mockResolvedValue(Response.json(RESULT))
5354
vi.stubGlobal('fetch', fetchMock)
5455
})
55-
afterEach(() => vi.unstubAllGlobals())
56+
afterEach(() => {
57+
vi.unstubAllGlobals()
58+
vi.restoreAllMocks()
59+
vi.useRealTimers()
60+
})
5661

5762
it.each(['jev-1.13.0', 'jev-latest', 'jev-preview'])(
5863
'routes %s through native BYOK evaluation',
@@ -131,6 +136,79 @@ describe('TypeSafe provider', () => {
131136
await expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow(
132137
'TypeSafe evaluation failed (HTTP 401)'
133138
)
139+
expect(fetchMock).toHaveBeenCalledTimes(1)
140+
})
141+
142+
it.each([408, 429, 500, 503])('retries HTTP %s and honors Retry-After', async (status) => {
143+
vi.useFakeTimers()
144+
fetchMock.mockResolvedValueOnce(new Response(null, { status, headers: { 'retry-after': '2' } }))
145+
const result = typesafeProvider.executeRequest(REQUEST)
146+
await vi.advanceTimersByTimeAsync(1999)
147+
expect(fetchMock).toHaveBeenCalledTimes(1)
148+
await vi.advanceTimersByTimeAsync(1)
149+
expect(await result).toMatchObject({ answers: RESULT.answers })
150+
expect(fetchMock).toHaveBeenCalledTimes(2)
151+
})
152+
153+
it.each([400, 403, 422])('does not retry HTTP %s', async (status) => {
154+
fetchMock.mockResolvedValueOnce(new Response(null, { status }))
155+
await expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow(`HTTP ${status}`)
156+
expect(fetchMock).toHaveBeenCalledTimes(1)
157+
})
158+
159+
it('retries connection failures within the shared provider retry budget', async () => {
160+
vi.useFakeTimers()
161+
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
162+
const result = expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow('fetch failed')
163+
await vi.runAllTimersAsync()
164+
await result
165+
expect(fetchMock).toHaveBeenCalledTimes(PROVIDER_MAX_RETRIES + 1)
166+
})
167+
168+
it('stops retrying repeated server failures', async () => {
169+
vi.useFakeTimers()
170+
fetchMock.mockImplementation(async () => new Response(null, { status: 503 }))
171+
const result = expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow('HTTP 503')
172+
await vi.runAllTimersAsync()
173+
await result
174+
expect(fetchMock).toHaveBeenCalledTimes(PROVIDER_MAX_RETRIES + 1)
175+
})
176+
177+
it('gives a timed-out attempt a fresh deadline', async () => {
178+
vi.useFakeTimers()
179+
const deadline = new AbortController()
180+
vi.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(deadline.signal)
181+
fetchMock.mockImplementationOnce(async () => {
182+
deadline.abort(new DOMException('Timed out', 'TimeoutError'))
183+
throw deadline.signal.reason
184+
})
185+
const result = typesafeProvider.executeRequest(REQUEST)
186+
await vi.runAllTimersAsync()
187+
expect(await result).toMatchObject({ answers: RESULT.answers })
188+
expect(fetchMock).toHaveBeenCalledTimes(2)
189+
expect(fetchMock.mock.calls[1][1]?.signal?.aborted).toBe(false)
190+
})
191+
192+
it('cancels immediately during Retry-After without sending another attempt', async () => {
193+
vi.useFakeTimers()
194+
const controller = new AbortController()
195+
fetchMock.mockResolvedValueOnce(
196+
new Response(null, { status: 429, headers: { 'retry-after': '30' } })
197+
)
198+
const result = expect(
199+
typesafeProvider.executeRequest({ ...REQUEST, abortSignal: controller.signal })
200+
).rejects.toThrow('Cancelled')
201+
await vi.advanceTimersByTimeAsync(1)
202+
controller.abort(new Error('Cancelled'))
203+
await result
204+
expect(fetchMock).toHaveBeenCalledTimes(1)
205+
expect(vi.getTimerCount()).toBe(0)
206+
})
207+
208+
it('does not retry a malformed successful response', async () => {
209+
fetchMock.mockResolvedValueOnce(new Response('{'))
210+
await expect(typesafeProvider.executeRequest(REQUEST)).rejects.toThrow()
211+
expect(fetchMock).toHaveBeenCalledTimes(1)
134212
})
135213

136214
it('honors cancellation before network access', async () => {
@@ -217,4 +295,14 @@ describe('Jev native schema', () => {
217295
])('rejects invalid provider responses', (value) => {
218296
expect(() => parseJevResponse(value, QUESTIONS)).toThrow('invalid Jev evaluation response')
219297
})
298+
299+
it.each(['unknown', 'toString'])('rejects an unrequested Choice option %s', (choice) => {
300+
const answers = {
301+
...RESULT.answers,
302+
department: { type: 'choice', choice, probabilities: { [choice]: 1 }, confidence: 1 },
303+
}
304+
expect(() => parseJevResponse({ ...RESULT, answers }, QUESTIONS)).toThrow(
305+
'outside the requested options'
306+
)
307+
})
220308
})

‎packages/workflow-types/src/blocks.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export type SubBlockType =
5959
| 'modal'
6060

6161
export interface OutputCondition {
62+
/** Keep the output selectable when the compared value is resolved at execution time. */
63+
allowReference?: boolean
6264
field: string
6365
value: string | number | boolean | Array<string | number | boolean>
6466
not?: boolean

0 commit comments

Comments
 (0)