From 59cb2f0d2db9238e724e0389e88192f471a2c83e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 10 Feb 2026 10:49:27 -0800 Subject: [PATCH] Copilot enterprise models --- apps/sim/app/api/copilot/chat/route.ts | 4 +- apps/sim/app/api/copilot/models/route.ts | 68 ++++++++++++++ .../model-selector/model-selector.tsx | 92 +++++++++++++------ .../components/user-input/constants.ts | 13 --- .../panel/components/copilot/copilot.tsx | 2 + .../hooks/use-copilot-initialization.ts | 13 +++ apps/sim/lib/copilot/chat-payload.ts | 50 ---------- apps/sim/lib/copilot/constants.ts | 3 + apps/sim/lib/copilot/models.ts | 2 +- apps/sim/lib/copilot/types.ts | 6 ++ apps/sim/stores/panel/copilot/store.ts | 49 ++++++++++ apps/sim/stores/panel/copilot/types.ts | 4 + 12 files changed, 214 insertions(+), 92 deletions(-) create mode 100644 apps/sim/app/api/copilot/models/route.ts diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 248298348..3fca3e32a 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -10,7 +10,7 @@ import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload' import { generateChatTitle } from '@/lib/copilot/chat-title' import { getCopilotModel } from '@/lib/copilot/config' -import { COPILOT_MODEL_IDS, COPILOT_REQUEST_MODES } from '@/lib/copilot/models' +import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models' import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' import { createStreamEventWriter, @@ -43,7 +43,7 @@ const ChatMessageSchema = z.object({ chatId: z.string().optional(), workflowId: z.string().optional(), workflowName: z.string().optional(), - model: z.enum(COPILOT_MODEL_IDS).optional().default('claude-4.6-opus'), + model: z.string().optional().default('claude-4.6-opus'), mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'), prefetch: z.boolean().optional(), createNewChat: z.boolean().optional().default(false), diff --git a/apps/sim/app/api/copilot/models/route.ts b/apps/sim/app/api/copilot/models/route.ts new file mode 100644 index 000000000..dfd471ff5 --- /dev/null +++ b/apps/sim/app/api/copilot/models/route.ts @@ -0,0 +1,68 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' +import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers' +import { env } from '@/lib/core/config/env' +import type { AvailableModel } from '@/lib/copilot/types' + +const logger = createLogger('CopilotModelsAPI') + +export async function GET(_req: NextRequest) { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const headers: Record = { + 'Content-Type': 'application/json', + } + if (env.COPILOT_API_KEY) { + headers['x-api-key'] = env.COPILOT_API_KEY + } + + try { + const response = await fetch(`${SIM_AGENT_API_URL}/api/get-available-models`, { + method: 'GET', + headers, + cache: 'no-store', + }) + + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + logger.warn('Failed to fetch available models from copilot backend', { + status: response.status, + }) + return NextResponse.json( + { + success: false, + error: payload?.error || 'Failed to fetch available models', + models: [], + }, + { status: response.status } + ) + } + + const rawModels = Array.isArray(payload?.models) ? payload.models : [] + const models: AvailableModel[] = rawModels + .filter((item: any) => item && typeof item.id === 'string') + .map((item: any) => ({ + id: item.id, + friendlyName: item.friendlyName || item.displayName || item.id, + provider: item.provider || 'unknown', + })) + + return NextResponse.json({ success: true, models }) + } catch (error) { + logger.error('Error fetching available models', { + error: error instanceof Error ? error.message : String(error), + }) + return NextResponse.json( + { + success: false, + error: 'Failed to fetch available models', + models: [], + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/model-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/model-selector.tsx index 7c639ed01..5eaf1af69 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/model-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/model-selector.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Badge, Popover, @@ -9,8 +9,14 @@ import { PopoverItem, PopoverScrollArea, } from '@/components/emcn' -import { getProviderIcon } from '@/providers/utils' -import { MODEL_OPTIONS } from '../../constants' +import { + AnthropicIcon, + AzureIcon, + BedrockIcon, + GeminiIcon, + OpenAIIcon, +} from '@/components/icons' +import { useCopilotStore } from '@/stores/panel' interface ModelSelectorProps { /** Currently selected model */ @@ -22,14 +28,22 @@ interface ModelSelectorProps { } /** - * Gets the appropriate icon component for a model + * Map a provider string (from the available-models API) to its icon component. + * Falls back to null when the provider is unrecognised. */ -function getModelIconComponent(modelValue: string) { - const IconComponent = getProviderIcon(modelValue) - if (!IconComponent) { - return null - } - return +const PROVIDER_ICON_MAP: Record> = { + anthropic: AnthropicIcon, + openai: OpenAIIcon, + gemini: GeminiIcon, + google: GeminiIcon, + bedrock: BedrockIcon, + azure: AzureIcon, + 'azure-openai': AzureIcon, + 'azure-anthropic': AzureIcon, +} + +function getIconForProvider(provider: string): React.ComponentType<{ className?: string }> | null { + return PROVIDER_ICON_MAP[provider] ?? null } /** @@ -43,17 +57,31 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model const [open, setOpen] = useState(false) const triggerRef = useRef(null) const popoverRef = useRef(null) + const availableModels = useCopilotStore((state) => state.availableModels) + + const modelOptions = useMemo(() => { + return availableModels.map((model) => ({ + value: model.id, + label: model.friendlyName || model.id, + provider: model.provider, + })) + }, [availableModels]) + + /** Look up the provider for a model id from the available-models list */ + const getProviderForModel = (modelId: string): string | undefined => { + return availableModels.find((m) => m.id === modelId)?.provider + } const getCollapsedModeLabel = () => { - const model = MODEL_OPTIONS.find((m) => m.value === selectedModel) - return model ? model.label : 'claude-4.5-sonnet' + const model = modelOptions.find((m) => m.value === selectedModel) + return model?.label || selectedModel || 'No models available' } const getModelIcon = () => { - const IconComponent = getProviderIcon(selectedModel) - if (!IconComponent) { - return null - } + const provider = getProviderForModel(selectedModel) + if (!provider) return null + const IconComponent = getIconForProvider(provider) + if (!IconComponent) return null return ( @@ -61,6 +89,14 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model ) } + const getModelIconComponent = (modelValue: string) => { + const provider = getProviderForModel(modelValue) + if (!provider) return null + const IconComponent = getIconForProvider(provider) + if (!IconComponent) return null + return + } + const handleSelect = (modelValue: string) => { onModelSelect(modelValue) setOpen(false) @@ -124,16 +160,20 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model onCloseAutoFocus={(e) => e.preventDefault()} > - {MODEL_OPTIONS.map((option) => ( - handleSelect(option.value)} - > - {getModelIconComponent(option.value)} - {option.label} - - ))} + {modelOptions.length > 0 ? ( + modelOptions.map((option) => ( + handleSelect(option.value)} + > + {getModelIconComponent(option.value)} + {option.label} + + )) + ) : ( +
No models available
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts index faff318f9..89173e92b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts @@ -242,19 +242,6 @@ export function getCommandDisplayLabel(commandId: string): string { return command?.label || commandId.charAt(0).toUpperCase() + commandId.slice(1) } -/** - * Model configuration options - */ -export const MODEL_OPTIONS = [ - { value: 'claude-4.6-opus', label: 'Claude 4.6 Opus' }, - { value: 'claude-4.5-opus', label: 'Claude 4.5 Opus' }, - { value: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet' }, - { value: 'claude-4.5-haiku', label: 'Claude 4.5 Haiku' }, - { value: 'gpt-5.2-codex', label: 'GPT 5.2 Codex' }, - { value: 'gpt-5.2-pro', label: 'GPT 5.2 Pro' }, - { value: 'gemini-3-pro', label: 'Gemini 3 Pro' }, -] as const - /** * Threshold for considering input "near top" of viewport (in pixels) */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 39e2a0095..18222f8df 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -112,6 +112,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref closePlanTodos, clearPlanArtifact, savePlanArtifact, + loadAvailableModels, loadAutoAllowedTools, resumeActiveStream, } = useCopilotStore() @@ -123,6 +124,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref chatsLoadedForWorkflow, setCopilotWorkflowId, loadChats, + loadAvailableModels, loadAutoAllowedTools, currentChat, isSendingMessage, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks/use-copilot-initialization.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks/use-copilot-initialization.ts index 1ffe80216..d82c4a83b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks/use-copilot-initialization.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks/use-copilot-initialization.ts @@ -11,6 +11,7 @@ interface UseCopilotInitializationProps { chatsLoadedForWorkflow: string | null setCopilotWorkflowId: (workflowId: string | null) => Promise loadChats: (forceRefresh?: boolean) => Promise + loadAvailableModels: () => Promise loadAutoAllowedTools: () => Promise currentChat: any isSendingMessage: boolean @@ -30,6 +31,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) { chatsLoadedForWorkflow, setCopilotWorkflowId, loadChats, + loadAvailableModels, loadAutoAllowedTools, currentChat, isSendingMessage, @@ -129,6 +131,17 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) { } }, [loadAutoAllowedTools]) + /** Load available models once on mount */ + const hasLoadedModelsRef = useRef(false) + useEffect(() => { + if (!hasLoadedModelsRef.current) { + hasLoadedModelsRef.current = true + loadAvailableModels().catch((err) => { + logger.warn('[Copilot] Failed to load available models', err) + }) + } + }, [loadAvailableModels]) + return { isInitialized, } diff --git a/apps/sim/lib/copilot/chat-payload.ts b/apps/sim/lib/copilot/chat-payload.ts index 54763ee02..c4d92dae0 100644 --- a/apps/sim/lib/copilot/chat-payload.ts +++ b/apps/sim/lib/copilot/chat-payload.ts @@ -1,10 +1,7 @@ import { createLogger } from '@sim/logger' import { processFileAttachments } from '@/lib/copilot/chat-context' -import { getCopilotModel } from '@/lib/copilot/config' import { SIM_AGENT_VERSION } from '@/lib/copilot/constants' import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' -import type { CopilotProviderConfig } from '@/lib/copilot/types' -import { env } from '@/lib/core/config/env' import { tools } from '@/tools/registry' import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils' @@ -46,57 +43,12 @@ interface CredentialsPayload { } } -function buildProviderConfig(selectedModel: string): CopilotProviderConfig | undefined { - const defaults = getCopilotModel('chat') - const envModel = env.COPILOT_MODEL || defaults.model - const providerEnv = env.COPILOT_PROVIDER - - if (!providerEnv) return undefined - - if (providerEnv === 'azure-openai') { - return { - provider: 'azure-openai', - model: envModel, - apiKey: env.AZURE_OPENAI_API_KEY, - apiVersion: 'preview', - endpoint: env.AZURE_OPENAI_ENDPOINT, - } - } - - if (providerEnv === 'azure-anthropic') { - return { - provider: 'azure-anthropic', - model: envModel, - apiKey: env.AZURE_ANTHROPIC_API_KEY, - apiVersion: env.AZURE_ANTHROPIC_API_VERSION, - endpoint: env.AZURE_ANTHROPIC_ENDPOINT, - } - } - - if (providerEnv === 'vertex') { - return { - provider: 'vertex', - model: envModel, - apiKey: env.COPILOT_API_KEY, - vertexProject: env.VERTEX_PROJECT, - vertexLocation: env.VERTEX_LOCATION, - } - } - - return { - provider: providerEnv as Exclude, - model: selectedModel, - apiKey: env.COPILOT_API_KEY, - } as CopilotProviderConfig -} - /** * Build the request payload for the copilot backend. */ export async function buildCopilotRequestPayload( params: BuildPayloadParams, options: { - providerConfig?: CopilotProviderConfig selectedModel: string } ): Promise> { @@ -113,7 +65,6 @@ export async function buildCopilotRequestPayload( } = params const selectedModel = options.selectedModel - const providerConfig = options.providerConfig ?? buildProviderConfig(selectedModel) const effectiveMode = mode === 'agent' ? 'build' : mode const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode @@ -198,7 +149,6 @@ export async function buildCopilotRequestPayload( mode: transportMode, messageId: userMessageId, version: SIM_AGENT_VERSION, - ...(providerConfig ? { provider: providerConfig } : {}), ...(contexts && contexts.length > 0 ? { context: contexts } : {}), ...(chatId ? { chatId } : {}), ...(processedFileContents.length > 0 ? { fileAttachments: processedFileContents } : {}), diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/copilot/constants.ts index f95ec48b3..4bdc08f43 100644 --- a/apps/sim/lib/copilot/constants.ts +++ b/apps/sim/lib/copilot/constants.ts @@ -104,6 +104,9 @@ export const COPILOT_CHECKPOINTS_REVERT_API_PATH = '/api/copilot/checkpoints/rev /** GET/POST/DELETE — manage auto-allowed tools. */ export const COPILOT_AUTO_ALLOWED_TOOLS_API_PATH = '/api/copilot/auto-allowed-tools' +/** GET — fetch dynamically available copilot models. */ +export const COPILOT_MODELS_API_PATH = '/api/copilot/models' + /** GET — fetch user credentials for masking. */ export const COPILOT_CREDENTIALS_API_PATH = '/api/copilot/credentials' diff --git a/apps/sim/lib/copilot/models.ts b/apps/sim/lib/copilot/models.ts index 90d43f1b0..96b03e660 100644 --- a/apps/sim/lib/copilot/models.ts +++ b/apps/sim/lib/copilot/models.ts @@ -24,7 +24,7 @@ export const COPILOT_MODEL_IDS = [ 'gemini-3-pro', ] as const -export type CopilotModelId = (typeof COPILOT_MODEL_IDS)[number] +export type CopilotModelId = string export const COPILOT_MODES = ['ask', 'build', 'plan'] as const export type CopilotMode = (typeof COPILOT_MODES)[number] diff --git a/apps/sim/lib/copilot/types.ts b/apps/sim/lib/copilot/types.ts index b9742f335..302f55064 100644 --- a/apps/sim/lib/copilot/types.ts +++ b/apps/sim/lib/copilot/types.ts @@ -11,6 +11,12 @@ export type NotificationStatus = export type { CopilotToolCall, ToolState } +export interface AvailableModel { + id: string + friendlyName: string + provider: string +} + // Provider configuration for Sim Agent requests. // This type is only for the `provider` field in requests sent to the Sim Agent. export type CopilotProviderConfig = diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 1dd8540ee..01f81fb6d 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -26,6 +26,7 @@ import { COPILOT_CONFIRM_API_PATH, COPILOT_CREDENTIALS_API_PATH, COPILOT_DELETE_CHAT_API_PATH, + COPILOT_MODELS_API_PATH, MAX_RESUME_ATTEMPTS, OPTIMISTIC_TITLE_MAX_LENGTH, QUEUE_PROCESS_DELAY_MS, @@ -41,6 +42,7 @@ import { saveMessageCheckpoint, } from '@/lib/copilot/messages' import type { CopilotTransportMode } from '@/lib/copilot/models' +import type { AvailableModel } from '@/lib/copilot/types' import { parseSSEStream } from '@/lib/copilot/orchestrator/sse-parser' import { abortAllInProgressTools, @@ -913,6 +915,8 @@ const initialState = { selectedModel: 'claude-4.6-opus' as CopilotStore['selectedModel'], agentPrefetch: false, enabledModels: null as string[] | null, // Null means not loaded yet, empty array means all disabled + availableModels: [] as AvailableModel[], + isLoadingModels: false, isCollapsed: false, currentChat: null as CopilotChat | null, chats: [] as CopilotChat[], @@ -979,6 +983,8 @@ export const useCopilotStore = create()( selectedModel: get().selectedModel, agentPrefetch: get().agentPrefetch, enabledModels: get().enabledModels, + availableModels: get().availableModels, + isLoadingModels: get().isLoadingModels, autoAllowedTools: get().autoAllowedTools, autoAllowedToolsLoaded: get().autoAllowedToolsLoaded, }) @@ -2191,6 +2197,49 @@ export const useCopilotStore = create()( }, setAgentPrefetch: (prefetch) => set({ agentPrefetch: prefetch }), setEnabledModels: (models) => set({ enabledModels: models }), + loadAvailableModels: async () => { + set({ isLoadingModels: true }) + try { + const response = await fetch(COPILOT_MODELS_API_PATH, { method: 'GET' }) + if (!response.ok) { + throw new Error(`Failed to fetch available models: ${response.status}`) + } + + const data = await response.json() + const models: unknown[] = Array.isArray(data?.models) ? data.models : [] + + const normalizedModels: AvailableModel[] = models + .filter((model: unknown): model is AvailableModel => { + return ( + typeof model === 'object' && + model !== null && + 'id' in model && + typeof (model as { id: unknown }).id === 'string' + ) + }) + .map((model: AvailableModel) => ({ + id: model.id, + friendlyName: model.friendlyName || model.id, + provider: model.provider || 'unknown', + })) + + const { selectedModel } = get() + const selectedModelExists = normalizedModels.some((model) => model.id === selectedModel) + const nextSelectedModel = + selectedModelExists || normalizedModels.length === 0 ? selectedModel : normalizedModels[0].id + + set({ + availableModels: normalizedModels, + selectedModel: nextSelectedModel as CopilotStore['selectedModel'], + isLoadingModels: false, + }) + } catch (error) { + logger.warn('[Copilot] Failed to load available models', { + error: error instanceof Error ? error.message : String(error), + }) + set({ isLoadingModels: false }) + } + }, loadAutoAllowedTools: async () => { try { diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts index 06b753232..451523711 100644 --- a/apps/sim/stores/panel/copilot/types.ts +++ b/apps/sim/stores/panel/copilot/types.ts @@ -1,4 +1,5 @@ import type { CopilotMode, CopilotModelId } from '@/lib/copilot/models' +import type { AvailableModel } from '@/lib/copilot/types' export type { CopilotMode, CopilotModelId } from '@/lib/copilot/models' @@ -116,6 +117,8 @@ export interface CopilotState { selectedModel: CopilotModelId agentPrefetch: boolean enabledModels: string[] | null // Null means not loaded yet, array of model IDs when loaded + availableModels: AvailableModel[] + isLoadingModels: boolean isCollapsed: boolean currentChat: CopilotChat | null @@ -184,6 +187,7 @@ export interface CopilotActions { setSelectedModel: (model: CopilotStore['selectedModel']) => Promise setAgentPrefetch: (prefetch: boolean) => void setEnabledModels: (models: string[] | null) => void + loadAvailableModels: () => Promise setWorkflowId: (workflowId: string | null) => Promise validateCurrentChat: () => boolean