Skip to content

Commit 8698db3

Browse files
committed
improvement(mothership): agent model dropdown validations, recommendation system
1 parent 38864fa commit 8698db3

4 files changed

Lines changed: 288 additions & 13 deletions

File tree

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import { normalizeConditionRouterIds } from './builders'
66

7-
const { mockValidateSelectorIds } = vi.hoisted(() => ({
7+
const { mockValidateSelectorIds, mockGetModelOptions } = vi.hoisted(() => ({
88
mockValidateSelectorIds: vi.fn(),
9+
mockGetModelOptions: vi.fn(() => []),
910
}))
1011

1112
const conditionBlockConfig = {
@@ -26,7 +27,24 @@ const routerBlockConfig = {
2627
type: 'router_v2',
2728
name: 'Router',
2829
outputs: {},
29-
subBlocks: [{ id: 'routes', type: 'router-input' }],
30+
subBlocks: [
31+
{ id: 'routes', type: 'router-input' },
32+
{ id: 'model', type: 'combobox', options: mockGetModelOptions },
33+
],
34+
}
35+
36+
const agentBlockConfig = {
37+
type: 'agent',
38+
name: 'Agent',
39+
outputs: {},
40+
subBlocks: [{ id: 'model', type: 'combobox', options: mockGetModelOptions }],
41+
}
42+
43+
const huggingfaceBlockConfig = {
44+
type: 'huggingface',
45+
name: 'HuggingFace',
46+
outputs: {},
47+
subBlocks: [{ id: 'model', type: 'short-input' }],
3048
}
3149

3250
vi.mock('@/blocks/registry', () => ({
@@ -37,7 +55,15 @@ vi.mock('@/blocks/registry', () => ({
3755
? oauthBlockConfig
3856
: type === 'router_v2'
3957
? routerBlockConfig
40-
: undefined,
58+
: type === 'agent'
59+
? agentBlockConfig
60+
: type === 'huggingface'
61+
? huggingfaceBlockConfig
62+
: undefined,
63+
}))
64+
65+
vi.mock('@/blocks/utils', () => ({
66+
getModelOptions: mockGetModelOptions,
4167
}))
4268

4369
vi.mock('@/lib/copilot/validation/selector-validator', () => ({
@@ -83,6 +109,95 @@ describe('validateInputsForBlock', () => {
83109
expect(result.errors).toHaveLength(1)
84110
expect(result.errors[0]?.error).toContain('expected a JSON array')
85111
})
112+
113+
it('accepts known agent model ids', () => {
114+
const result = validateInputsForBlock('agent', { model: 'claude-sonnet-4-6' }, 'agent-1')
115+
116+
expect(result.errors).toHaveLength(0)
117+
expect(result.validInputs.model).toBe('claude-sonnet-4-6')
118+
})
119+
120+
it('rejects hallucinated agent model ids that match a static provider pattern', () => {
121+
const result = validateInputsForBlock('agent', { model: 'claude-sonnet-4.6' }, 'agent-1')
122+
123+
expect(result.validInputs.model).toBeUndefined()
124+
expect(result.errors).toHaveLength(1)
125+
expect(result.errors[0]?.field).toBe('model')
126+
expect(result.errors[0]?.error).toContain('Unknown model id')
127+
expect(result.errors[0]?.error).toContain('claude-sonnet-4-6')
128+
})
129+
130+
it('rejects legacy claude-4.5-haiku style ids', () => {
131+
const result = validateInputsForBlock('agent', { model: 'claude-4.5-haiku' }, 'agent-1')
132+
133+
expect(result.errors).toHaveLength(1)
134+
expect(result.errors[0]?.error).toContain('Unknown model id')
135+
})
136+
137+
it('allows empty model values', () => {
138+
const result = validateInputsForBlock('agent', { model: '' }, 'agent-1')
139+
140+
expect(result.errors).toHaveLength(0)
141+
expect(result.validInputs.model).toBe('')
142+
})
143+
144+
it('allows custom ollama-prefixed model ids', () => {
145+
const result = validateInputsForBlock('agent', { model: 'ollama/my-private-model' }, 'agent-1')
146+
147+
expect(result.errors).toHaveLength(0)
148+
expect(result.validInputs.model).toBe('ollama/my-private-model')
149+
})
150+
151+
it('validates the model field on router_v2 blocks too', () => {
152+
const valid = validateInputsForBlock('router_v2', { model: 'claude-sonnet-4-6' }, 'router-1')
153+
expect(valid.errors).toHaveLength(0)
154+
expect(valid.validInputs.model).toBe('claude-sonnet-4-6')
155+
156+
const invalid = validateInputsForBlock('router_v2', { model: 'claude-sonnet-4.6' }, 'router-1')
157+
expect(invalid.validInputs.model).toBeUndefined()
158+
expect(invalid.errors).toHaveLength(1)
159+
expect(invalid.errors[0]?.blockType).toBe('router_v2')
160+
expect(invalid.errors[0]?.field).toBe('model')
161+
expect(invalid.errors[0]?.error).toContain('Unknown model id')
162+
})
163+
164+
it("does not apply model validation to blocks whose model field is not Sim's catalog", () => {
165+
const result = validateInputsForBlock(
166+
'huggingface',
167+
{ model: 'mistralai/Mistral-7B-Instruct-v0.3' },
168+
'hf-1'
169+
)
170+
171+
expect(result.errors).toHaveLength(0)
172+
expect(result.validInputs.model).toBe('mistralai/Mistral-7B-Instruct-v0.3')
173+
})
174+
175+
it('accepts date-suffixed variants of known Anthropic ids', () => {
176+
const result = validateInputsForBlock(
177+
'agent',
178+
{ model: 'claude-sonnet-4-5-20250929' },
179+
'agent-1'
180+
)
181+
182+
expect(result.errors).toHaveLength(0)
183+
expect(result.validInputs.model).toBe('claude-sonnet-4-5-20250929')
184+
})
185+
186+
it('trims whitespace around catalog model ids and stores the trimmed value', () => {
187+
const result = validateInputsForBlock('agent', { model: ' gpt-5.4 ' }, 'agent-1')
188+
189+
expect(result.errors).toHaveLength(0)
190+
expect(result.validInputs.model).toBe('gpt-5.4')
191+
})
192+
193+
it('rejects a pattern-matching but uncataloged id even with surrounding whitespace', () => {
194+
const result = validateInputsForBlock('agent', { model: ' gpt-100-ultra ' }, 'agent-1')
195+
196+
expect(result.validInputs.model).toBeUndefined()
197+
expect(result.errors).toHaveLength(1)
198+
expect(result.errors[0]?.error).toContain('gpt-100-ultra')
199+
expect(result.errors[0]?.error).not.toMatch(/\s{2,}/)
200+
})
86201
})
87202

88203
describe('normalizeConditionRouterIds', () => {

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator
33
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
44
import { getBlock } from '@/blocks/registry'
55
import type { SubBlockConfig } from '@/blocks/types'
6+
import { getModelOptions } from '@/blocks/utils'
67
import { EDGE, normalizeName } from '@/executor/constants'
8+
import { isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models'
79
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
810
import type {
911
EdgeHandleValidationResult,
@@ -350,12 +352,37 @@ export function validateValueForSubBlockType(
350352
case 'short-input':
351353
case 'long-input':
352354
case 'combobox': {
353-
// Should be string (combobox allows custom values)
355+
let stringValue: string
354356
if (typeof value !== 'string' && typeof value !== 'number') {
355-
// Convert to string but don't error
356-
return { valid: true, value: String(value) }
357+
stringValue = String(value)
358+
} else {
359+
stringValue = typeof value === 'number' ? String(value) : value
357360
}
358-
return { valid: true, value }
361+
362+
const usesProviderCatalog =
363+
fieldName === 'model' && subBlockConfig.options === getModelOptions
364+
365+
if (usesProviderCatalog) {
366+
const trimmed = stringValue.trim()
367+
if (trimmed !== '' && !isKnownModelId(trimmed)) {
368+
const suggestions = suggestModelIdsForUnknownModel(trimmed)
369+
const suggestionText =
370+
suggestions.length > 0 ? ` Valid options include: ${suggestions.join(', ')}.` : ''
371+
return {
372+
valid: false,
373+
error: {
374+
blockId,
375+
blockType,
376+
field: fieldName,
377+
value,
378+
error: `Unknown model id "${trimmed}" for block "${blockType}". Read components/blocks/${blockType}.json (the model.options array) for valid ids; prefer entries with recommended: true and avoid deprecated: true.${suggestionText}`,
379+
},
380+
}
381+
}
382+
return { valid: true, value: trimmed }
383+
}
384+
385+
return { valid: true, value: typeof value === 'string' ? value : stringValue }
359386
}
360387

361388
// Selector types - allow strings (IDs) or arrays of strings

apps/sim/lib/copilot/vfs/serializers.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,24 +320,75 @@ export function serializeTableMeta(table: {
320320
* Excludes dynamic providers (ollama, vllm, openrouter) whose models are user-configured.
321321
* Includes provider ID and whether the model is hosted by Sim (no API key required).
322322
*/
323-
function getStaticModelOptionsForVFS(): Array<{
323+
interface StaticModelOption {
324324
id: string
325325
provider: string
326326
hosted: boolean
327-
}> {
327+
recommended?: boolean
328+
speedOptimized?: boolean
329+
deprecated?: boolean
330+
}
331+
332+
interface TierFlags {
333+
recommended?: boolean
334+
speedOptimized?: boolean
335+
deprecated?: boolean
336+
}
337+
338+
const RESELLER_BASE_PREFIX: Record<string, string> = {
339+
'azure-openai': 'azure/',
340+
'azure-anthropic': 'azure-anthropic/',
341+
vertex: 'vertex/',
342+
}
343+
344+
const DYNAMIC_PROVIDERS_NOTE = {
345+
note: 'The options array above lists Sim\'s static provider catalog. These four providers also accept user-configured models that are NOT enumerated here: the user may have additional ids available at runtime. Any model id prefixed with one of the slashes below is accepted by the server, as is any bare id that does not match a static provider pattern (typically a local Ollama tag like "llama3.1:8b"). The UI dropdown shows the user\'s actual installed models; if the user references one by name, use that id verbatim.',
346+
prefixes: ['ollama/', 'vllm/', 'openrouter/', 'fireworks/'],
347+
} as const
348+
349+
function getStaticModelOptionsForVFS(): StaticModelOption[] {
328350
const hostedProviders = new Set(['openai', 'anthropic', 'google'])
329351
const dynamicProviders = new Set(['ollama', 'vllm', 'openrouter', 'fireworks'])
330352

331-
const models: Array<{ id: string; provider: string; hosted: boolean }> = []
353+
const baseTierFlags = new Map<string, TierFlags>()
354+
for (const providerId of hostedProviders) {
355+
const def = PROVIDER_DEFINITIONS[providerId]
356+
if (!def) continue
357+
for (const model of def.models) {
358+
if (model.recommended || model.speedOptimized || model.deprecated) {
359+
baseTierFlags.set(model.id, {
360+
...(model.recommended && { recommended: true }),
361+
...(model.speedOptimized && { speedOptimized: true }),
362+
...(model.deprecated && { deprecated: true }),
363+
})
364+
}
365+
}
366+
}
367+
368+
const models: StaticModelOption[] = []
332369

333370
for (const [providerId, def] of Object.entries(PROVIDER_DEFINITIONS)) {
334371
if (dynamicProviders.has(providerId)) continue
335372
for (const model of def.models) {
336-
models.push({
373+
const option: StaticModelOption = {
337374
id: model.id,
338375
provider: providerId,
339376
hosted: hostedProviders.has(providerId),
340-
})
377+
}
378+
if (model.recommended) option.recommended = true
379+
if (model.speedOptimized) option.speedOptimized = true
380+
if (model.deprecated) option.deprecated = true
381+
382+
if (!option.recommended && !option.speedOptimized && !option.deprecated) {
383+
const prefix = RESELLER_BASE_PREFIX[providerId]
384+
if (prefix && model.id.startsWith(prefix)) {
385+
const baseId = model.id.slice(prefix.length)
386+
const inherited = baseTierFlags.get(baseId)
387+
if (inherited) Object.assign(option, inherited)
388+
}
389+
}
390+
391+
models.push(option)
341392
}
342393
}
343394

@@ -378,9 +429,9 @@ export function serializeBlockSchema(block: BlockConfig): string {
378429
.map((sb) => {
379430
const serialized = serializeSubBlock(sb)
380431

381-
// For model comboboxes with function options, inject static model data with hosting info
382432
if (sb.id === 'model' && sb.type === 'combobox' && typeof sb.options === 'function') {
383433
serialized.options = getStaticModelOptionsForVFS()
434+
serialized.dynamicProviders = DYNAMIC_PROVIDERS_NOTE
384435
}
385436

386437
return serialized

0 commit comments

Comments
 (0)