feat(openrouter): add open router to model block (#1172)

* feat(openrouter): add open router to model block

* improvement(openrouter): streaming fix, temperature fix

* pr comments

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
This commit is contained in:
Vikhyath Mondreti
2025-08-28 18:47:36 -07:00
committed by GitHub
co-authored by waleedlatif1
parent df646256b3
commit 7d62c200fa
15 changed files with 812 additions and 123 deletions
@@ -0,0 +1,46 @@
import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OpenRouterModelsAPI')
export const dynamic = 'force-dynamic'
export async function GET(_request: NextRequest) {
try {
const response = await fetch('https://openrouter.ai/api/v1/models', {
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
})
if (!response.ok) {
logger.warn('Failed to fetch OpenRouter models', {
status: response.status,
statusText: response.statusText,
})
return NextResponse.json({ models: [] })
}
const data = await response.json()
const models = Array.isArray(data?.data)
? Array.from(
new Set(
data.data
.map((m: any) => m?.id)
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0)
.map((id: string) => `openrouter/${id}`)
)
)
: []
logger.info('Successfully fetched OpenRouter models', {
count: models.length,
})
return NextResponse.json({ models })
} catch (error) {
logger.error('Error fetching OpenRouter models', {
error: error instanceof Error ? error.message : 'Unknown error',
})
return NextResponse.json({ models: [] })
}
}
@@ -28,6 +28,7 @@ interface ComboBoxProps {
placeholder?: string
isConnecting: boolean
config: SubBlockConfig
isWide?: boolean
}
export function ComboBox({
@@ -42,6 +43,7 @@ export function ComboBox({
placeholder = 'Type or select an option...',
isConnecting,
config,
isWide = false,
}: ComboBoxProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
const [storeInitialized, setStoreInitialized] = useState(false)
@@ -446,7 +448,12 @@ export function ComboBox({
{/* Dropdown */}
{open && (
<div className='absolute top-full left-0 z-[100] mt-1 w-full min-w-[286px]'>
<div
className={cn(
'absolute top-full left-0 z-[100] mt-1 w-full overflow-visible',
isWide ? 'min-w-[350px]' : 'min-w-[286px]'
)}
>
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
<div
ref={dropdownRef}
@@ -1457,19 +1457,19 @@ export function ToolInput({
{tool.usageControl === 'auto' && (
<span>
{' '}
<span className='font-medium'>Auto:</span> Let the model decide
<span className='font-medium'> Auto:</span> Let the model decide
when to use the tool
</span>
)}
{tool.usageControl === 'force' && (
<span>
<span className='font-medium'>Force:</span> Always use this tool
<span className='font-medium'> Force:</span> Always use this tool
in the response
</span>
)}
{tool.usageControl === 'none' && (
<span>
<span className='font-medium'>Deny:</span> Never use this tool
<span className='font-medium'> Deny:</span> Never use this tool
</span>
)}
</p>
@@ -45,6 +45,7 @@ interface SubBlockProps {
disabled?: boolean
fieldDiffStatus?: FieldDiffStatus
allowExpandInPreview?: boolean
isWide?: boolean
}
export function SubBlock({
@@ -56,6 +57,7 @@ export function SubBlock({
disabled = false,
fieldDiffStatus,
allowExpandInPreview,
isWide = false,
}: SubBlockProps) {
const [isValidJson, setIsValidJson] = useState(true)
@@ -148,6 +150,7 @@ export function SubBlock({
disabled={isDisabled}
isConnecting={isConnecting}
config={config}
isWide={isWide}
/>
</div>
)
@@ -995,6 +995,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
: undefined
}
allowExpandInPreview={currentWorkflow.isDiffMode}
isWide={displayIsWide}
/>
</div>
))}
+21 -30
View File
@@ -6,13 +6,12 @@ import {
getAllModelProviders,
getBaseModelProviders,
getHostedModels,
getMaxTemperature,
getProviderIcon,
MODELS_TEMP_RANGE_0_1,
MODELS_TEMP_RANGE_0_2,
MODELS_WITH_REASONING_EFFORT,
MODELS_WITH_TEMPERATURE_SUPPORT,
MODELS_WITH_VERBOSITY,
providers,
supportsTemperature,
} from '@/providers/utils'
// Get current Ollama models dynamically
@@ -21,6 +20,7 @@ const getCurrentOllamaModels = () => {
}
import { useOllamaStore } from '@/stores/ollama/store'
import { useOpenRouterStore } from '@/stores/openrouter/store'
import type { ToolResponse } from '@/tools/types'
const logger = createLogger('AgentBlock')
@@ -159,8 +159,9 @@ Create a system prompt appropriately detailed for the request, using clear langu
required: true,
options: () => {
const ollamaModels = useOllamaStore.getState().models
const openrouterModels = useOpenRouterStore.getState().models
const baseModels = Object.keys(getBaseModelProviders())
const allModels = [...baseModels, ...ollamaModels]
const allModels = Array.from(new Set([...baseModels, ...ollamaModels, ...openrouterModels]))
return allModels.map((model) => {
const icon = getProviderIcon(model)
@@ -175,10 +176,15 @@ Create a system prompt appropriately detailed for the request, using clear langu
layout: 'half',
min: 0,
max: 1,
condition: {
condition: () => ({
field: 'model',
value: MODELS_TEMP_RANGE_0_1,
},
value: (() => {
const allModels = Object.keys(getAllModelProviders())
return allModels.filter(
(model) => supportsTemperature(model) && getMaxTemperature(model) === 1
)
})(),
}),
},
{
id: 'temperature',
@@ -187,30 +193,15 @@ Create a system prompt appropriately detailed for the request, using clear langu
layout: 'half',
min: 0,
max: 2,
condition: {
condition: () => ({
field: 'model',
value: MODELS_TEMP_RANGE_0_2,
},
},
{
id: 'temperature',
title: 'Temperature',
type: 'slider',
layout: 'full',
min: 0,
max: 2,
condition: {
field: 'model',
value: [...MODELS_TEMP_RANGE_0_1, ...MODELS_TEMP_RANGE_0_2],
not: true,
and: {
field: 'model',
value: Object.keys(getBaseModelProviders()).filter(
(model) => !MODELS_WITH_TEMPERATURE_SUPPORT.includes(model)
),
not: true,
},
},
value: (() => {
const allModels = Object.keys(getAllModelProviders())
return allModels.filter(
(model) => supportsTemperature(model) && getMaxTemperature(model) === 2
)
})(),
}),
},
{
id: 'reasoningEffort',
+16
View File
@@ -3436,3 +3436,19 @@ export function MySQLIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function OpenRouterIcon(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='M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z' />
</svg>
)
}
+18 -1
View File
@@ -5,6 +5,7 @@ import {
calculateCost,
generateStructuredOutputInstructions,
getProvider,
shouldBillModelUsage,
supportsTemperature,
} from '@/providers/utils'
@@ -85,7 +86,23 @@ export async function executeProviderRequest(
const { prompt: promptTokens = 0, completion: completionTokens = 0 } = response.tokens
const useCachedInput = !!request.context && request.context.length > 0
response.cost = calculateCost(response.model, promptTokens, completionTokens, useCachedInput)
if (shouldBillModelUsage(response.model, request.apiKey)) {
response.cost = calculateCost(response.model, promptTokens, completionTokens, useCachedInput)
} else {
response.cost = {
input: 0,
output: 0,
total: 0,
pricing: {
input: 0,
output: 0,
updatedAt: new Date().toISOString(),
},
}
logger.debug(
`Not billing model usage for ${response.model} - user provided API key or not hosted model`
)
}
}
return response
+90 -85
View File
@@ -17,6 +17,7 @@ import {
GroqIcon,
OllamaIcon,
OpenAIIcon,
OpenRouterIcon,
xAIIcon,
} from '@/components/icons'
@@ -56,12 +57,26 @@ export interface ProviderDefinition {
defaultModel: string
modelPatterns?: RegExp[]
icon?: React.ComponentType<{ className?: string }>
capabilities?: ModelCapabilities
}
/**
* Comprehensive provider definitions, single source of truth
*/
export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
openrouter: {
id: 'openrouter',
name: 'OpenRouter',
description: 'Unified access to many models via OpenRouter',
defaultModel: '',
modelPatterns: [/^openrouter\//],
icon: OpenRouterIcon,
capabilities: {
temperature: { min: 0, max: 2 },
toolUsageControl: true,
},
models: [],
},
openai: {
id: 'openai',
name: 'OpenAI',
@@ -69,6 +84,9 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
defaultModel: 'gpt-4o',
modelPatterns: [/^gpt/, /^o1/],
icon: OpenAIIcon,
capabilities: {
toolUsageControl: true,
},
models: [
{
id: 'gpt-4o',
@@ -80,7 +98,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
toolUsageControl: true,
},
},
{
@@ -92,7 +109,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-08-07',
},
capabilities: {
toolUsageControl: true,
reasoningEffort: {
values: ['minimal', 'low', 'medium', 'high'],
},
@@ -110,7 +126,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-08-07',
},
capabilities: {
toolUsageControl: true,
reasoningEffort: {
values: ['minimal', 'low', 'medium', 'high'],
},
@@ -128,7 +143,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-08-07',
},
capabilities: {
toolUsageControl: true,
reasoningEffort: {
values: ['minimal', 'low', 'medium', 'high'],
},
@@ -145,9 +159,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 10.0,
updatedAt: '2025-08-07',
},
capabilities: {
toolUsageControl: true,
},
capabilities: {},
},
{
id: 'o1',
@@ -157,9 +169,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 60,
updatedAt: '2025-06-17',
},
capabilities: {
toolUsageControl: true,
},
capabilities: {},
},
{
id: 'o3',
@@ -169,9 +179,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 8,
updatedAt: '2025-06-17',
},
capabilities: {
toolUsageControl: true,
},
capabilities: {},
},
{
id: 'o4-mini',
@@ -181,9 +189,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 4.4,
updatedAt: '2025-06-17',
},
capabilities: {
toolUsageControl: true,
},
capabilities: {},
},
{
id: 'gpt-4.1',
@@ -195,7 +201,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
toolUsageControl: true,
},
},
{
@@ -208,7 +213,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
toolUsageControl: true,
},
},
{
@@ -221,7 +225,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
toolUsageControl: true,
},
},
],
@@ -232,6 +235,9 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
description: 'Microsoft Azure OpenAI Service models',
defaultModel: 'azure/gpt-4o',
modelPatterns: [/^azure\//],
capabilities: {
toolUsageControl: true,
},
icon: AzureIcon,
models: [
{
@@ -244,7 +250,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
toolUsageControl: true,
},
},
{
@@ -256,7 +261,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-08-07',
},
capabilities: {
toolUsageControl: true,
reasoningEffort: {
values: ['minimal', 'low', 'medium', 'high'],
},
@@ -274,7 +278,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-08-07',
},
capabilities: {
toolUsageControl: true,
reasoningEffort: {
values: ['minimal', 'low', 'medium', 'high'],
},
@@ -292,7 +295,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
updatedAt: '2025-08-07',
},
capabilities: {
toolUsageControl: true,
reasoningEffort: {
values: ['minimal', 'low', 'medium', 'high'],
},
@@ -309,9 +311,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 10.0,
updatedAt: '2025-08-07',
},
capabilities: {
toolUsageControl: true,
},
capabilities: {},
},
{
id: 'azure/o3',
@@ -321,9 +321,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 40,
updatedAt: '2025-06-15',
},
capabilities: {
toolUsageControl: true,
},
capabilities: {},
},
{
id: 'azure/o4-mini',
@@ -333,9 +331,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 4.4,
updatedAt: '2025-06-15',
},
capabilities: {
toolUsageControl: true,
},
capabilities: {},
},
{
id: 'azure/gpt-4.1',
@@ -345,9 +341,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 8.0,
updatedAt: '2025-06-15',
},
capabilities: {
toolUsageControl: true,
},
capabilities: {},
},
{
id: 'azure/model-router',
@@ -357,9 +351,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 8.0,
updatedAt: '2025-06-15',
},
capabilities: {
toolUsageControl: true,
},
capabilities: {},
},
],
},
@@ -370,6 +362,9 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
defaultModel: 'claude-sonnet-4-0',
modelPatterns: [/^claude/],
icon: AnthropicIcon,
capabilities: {
toolUsageControl: true,
},
models: [
{
id: 'claude-sonnet-4-0',
@@ -381,7 +376,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
toolUsageControl: true,
},
},
{
@@ -394,7 +388,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
toolUsageControl: true,
},
},
{
@@ -407,7 +400,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
toolUsageControl: true,
computerUse: true,
},
},
@@ -421,7 +413,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
toolUsageControl: true,
computerUse: true,
},
},
@@ -433,6 +424,9 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
description: "Google's Gemini models",
defaultModel: 'gemini-2.5-pro',
modelPatterns: [/^gemini/],
capabilities: {
toolUsageControl: true,
},
icon: GeminiIcon,
models: [
{
@@ -445,7 +439,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
toolUsageControl: true,
},
},
{
@@ -458,7 +451,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
toolUsageControl: true,
},
},
],
@@ -470,6 +462,9 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
defaultModel: 'deepseek-chat',
modelPatterns: [],
icon: DeepseekIcon,
capabilities: {
toolUsageControl: true,
},
models: [
{
id: 'deepseek-chat',
@@ -479,9 +474,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 1.0,
updatedAt: '2025-03-21',
},
capabilities: {
toolUsageControl: true,
},
capabilities: {},
},
{
id: 'deepseek-v3',
@@ -493,7 +486,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 2 },
toolUsageControl: true,
},
},
{
@@ -504,9 +496,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 1.5,
updatedAt: '2025-03-21',
},
capabilities: {
toolUsageControl: true,
},
capabilities: {},
},
],
},
@@ -517,6 +507,9 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
defaultModel: 'grok-4-latest',
modelPatterns: [/^grok/],
icon: xAIIcon,
capabilities: {
toolUsageControl: true,
},
models: [
{
id: 'grok-4-latest',
@@ -528,7 +521,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
toolUsageControl: true,
},
},
{
@@ -541,7 +533,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
toolUsageControl: true,
},
},
{
@@ -554,7 +545,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
capabilities: {
temperature: { min: 0, max: 1 },
toolUsageControl: true,
},
},
],
@@ -566,6 +556,9 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
defaultModel: 'cerebras/llama-3.3-70b',
modelPatterns: [/^cerebras/],
icon: CerebrasIcon,
capabilities: {
toolUsageControl: false,
},
models: [
{
id: 'cerebras/llama-3.3-70b',
@@ -575,9 +568,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 0.94,
updatedAt: '2025-03-21',
},
capabilities: {
toolUsageControl: false,
},
capabilities: {},
},
],
},
@@ -588,6 +579,9 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
defaultModel: 'groq/openai/gpt-oss-120b',
modelPatterns: [/^groq/],
icon: GroqIcon,
capabilities: {
toolUsageControl: false,
},
models: [
{
id: 'groq/openai/gpt-oss-120b',
@@ -597,9 +591,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 0.75,
updatedAt: '2025-08-05',
},
capabilities: {
toolUsageControl: false,
},
capabilities: {},
},
{
id: 'groq/openai/gpt-oss-20b',
@@ -609,9 +601,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 0.25,
updatedAt: '2025-08-05',
},
capabilities: {
toolUsageControl: false,
},
capabilities: {},
},
{
id: 'groq/gemma2-9b-it',
@@ -621,9 +611,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 0.04,
updatedAt: '2025-08-05',
},
capabilities: {
toolUsageControl: false,
},
capabilities: {},
},
{
id: 'groq/llama-3.1-8b-instant',
@@ -633,9 +621,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 0.08,
updatedAt: '2025-08-05',
},
capabilities: {
toolUsageControl: false,
},
capabilities: {},
},
{
id: 'groq/llama-3.3-70b-versatile',
@@ -645,9 +631,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 0.61,
updatedAt: '2025-08-05',
},
capabilities: {
toolUsageControl: false,
},
capabilities: {},
},
{
id: 'groq/meta-llama/llama-guard-4-12b',
@@ -657,9 +641,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 0.2,
updatedAt: '2025-08-05',
},
capabilities: {
toolUsageControl: false,
},
capabilities: {},
},
{
id: 'groq/deepseek-r1-distill-llama-70b',
@@ -669,9 +651,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 0.99,
updatedAt: '2025-08-05',
},
capabilities: {
toolUsageControl: false,
},
capabilities: {},
},
{
id: 'groq/meta-llama/llama-4-maverick-17b-128e-instruct',
@@ -681,9 +661,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
output: 0.6,
updatedAt: '2025-08-05',
},
capabilities: {
toolUsageControl: false,
},
capabilities: {},
},
],
},
@@ -698,8 +676,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
}
// Helper functions to extract information from the comprehensive definitions
/**
* Get all models for a specific provider
*/
@@ -734,9 +710,23 @@ export function getModelCapabilities(modelId: string): ModelCapabilities | null
for (const provider of Object.values(PROVIDER_DEFINITIONS)) {
const model = provider.models.find((m) => m.id.toLowerCase() === modelId.toLowerCase())
if (model) {
return model.capabilities
// Merge provider capabilities with model capabilities, model takes precedence
const capabilities: ModelCapabilities = { ...provider.capabilities, ...model.capabilities }
return capabilities
}
}
// If no model found, check for provider-level capabilities for dynamically fetched models
for (const provider of Object.values(PROVIDER_DEFINITIONS)) {
if (provider.modelPatterns) {
for (const pattern of provider.modelPatterns) {
if (pattern.test(modelId.toLowerCase())) {
return provider.capabilities || null
}
}
}
}
return null
}
@@ -791,7 +781,7 @@ export function getModelsWithTempRange02(): string[] {
export function getProvidersWithToolUsageControl(): string[] {
const providers: string[] = []
for (const [providerId, provider] of Object.entries(PROVIDER_DEFINITIONS)) {
if (provider.models.some((model) => model.capabilities.toolUsageControl)) {
if (provider.capabilities?.toolUsageControl) {
providers.push(providerId)
}
}
@@ -859,6 +849,21 @@ export function updateOllamaModels(models: string[]): void {
}))
}
/**
* Update OpenRouter models dynamically
*/
export function updateOpenRouterModels(models: string[]): void {
PROVIDER_DEFINITIONS.openrouter.models = models.map((modelId) => ({
id: modelId,
pricing: {
input: 0,
output: 0,
updatedAt: new Date().toISOString().split('T')[0],
},
capabilities: {},
}))
}
/**
* Embedding model pricing - separate from chat models
*/
+481
View File
@@ -0,0 +1,481 @@
import OpenAI from 'openai'
import { createLogger } from '@/lib/logs/console/logger'
import type { StreamingExecution } from '@/executor/types'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import type {
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import {
prepareToolExecution,
prepareToolsWithUsageControl,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('OpenRouterProvider')
function createReadableStreamFromOpenAIStream(
openaiStream: any,
onComplete?: (content: string, usage?: any) => void
): ReadableStream {
let fullContent = ''
let usageData: any = null
return new ReadableStream({
async start(controller) {
try {
for await (const chunk of openaiStream) {
if (chunk.usage) {
usageData = chunk.usage
}
const content = chunk.choices[0]?.delta?.content || ''
if (content) {
fullContent += content
controller.enqueue(new TextEncoder().encode(content))
}
}
if (onComplete) {
onComplete(fullContent, usageData)
}
controller.close()
} catch (error) {
controller.error(error)
}
},
})
}
export const openRouterProvider: ProviderConfig = {
id: 'openrouter',
name: 'OpenRouter',
description: 'Unified access to many models via OpenRouter',
version: '1.0.0',
models: getProviderModels('openrouter'),
defaultModel: getProviderDefaultModel('openrouter'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for OpenRouter')
}
const client = new OpenAI({
apiKey: request.apiKey,
baseURL: 'https://openrouter.ai/api/v1',
})
const requestedModel = (request.model || '').replace(/^openrouter\//, '')
logger.info('Preparing OpenRouter request', {
model: requestedModel,
hasSystemPrompt: !!request.systemPrompt,
hasMessages: !!request.messages?.length,
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
stream: !!request.stream,
})
const allMessages = [] as any[]
if (request.systemPrompt) {
allMessages.push({ role: 'system', content: request.systemPrompt })
}
if (request.context) {
allMessages.push({ role: 'user', content: request.context })
}
if (request.messages) {
allMessages.push(...request.messages)
}
const tools = request.tools?.length
? request.tools.map((tool) => ({
type: 'function',
function: {
name: tool.id,
description: tool.description,
parameters: tool.parameters,
},
}))
: undefined
const payload: any = {
model: requestedModel,
messages: allMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens !== undefined) payload.max_tokens = request.maxTokens
if (request.responseFormat) {
payload.response_format = {
type: 'json_schema',
json_schema: {
name: request.responseFormat.name || 'response_schema',
schema: request.responseFormat.schema || request.responseFormat,
strict: request.responseFormat.strict !== false,
},
}
}
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
let hasActiveTools = false
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openrouter')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = toolChoice
hasActiveTools = true
}
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
const streamResponse = await client.chat.completions.create({
...payload,
stream: true,
stream_options: { include_usage: true },
})
const tokenUsage = { prompt: 0, completion: 0, total: 0 }
const streamingResult = {
stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => {
if (usage) {
const newTokens = {
prompt: usage.prompt_tokens || tokenUsage.prompt,
completion: usage.completion_tokens || tokenUsage.completion,
total: usage.total_tokens || tokenUsage.total,
}
streamingResult.execution.output.tokens = newTokens
}
streamingResult.execution.output.content = content
const end = Date.now()
const endISO = new Date(end).toISOString()
if (streamingResult.execution.output.providerTiming) {
streamingResult.execution.output.providerTiming.endTime = endISO
streamingResult.execution.output.providerTiming.duration = end - providerStartTime
if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) {
streamingResult.execution.output.providerTiming.timeSegments[0].endTime = end
streamingResult.execution.output.providerTiming.timeSegments[0].duration =
end - providerStartTime
}
}
}),
execution: {
success: true,
output: {
content: '',
model: requestedModel,
tokens: tokenUsage,
toolCalls: undefined,
providerTiming: {
startTime: providerStartTimeISO,
endTime: new Date().toISOString(),
duration: Date.now() - providerStartTime,
timeSegments: [
{
type: 'model',
name: 'Streaming response',
startTime: providerStartTime,
endTime: Date.now(),
duration: Date.now() - providerStartTime,
},
],
},
},
logs: [],
metadata: {
startTime: providerStartTimeISO,
endTime: new Date().toISOString(),
duration: Date.now() - providerStartTime,
},
},
} as StreamingExecution
return streamingResult as StreamingExecution
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let currentResponse = await client.chat.completions.create(payload)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
prompt: currentResponse.usage?.prompt_tokens || 0,
completion: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = [] as any[]
const toolResults = [] as any[]
const currentMessages = [...allMessages]
let iterationCount = 0
const MAX_ITERATIONS = 10
let modelTime = firstResponseTime
let toolsTime = 0
let hasUsedForcedTool = false
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: 'Initial response',
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
const checkForForcedToolUsage = (
response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }
) => {
if (typeof toolChoice === 'object' && response.choices[0]?.message?.tool_calls) {
const toolCallsResponse = response.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
toolChoice,
logger,
'openrouter',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
}
checkForForcedToolUsage(currentResponse, originalToolChoice)
while (iterationCount < MAX_ITERATIONS) {
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
for (const toolCall of toolCallsInResponse) {
try {
const toolName = toolCall.function.name
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) continue
const toolCallStartTime = Date.now()
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, true)
const toolCallEndTime = Date.now()
const toolCallDuration = toolCallEndTime - toolCallStartTime
timeSegments.push({
type: 'tool',
name: toolName,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallDuration,
})
let resultContent: any
if (result.success) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(toolCallStartTime).toISOString(),
endTime: new Date(toolCallEndTime).toISOString(),
duration: toolCallDuration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: [
{
id: toolCall.id,
type: 'function',
function: {
name: toolName,
arguments: toolCall.function.arguments,
},
},
],
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
} catch (error) {
logger.error('Error processing tool call (OpenRouter):', {
error: error instanceof Error ? error.message : String(error),
toolName: toolCall?.function?.name,
})
}
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload: any = {
...payload,
messages: currentMessages,
}
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = { type: 'function', function: { name: remainingTools[0] } }
} else {
nextPayload.tool_choice = 'auto'
}
}
const nextModelStartTime = Date.now()
currentResponse = await client.chat.completions.create(nextPayload)
checkForForcedToolUsage(currentResponse, nextPayload.tool_choice)
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: `Model response (iteration ${iterationCount + 1})`,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.prompt += currentResponse.usage.prompt_tokens || 0
tokens.completion += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (request.stream && iterationCount > 0) {
const streamingPayload = {
...payload,
messages: currentMessages,
tool_choice: 'auto',
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await client.chat.completions.create(streamingPayload)
const streamingResult = {
stream: createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => {
if (usage) {
const newTokens = {
prompt: usage.prompt_tokens || tokens.prompt,
completion: usage.completion_tokens || tokens.completion,
total: usage.total_tokens || tokens.total,
}
streamingResult.execution.output.tokens = newTokens
}
streamingResult.execution.output.content = content
}),
execution: {
success: true,
output: {
content: '',
model: requestedModel,
tokens: { prompt: tokens.prompt, completion: tokens.completion, total: tokens.total },
toolCalls:
toolCalls.length > 0
? {
list: toolCalls,
count: toolCalls.length,
}
: undefined,
providerTiming: {
startTime: providerStartTimeISO,
endTime: new Date().toISOString(),
duration: Date.now() - providerStartTime,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
},
logs: [],
metadata: {
startTime: providerStartTimeISO,
endTime: new Date().toISOString(),
duration: Date.now() - providerStartTime,
},
},
} as StreamingExecution
return streamingResult as StreamingExecution
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: requestedModel,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in OpenRouter request:', {
error: error instanceof Error ? error.message : String(error),
duration: totalDuration,
})
const enhancedError = new Error(error instanceof Error ? error.message : String(error))
// @ts-ignore
enhancedError.timing = {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
}
throw enhancedError
}
},
}
+1
View File
@@ -10,6 +10,7 @@ export type ProviderId =
| 'cerebras'
| 'groq'
| 'ollama'
| 'openrouter'
/**
* Model pricing information per million tokens
+18 -1
View File
@@ -166,6 +166,15 @@ describe('Model Capabilities', () => {
expect(supportsTemperature('GPT-4O')).toBe(true)
expect(supportsTemperature('claude-sonnet-4-0')).toBe(true)
})
it.concurrent(
'should inherit temperature support from provider for dynamically fetched models',
() => {
// OpenRouter models should inherit temperature support from provider capabilities
expect(supportsTemperature('openrouter/anthropic/claude-3.5-sonnet')).toBe(true)
expect(supportsTemperature('openrouter/openai/gpt-4')).toBe(true)
}
)
})
describe('getMaxTemperature', () => {
@@ -209,7 +218,7 @@ describe('Model Capabilities', () => {
expect(getMaxTemperature('azure/o3')).toBeUndefined()
expect(getMaxTemperature('azure/o4-mini')).toBeUndefined()
expect(getMaxTemperature('deepseek-r1')).toBeUndefined()
// GPT-5 models don't support temperature (removed in our implementation)
// GPT-5 models don't support temperature
expect(getMaxTemperature('gpt-5')).toBeUndefined()
expect(getMaxTemperature('gpt-5-mini')).toBeUndefined()
expect(getMaxTemperature('gpt-5-nano')).toBeUndefined()
@@ -224,6 +233,14 @@ describe('Model Capabilities', () => {
expect(getMaxTemperature('GPT-4O')).toBe(2)
expect(getMaxTemperature('CLAUDE-SONNET-4-0')).toBe(1)
})
it.concurrent(
'should inherit max temperature from provider for dynamically fetched models',
() => {
expect(getMaxTemperature('openrouter/anthropic/claude-3.5-sonnet')).toBe(2)
expect(getMaxTemperature('openrouter/openai/gpt-4')).toBe(2)
}
)
})
describe('supportsToolUsageControl', () => {
+32 -2
View File
@@ -26,6 +26,7 @@ import {
} from '@/providers/models'
import { ollamaProvider } from '@/providers/ollama'
import { openaiProvider } from '@/providers/openai'
import { openRouterProvider } from '@/providers/openrouter'
import type { ProviderConfig, ProviderId, ProviderToolConfig } from '@/providers/types'
import { xAIProvider } from '@/providers/xai'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
@@ -88,6 +89,11 @@ export const providers: Record<
models: getProviderModelsFromDefinitions('azure-openai'),
modelPatterns: PROVIDER_DEFINITIONS['azure-openai'].modelPatterns,
},
openrouter: {
...openRouterProvider,
models: getProviderModelsFromDefinitions('openrouter'),
modelPatterns: PROVIDER_DEFINITIONS.openrouter.modelPatterns,
},
ollama: {
...ollamaProvider,
models: getProviderModelsFromDefinitions('ollama'),
@@ -95,7 +101,6 @@ export const providers: Record<
},
}
// Initialize all providers that have initialize method
Object.entries(providers).forEach(([id, provider]) => {
if (provider.initialize) {
provider.initialize().catch((error) => {
@@ -106,12 +111,17 @@ Object.entries(providers).forEach(([id, provider]) => {
}
})
// Function to update Ollama provider models
export function updateOllamaProviderModels(models: string[]): void {
updateOllamaModelsInDefinitions(models)
providers.ollama.models = getProviderModelsFromDefinitions('ollama')
}
export async function updateOpenRouterProviderModels(models: string[]): Promise<void> {
const { updateOpenRouterModels } = await import('@/providers/models')
updateOpenRouterModels(models)
providers.openrouter.models = getProviderModelsFromDefinitions('openrouter')
}
export function getBaseModelProviders(): Record<string, ProviderId> {
return Object.entries(providers)
.filter(([providerId]) => providerId !== 'ollama')
@@ -537,6 +547,26 @@ export function getHostedModels(): string[] {
return getHostedModelsFromDefinitions()
}
/**
* Determine if model usage should be billed to the user
*
* @param model The model name
* @param userProvidedApiKey Whether the user provided their own API key
* @returns true if the usage should be billed to the user
*/
export function shouldBillModelUsage(model: string, userProvidedApiKey?: string): boolean {
const hostedModels = getHostedModels()
if (!hostedModels.includes(model)) {
return false
}
if (userProvidedApiKey && userProvidedApiKey.trim() !== '') {
return false
}
return true
}
/**
* Get an API key for a specific provider, handling rotation and fallbacks
* For use server-side only
+68
View File
@@ -0,0 +1,68 @@
import { create } from 'zustand'
import { createLogger } from '@/lib/logs/console/logger'
import { updateOpenRouterProviderModels } from '@/providers/utils'
import type { OpenRouterStore } from '@/stores/openrouter/types'
const logger = createLogger('OpenRouterStore')
const fetchOpenRouterModels = async (): Promise<string[]> => {
try {
const response = await fetch('/api/providers/openrouter/models')
if (!response.ok) {
logger.warn('Failed to fetch OpenRouter models from API', {
status: response.status,
statusText: response.statusText,
})
return []
}
const data = await response.json()
return data.models || []
} catch (error) {
logger.error('Error fetching OpenRouter models', {
error: error instanceof Error ? error.message : 'Unknown error',
})
return []
}
}
export const useOpenRouterStore = create<OpenRouterStore>((set, get) => ({
models: [],
isLoading: false,
setModels: (models) => {
const unique = Array.from(new Set(models))
set({ models: unique })
updateOpenRouterProviderModels(models)
},
fetchModels: async () => {
if (typeof window === 'undefined') {
logger.info('Skipping client-side model fetch on server')
return
}
if (get().isLoading) {
logger.info('Model fetch already in progress')
return
}
logger.info('Fetching OpenRouter models from API')
set({ isLoading: true })
try {
const models = await fetchOpenRouterModels()
logger.info('Successfully fetched OpenRouter models', {
count: models.length,
})
get().setModels(models)
} catch (error) {
logger.error('Failed to fetch OpenRouter models', {
error: error instanceof Error ? error.message : 'Unknown error',
})
} finally {
set({ isLoading: false })
}
},
}))
if (typeof window !== 'undefined') {
setTimeout(() => {
useOpenRouterStore.getState().fetchModels()
}, 1000)
}
+6
View File
@@ -0,0 +1,6 @@
export interface OpenRouterStore {
models: string[]
isLoading: boolean
setModels: (models: string[]) => void
fetchModels: () => Promise<void>
}