Compare commits

...

1 Commits

Author SHA1 Message Date
Siddharth Ganesan
59cb2f0d2d Copilot enterprise models 2026-02-10 10:49:27 -08:00
12 changed files with 214 additions and 92 deletions

View File

@@ -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),

View File

@@ -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<string, string> = {
'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 }
)
}
}

View File

@@ -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 <IconComponent className='h-3.5 w-3.5' />
const PROVIDER_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
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<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(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 (
<span className='flex-shrink-0'>
<IconComponent className='h-3 w-3' />
@@ -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 <IconComponent className='h-3.5 w-3.5' />
}
const handleSelect = (modelValue: string) => {
onModelSelect(modelValue)
setOpen(false)
@@ -124,16 +160,20 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PopoverScrollArea className='space-y-[2px]'>
{MODEL_OPTIONS.map((option) => (
<PopoverItem
key={option.value}
active={selectedModel === option.value}
onClick={() => handleSelect(option.value)}
>
{getModelIconComponent(option.value)}
<span>{option.label}</span>
</PopoverItem>
))}
{modelOptions.length > 0 ? (
modelOptions.map((option) => (
<PopoverItem
key={option.value}
active={selectedModel === option.value}
onClick={() => handleSelect(option.value)}
>
{getModelIconComponent(option.value)}
<span>{option.label}</span>
</PopoverItem>
))
) : (
<div className='px-2 py-2 text-xs text-[var(--text-muted)]'>No models available</div>
)}
</PopoverScrollArea>
</PopoverContent>
</Popover>

View File

@@ -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)
*/

View File

@@ -112,6 +112,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
closePlanTodos,
clearPlanArtifact,
savePlanArtifact,
loadAvailableModels,
loadAutoAllowedTools,
resumeActiveStream,
} = useCopilotStore()
@@ -123,6 +124,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
chatsLoadedForWorkflow,
setCopilotWorkflowId,
loadChats,
loadAvailableModels,
loadAutoAllowedTools,
currentChat,
isSendingMessage,

View File

@@ -11,6 +11,7 @@ interface UseCopilotInitializationProps {
chatsLoadedForWorkflow: string | null
setCopilotWorkflowId: (workflowId: string | null) => Promise<void>
loadChats: (forceRefresh?: boolean) => Promise<void>
loadAvailableModels: () => Promise<void>
loadAutoAllowedTools: () => Promise<void>
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,
}

View File

@@ -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<string, 'azure-openai' | 'vertex'>,
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<Record<string, unknown>> {
@@ -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 } : {}),

View File

@@ -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'

View File

@@ -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]

View File

@@ -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 =

View File

@@ -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<CopilotStore>()(
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<CopilotStore>()(
},
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 {

View File

@@ -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<void>
setAgentPrefetch: (prefetch: boolean) => void
setEnabledModels: (models: string[] | null) => void
loadAvailableModels: () => Promise<void>
setWorkflowId: (workflowId: string | null) => Promise<void>
validateCurrentChat: () => boolean