Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions apps/docs/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5152,6 +5152,97 @@ export function MondayIcon(props: SVGProps<SVGSVGElement>) {
)
}

export function ModalIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const leftHighlight = `modal_left_highlight_${id}`
const leftBody = `modal_left_body_${id}`
const rightHighlight = `modal_right_highlight_${id}`
const rightBody = `modal_right_body_${id}`

return (
<svg {...props} viewBox='0 0 300 300' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M121.683 75.25L149.997 124L91.482 224.75C90.313 226.757 88.155 228 85.817 228H32.966C31.798 228 30.678 227.691 29.697 227.131C28.716 226.57 27.891 225.758 27.302 224.75L0.877 179.25C-0.292 177.243 -0.292 174.765 0.877 172.75L57.512 75.25C58.092 74.243 58.926 73.43 59.907 72.869C60.888 72.309 62.007 72 63.176 72H116.027C118.365 72 120.523 73.243 121.692 75.25H121.683ZM299.125 172.75L242.49 75.25C241.91 74.243 241.076 73.43 240.095 72.869C239.114 72.309 237.995 72 236.826 72H183.975C181.637 72 179.479 73.243 178.311 75.25L149.997 124L208.512 224.75C209.681 226.757 211.839 228 214.177 228H267.027C268.196 228 269.316 227.691 270.297 227.131C271.278 226.57 272.103 225.758 272.692 224.75L299.117 179.25C300.286 177.243 300.286 174.765 299.117 172.75H299.125Z'
fill='#62DE61'
/>
<path
d='M89.602 124H150.005L121.692 75.25C120.523 73.243 118.365 72 116.027 72H63.176C62.007 72 60.888 72.309 59.907 72.869L89.602 124Z'
fill={`url(#${leftHighlight})`}
/>
<path
d='M89.602 124L59.907 72.869C58.926 73.43 58.1 74.243 57.512 75.25L0.877 172.75C-0.292 174.765 -0.292 177.235 0.877 179.25L27.302 224.75C27.883 225.758 28.716 226.57 29.697 227.131L89.594 124H89.602Z'
fill={`url(#${leftBody})`}
/>
<path
d='M149.997 124H89.594L29.697 227.131C30.678 227.691 31.798 228 32.966 228H85.817C88.155 228 90.313 226.757 91.482 224.75L149.997 124Z'
fill='#09AF58'
/>
<path
d='M299.125 179.25C299.706 178.243 300 177.121 300 176H240.61L210.915 227.131C211.896 227.691 213.016 228 214.185 228H267.036C269.373 228 271.531 226.757 272.7 224.75L299.125 179.25Z'
fill='#09AF58'
/>
<path
d='M183.975 72C182.806 72 181.686 72.309 180.705 72.869L240.602 176H299.992C299.992 174.879 299.698 173.758 299.117 172.75L242.49 75.25C241.321 73.243 239.163 72 236.826 72H183.967H183.975Z'
fill={`url(#${rightHighlight})`}
/>
<path
d='M210.907 227.131L240.602 176L180.705 72.869C179.725 73.43 178.899 74.243 178.311 75.25L149.997 124L208.512 224.75C209.093 225.758 209.926 226.57 210.907 227.131Z'
fill={`url(#${rightBody})`}
/>
<defs>
<linearGradient
id={leftHighlight}
x1='127.348'
y1='137'
x2='82.956'
y2='59.64'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#BFF9B4' />
<stop offset='1' stopColor='#80EE64' />
</linearGradient>
<linearGradient
id={leftBody}
x1='7.048'
y1='214.131'
x2='81.128'
y2='85.056'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#80EE64' />
<stop offset='0.36' stopColor='#6FE562' />
<stop offset='0.74' stopColor='#3DCA5D' />
<stop offset='1' stopColor='#09AF58' />
</linearGradient>
<linearGradient
id={rightHighlight}
x1='278.103'
y1='188.561'
x2='204.022'
y2='59.486'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#BFF9B4' />
<stop offset='1' stopColor='#80EE64' />
</linearGradient>
<linearGradient
id={rightBody}
x1='232.804'
y1='214.569'
x2='158.724'
y2='85.486'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#80EE64' />
<stop offset='0.36' stopColor='#6FE562' />
<stop offset='0.74' stopColor='#3DCA5D' />
<stop offset='1' stopColor='#09AF58' />
</linearGradient>
</defs>
</svg>
)
}

export function MongoDBIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'>
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ import {
MillionVerifierIcon,
MintlifyIcon,
MistralIcon,
ModalIcon,
MondayIcon,
MongoDBIcon,
MySQLIcon,
Expand Down Expand Up @@ -448,6 +449,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
mistral_parse: MistralIcon,
mistral_parse_v2: MistralIcon,
mistral_parse_v3: MistralIcon,
modal: ModalIcon,
monday: MondayIcon,
mongodb: MongoDBIcon,
mssql: MicrosoftSqlIcon,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/integrations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@
"millionverifier",
"mintlify",
"mistral_parse",
"modal",
"monday",
"monday-service-account",
"mongodb",
Expand Down
94 changes: 94 additions & 0 deletions apps/docs/content/docs/en/integrations/modal.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
title: Modal
description: Call deployed Modal functions and endpoints
---

import { BlockInfoCard } from "@/components/ui/block-info-card"

<BlockInfoCard
type="modal"
color="#000000"
/>

## Usage Instructions

Integrate Modal into your workflow to reach the serverless compute you already run there. Invoke a deployed Web Function or Server over HTTPS with proxy-token auth, generate completions from a model served by a Modal Endpoint, and list the models a token can reach.



## Actions

### Modal Call Function

Invoke a deployed Modal Web Function or Server over HTTPS

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `url` | string | Yes | Public URL of the deployed Modal Web Function or Server \(e.g., https://your-workspace--your-app-your-function.modal.run\) |
| `method` | string | No | HTTP method to use: GET, POST, PUT, PATCH, DELETE, or HEAD |
| `body` | json | No | JSON request body sent to the function |
| `queryParams` | json | No | Query parameters to append to the URL as key-value pairs |
| `headers` | json | No | Additional request headers as key-value pairs |
| `tokenId` | string | No | Modal proxy token ID \(wk-...\), required for authenticated functions |
| `tokenSecret` | string | No | Modal proxy token secret \(ws-...\), required for authenticated functions |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | json | Body returned by the function — parsed JSON when it responds with application/json, otherwise the raw text |
| `status` | number | HTTP status code of the response |
| `headers` | json | Response headers as key-value pairs |

### Modal Chat Completion

Generate a chat completion from a model served by a Modal Endpoint

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `endpointUrl` | string | No | Endpoint URL from the Modal dashboard or `modal endpoint list`. Defaults to https://inference.us-west.modal.direct, which routes to Shared Endpoints on the model ID |
| `model` | string | Yes | Model to generate with — the base model repo ID for a dedicated endpoint, or the endpoint hostname for a Shared Endpoint |
| `content` | string | Yes | The user message content to send to the model |
| `systemPrompt` | string | No | System prompt to guide the model behavior |
| `maxTokens` | number | No | Maximum number of tokens to generate |
| `temperature` | number | No | Sampling temperature \(e.g., 0 for deterministic, 0.7 for creative\) |
| `topP` | number | No | Nucleus sampling probability mass between 0 and 1 |
| `tokenId` | string | Yes | Modal proxy token ID \(wk-...\) |
| `tokenSecret` | string | Yes | Modal proxy token secret \(ws-...\) |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | Generated text content |
| `model` | string | Model that produced the completion |
| `finishReason` | string | Why generation stopped \(e.g., stop, length\) |
| `usage` | object | Token usage reported by the endpoint |
| ↳ `prompt_tokens` | number | Number of tokens in the prompt |
| ↳ `completion_tokens` | number | Number of tokens in the completion |
| ↳ `total_tokens` | number | Total number of tokens used |

### Modal List Models

List the model IDs a Modal proxy token can reach on an endpoint

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `endpointUrl` | string | No | Endpoint URL to query. Defaults to https://inference.us-west.modal.direct, which lists every Shared Endpoint the token can reach |
| `tokenId` | string | Yes | Modal proxy token ID \(wk-...\) |
| `tokenSecret` | string | Yes | Modal proxy token secret \(ws-...\) |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `models` | array | Models the token can reach on the endpoint |
| `count` | number | Number of models returned |


137 changes: 137 additions & 0 deletions apps/sim/blocks/blocks/modal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { ModalBlock } from '@/blocks/blocks/modal'

/**
* Every assertion here runs against `{ ...inputs, ...buildParams(inputs) }`, the
* shape the generic tool handler actually forwards. A key the mapper omits is
* *not* dropped by that merge — the raw subBlock value survives — so asserting
* on the mapper's return alone would prove nothing about what the tool receives.
*/
describe('ModalBlock', () => {
const buildParams = ModalBlock.tools.config!.params!
const selectTool = ModalBlock.tools.config!.tool!

const resolve = (inputs: Record<string, unknown>) => ({ ...inputs, ...buildParams(inputs) })

const operationIds =
ModalBlock.subBlocks
.find((subBlock) => subBlock.id === 'operation')
?.options?.map((option) => (option as { id: string }).id) ?? []

it('maps every dropdown operation onto a registered tool', () => {
expect(operationIds).toHaveLength(3)
expect(new Set(operationIds.map((id) => selectTool({ operation: id })))).toEqual(
new Set(ModalBlock.tools.access)
)
})

it('gives every subblock a unique id', () => {
const ids = ModalBlock.subBlocks.map((subBlock) => subBlock.id)
expect(ids).toHaveLength(new Set(ids).size)
})

it('forwards the proxy token pair on every operation', () => {
for (const operation of operationIds) {
const params = buildParams({ operation, tokenId: 'wk-1', tokenSecret: 'ws-2' })
expect(params).toMatchObject({ tokenId: 'wk-1', tokenSecret: 'ws-2' })
}
})

it('renames the call-function subblocks onto the tool param names', () => {
const params = resolve({
operation: 'call_function',
functionUrl: 'https://acme--app-fn.modal.run',
method: 'PUT',
requestBody: '{"prompt":"hi"}',
requestHeaders: [{ id: '1', cells: { Key: 'X-Trace', Value: 'abc' } }],
queryParams: [{ id: '2', cells: { Key: 'debug', Value: '1' } }],
})

expect(params).toMatchObject({
url: 'https://acme--app-fn.modal.run',
method: 'PUT',
body: '{"prompt":"hi"}',
headers: [{ id: '1', cells: { Key: 'X-Trace', Value: 'abc' } }],
queryParams: [{ id: '2', cells: { Key: 'debug', Value: '1' } }],
})
})

it('omits an empty body so a GET call is not given one', () => {
const params = buildParams({
operation: 'call_function',
functionUrl: 'https://acme--app-fn.modal.run',
method: 'GET',
requestBody: '',
})

expect(params).not.toHaveProperty('body')
})

it('coerces the chat sampling controls to numbers at execution time', () => {
const params = resolve({
operation: 'chat_completion',
endpointUrl: 'https://my-endpoint.us-west.modal.direct',
model: 'Qwen/Qwen3.5-4B',
content: 'hello',
systemPrompt: 'be terse',
maxTokens: '256',
temperature: '0',
topP: '0.9',
})

expect(params).toMatchObject({
endpointUrl: 'https://my-endpoint.us-west.modal.direct',
model: 'Qwen/Qwen3.5-4B',
content: 'hello',
systemPrompt: 'be terse',
maxTokens: 256,
temperature: 0,
topP: 0.9,
})
})

it('drops blank sampling controls rather than sending NaN', () => {
const params = buildParams({
operation: 'chat_completion',
endpointUrl: 'https://my-endpoint.us-west.modal.direct',
model: 'Qwen/Qwen3.5-4B',
content: 'hello',
maxTokens: '',
temperature: '',
topP: '',
})

expect(params).not.toHaveProperty('maxTokens')
expect(params).not.toHaveProperty('temperature')
expect(params).not.toHaveProperty('topP')
})

it('leaves a blank endpoint URL unset so the shared inference default applies', () => {
for (const operation of ['list_models', 'chat_completion']) {
expect(buildParams({ operation, endpointUrl: '' })).not.toHaveProperty('endpointUrl')
expect(
buildParams({ operation, endpointUrl: 'https://my-endpoint.modal.direct' })
).toMatchObject({ endpointUrl: 'https://my-endpoint.modal.direct' })
}
})

it('never marks the endpoint URL required, matching the skill that says to leave it empty', () => {
expect(
ModalBlock.subBlocks.find((subBlock) => subBlock.id === 'endpointUrl')?.required
).toBeUndefined()
})

it('requires the token pair only where Modal always authenticates', () => {
const requiredFor = (id: string) =>
ModalBlock.subBlocks.find((subBlock) => subBlock.id === id)?.required

expect(requiredFor('tokenId')).toEqual({
field: 'operation',
value: ['chat_completion', 'list_models'],
})
expect(requiredFor('tokenSecret')).toEqual(requiredFor('tokenId'))
})
})
Loading
Loading