Skip to content

Commit 5ee7e31

Browse files
committed
feat(jev): support hosted TypeSafe keys and default to latest
1 parent 5d8a181 commit 5ee7e31

15 files changed

Lines changed: 207 additions & 15 deletions

File tree

‎apps/docs/content/docs/platform/self-hosting/environment-variables.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { Callout } from 'fumadocs-ui/components/callout'
4747
| `MISTRAL_API_KEY` | Mistral |
4848
| `XAI_API_KEY_1` | xAI |
4949
| `KIMI_API_KEY_1` | Moonshot Kimi |
50+
| `TYPESAFE_API_KEY_1` / `_2` / `_3` | TypeSafe Jev hosted key rotation |
5051
| `ZAI_API_KEY_1` | Z.ai |
5152
| `TOGETHER_API_KEY` | Together AI |
5253
| `FIREWORKS_API_KEY` | Fireworks AI |

‎apps/docs/content/docs/workflows/blocks/agent.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Ollama Cloud, OpenRouter, Fireworks, Together AI, Baseten, Ollama, vLLM, and Lit
3333

3434
### Jev evaluation models
3535

36-
Select `jev-1.13.0`, `jev-latest`, or `jev-preview` from TypeSafe in the Agent model selector, then enter your TypeSafe API key. These models use **State** and **Questions** in place of conversational messages. State accepts text or a reference to a JSON object or array. Questions is a JSON object keyed by the answer names you want:
36+
Select `jev-latest` from TypeSafe in the Agent model selector. Hosted Sim supplies a key and bills model usage through the normal credit system; workspace or organization BYOK keys override the hosted key without model charges. Self-hosted users enter their TypeSafe key in the block. Use `jev-1.13.0` to pin a version or `jev-preview` to follow preview releases. These models use **State** and **Questions** in place of conversational messages. State accepts text or a reference to a JSON object or array. Questions is a JSON object keyed by the answer names you want:
3737

3838
```json
3939
{
@@ -51,9 +51,9 @@ Select `jev-1.13.0`, `jev-latest`, or `jev-preview` from TypeSafe in the Agent m
5151
}
5252
```
5353

54-
Read results from `<agent.answers>`. Each Choice answer includes `choice`, `probabilities`, and `confidence`; each Score answer includes `score`, `legend`, `probabilities`, and `confidence`; each Noul answer includes `noul`, a probability from 0 to 1. `content` contains the same answers as JSON text, and the standard model, token, timing, and cost outputs remain available. Use a Condition block to route on these results.
54+
Read results from `<agent.answers>` or expand an answer in the reference picker, such as `<agent.answers.route.choice>`. Each Choice answer includes `choice`, `probabilities`, and `confidence`; each Score answer includes `score`, `legend`, `probabilities`, and `confidence`; each Noul answer includes `noul`, a probability from 0 to 1. `content` contains the same answers as JSON text, and the standard model, token, timing, and cost outputs remain available. Use a Condition block to route on these results.
5555

56-
Jev evaluates the supplied state in one request. Chat messages, files, tools, skills, conversation memory, response-format schemas, and chat model fallbacks are hidden for these models. Saved settings return when you switch back to a chat model. Bring your own key; Sim does not provide hosted Jev credits. TypeSafe documents a 64,000-token total request limit and a 32,000-token limit for state plus the longest question. See [TypeSafe's model documentation](https://docs.typesafe.ai/models) and [question formats](https://docs.typesafe.ai/api).
56+
Jev evaluates the supplied state in one request. Chat messages, files, tools, skills, conversation memory, response-format schemas, and chat model fallbacks are hidden for these models. Saved settings return when you switch back to a chat model. TypeSafe documents a 64,000-token total request limit and a 32,000-token limit for state plus the longest question. See [TypeSafe's model documentation](https://docs.typesafe.ai/models) and [question formats](https://docs.typesafe.ai/api).
5757

5858
### Files
5959

@@ -128,7 +128,7 @@ Some settings live under advanced, or appear only for models that support them:
128128
- **Max output tokens.** Caps the response length. Defaults to the model's full limit.
129129
- **Reasoning effort / Thinking level.** For models with extended reasoning, how much the model thinks before answering. Higher is more thorough but slower and costs more tokens.
130130
- **Prompt caching.** For Anthropic Claude models, reuses the system prompt and tool definitions between runs instead of re-reading them every time. Cached input costs a tenth of the normal rate, but writing the cache costs 1.25x, so leave it off for one-off runs and turn it on when the same agent runs repeatedly. The cache covers a prefix only if it reaches 1,024 tokens (2,048 on Haiku) — below that Anthropic ignores it and nothing changes. Entries expire after five minutes of no use.
131-
- **API key.** Your key for the chosen provider. Hidden when hosted Sim supplies a key for the selected model. Jev requires your own TypeSafe key.
131+
- **API key.** Your key for the chosen provider. Hidden when hosted Sim supplies a key for the selected model, including Jev.
132132
- **Fallback models.** An ordered list of up to five models to try when the request to the selected model fails, whether the provider is overloaded, rate-limited, or down. Sim tries the 2nd choice, then the 3rd, and so on, once each, and `<agent.model>` reports the model that answered. On hosted Sim, hosted models use your workspace's BYOK or platform credentials; local and self-hosted installations may still require a key. A model that needs its own key takes it from a workspace environment variable you pick on the row; a model on the same provider as the selected model reuses the block's key. A stored row key stops applying when its key field is hidden. Providers that require family-specific credentials, such as Vertex, can only be fallbacks for a selected model of the same family. The Auto model cannot be a fallback. A fallback runs with the selected model's settings where its provider accepts them: temperature and max output tokens are clamped to the fallback's limits, and when the fallback has a reasoning effort, thinking level, or verbosity setting that the selected model's value does not fit, the row shows that field so you can pick a value for it; leave it empty and the provider's default applies.
133133
- **Retry on fail.** Retries the selected model after a failure, up to a maximum number of tries with a wait between them. When its tries run out, the fallback models are tried in order, once each, with no wait before the first of them. A fallback is never retried. See [Retries and fallbacks](#retries-and-fallbacks) for how recorded tool results are reused and when a tool can execute again.
134134

‎apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
SerperIcon,
4040
TinyFishIcon,
4141
TogetherIcon,
42+
TypeSafeIcon,
4243
WizaIcon,
4344
xAIIcon,
4445
ZaiIcon,
@@ -130,6 +131,13 @@ const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [
130131
description: 'LLM calls',
131132
placeholder: 'sk-...',
132133
},
134+
{
135+
id: 'typesafe',
136+
name: 'TypeSafe',
137+
icon: TypeSafeIcon,
138+
description: 'Jev evaluation models',
139+
placeholder: 'Enter your TypeSafe API key',
140+
},
133141
{
134142
id: 'fireworks',
135143
name: 'Fireworks',
@@ -352,6 +360,7 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [
352360
'cohere',
353361
'xai',
354362
'kimi',
363+
'typesafe',
355364
'fireworks',
356365
'together',
357366
'baseten',

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @vitest-environment node */
2-
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
34
import {
45
getEffectiveBlockOutputPaths,
56
getEffectiveBlockOutputs,
@@ -19,6 +20,7 @@ const { mockGetBlock } = vi.hoisted(() => ({ mockGetBlock: vi.fn() }))
1920
vi.mock('@/blocks', () => ({ getBlock: mockGetBlock }))
2021

2122
describe('Agent evaluation configuration', () => {
23+
afterEach(resetEnvFlagsMock)
2224
beforeEach(() => {
2325
mockGetBlock.mockReturnValue(AgentBlock)
2426
})
@@ -39,6 +41,12 @@ describe('Agent evaluation configuration', () => {
3941
}
4042
})
4143

44+
it.each([false, true])('shows TypeSafe credentials only when needed, hosted=%s', (hosted) => {
45+
setEnvFlags({ isHosted: hosted })
46+
const apiKey = AgentBlock.subBlocks.find((field) => field.id === 'apiKey')!
47+
expect(evaluateSubBlockCondition(apiKey.condition, { model: 'jev-latest' })).toBe(!hosted)
48+
})
49+
4250
it.each(['jev-1.13.0', '<start.model>', '{{MODEL_ID}}'])(
4351
'exposes answers for %s in downstream selectors',
4452
(model) => {

‎apps/sim/lib/api-key/byok.test.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ vi.mock('@/stores/providers/store', () => ({
5959
useProvidersStore: { getState: vi.fn() },
6060
}))
6161

62+
import { byokProviderIdSchema } from '@/lib/api/contracts/byok-keys'
6263
import { getApiKeyWithBYOK, getBYOKKey } from '@/lib/api-key/byok'
6364
import { useProvidersStore } from '@/stores/providers/store'
6465

@@ -544,6 +545,89 @@ describe('getApiKeyWithBYOK provider classification', () => {
544545
})
545546
})
546547

548+
describe('getApiKeyWithBYOK for TypeSafe', () => {
549+
beforeEach(() => {
550+
vi.clearAllMocks()
551+
resetDbChainMock()
552+
mockIsHosted.value = true
553+
mockGetHostedModels.mockReturnValue(['jev-latest', 'jev-1.13.0', 'jev-preview'])
554+
mockGetRotatingApiKey.mockReturnValue('hosted-typesafe-key')
555+
mockDecryptSecret.mockImplementation(async (encrypted: string) => ({
556+
decrypted: encrypted.replace('encrypted-', 'decrypted-'),
557+
}))
558+
mockIsOrganizationBYOKEntitled.mockResolvedValue(true)
559+
})
560+
561+
it('accepts TypeSafe in workspace and organization BYOK contracts', () => {
562+
expect(byokProviderIdSchema.parse('typesafe')).toBe('typesafe')
563+
})
564+
565+
it.each(['jev-latest', 'jev-1.13.0', 'jev-preview'])(
566+
'resolves the platform pool when %s has no BYOK key',
567+
async (model) => {
568+
await expect(getApiKeyWithBYOK('typesafe', model, uniqueWorkspaceId())).resolves.toEqual({
569+
apiKey: 'hosted-typesafe-key',
570+
isBYOK: false,
571+
})
572+
expect(mockGetRotatingApiKey).toHaveBeenCalledWith('typesafe')
573+
}
574+
)
575+
576+
it('prefers the workspace pool without selecting a hosted key', async () => {
577+
dbChainMockFns.orderBy.mockResolvedValueOnce([storedKey('workspace-key')])
578+
await expect(getApiKeyWithBYOK('typesafe', 'jev-latest', uniqueWorkspaceId())).resolves.toEqual(
579+
{
580+
apiKey: 'decrypted-workspace-key',
581+
isBYOK: true,
582+
scope: 'workspace',
583+
}
584+
)
585+
expect(mockGetRotatingApiKey).not.toHaveBeenCalled()
586+
expect(mockIsOrganizationBYOKEntitled).not.toHaveBeenCalled()
587+
})
588+
589+
it('inherits an entitled organization pool before using hosted credits', async () => {
590+
dbChainMockFns.orderBy
591+
.mockResolvedValueOnce([])
592+
.mockResolvedValueOnce([storedOrganizationKey(uniqueOrganizationId(), 'organization-key')])
593+
await expect(getApiKeyWithBYOK('typesafe', 'jev-latest', uniqueWorkspaceId())).resolves.toEqual(
594+
{
595+
apiKey: 'decrypted-organization-key',
596+
isBYOK: true,
597+
scope: 'organization',
598+
}
599+
)
600+
expect(mockGetRotatingApiKey).not.toHaveBeenCalled()
601+
})
602+
603+
it('rejects missing hosted credentials instead of making an unauthenticated request', async () => {
604+
mockGetRotatingApiKey.mockImplementation(() => {
605+
throw new Error('No configured key')
606+
})
607+
await expect(getApiKeyWithBYOK('typesafe', 'jev-latest', uniqueWorkspaceId())).rejects.toThrow(
608+
'No API key available for typesafe jev-latest'
609+
)
610+
})
611+
612+
it('never gives the hosted key to an unlisted model', async () => {
613+
await expect(getApiKeyWithBYOK('typesafe', 'jev-custom', uniqueWorkspaceId())).rejects.toThrow(
614+
'API key is required'
615+
)
616+
expect(mockGetRotatingApiKey).not.toHaveBeenCalled()
617+
})
618+
619+
it('requires caller credentials on self-hosted deployments', async () => {
620+
mockIsHosted.value = false
621+
await expect(
622+
getApiKeyWithBYOK('typesafe', 'jev-latest', uniqueWorkspaceId(), 'caller-key')
623+
).resolves.toEqual({ apiKey: 'caller-key', isBYOK: false })
624+
await expect(getApiKeyWithBYOK('typesafe', 'jev-latest', uniqueWorkspaceId())).rejects.toThrow(
625+
'API key is required'
626+
)
627+
expect(mockGetRotatingApiKey).not.toHaveBeenCalled()
628+
})
629+
})
630+
547631
describe('getApiKeyWithBYOK for Fireworks', () => {
548632
const HOSTED_POOL_MODEL = 'fireworks/glm-5.2'
549633

‎apps/sim/lib/api-key/byok.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ export async function getApiKeyWithBYOK(
328328
const isZaiModel = provider === 'zai'
329329
const isXaiModel = provider === 'xai'
330330
const isKimiModel = provider === 'kimi'
331+
const isTypeSafeModel = provider === 'typesafe'
331332

332333
const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId)
333334

@@ -340,7 +341,8 @@ export async function getApiKeyWithBYOK(
340341
isMistralModel ||
341342
isZaiModel ||
342343
isXaiModel ||
343-
isKimiModel)
344+
isKimiModel ||
345+
isTypeSafeModel)
344346
) {
345347
const hostedModels = getHostedModels()
346348
const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase())

‎apps/sim/lib/api/contracts/byok-keys.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const byokProviderIdSchema = z.enum([
99
'mistral',
1010
'zai',
1111
'kimi',
12+
'typesafe',
1213
'xai',
1314
'fireworks',
1415
'together',

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,9 @@ export const env = createEnv({
261261
KIMI_API_KEY_1: z.string().min(1).optional(), // Primary Kimi (Moonshot AI) API key for load balancing
262262
KIMI_API_KEY_2: z.string().min(1).optional(), // Additional Kimi API key for load balancing
263263
KIMI_API_KEY_3: z.string().min(1).optional(), // Additional Kimi API key for load balancing
264+
TYPESAFE_API_KEY_1: z.string().min(1).optional(),
265+
TYPESAFE_API_KEY_2: z.string().min(1).optional(),
266+
TYPESAFE_API_KEY_3: z.string().min(1).optional(),
264267
XAI_API_KEY_1: z.string().min(1).optional(), // Primary xAI API key for load balancing
265268
XAI_API_KEY_2: z.string().min(1).optional(), // Additional xAI API key for load balancing
266269
XAI_API_KEY_3: z.string().min(1).optional(), // Additional xAI API key for load balancing

‎apps/sim/lib/core/utils.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ beforeAll(() => {
2424
XAI_API_KEY_1: 'test-xai-key-1',
2525
XAI_API_KEY_2: 'test-xai-key-2',
2626
XAI_API_KEY_3: 'test-xai-key-3',
27+
TYPESAFE_API_KEY_1: 'test-typesafe-key-1',
28+
TYPESAFE_API_KEY_2: 'test-typesafe-key-2',
29+
TYPESAFE_API_KEY_3: 'test-typesafe-key-3',
2730
FIREWORKS_API_KEY_1: 'test-fireworks-key-1',
2831
FIREWORKS_API_KEY_2: 'test-fireworks-key-2',
2932
FIREWORKS_API_KEY_3: 'test-fireworks-key-3',
@@ -321,6 +324,9 @@ describe('getInvalidCharacters', () => {
321324
})
322325

323326
describe('getRotatingApiKey', () => {
327+
it.concurrent('rotates the TypeSafe key pool through the shared selector', () => {
328+
expect(getRotatingApiKey('typesafe')).toMatch(/^test-typesafe-key-[1-3]$/)
329+
})
324330
it.concurrent('should return OpenAI API key based on current minute', () => {
325331
const result = getRotatingApiKey('openai')
326332
expect(result).toMatch(/^test-openai-key-[1-3]$/)

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

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2082,13 +2082,14 @@ describe('executeProviderRequest — model level normalization', () => {
20822082
describe('native evaluation provider boundary', () => {
20832083
beforeEach(() => {
20842084
vi.clearAllMocks()
2085+
envFlagsMockFns.getCostMultiplier.mockReturnValue(2)
20852086
mockExecuteRequest.mockResolvedValue({
20862087
content: '{"passed":true}',
20872088
model: 'jev-1.13.0',
20882089
answers: { passed: true },
20892090
tokens: { input: 100, output: 10, total: 110 },
20902091
})
2091-
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'resolved-typesafe-key', isBYOK: false })
2092+
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'resolved-typesafe-key', isBYOK: true })
20922093
})
20932094

20942095
it.each([
@@ -2130,4 +2131,59 @@ describe('native evaluation provider boundary', () => {
21302131
cost: { input: 0, output: 0, total: 0 },
21312132
})
21322133
})
2134+
2135+
it.each(['jev-latest', 'jev-1.13.0', 'jev-preview'])(
2136+
'bills hosted %s using the resolved model price and shared multiplier once',
2137+
async (model) => {
2138+
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'hosted-typesafe-key', isBYOK: false })
2139+
const result = await executeProviderRequest('typesafe', {
2140+
model,
2141+
workspaceId: 'test-workspace',
2142+
evaluation: {
2143+
state: 'Task complete',
2144+
questions: { passed: { type: 'noul', instructions: 'Passed?' } },
2145+
},
2146+
})
2147+
expect(mockExecuteRequest).toHaveBeenCalledWith(
2148+
expect.objectContaining({ apiKey: 'hosted-typesafe-key', isBYOK: false })
2149+
)
2150+
expect(result).toMatchObject({
2151+
model: 'jev-1.13.0',
2152+
cost: { input: 0.0000084, output: 0, total: 0.0000084 },
2153+
})
2154+
}
2155+
)
2156+
2157+
it.each([false, true])('applies Jev streaming billing consistently, BYOK=%s', async (isBYOK) => {
2158+
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'resolved-typesafe-key', isBYOK })
2159+
const streaming: StreamingExecution = {
2160+
stream: new ReadableStream(),
2161+
execution: {
2162+
success: true,
2163+
output: {
2164+
content: '{"passed":{"type":"noul","noul":0.9}}',
2165+
answers: { passed: { type: 'noul', noul: 0.9 } },
2166+
model: 'jev-1.13.0',
2167+
tokens: { input: 100, output: 10, total: 110 },
2168+
cost: { input: 0.0000042, output: 0, total: 0.0000042 },
2169+
},
2170+
logs: [],
2171+
},
2172+
}
2173+
mockExecuteRequest.mockResolvedValue(streaming)
2174+
await executeProviderRequest('typesafe', {
2175+
model: 'jev-latest',
2176+
workspaceId: 'test-workspace',
2177+
stream: true,
2178+
evaluation: {
2179+
state: 'Task complete',
2180+
questions: { passed: { type: 'noul', instructions: 'Passed?' } },
2181+
},
2182+
})
2183+
expect(streaming.execution.output.cost).toMatchObject({
2184+
input: isBYOK ? 0 : 0.0000084,
2185+
output: 0,
2186+
total: isBYOK ? 0 : 0.0000084,
2187+
})
2188+
})
21332189
})

0 commit comments

Comments
 (0)