Skip to content
Open
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: 2 additions & 2 deletions apps/docs/content/docs/en/workflows/blocks/agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Answer in two sentences, cite the doc you used, and never guess a price.

### Model

The model that runs the step. Defaults to `claude-sonnet-4-6`. Type or pick any model from OpenAI, Anthropic, Google, xAI, Groq, Cerebras, DeepSeek, Azure, AWS Bedrock, Google Vertex, or OpenRouter, or a local model through Ollama or VLLM.
The model that runs the step. Defaults to `claude-sonnet-4-6`. Type or pick any model from OpenAI, Anthropic, Google, xAI, Groq, Cerebras, DeepSeek, Azure, AWS Bedrock, Google Vertex, OpenRouter, or [OrcaRouter](https://www.orcarouter.ai), or a local model through Ollama or VLLM.

### Files

Expand Down Expand Up @@ -139,7 +139,7 @@ The Agent reads the message from Start with `<start.input>` and returns a result
- **Use a response format when a downstream block needs specific fields.** It guarantees the shape, and you read each field as `<agent.field>`.

<FAQ items={[
{ question: "What LLM providers does the Agent block support?", answer: "OpenAI, Anthropic, Google (Gemini), xAI (Grok), DeepSeek, Groq, Cerebras, Azure OpenAI, Azure Anthropic, Google Vertex AI, AWS Bedrock, OpenRouter, and local models via Ollama or VLLM. Type or select any supported model from the model combobox." },
{ question: "What LLM providers does the Agent block support?", answer: "OpenAI, Anthropic, Google (Gemini), xAI (Grok), DeepSeek, Groq, Cerebras, Azure OpenAI, Azure Anthropic, Google Vertex AI, AWS Bedrock, OpenRouter, [OrcaRouter](https://www.orcarouter.ai), and local models via Ollama or VLLM. Type or select any supported model from the model combobox." },
{ question: "What are the memory options for the Agent block?", answer: "Four modes: None (no memory, each run is independent), Conversation (full history keyed by a conversation ID), Sliding window by messages (the N most recent messages), and Sliding window by tokens (messages up to a token budget). Memory needs a conversation ID to persist across runs." },
{ question: "What is the difference between the tool usage controls (Auto, Force, None)?", answer: "In Auto, the model decides when to call a tool based on context. In Force, the model must call the tool on every run. In None, the tool is hidden from the model and never sent, which disables it without removing it from the block." },
{ question: "How does the Response Format work?", answer: "It enforces structured output by providing a JSON Schema. When set, the model's response is constrained to match the schema exactly, and each field is read directly by downstream blocks using <agent.fieldName>. Without a response format, the agent returns its standard outputs: content, model, tokens, and toolCalls." },
Expand Down
1 change: 1 addition & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible)
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
# OPENROUTER_API_KEY= # Optional self-hosted fallback for OpenAI knowledge-base embeddings
# ORCAROUTER_API_KEY= # Optional OrcaRouter API key for model listing and inference
# NEXT_PUBLIC_FORCE_HOSTED=true # Dev only: treat this instance as hosted Sim (sim-auto pool, platform keys); ignored in production builds
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing and inference
# FIREWORKS_API_KEY_1= # Optional Fireworks API key for rotation (hosted deployments)
Expand Down
71 changes: 71 additions & 0 deletions apps/sim/app/api/providers/orcarouter/models/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { providerModelsResponseSchema } from '@/lib/api/contracts/providers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils'

const logger = createLogger('OrcaRouterModelsAPI')

interface OrcaRouterModel {
id: string
supported_endpoint_types?: string[]
}

interface OrcaRouterResponse {
data: OrcaRouterModel[]
}

/**
* Enumerates the public OrcaRouter model catalog. OrcaRouter is an
* OpenAI-compatible gateway that routes across many upstream providers, so its
* `/v1/models` endpoint (like OpenRouter's) is public — no key needed to list.
* Chat models are filtered to those exposing chat completions.
*/
export const GET = withRouteHandler(async (_request: NextRequest) => {
if (isProviderBlacklisted('orcarouter')) {
logger.info('OrcaRouter provider is blacklisted, returning empty models')
return NextResponse.json({ models: [], modelInfo: {} })
}

try {
const response = await fetch('https://api.orcarouter.ai/v1/models', {
headers: { 'Content-Type': 'application/json' },
next: { revalidate: 300 },
})

if (!response.ok) {
logger.warn('Failed to fetch OrcaRouter models', {
status: response.status,
statusText: response.statusText,
})
return NextResponse.json({ models: [], modelInfo: {} })
}

const data: OrcaRouterResponse = await response.json()

const allModels: string[] = []
for (const model of data.data ?? []) {
const endpoints = model.supported_endpoint_types ?? []
// OrcaRouter exposes chat completions through the OpenAI-compatible
// endpoint (`openai`); models without an endpoint list default to included.
if (endpoints.length > 0 && !endpoints.includes('openai')) continue
allModels.push(`orcarouter/${model.id}`)
}

const uniqueModels = Array.from(new Set(allModels))
const models = filterBlacklistedModels(uniqueModels)

logger.info('Successfully fetched OrcaRouter models', {
count: models.length,
filtered: uniqueModels.length - models.length,
})

return NextResponse.json(providerModelsResponseSchema.parse({ models, modelInfo: {} }))
} catch (error) {
logger.error('Error fetching OrcaRouter models', {
error: getErrorMessage(error, 'Unknown error'),
})
return NextResponse.json({ models: [], modelInfo: {} })
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
updateOllamaCloudProviderModels,
updateOllamaProviderModels,
updateOpenRouterProviderModels,
updateOrcaRouterProviderModels,
updateTogetherProviderModels,
updateVLLMProviderModels,
} from '@/providers/utils'
Expand Down Expand Up @@ -45,6 +46,8 @@ function useSyncProvider(provider: ProviderName, workspaceId?: string) {
if (data.modelInfo) {
setOpenRouterModelInfo(data.modelInfo)
}
} else if (provider === 'orcarouter') {
void updateOrcaRouterProviderModels(data.models)
} else if (provider === 'fireworks') {
void updateFireworksProviderModels(data.models)
} else if (provider === 'together') {
Expand Down Expand Up @@ -76,6 +79,7 @@ export function ProviderModelsLoader() {
useSyncProvider('vllm')
useSyncProvider('litellm')
useSyncProvider('openrouter')
useSyncProvider('orcarouter')
useSyncProvider('fireworks', workspaceId)
useSyncProvider('together', workspaceId)
useSyncProvider('baseten', workspaceId)
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/blocks/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const { mockProviders } = vi.hoisted(() => ({
vllm: { models: [] as string[], isLoading: false },
litellm: { models: [] as string[], isLoading: false },
openrouter: { models: [] as string[], isLoading: false },
orcarouter: { models: [] as string[], isLoading: false },
fireworks: { models: [] as string[], isLoading: false },
},
},
Expand Down Expand Up @@ -109,6 +110,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => {
vllm: { models: [], isLoading: false },
litellm: { models: [], isLoading: false },
openrouter: { models: [], isLoading: false },
orcarouter: { models: [], isLoading: false },
fireworks: { models: [], isLoading: false },
}
mockGetHostedModels.mockReturnValue([])
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/blocks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function getModelOptions() {
const vllmModels = providersState.providers.vllm.models
const litellmModels = providersState.providers.litellm.models
const openrouterModels = providersState.providers.openrouter.models
const orcarouterModels = providersState.providers.orcarouter.models
const fireworksModels = providersState.providers.fireworks.models
const togetherModels = providersState.providers.together.models
const basetenModels = providersState.providers.baseten.models
Expand All @@ -73,6 +74,7 @@ export function getModelOptions() {
...vllmModels,
...litellmModels,
...openrouterModels,
...orcarouterModels,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot mock missing provider

Medium Severity

getModelOptions now reads providers.orcarouter.models, but the copilot metadata fallback mock still omits orcarouter. When that mock replaces the store, option resolution throws, is caught, and Agent model options come back empty for copilot.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3cafb60. Configure here.

...fireworksModels,
...togetherModels,
...basetenModels,
Expand Down
16 changes: 16 additions & 0 deletions apps/sim/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5135,6 +5135,22 @@ export function TogetherIcon(props: SVGProps<SVGSVGElement>) {
)
}

export function OrcaRouterIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
fill='currentColor'
fillRule='evenodd'
height='1em'
viewBox='0 0 24 24'
width='1em'
xmlns='http://www.w3.org/2000/svg'
>
<path d='M4 4h6.5v6.5H4V4zm9.5 0H20v6.5h-6.5V4zM4 13.5h6.5V20H4v-6.5zm9.5 0H20V20h-6.5v-6.5zM5.5 5.5v3.5h3.5V5.5H5.5zM15 5.5V9h3.5V5.5H15zM5.5 15v3.5H9V15H5.5zM15 15v3.5h3.5V15H15z' />
</svg>
)
}

export function BasetenIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/hooks/queries/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getOllamaProviderModelsContract,
getOpenRouterEmbeddingModelsContract,
getOpenRouterProviderModelsContract,
getOrcaRouterProviderModelsContract,
getTogetherProviderModelsContract,
getVllmProviderModelsContract,
type ProviderModelsResponse,
Expand Down Expand Up @@ -73,6 +74,8 @@ async function requestProviderModels(
return requestJson(getLitellmProviderModelsContract, { signal })
case 'openrouter':
return requestJson(getOpenRouterProviderModelsContract, { signal })
case 'orcarouter':
return requestJson(getOrcaRouterProviderModelsContract, { signal })
case 'openrouter-embeddings':
return requestJson(getOpenRouterEmbeddingModelsContract, { signal })
case 'fireworks':
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/lib/api-key/byok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,27 @@ export async function getApiKeyWithBYOK(
return { apiKey: PROVIDER_PLACEHOLDER_KEY, isBYOK: false }
}

const isOrcaRouterModel =
provider === 'orcarouter' ||
useProvidersStore.getState().providers.orcarouter.models.includes(model)
if (isOrcaRouterModel) {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'orcarouter')
if (byokResult) {
logger.info('Using BYOK key for OrcaRouter', {
model,
workspaceId,
scope: byokResult.scope,
})
return byokResult
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
throw new Error(`API key is required for OrcaRouter ${model}`)
}
Comment on lines +325 to +344

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Environment key fallback is skipped

When a self-hosted deployment configures ORCAROUTER_API_KEY without a workspace BYOK or per-block key, this branch throws instead of using the documented environment fallback, causing every OrcaRouter inference request to fail with an API-key-required error.

Suggested change
const isOrcaRouterModel =
provider === 'orcarouter' ||
useProvidersStore.getState().providers.orcarouter.models.includes(model)
if (isOrcaRouterModel) {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'orcarouter')
if (byokResult) {
logger.info('Using BYOK key for OrcaRouter', {
model,
workspaceId,
scope: byokResult.scope,
})
return byokResult
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
throw new Error(`API key is required for OrcaRouter ${model}`)
}
const isOrcaRouterModel =
provider === 'orcarouter' ||
useProvidersStore.getState().providers.orcarouter.models.includes(model)
if (isOrcaRouterModel) {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'orcarouter')
if (byokResult) {
logger.info('Using BYOK key for OrcaRouter', {
model,
workspaceId,
scope: byokResult.scope,
})
return byokResult
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
if (env.ORCAROUTER_API_KEY) {
return { apiKey: env.ORCAROUTER_API_KEY, isBYOK: false }
}
throw new Error(`API key is required for OrcaRouter ${model}`)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Env API key never used

Medium Severity

ORCAROUTER_API_KEY is documented as a self-hosted fallback for inference, and similar BYOK resellers (together, baseten, fireworks) fall back to their env keys after BYOK and the block key. The new OrcaRouter branch only tries BYOK then the user-provided key, then throws, so a configured env key alone never authorizes requests.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3cafb60. Configure here.


if (provider === 'azure-openai') {
return { apiKey: userProvidedKey || env.AZURE_OPENAI_API_KEY || '', isBYOK: false }
}
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/api/contracts/byok-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const byokProviderIdSchema = z.enum([
'together',
'baseten',
'ollama-cloud',
'orcarouter',
'falai',
'firecrawl',
'exa',
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/lib/api/contracts/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,15 @@ export const getOpenRouterProviderModelsContract = defineRouteContract({
},
})

export const getOrcaRouterProviderModelsContract = defineRouteContract({
method: 'GET',
path: '/api/providers/orcarouter/models',
response: {
mode: 'json',
schema: providerModelsResponseSchema,
},
})

export const getOpenRouterEmbeddingModelsContract = defineRouteContract({
method: 'GET',
path: '/api/providers/openrouter/embeddings/models',
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ export const env = createEnv({
OPENAI_API_KEY_2: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
OPENAI_API_KEY_3: z.string().min(1).optional(), // Additional OpenAI API key for load balancing
OPENROUTER_API_KEY: z.string().min(1).optional(), // OpenRouter API key; self-hosted fallback for OpenAI knowledge-base embeddings
ORCAROUTER_API_KEY: z.string().min(1).optional(), // OrcaRouter API key; optional self-hosted fallback for model listing and inference
MISTRAL_API_KEY: z.string().min(1).optional(), // Mistral AI API key
ANTHROPIC_API_KEY_1: z.string().min(1).optional(), // Primary Anthropic Claude API key
ANTHROPIC_API_KEY_2: z.string().min(1).optional(), // Additional Anthropic API key for load balancing
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/providers/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type AttachmentProvider =
| 'google'
| 'bedrock'
| 'openrouter'
| 'orcarouter'
| 'mistral'
| 'groq'
| 'fireworks'
Expand Down Expand Up @@ -164,6 +165,7 @@ const PROVIDER_SUPPORTED_LABELS: Record<AttachmentProvider, string> = {
google: 'images, audio, video, PDFs, and text documents through Gemini inlineData',
bedrock: 'Bedrock Converse image, document, and video content blocks',
openrouter: 'images and PDFs through OpenRouter multimodal message parts',
orcarouter: 'images and PDFs through OrcaRouter multimodal message parts',
mistral: 'images through image_url message parts',
groq: 'images through image_url message parts on multimodal models',
fireworks: 'images through image_url message parts on vision models',
Expand All @@ -188,6 +190,7 @@ export function getAttachmentProvider(providerId: ProviderId | string): Attachme
if (providerId === 'google' || providerId === 'vertex') return 'google'
if (providerId === 'bedrock') return 'bedrock'
if (providerId === 'openrouter') return 'openrouter'
if (providerId === 'orcarouter') return 'orcarouter'
if (providerId === 'mistral') return 'mistral'
if (providerId === 'groq') return 'groq'
if (providerId === 'fireworks') return 'fireworks'
Expand Down Expand Up @@ -306,7 +309,8 @@ export function isProviderAttachmentFilenameModelBound(
providerId === 'openai' ||
provider === 'anthropic' ||
provider === 'bedrock' ||
provider === 'openrouter'
provider === 'openrouter' ||
provider === 'orcarouter'
)
}

Expand Down Expand Up @@ -401,6 +405,7 @@ function isMimeTypeSupportedByProvider(
(contentType === 'video' && BEDROCK_VIDEO_FORMATS.has(extension))
)
case 'openrouter':
case 'orcarouter':

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PDF attachments formatted incorrectly

High Severity

orcarouter is registered for image and PDF attachments like OpenRouter, but formatMessagesForProvider only routes OpenRouter through buildOpenRouterMessageContent. OrcaRouter falls through to buildOpenAICompatibleChatContent, which sends every attachment as image_url, so PDFs never become file parts and multimodal document runs fail or misbehave.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3cafb60. Configure here.

return isImageMimeType(mimeType) || mimeType === PDF_MIME_TYPE
case 'mistral':
case 'groq':
Expand Down
1 change: 1 addition & 0 deletions apps/sim/providers/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const DYNAMIC_PROVIDERS = new Set([
'vllm',
'litellm',
'openrouter',
'orcarouter',
'fireworks',
'together',
'baseten',
Expand Down
30 changes: 30 additions & 0 deletions apps/sim/providers/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
OllamaIcon,
OpenAIIcon,
OpenRouterIcon,
OrcaRouterIcon,
SakanaIcon,
TogetherIcon,
VertexIcon,
Expand Down Expand Up @@ -269,6 +270,22 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
contextInformationAvailable: false,
models: [],
},
orcarouter: {
id: 'orcarouter',
fileAttachment: { maxBytes: 50 * 1024 * 1024, strategy: 'remote-url' },
name: 'OrcaRouter',
description: 'Unified access to many models via OrcaRouter',
defaultModel: '',
modelPatterns: [/^orcarouter\//],
icon: OrcaRouterIcon,
isReseller: true,
capabilities: {
temperature: { min: 0, max: 2 },
toolUsageControl: true,
},
contextInformationAvailable: false,
models: [],
},
'ollama-cloud': {
id: 'ollama-cloud',
name: 'Ollama Cloud',
Expand Down Expand Up @@ -4038,6 +4055,7 @@ export const DYNAMIC_MODEL_PROVIDERS = [
'vllm',
'litellm',
'openrouter',
'orcarouter',
'fireworks',
'together',
'baseten',
Expand Down Expand Up @@ -4405,6 +4423,18 @@ export function updateOpenRouterModels(models: string[]): void {
}))
}

export function updateOrcaRouterModels(models: string[]): void {
PROVIDER_DEFINITIONS.orcarouter.models = models.map((modelId) => ({
id: modelId,
pricing: {
input: 0,
output: 0,
updatedAt: new Date().toISOString().split('T')[0],
},
capabilities: {},
}))
}

export const EMBEDDING_MODEL_PRICING: Record<string, ModelPricing> = {
'text-embedding-3-small': {
input: 0.02, // $0.02 per 1M tokens
Expand Down
Loading