mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-10 14:45:16 -05:00
Compare commits
2 Commits
feat/copil
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ff7e57f99 | ||
|
|
8902752c17 |
@@ -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_REQUEST_MODES } from '@/lib/copilot/models'
|
||||
import { COPILOT_MODEL_IDS, 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.string().optional().default('claude-4.6-opus'),
|
||||
model: z.enum(COPILOT_MODEL_IDS).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),
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
if (job.status === JOB_STATUS.PROCESSING || job.status === JOB_STATUS.PENDING) {
|
||||
response.estimatedDuration = 180000
|
||||
response.estimatedDuration = 300000
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
|
||||
@@ -59,7 +59,6 @@ export async function POST(
|
||||
checkDeployment: false, // Resuming existing execution, deployment already checked
|
||||
skipUsageLimits: true, // Resume is continuation of authorized execution - don't recheck limits
|
||||
workspaceId: workflow.workspaceId || undefined,
|
||||
isResumeContext: true, // Enable billing fallback for paused workflow resumes
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Popover,
|
||||
@@ -9,14 +9,8 @@ import {
|
||||
PopoverItem,
|
||||
PopoverScrollArea,
|
||||
} from '@/components/emcn'
|
||||
import {
|
||||
AnthropicIcon,
|
||||
AzureIcon,
|
||||
BedrockIcon,
|
||||
GeminiIcon,
|
||||
OpenAIIcon,
|
||||
} from '@/components/icons'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
import { getProviderIcon } from '@/providers/utils'
|
||||
import { MODEL_OPTIONS } from '../../constants'
|
||||
|
||||
interface ModelSelectorProps {
|
||||
/** Currently selected model */
|
||||
@@ -28,22 +22,14 @@ interface ModelSelectorProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a provider string (from the available-models API) to its icon component.
|
||||
* Falls back to null when the provider is unrecognised.
|
||||
* Gets the appropriate icon component for a model
|
||||
*/
|
||||
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
|
||||
function getModelIconComponent(modelValue: string) {
|
||||
const IconComponent = getProviderIcon(modelValue)
|
||||
if (!IconComponent) {
|
||||
return null
|
||||
}
|
||||
return <IconComponent className='h-3.5 w-3.5' />
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,31 +43,17 @@ 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 = modelOptions.find((m) => m.value === selectedModel)
|
||||
return model?.label || selectedModel || 'No models available'
|
||||
const model = MODEL_OPTIONS.find((m) => m.value === selectedModel)
|
||||
return model ? model.label : 'claude-4.5-sonnet'
|
||||
}
|
||||
|
||||
const getModelIcon = () => {
|
||||
const provider = getProviderForModel(selectedModel)
|
||||
if (!provider) return null
|
||||
const IconComponent = getIconForProvider(provider)
|
||||
if (!IconComponent) return null
|
||||
const IconComponent = getProviderIcon(selectedModel)
|
||||
if (!IconComponent) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<span className='flex-shrink-0'>
|
||||
<IconComponent className='h-3 w-3' />
|
||||
@@ -89,14 +61,6 @@ 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)
|
||||
@@ -160,20 +124,16 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<PopoverScrollArea className='space-y-[2px]'>
|
||||
{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>
|
||||
)}
|
||||
{MODEL_OPTIONS.map((option) => (
|
||||
<PopoverItem
|
||||
key={option.value}
|
||||
active={selectedModel === option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
{getModelIconComponent(option.value)}
|
||||
<span>{option.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -242,6 +242,19 @@ 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)
|
||||
*/
|
||||
|
||||
@@ -112,7 +112,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
closePlanTodos,
|
||||
clearPlanArtifact,
|
||||
savePlanArtifact,
|
||||
loadAvailableModels,
|
||||
loadAutoAllowedTools,
|
||||
resumeActiveStream,
|
||||
} = useCopilotStore()
|
||||
@@ -124,7 +123,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
chatsLoadedForWorkflow,
|
||||
setCopilotWorkflowId,
|
||||
loadChats,
|
||||
loadAvailableModels,
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
|
||||
@@ -11,7 +11,6 @@ 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
|
||||
@@ -31,7 +30,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
chatsLoadedForWorkflow,
|
||||
setCopilotWorkflowId,
|
||||
loadChats,
|
||||
loadAvailableModels,
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
@@ -131,17 +129,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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'
|
||||
|
||||
@@ -43,12 +46,57 @@ 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>> {
|
||||
@@ -65,6 +113,7 @@ 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
|
||||
@@ -149,6 +198,7 @@ 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 } : {}),
|
||||
|
||||
@@ -104,9 +104,6 @@ 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'
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export const COPILOT_MODEL_IDS = [
|
||||
'gemini-3-pro',
|
||||
] as const
|
||||
|
||||
export type CopilotModelId = string
|
||||
export type CopilotModelId = (typeof COPILOT_MODEL_IDS)[number]
|
||||
|
||||
export const COPILOT_MODES = ['ask', 'build', 'plan'] as const
|
||||
export type CopilotMode = (typeof COPILOT_MODES)[number]
|
||||
|
||||
@@ -11,12 +11,6 @@ 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 =
|
||||
|
||||
@@ -19,94 +19,6 @@ const BILLING_ERROR_MESSAGES = {
|
||||
BILLING_ERROR_GENERIC: 'Error resolving billing account',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Attempts to resolve billing actor with fallback for resume contexts.
|
||||
* Returns the resolved actor user ID or null if resolution fails and should block execution.
|
||||
*
|
||||
* For resume contexts, this function allows fallback to the workflow owner if workspace
|
||||
* billing cannot be resolved, ensuring users can complete their paused workflows even
|
||||
* if billing configuration changes mid-execution.
|
||||
*
|
||||
* @returns Object containing actorUserId (null if should block) and shouldBlock flag
|
||||
*/
|
||||
async function resolveBillingActorWithFallback(params: {
|
||||
requestId: string
|
||||
workflowId: string
|
||||
workspaceId: string
|
||||
executionId: string
|
||||
triggerType: string
|
||||
workflowRecord: WorkflowRecord
|
||||
userId: string
|
||||
isResumeContext: boolean
|
||||
baseActorUserId: string | null
|
||||
failureReason: 'null' | 'error'
|
||||
error?: unknown
|
||||
loggingSession?: LoggingSession
|
||||
}): Promise<
|
||||
{ actorUserId: string; shouldBlock: false } | { actorUserId: null; shouldBlock: true }
|
||||
> {
|
||||
const {
|
||||
requestId,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
executionId,
|
||||
triggerType,
|
||||
workflowRecord,
|
||||
userId,
|
||||
isResumeContext,
|
||||
baseActorUserId,
|
||||
failureReason,
|
||||
error,
|
||||
loggingSession,
|
||||
} = params
|
||||
|
||||
if (baseActorUserId) {
|
||||
return { actorUserId: baseActorUserId, shouldBlock: false }
|
||||
}
|
||||
|
||||
const workflowOwner = workflowRecord.userId?.trim()
|
||||
if (isResumeContext && workflowOwner) {
|
||||
const logMessage =
|
||||
failureReason === 'null'
|
||||
? '[BILLING_FALLBACK] Workspace billing account is null. Using workflow owner for billing.'
|
||||
: '[BILLING_FALLBACK] Exception during workspace billing resolution. Using workflow owner for billing.'
|
||||
|
||||
logger.warn(`[${requestId}] ${logMessage}`, {
|
||||
workflowId,
|
||||
workspaceId,
|
||||
fallbackUserId: workflowOwner,
|
||||
...(error ? { error } : {}),
|
||||
})
|
||||
|
||||
return { actorUserId: workflowOwner, shouldBlock: false }
|
||||
}
|
||||
|
||||
const fallbackUserId = workflowRecord.userId || userId || 'unknown'
|
||||
const errorMessage =
|
||||
failureReason === 'null'
|
||||
? BILLING_ERROR_MESSAGES.BILLING_REQUIRED
|
||||
: BILLING_ERROR_MESSAGES.BILLING_ERROR_GENERIC
|
||||
|
||||
logger.warn(`[${requestId}] ${errorMessage}`, {
|
||||
workflowId,
|
||||
workspaceId,
|
||||
...(error ? { error } : {}),
|
||||
})
|
||||
|
||||
await logPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: fallbackUserId,
|
||||
workspaceId,
|
||||
errorMessage,
|
||||
loggingSession,
|
||||
})
|
||||
|
||||
return { actorUserId: null, shouldBlock: true }
|
||||
}
|
||||
|
||||
export interface PreprocessExecutionOptions {
|
||||
// Required fields
|
||||
workflowId: string
|
||||
@@ -123,7 +35,7 @@ export interface PreprocessExecutionOptions {
|
||||
// Context information
|
||||
workspaceId?: string // If known, used for billing resolution
|
||||
loggingSession?: LoggingSession // If provided, will be used for error logging
|
||||
isResumeContext?: boolean // If true, allows fallback billing on resolution failure (for paused workflow resumes)
|
||||
isResumeContext?: boolean // Deprecated: no billing fallback is allowed
|
||||
useAuthenticatedUserAsActor?: boolean // If true, use the authenticated userId as actorUserId (for client-side executions and personal API keys)
|
||||
/** @deprecated No longer used - background/async executions always use deployed state */
|
||||
useDraftState?: boolean
|
||||
@@ -170,7 +82,7 @@ export async function preprocessExecution(
|
||||
skipUsageLimits = false,
|
||||
workspaceId: providedWorkspaceId,
|
||||
loggingSession: providedLoggingSession,
|
||||
isResumeContext = false,
|
||||
isResumeContext: _isResumeContext = false,
|
||||
useAuthenticatedUserAsActor = false,
|
||||
} = options
|
||||
|
||||
@@ -274,68 +186,54 @@ export async function preprocessExecution(
|
||||
}
|
||||
|
||||
if (!actorUserId) {
|
||||
actorUserId = workflowRecord.userId || userId
|
||||
logger.info(`[${requestId}] Using workflow owner as actor: ${actorUserId}`)
|
||||
}
|
||||
|
||||
if (!actorUserId) {
|
||||
const result = await resolveBillingActorWithFallback({
|
||||
requestId,
|
||||
const fallbackUserId = userId || workflowRecord.userId || 'unknown'
|
||||
logger.warn(`[${requestId}] ${BILLING_ERROR_MESSAGES.BILLING_REQUIRED}`, {
|
||||
workflowId,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
await logPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
workflowRecord,
|
||||
userId,
|
||||
isResumeContext,
|
||||
baseActorUserId: actorUserId,
|
||||
failureReason: 'null',
|
||||
requestId,
|
||||
userId: fallbackUserId,
|
||||
workspaceId,
|
||||
errorMessage: BILLING_ERROR_MESSAGES.BILLING_REQUIRED,
|
||||
loggingSession: providedLoggingSession,
|
||||
})
|
||||
|
||||
if (result.shouldBlock) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Unable to resolve billing account',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
actorUserId = result.actorUserId
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error resolving billing actor`, { error, workflowId })
|
||||
|
||||
const result = await resolveBillingActorWithFallback({
|
||||
requestId,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
executionId,
|
||||
triggerType,
|
||||
workflowRecord,
|
||||
userId,
|
||||
isResumeContext,
|
||||
baseActorUserId: null,
|
||||
failureReason: 'error',
|
||||
error,
|
||||
loggingSession: providedLoggingSession,
|
||||
})
|
||||
|
||||
if (result.shouldBlock) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Error resolving billing account',
|
||||
message: 'Unable to resolve billing account',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error resolving billing actor`, { error, workflowId })
|
||||
const fallbackUserId = userId || workflowRecord.userId || 'unknown'
|
||||
await logPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: fallbackUserId,
|
||||
workspaceId,
|
||||
errorMessage: BILLING_ERROR_MESSAGES.BILLING_ERROR_GENERIC,
|
||||
loggingSession: providedLoggingSession,
|
||||
})
|
||||
|
||||
actorUserId = result.actorUserId
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Error resolving billing account',
|
||||
statusCode: 500,
|
||||
logCreated: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STEP 4: Get User Subscription ==========
|
||||
|
||||
@@ -14,7 +14,7 @@ import { mistralParserTool } from '@/tools/mistral/parser'
|
||||
const logger = createLogger('DocumentProcessor')
|
||||
|
||||
const TIMEOUTS = {
|
||||
FILE_DOWNLOAD: 180000,
|
||||
FILE_DOWNLOAD: 600000,
|
||||
MISTRAL_OCR_API: 120000,
|
||||
} as const
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ import type {
|
||||
WorkflowExecutionSnapshot,
|
||||
WorkflowState,
|
||||
} from '@/lib/logs/types'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import type { SerializableExecutionState } from '@/executor/execution/types'
|
||||
|
||||
export interface ToolCall {
|
||||
@@ -210,16 +209,15 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
|
||||
logger.debug(`Completing workflow execution ${executionId}`, { isResume })
|
||||
|
||||
// If this is a resume, fetch the existing log to merge data
|
||||
let existingLog: any = null
|
||||
if (isResume) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(workflowExecutionLogs)
|
||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||
.limit(1)
|
||||
existingLog = existing
|
||||
}
|
||||
const [existingLog] = await db
|
||||
.select()
|
||||
.from(workflowExecutionLogs)
|
||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||
.limit(1)
|
||||
const billingUserId = this.extractBillingUserId(existingLog?.executionData)
|
||||
const existingExecutionData = existingLog?.executionData as
|
||||
| { traceSpans?: TraceSpan[] }
|
||||
| undefined
|
||||
|
||||
// Determine if workflow failed by checking trace spans for errors
|
||||
// Use the override if provided (for cost-only fallback scenarios)
|
||||
@@ -244,7 +242,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
const mergedTraceSpans = isResume
|
||||
? traceSpans && traceSpans.length > 0
|
||||
? traceSpans
|
||||
: existingLog?.executionData?.traceSpans || []
|
||||
: existingExecutionData?.traceSpans || []
|
||||
: traceSpans
|
||||
|
||||
const filteredTraceSpans = filterForDisplay(mergedTraceSpans)
|
||||
@@ -329,7 +327,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type'],
|
||||
executionId
|
||||
executionId,
|
||||
billingUserId
|
||||
)
|
||||
|
||||
const limit = before.usageData.limit
|
||||
@@ -367,7 +366,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type'],
|
||||
executionId
|
||||
executionId,
|
||||
billingUserId
|
||||
)
|
||||
|
||||
const percentBefore =
|
||||
@@ -393,7 +393,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type'],
|
||||
executionId
|
||||
executionId,
|
||||
billingUserId
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -401,7 +402,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type'],
|
||||
executionId
|
||||
executionId,
|
||||
billingUserId
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -410,7 +412,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
updatedLog.workflowId,
|
||||
costSummary,
|
||||
updatedLog.trigger as ExecutionTrigger['type'],
|
||||
executionId
|
||||
executionId,
|
||||
billingUserId
|
||||
)
|
||||
} catch {}
|
||||
logger.warn('Usage threshold notification check failed (non-fatal)', { error: e })
|
||||
@@ -472,6 +475,22 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
* Updates user stats with cost and token information
|
||||
* Maintains same logic as original execution logger for billing consistency
|
||||
*/
|
||||
private extractBillingUserId(executionData: unknown): string | null {
|
||||
if (!executionData || typeof executionData !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const environment = (executionData as { environment?: { userId?: unknown } }).environment
|
||||
const userId = environment?.userId
|
||||
|
||||
if (typeof userId !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmedUserId = userId.trim()
|
||||
return trimmedUserId.length > 0 ? trimmedUserId : null
|
||||
}
|
||||
|
||||
private async updateUserStats(
|
||||
workflowId: string | null,
|
||||
costSummary: {
|
||||
@@ -494,7 +513,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
>
|
||||
},
|
||||
trigger: ExecutionTrigger['type'],
|
||||
executionId?: string
|
||||
executionId?: string,
|
||||
billingUserId?: string | null
|
||||
): Promise<void> {
|
||||
if (!isBillingEnabled) {
|
||||
logger.debug('Billing is disabled, skipping user stats cost update')
|
||||
@@ -512,7 +532,6 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the workflow record to get workspace and fallback userId
|
||||
const [workflowRecord] = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
@@ -524,12 +543,16 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
return
|
||||
}
|
||||
|
||||
let billingUserId: string | null = null
|
||||
if (workflowRecord.workspaceId) {
|
||||
billingUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
|
||||
const userId = billingUserId?.trim() || null
|
||||
if (!userId) {
|
||||
logger.error('Missing billing actor in execution context; skipping stats update', {
|
||||
workflowId,
|
||||
trigger,
|
||||
executionId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const userId = billingUserId || workflowRecord.userId
|
||||
const costToStore = costSummary.totalCost
|
||||
|
||||
const existing = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import type { Logger } from '@sim/logger'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
@@ -135,7 +136,10 @@ export async function resolveFileInputToUrl(
|
||||
* For internal URLs, uses direct storage access (server-side only)
|
||||
* For external URLs, validates DNS/SSRF and uses secure fetch with IP pinning
|
||||
*/
|
||||
export async function downloadFileFromUrl(fileUrl: string, timeoutMs = 180000): Promise<Buffer> {
|
||||
export async function downloadFileFromUrl(
|
||||
fileUrl: string,
|
||||
timeoutMs = getMaxExecutionTimeout()
|
||||
): Promise<Buffer> {
|
||||
const { parseInternalFileUrl } = await import('./file-utils')
|
||||
|
||||
if (isInternalFileUrl(fileUrl)) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { Logger } from '@sim/logger'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
|
||||
import { bufferToBase64 } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadFileFromStorage, downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
|
||||
const DEFAULT_MAX_BASE64_BYTES = 10 * 1024 * 1024
|
||||
const DEFAULT_TIMEOUT_MS = 180000
|
||||
const DEFAULT_TIMEOUT_MS = getMaxExecutionTimeout()
|
||||
const DEFAULT_CACHE_TTL_SECONDS = 300
|
||||
const REDIS_KEY_PREFIX = 'user-file:base64:'
|
||||
|
||||
|
||||
@@ -739,7 +739,6 @@ export class PauseResumeManager {
|
||||
skipUsageLimits: true, // Resume is continuation of authorized execution - don't recheck limits
|
||||
workspaceId: baseSnapshot.metadata.workspaceId,
|
||||
loggingSession,
|
||||
isResumeContext: true, // Enable billing fallback for paused workflow resumes
|
||||
})
|
||||
|
||||
if (!preprocessingResult.success) {
|
||||
|
||||
@@ -26,7 +26,6 @@ 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,
|
||||
@@ -42,7 +41,6 @@ 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,
|
||||
@@ -915,8 +913,6 @@ 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[],
|
||||
@@ -983,8 +979,6 @@ 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,
|
||||
})
|
||||
@@ -2197,49 +2191,6 @@ 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 {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { CopilotMode, CopilotModelId } from '@/lib/copilot/models'
|
||||
import type { AvailableModel } from '@/lib/copilot/types'
|
||||
|
||||
export type { CopilotMode, CopilotModelId } from '@/lib/copilot/models'
|
||||
|
||||
@@ -117,8 +116,6 @@ 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
|
||||
@@ -187,7 +184,6 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user