Skip to content

Commit 3e880c1

Browse files
committed
updates
1 parent 6acf022 commit 3e880c1

13 files changed

Lines changed: 486 additions & 19 deletions

File tree

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,6 +1238,7 @@ export class AgentBlockHandler implements BlockHandler {
12381238
: ('catalog' as const),
12391239
credentialMode: customModelConfig?.credentials.mode,
12401240
providerOptions: customModelConfig?.providerOptions,
1241+
providerModel: customModelConfig?.deployment,
12411242
previousInteractionId: inputs.previousInteractionId,
12421243
/** Agent-events remains the opt-in for exposing thinking and tool lifecycle events. */
12431244
agentEvents: streaming && ctx.metadata?.agentEvents === true,

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

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ vi.mock('@/blocks/registry', () => ({
1717
subBlocks: [
1818
{ id: 'systemPrompt', type: 'long-input' },
1919
{ id: 'model', type: 'combobox' },
20+
{ id: 'customModelConfig', type: 'code' },
2021
],
2122
},
2223
{
@@ -41,6 +42,7 @@ vi.mock('@/blocks/registry', () => ({
4142
subBlocks: [
4243
{ id: 'systemPrompt', type: 'long-input' },
4344
{ id: 'model', type: 'combobox' },
45+
{ id: 'customModelConfig', type: 'code' },
4446
],
4547
},
4648
function: {
@@ -561,3 +563,126 @@ describe('forward-reference connections (pending resolution)', () => {
561563
expect(state.blocks[BLOCK_A].data?.pendingConnections).toBeUndefined()
562564
})
563565
})
566+
567+
describe('custom model credential round trips', () => {
568+
const existingSecret = 'sk-existing-custom-secret'
569+
570+
function makeCustomAgentWorkflow(includeConfig = true) {
571+
const customModelConfig = JSON.stringify({
572+
provider: 'openai',
573+
model: 'gpt-5.6-terra',
574+
parameters: { reasoningEffort: 'medium', temperature: 0.2 },
575+
credentials: { mode: 'explicit', apiKey: existingSecret },
576+
providerOptions: {},
577+
})
578+
579+
return {
580+
blocks: {
581+
'agent-1': {
582+
id: 'agent-1',
583+
type: 'agent',
584+
name: 'Agent 1',
585+
position: { x: 0, y: 0 },
586+
enabled: true,
587+
subBlocks: {
588+
model: { id: 'model', type: 'combobox', value: 'sim-custom' },
589+
...(includeConfig
590+
? {
591+
customModelConfig: {
592+
id: 'customModelConfig',
593+
type: 'code',
594+
value: customModelConfig,
595+
},
596+
}
597+
: {}),
598+
},
599+
outputs: {},
600+
data: {},
601+
},
602+
},
603+
edges: [] as any[],
604+
loops: {},
605+
parallels: {},
606+
}
607+
}
608+
609+
it('restores the existing literal key when an edit round-trips the VFS placeholder', () => {
610+
const result = applyOperationsToWorkflowState(makeCustomAgentWorkflow(), [
611+
{
612+
operation_type: 'edit',
613+
block_id: 'agent-1',
614+
params: {
615+
inputs: {
616+
customModelConfig: {
617+
provider: 'openai',
618+
model: 'gpt-5.6-sol',
619+
parameters: { reasoningEffort: 'high', temperature: 0.1 },
620+
credentials: { mode: 'explicit', apiKey: '<redacted>' },
621+
providerOptions: {},
622+
},
623+
},
624+
},
625+
},
626+
])
627+
628+
expect(result.validationErrors).toHaveLength(0)
629+
const stored = JSON.parse(
630+
result.state.blocks['agent-1'].subBlocks.customModelConfig.value as string
631+
)
632+
expect(stored).toMatchObject({
633+
provider: 'openai',
634+
model: 'gpt-5.6-sol',
635+
parameters: { reasoningEffort: 'high', temperature: 0.1 },
636+
credentials: { mode: 'explicit', apiKey: existingSecret },
637+
})
638+
})
639+
640+
it('rejects the placeholder on a provider change without overwriting the stored config', () => {
641+
const workflow = makeCustomAgentWorkflow()
642+
const original = workflow.blocks['agent-1'].subBlocks.customModelConfig.value
643+
const result = applyOperationsToWorkflowState(workflow, [
644+
{
645+
operation_type: 'edit',
646+
block_id: 'agent-1',
647+
params: {
648+
inputs: {
649+
customModelConfig: {
650+
provider: 'xai',
651+
model: 'grok-4.5',
652+
credentials: { mode: 'explicit', apiKey: '<redacted>' },
653+
providerOptions: {},
654+
},
655+
},
656+
},
657+
},
658+
])
659+
660+
expect(result.validationErrors).toHaveLength(1)
661+
expect(result.validationErrors[0]?.error).toContain('when changing providers')
662+
expect(result.state.blocks['agent-1'].subBlocks.customModelConfig.value).toBe(original)
663+
expect(JSON.stringify(result.validationErrors)).not.toContain(existingSecret)
664+
})
665+
666+
it('rejects the placeholder when the existing block has no key to preserve', () => {
667+
const result = applyOperationsToWorkflowState(makeCustomAgentWorkflow(false), [
668+
{
669+
operation_type: 'edit',
670+
block_id: 'agent-1',
671+
params: {
672+
inputs: {
673+
customModelConfig: {
674+
provider: 'openai',
675+
model: 'gpt-5.6-terra',
676+
credentials: { mode: 'explicit', apiKey: '<redacted>' },
677+
providerOptions: {},
678+
},
679+
},
680+
},
681+
},
682+
])
683+
684+
expect(result.validationErrors).toHaveLength(1)
685+
expect(result.validationErrors[0]?.error).toContain('without an existing stored key')
686+
expect(result.state.blocks['agent-1'].subBlocks).not.toHaveProperty('customModelConfig')
687+
})
688+
})

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,8 @@ function mergeNestedNodesForParent(
201201
const childValidation = validateInputsForBlock(
202202
existingBlock.type,
203203
childBlock.inputs,
204-
existingId
204+
existingId,
205+
{ existingSubBlocks: existingBlock.subBlocks }
205206
)
206207
validationErrors.push(...childValidation.errors)
207208

@@ -426,7 +427,9 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon
426427
if (!block.subBlocks) block.subBlocks = {}
427428

428429
// Validate inputs against block configuration
429-
const validationResult = validateInputsForBlock(block.type, params.inputs, block_id)
430+
const validationResult = validateInputsForBlock(block.type, params.inputs, block_id, {
431+
existingSubBlocks: block.subBlocks,
432+
})
430433
validationErrors.push(...validationResult.errors)
431434

432435
Object.entries(validationResult.validInputs).forEach(([inputKey, value]) => {
@@ -898,7 +901,9 @@ export function handleInsertIntoSubflowOperation(
898901
// Update inputs if provided (with validation)
899902
if (params.inputs) {
900903
// Validate inputs against block configuration
901-
const validationResult = validateInputsForBlock(existingBlock.type, params.inputs, block_id)
904+
const validationResult = validateInputsForBlock(existingBlock.type, params.inputs, block_id, {
905+
existingSubBlocks: existingBlock.subBlocks,
906+
})
902907
validationErrors.push(...validationResult.errors)
903908

904909
Object.entries(validationResult.validInputs).forEach(([key, value]) => {

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ import { SELECTOR_TYPES } from './types'
4040

4141
const validationLogger = createLogger('EditWorkflowValidation')
4242
const agentToolLintLogger = createLogger('EditWorkflowAgentToolLint')
43+
const REDACTED_CUSTOM_MODEL_API_KEY = '<redacted>'
44+
45+
interface ValidateInputsForBlockOptions {
46+
existingSubBlocks?: Record<string, { value?: unknown } | undefined>
47+
}
4348

4449
/**
4550
* Detect privileged Agent custom-model writes anywhere in an edit operation,
@@ -80,7 +85,8 @@ export function findBlockWithDuplicateNormalizedName(
8085
export function validateInputsForBlock(
8186
blockType: string,
8287
inputs: Record<string, any>,
83-
blockId: string
88+
blockId: string,
89+
options?: ValidateInputsForBlockOptions
8490
): ValidationResult {
8591
const errors: ValidationError[] = []
8692

@@ -175,7 +181,8 @@ export function validateInputsForBlock(
175181
value,
176182
key,
177183
blockType,
178-
blockId
184+
blockId,
185+
options?.existingSubBlocks?.[key]?.value
179186
)
180187
if (validationResult.valid) {
181188
validatedInputs[key] = validationResult.value
@@ -307,7 +314,8 @@ export function validateValueForSubBlockType(
307314
value: any,
308315
fieldName: string,
309316
blockType: string,
310-
blockId: string
317+
blockId: string,
318+
existingValue?: unknown
311319
): ValueValidationResult {
312320
const { type } = subBlockConfig
313321

@@ -538,6 +546,38 @@ export function validateValueForSubBlockType(
538546
// subblock persists text. Accept the object Mothership naturally
539547
// produces, validate it now, and store one canonical JSON string.
540548
const parsed = parseCustomModelConfig(value)
549+
550+
if (parsed.credentials.apiKey === REDACTED_CUSTOM_MODEL_API_KEY) {
551+
if (existingValue === undefined || existingValue === null) {
552+
throw new Error(
553+
'credentials.apiKey cannot be "<redacted>" without an existing stored key; provide an environment-variable reference or a new key'
554+
)
555+
}
556+
557+
const existing = parseCustomModelConfig(existingValue)
558+
if (existing.provider !== parsed.provider) {
559+
throw new Error(
560+
'credentials.apiKey cannot remain "<redacted>" when changing providers; provide the new provider key or an environment-variable reference'
561+
)
562+
}
563+
564+
const existingApiKey = existing.credentials.apiKey
565+
if (
566+
existing.credentials.mode !== 'explicit' ||
567+
!existingApiKey ||
568+
existingApiKey === REDACTED_CUSTOM_MODEL_API_KEY
569+
) {
570+
throw new Error(
571+
'credentials.apiKey cannot be "<redacted>" because no usable stored key exists; provide an environment-variable reference or a new key'
572+
)
573+
}
574+
575+
// `<redacted>` is a read-view sentinel, never a credential. Restore
576+
// the existing value only after the provider identity is proven to
577+
// match, so a model/parameter edit cannot destroy or misroute it.
578+
parsed.credentials.apiKey = existingApiKey
579+
}
580+
541581
return { valid: true, value: JSON.stringify(parsed, null, 2) }
542582
} catch (error) {
543583
return {

apps/sim/providers/cost-policy.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ export interface ModelUsage {
185185
cacheRead?: number
186186
/** Tokens written to the cache, each bucket at its own premium. */
187187
cacheWrites?: CacheWriteUsage[]
188+
/** Whole provider prompt for long-context selection when input is split into cache buckets. */
189+
contextInputTokens?: number
190+
/** Effective processing tier reported by the provider, or requested when no response field exists. */
191+
serviceTier?: 'default' | 'priority'
188192
}
189193

190194
/**
@@ -210,10 +214,30 @@ export function priceModelUsage(
210214
}
211215

212216
const multiplier = policy.multiplier
213-
const base = calculateCost(model, usage.input, usage.output, false, multiplier, multiplier)
214-
215217
const cacheRead = usage.cacheRead ?? 0
216-
const read = cacheRead > 0 ? calculateCost(model, cacheRead, 0, true, multiplier, 0) : undefined
218+
const cacheWriteTokens = (usage.cacheWrites ?? []).reduce(
219+
(total, write) => total + Math.max(0, write.tokens),
220+
0
221+
)
222+
const contextInputTokens = usage.contextInputTokens ?? usage.input + cacheRead + cacheWriteTokens
223+
const pricingOptions = {
224+
contextInputTokens,
225+
serviceTier: usage.serviceTier,
226+
}
227+
const base = calculateCost(
228+
model,
229+
usage.input,
230+
usage.output,
231+
false,
232+
multiplier,
233+
multiplier,
234+
pricingOptions
235+
)
236+
237+
const read =
238+
cacheRead > 0
239+
? calculateCost(model, cacheRead, 0, true, multiplier, 0, pricingOptions)
240+
: undefined
217241

218242
let writeInputCost = 0
219243
for (const write of usage.cacheWrites ?? []) {
@@ -224,7 +248,8 @@ export function priceModelUsage(
224248
0,
225249
false,
226250
multiplier * write.inputRateMultiplier,
227-
0
251+
0,
252+
pricingOptions
228253
).input
229254
}
230255

0 commit comments

Comments
 (0)