mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
feat(modal): add Modal Labs integration (#6896)
* feat(modal): add Modal Labs integration Modal has no public REST control plane — the Python/JS/Go SDKs all speak gRPC — so this covers the two surfaces that are reachable over HTTP: deployed Web Functions/Servers, and the OpenAI-compatible Endpoints API. Three operations: call a deployed function with proxy-token auth, generate a chat completion on an Endpoint, and list the models a token can reach. Auth sends the token pair as Modal-Key/Modal-Secret rather than the combined bearer form, so a Web Function that validates its own bearer token keeps the Authorization header free. Both URL fields require https since Modal terminates TLS everywhere, and a cleartext URL would leak the token. Chat completion declares request.modelInput so the system prompt and user message project to canonical placeholders before egress. Call Function deliberately does not — a Web Function runs arbitrary user code, and nothing proves its body reaches a model. /v1/models fields beyond `id` are inferred from OpenAI compatibility rather than printed in Modal's docs, so they are marked optional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(modal): type the wire payloads and default chat to the shared endpoint Chat Completion required an endpoint URL and passed a blank one straight into modalOpenAiUrl, which throws — while List Models already fell back to the shared inference host and the generate-on-modal-endpoint skill tells agents to leave the field empty for Shared Endpoints. Skill-driven chat calls against the shared host failed instead of using that default. Chat now falls back the same way and the block field is no longer required. Replaces every `any` in the Modal tools with declared wire types for the OpenAI-compatible /v1 payloads. Fields stay optional because the shape comes from whichever inference engine backs the endpoint, so the readers keep their defensive `??` guards — the types exist so a future change to that mapping fails the compiler instead of shipping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
3b4d9e9149
commit
97c1688c49
@@ -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'>
|
||||
|
||||
@@ -157,6 +157,7 @@ import {
|
||||
MillionVerifierIcon,
|
||||
MintlifyIcon,
|
||||
MistralIcon,
|
||||
ModalIcon,
|
||||
MondayIcon,
|
||||
MongoDBIcon,
|
||||
MySQLIcon,
|
||||
@@ -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,
|
||||
|
||||
@@ -164,6 +164,7 @@
|
||||
"millionverifier",
|
||||
"mintlify",
|
||||
"mistral_parse",
|
||||
"modal",
|
||||
"monday",
|
||||
"monday-service-account",
|
||||
"mongodb",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,386 @@
|
||||
import { ModalIcon } from '@/components/icons'
|
||||
import type { BlockConfig, BlockMeta } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import { MODAL_SHARED_INFERENCE_URL } from '@/tools/modal/utils'
|
||||
|
||||
/** Operations that talk to a Modal Endpoint's OpenAI-compatible `/v1` API. */
|
||||
const ENDPOINT_OPERATIONS = ['chat_completion', 'list_models']
|
||||
|
||||
export const ModalBlock: BlockConfig = {
|
||||
type: 'modal',
|
||||
name: 'Modal',
|
||||
description: 'Call deployed Modal functions and endpoints',
|
||||
longDescription:
|
||||
'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.',
|
||||
docsLink: 'https://docs.sim.ai/integrations/modal',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.AI,
|
||||
bgColor: '#000000',
|
||||
icon: ModalIcon,
|
||||
authMode: AuthMode.ApiKey,
|
||||
canvasPresentation: {
|
||||
defaultTitle: 'Modal',
|
||||
sentences: {
|
||||
byOperation: {
|
||||
call_function: [
|
||||
{ text: 'Call', field: 'functionUrl', core: true },
|
||||
{ text: 'with a', field: 'method', after: 'request' },
|
||||
],
|
||||
chat_completion: [
|
||||
{ text: 'Generate a completion with', field: 'model', core: true },
|
||||
{ text: 'on', field: 'endpointUrl' },
|
||||
{ text: ', prompted with', field: 'content' },
|
||||
],
|
||||
list_models: ['List the models this token can reach', { text: 'on', field: 'endpointUrl' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Call Function', id: 'call_function' },
|
||||
{ label: 'Chat Completion', id: 'chat_completion' },
|
||||
{ label: 'List Models', id: 'list_models' },
|
||||
],
|
||||
value: () => 'call_function',
|
||||
},
|
||||
{
|
||||
id: 'tokenId',
|
||||
title: 'Token ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'wk-...',
|
||||
description: 'Proxy token pair from `modal workspace proxy-tokens create`',
|
||||
password: true,
|
||||
required: { field: 'operation', value: ENDPOINT_OPERATIONS },
|
||||
},
|
||||
{
|
||||
id: 'tokenSecret',
|
||||
title: 'Token Secret',
|
||||
type: 'short-input',
|
||||
placeholder: 'ws-...',
|
||||
password: true,
|
||||
required: { field: 'operation', value: ENDPOINT_OPERATIONS },
|
||||
},
|
||||
|
||||
// Call Function fields
|
||||
{
|
||||
id: 'functionUrl',
|
||||
title: 'Function URL',
|
||||
canvasNoun: 'a function',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://your-workspace--your-app-your-function.modal.run',
|
||||
condition: { field: 'operation', value: 'call_function' },
|
||||
required: { field: 'operation', value: 'call_function' },
|
||||
},
|
||||
{
|
||||
id: 'method',
|
||||
title: 'Method',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'POST', id: 'POST' },
|
||||
{ label: 'GET', id: 'GET' },
|
||||
{ label: 'PUT', id: 'PUT' },
|
||||
{ label: 'PATCH', id: 'PATCH' },
|
||||
{ label: 'DELETE', id: 'DELETE' },
|
||||
{ label: 'HEAD', id: 'HEAD' },
|
||||
],
|
||||
value: () => 'POST',
|
||||
condition: { field: 'operation', value: 'call_function' },
|
||||
},
|
||||
{
|
||||
id: 'requestBody',
|
||||
title: 'Body',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: '{\n "prompt": "hello"\n}',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'call_function',
|
||||
and: { field: 'method', value: ['GET', 'HEAD'], not: true },
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Generate a JSON request body for a Modal Web Function. Return ONLY the JSON object without any markdown formatting or explanation.',
|
||||
generationType: 'json-object',
|
||||
placeholder: 'Describe the request body you want to send',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'queryParams',
|
||||
title: 'Query Parameters',
|
||||
type: 'table',
|
||||
columns: ['Key', 'Value'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'call_function' },
|
||||
},
|
||||
{
|
||||
id: 'requestHeaders',
|
||||
title: 'Headers',
|
||||
type: 'table',
|
||||
columns: ['Key', 'Value'],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'call_function' },
|
||||
},
|
||||
|
||||
// Endpoint fields
|
||||
{
|
||||
id: 'endpointUrl',
|
||||
title: 'Endpoint URL',
|
||||
canvasNoun: 'an endpoint',
|
||||
type: 'short-input',
|
||||
placeholder: MODAL_SHARED_INFERENCE_URL,
|
||||
condition: { field: 'operation', value: ENDPOINT_OPERATIONS },
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
title: 'Model',
|
||||
type: 'short-input',
|
||||
placeholder: 'Base model repo ID, or the endpoint hostname for a Shared Endpoint',
|
||||
condition: { field: 'operation', value: 'chat_completion' },
|
||||
required: { field: 'operation', value: 'chat_completion' },
|
||||
},
|
||||
{
|
||||
id: 'systemPrompt',
|
||||
title: 'System Prompt',
|
||||
type: 'long-input',
|
||||
rows: 3,
|
||||
placeholder: 'Instructions that guide how the model responds',
|
||||
condition: { field: 'operation', value: 'chat_completion' },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Generate a system prompt that guides an LLM to behave as described. Return ONLY the prompt text without any markdown formatting or explanation.',
|
||||
generationType: 'system-prompt',
|
||||
placeholder: 'Describe how the model should behave',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
title: 'User Message',
|
||||
canvasNoun: 'a message',
|
||||
type: 'long-input',
|
||||
rows: 3,
|
||||
placeholder: 'Message to send to the model',
|
||||
condition: { field: 'operation', value: 'chat_completion' },
|
||||
required: { field: 'operation', value: 'chat_completion' },
|
||||
},
|
||||
{
|
||||
id: 'maxTokens',
|
||||
title: 'Max Tokens',
|
||||
type: 'short-input',
|
||||
placeholder: 'Maximum number of tokens to generate',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'chat_completion' },
|
||||
},
|
||||
{
|
||||
id: 'temperature',
|
||||
title: 'Temperature',
|
||||
type: 'short-input',
|
||||
placeholder: 'Sampling temperature (e.g., 0.7)',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'chat_completion' },
|
||||
},
|
||||
{
|
||||
id: 'topP',
|
||||
title: 'Top P',
|
||||
type: 'short-input',
|
||||
placeholder: 'Nucleus sampling probability mass between 0 and 1',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'chat_completion' },
|
||||
},
|
||||
],
|
||||
|
||||
tools: {
|
||||
access: ['modal_call_function', 'modal_chat_completion', 'modal_list_models'],
|
||||
config: {
|
||||
tool: (params) => `modal_${params.operation}`,
|
||||
params: (params) => {
|
||||
const { operation, tokenId, tokenSecret, ...rest } = params
|
||||
|
||||
const baseParams: Record<string, unknown> = { tokenId, tokenSecret }
|
||||
|
||||
switch (operation) {
|
||||
case 'call_function':
|
||||
baseParams.url = rest.functionUrl
|
||||
if (rest.method) baseParams.method = rest.method
|
||||
if (rest.requestBody !== undefined && rest.requestBody !== '') {
|
||||
baseParams.body = rest.requestBody
|
||||
}
|
||||
if (rest.queryParams) baseParams.queryParams = rest.queryParams
|
||||
if (rest.requestHeaders) baseParams.headers = rest.requestHeaders
|
||||
break
|
||||
case 'chat_completion':
|
||||
if (rest.endpointUrl) baseParams.endpointUrl = rest.endpointUrl
|
||||
baseParams.model = rest.model
|
||||
baseParams.content = rest.content
|
||||
if (rest.systemPrompt) baseParams.systemPrompt = rest.systemPrompt
|
||||
if (rest.maxTokens !== undefined && rest.maxTokens !== '') {
|
||||
baseParams.maxTokens = Number(rest.maxTokens)
|
||||
}
|
||||
if (rest.temperature !== undefined && rest.temperature !== '') {
|
||||
baseParams.temperature = Number(rest.temperature)
|
||||
}
|
||||
if (rest.topP !== undefined && rest.topP !== '') {
|
||||
baseParams.topP = Number(rest.topP)
|
||||
}
|
||||
break
|
||||
case 'list_models':
|
||||
if (rest.endpointUrl) baseParams.endpointUrl = rest.endpointUrl
|
||||
break
|
||||
}
|
||||
|
||||
return baseParams
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
tokenId: { type: 'string', description: 'Modal proxy token ID' },
|
||||
tokenSecret: { type: 'string', description: 'Modal proxy token secret' },
|
||||
functionUrl: {
|
||||
type: 'string',
|
||||
description: 'URL of the deployed Modal Web Function or Server',
|
||||
},
|
||||
method: { type: 'string', description: 'HTTP method for the function call' },
|
||||
requestBody: { type: 'json', description: 'JSON request body sent to the function' },
|
||||
queryParams: { type: 'json', description: 'Query parameters appended to the function URL' },
|
||||
requestHeaders: { type: 'json', description: 'Additional request headers' },
|
||||
endpointUrl: { type: 'string', description: 'URL of the Modal Endpoint to call' },
|
||||
model: { type: 'string', description: 'Model to generate the completion with' },
|
||||
systemPrompt: { type: 'string', description: 'System prompt guiding the model' },
|
||||
content: { type: 'string', description: 'User message sent to the model' },
|
||||
maxTokens: { type: 'number', description: 'Maximum number of tokens to generate' },
|
||||
temperature: { type: 'number', description: 'Sampling temperature' },
|
||||
topP: { type: 'number', description: 'Nucleus sampling probability mass' },
|
||||
},
|
||||
|
||||
outputs: {
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Response body from the function (call function operation)',
|
||||
},
|
||||
status: { type: 'number', description: 'HTTP status code (call function operation)' },
|
||||
headers: { type: 'json', description: 'Response headers (call function operation)' },
|
||||
content: { type: 'string', description: 'Generated text (chat completion operation)' },
|
||||
model: { type: 'string', description: 'Model used (chat completion operation)' },
|
||||
finishReason: {
|
||||
type: 'string',
|
||||
description: 'Why generation stopped (chat completion operation)',
|
||||
},
|
||||
usage: { type: 'json', description: 'Token usage (chat completion operation)' },
|
||||
models: { type: 'json', description: 'Models the token can reach (list models operation)' },
|
||||
count: { type: 'number', description: 'Number of models returned (list models operation)' },
|
||||
},
|
||||
}
|
||||
|
||||
export const ModalBlockMeta = {
|
||||
tags: ['llm', 'cloud', 'agentic'],
|
||||
url: 'https://modal.com',
|
||||
templates: [
|
||||
{
|
||||
icon: ModalIcon,
|
||||
title: 'Modal GPU inference',
|
||||
prompt:
|
||||
'Build a workflow where an agent receives a question, calls a deployed Modal Web Function that runs a GPU model, and replies with the model output.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['automation', 'inference'],
|
||||
},
|
||||
{
|
||||
icon: ModalIcon,
|
||||
title: 'Modal self-hosted chat',
|
||||
prompt:
|
||||
'Create a workflow that answers incoming Slack messages by generating a chat completion on a Modal Endpoint running an open-weight model, then posts the reply back to the thread.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['llm', 'automation'],
|
||||
alsoIntegrations: ['slack'],
|
||||
},
|
||||
{
|
||||
icon: ModalIcon,
|
||||
title: 'Modal batch scoring',
|
||||
prompt:
|
||||
'Build a scheduled workflow that reads pending rows from a table, calls a deployed Modal function to score each one, and writes the scores back to the table.',
|
||||
modules: ['scheduled', 'tables', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['automation', 'data'],
|
||||
},
|
||||
{
|
||||
icon: ModalIcon,
|
||||
title: 'Modal document summarizer',
|
||||
prompt:
|
||||
'Create a workflow that takes an uploaded document, sends its text to a Modal Endpoint for summarization, and emails the summary back to the requester.',
|
||||
modules: ['files', 'workflows'],
|
||||
category: 'productivity',
|
||||
tags: ['llm', 'documents'],
|
||||
alsoIntegrations: ['gmail'],
|
||||
},
|
||||
{
|
||||
icon: ModalIcon,
|
||||
title: 'Modal endpoint health check',
|
||||
prompt:
|
||||
'Build a scheduled workflow that lists the models a Modal token can reach, sends a short test completion to each one, records the latency in a table, and alerts the engineering channel when an endpoint stops responding.',
|
||||
modules: ['scheduled', 'tables', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['monitoring', 'automation'],
|
||||
alsoIntegrations: ['slack'],
|
||||
},
|
||||
{
|
||||
icon: ModalIcon,
|
||||
title: 'Modal image generation',
|
||||
prompt:
|
||||
'Create a workflow that takes a prompt from a form, calls a deployed Modal function that generates an image, and shares the result in Slack.',
|
||||
modules: ['workflows'],
|
||||
category: 'marketing',
|
||||
tags: ['automation', 'image-generation'],
|
||||
alsoIntegrations: ['slack'],
|
||||
},
|
||||
{
|
||||
icon: ModalIcon,
|
||||
title: 'Modal model comparison',
|
||||
prompt:
|
||||
'Build a workflow that sends the same prompt to two Modal Endpoints, has an agent compare the two completions for accuracy and tone, and writes the verdict to a table.',
|
||||
modules: ['agent', 'tables', 'workflows'],
|
||||
category: 'engineering',
|
||||
tags: ['llm', 'evaluation'],
|
||||
},
|
||||
{
|
||||
icon: ModalIcon,
|
||||
title: 'Modal enrichment pipeline',
|
||||
prompt:
|
||||
'Create a workflow triggered when a new record lands in a table that calls a deployed Modal function to extract structured fields from the raw text, then updates the record with the extracted values.',
|
||||
modules: ['tables', 'workflows'],
|
||||
category: 'operations',
|
||||
tags: ['enrichment', 'automation'],
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
name: 'call-modal-function',
|
||||
description:
|
||||
'Invoke a deployed Modal Web Function or Server over HTTPS and use its response. Use when compute lives on Modal rather than in the workflow.',
|
||||
content:
|
||||
'# Call Modal Function\n\nRun compute that already lives on Modal.\n\n## Steps\n1. Get the function URL from the `modal deploy` output or the Modal dashboard. It looks like `https://<workspace>--<app>-<function>.modal.run`.\n2. Use Call Function with that URL. Pick the method the function expects — POST for a function that reads a JSON body, GET for one that reads query parameters.\n3. If the function is authenticated (Servers require this by default, Web Functions do not), fill in the Token ID and Token Secret from `modal workspace proxy-tokens create`. Leave both empty for an unauthenticated function.\n4. Read the result from the data output. It is parsed JSON when the function responds with `application/json`, and raw text otherwise.\n\n## Output\nReturn what the function produced. If the call fails, report the status code and the error body verbatim rather than retrying blindly — a 401 means the proxy token is missing or not scoped to that environment, and a 503 means a Server has no warm containers yet.',
|
||||
},
|
||||
{
|
||||
name: 'generate-on-modal-endpoint',
|
||||
description:
|
||||
'Generate a chat completion from an open-weight model served by a Modal Endpoint. Use when inference should run on your own Modal compute.',
|
||||
content:
|
||||
'# Generate On Modal Endpoint\n\nRun inference on a model you host on Modal.\n\n## Steps\n1. Create a proxy token with `modal workspace proxy-tokens create` and fill in the Token ID and Token Secret. Endpoints are authenticated by default.\n2. Set the Endpoint URL. Use the URL from `modal endpoint list` for a dedicated endpoint, or leave it empty to reach Shared Endpoints through `https://inference.us-west.modal.direct`.\n3. Set the Model. For a dedicated endpoint this is the base model repo ID; for a Shared Endpoint it is the endpoint hostname. Run List Models first when you are unsure which IDs the token can reach.\n4. Write the user message, and add a system prompt when the response needs a fixed format or persona. Set max tokens and temperature in advanced options for long or deterministic outputs.\n\n## Output\nReturn the generated content. Include the finish reason when it is not `stop` — `length` means the response was cut off and max tokens needs raising.',
|
||||
},
|
||||
{
|
||||
name: 'audit-modal-endpoints',
|
||||
description:
|
||||
'List the models a Modal proxy token can reach and verify each one responds. Use for access audits and endpoint health checks.',
|
||||
content:
|
||||
'# Audit Modal Endpoints\n\nCheck which models a token reaches and whether they are serving.\n\n## Steps\n1. Use List Models with the token you want to audit. Leave the Endpoint URL empty to enumerate every Shared Endpoint the token can reach, or set it to a specific endpoint URL to inspect just that one.\n2. For each model ID returned, send a short Chat Completion (a one-word prompt with max tokens set low) to confirm it is actually serving.\n3. Record which models responded, which failed, and the error for each failure.\n\n## Output\nReturn the reachable model IDs and the result per model. A 401 means the token is invalid; a 403 on an RBAC workspace means the token is not scoped to that environment and needs `modal workspace proxy-tokens allow`.',
|
||||
},
|
||||
],
|
||||
} as const satisfies BlockMeta
|
||||
@@ -221,6 +221,7 @@ import {
|
||||
MistralParseV2Block,
|
||||
MistralParseV3Block,
|
||||
} from '@/blocks/blocks/mistral_parse'
|
||||
import { ModalBlock, ModalBlockMeta } from '@/blocks/blocks/modal'
|
||||
import { MondayBlock, MondayBlockMeta } from '@/blocks/blocks/monday'
|
||||
import { MongoDBBlock, MongoDBBlockMeta } from '@/blocks/blocks/mongodb'
|
||||
import { MothershipBlock } from '@/blocks/blocks/mothership'
|
||||
@@ -558,6 +559,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
|
||||
mistral_parse: MistralParseBlock,
|
||||
mistral_parse_v2: MistralParseV2Block,
|
||||
mistral_parse_v3: MistralParseV3Block,
|
||||
modal: ModalBlock,
|
||||
monday: MondayBlock,
|
||||
mongodb: MongoDBBlock,
|
||||
mothership: MothershipBlock,
|
||||
@@ -871,6 +873,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
|
||||
millionverifier: MillionVerifierBlockMeta,
|
||||
mintlify: MintlifyBlockMeta,
|
||||
mistral_parse: MistralParseBlockMeta,
|
||||
modal: ModalBlockMeta,
|
||||
monday: MondayBlockMeta,
|
||||
mongodb: MongoDBBlockMeta,
|
||||
mssql: MSSQLBlockMeta,
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -156,6 +156,7 @@ import {
|
||||
MillionVerifierIcon,
|
||||
MintlifyIcon,
|
||||
MistralIcon,
|
||||
ModalIcon,
|
||||
MondayIcon,
|
||||
MongoDBIcon,
|
||||
MySQLIcon,
|
||||
@@ -432,6 +433,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
millionverifier: MillionVerifierIcon,
|
||||
mintlify: MintlifyIcon,
|
||||
mistral_parse_v3: MistralIcon,
|
||||
modal: ModalIcon,
|
||||
monday: MondayIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mssql: MicrosoftSqlIcon,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { modalCallFunctionTool } from '@/tools/modal/call_function'
|
||||
import type { ModalCallFunctionParams } from '@/tools/modal/types'
|
||||
|
||||
const transform = modalCallFunctionTool.transformResponse!
|
||||
const buildUrl = modalCallFunctionTool.request.url as (params: ModalCallFunctionParams) => string
|
||||
const buildHeaders = modalCallFunctionTool.request.headers as (
|
||||
params: ModalCallFunctionParams
|
||||
) => Record<string, string>
|
||||
const buildBody = modalCallFunctionTool.request.body as (params: ModalCallFunctionParams) => unknown
|
||||
const resolveMethod = modalCallFunctionTool.request.method as (
|
||||
params: ModalCallFunctionParams
|
||||
) => string
|
||||
|
||||
describe('modalCallFunctionTool request', () => {
|
||||
it('defaults to POST and appends query parameters from a table', () => {
|
||||
const params = {
|
||||
url: 'https://acme--app-fn.modal.run',
|
||||
queryParams: [{ id: '1', cells: { Key: 'debug', Value: 'true' } }],
|
||||
}
|
||||
expect(resolveMethod(params)).toBe('POST')
|
||||
expect(buildUrl(params)).toBe('https://acme--app-fn.modal.run?debug=true')
|
||||
})
|
||||
|
||||
it('normalizes a lowercase method so the bodyless check still matches', () => {
|
||||
const params = { url: 'https://acme--app-fn.modal.run', method: 'get', body: { a: 1 } }
|
||||
expect(resolveMethod(params)).toBe('GET')
|
||||
expect(buildBody(params)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a stale body on GET, so switching the method after typing one is safe', () => {
|
||||
const params = { url: 'https://acme--app-fn.modal.run', method: 'GET', body: '{"a":1}' }
|
||||
expect(buildBody(params)).toBeUndefined()
|
||||
expect(buildHeaders(params)).not.toHaveProperty('Content-Type')
|
||||
})
|
||||
|
||||
it('passes a JSON string through untouched so the transport does not double-encode it', () => {
|
||||
const params = { url: 'https://acme--app-fn.modal.run', body: '{"a":1}' }
|
||||
expect(buildBody(params)).toBe('{"a":1}')
|
||||
expect(buildHeaders(params)['Content-Type']).toBe('application/json')
|
||||
})
|
||||
|
||||
it('lets a caller-supplied content type win over the JSON default', () => {
|
||||
const params = {
|
||||
url: 'https://acme--app-fn.modal.run',
|
||||
body: 'a=1',
|
||||
headers: [
|
||||
{ id: '1', cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } },
|
||||
],
|
||||
}
|
||||
expect(buildHeaders(params)['Content-Type']).toBe('application/x-www-form-urlencoded')
|
||||
})
|
||||
|
||||
it('omits proxy auth for an unauthenticated Web Function', () => {
|
||||
const headers = buildHeaders({ url: 'https://acme--app-fn.modal.run' })
|
||||
expect(headers).not.toHaveProperty('Modal-Key')
|
||||
expect(headers).not.toHaveProperty('Modal-Secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('modalCallFunctionTool transformResponse', () => {
|
||||
it('parses a JSON body the function returned', async () => {
|
||||
const response = new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
await expect(transform(response)).resolves.toMatchObject({
|
||||
success: true,
|
||||
output: { data: { ok: true }, status: 200 },
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a mislabelled JSON body as raw text instead of failing the call', async () => {
|
||||
const response = new Response('not json at all', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
await expect(transform(response)).resolves.toMatchObject({
|
||||
success: true,
|
||||
output: { data: 'not json at all', status: 200 },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a non-JSON body verbatim', async () => {
|
||||
const response = new Response('plain text', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
})
|
||||
await expect(transform(response)).resolves.toMatchObject({ output: { data: 'plain text' } })
|
||||
})
|
||||
|
||||
it('raises the function error rather than reporting success on a 500', async () => {
|
||||
const response = new Response(JSON.stringify({ error: 'boom' }), {
|
||||
status: 500,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
await expect(transform(response)).rejects.toThrow(
|
||||
'Modal function call failed (status 500): boom'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a response body past the size cap instead of buffering it', async () => {
|
||||
const response = new Response('x'.repeat(64), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/plain', 'content-length': String(64 * 1024 * 1024) },
|
||||
})
|
||||
await expect(transform(response)).rejects.toThrow(/exceeds maximum size/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
|
||||
import type { ModalCallFunctionParams, ModalCallFunctionResponse } from '@/tools/modal/types'
|
||||
import {
|
||||
appendModalQueryParams,
|
||||
extractModalError,
|
||||
MAX_MODAL_RESPONSE_BODY_BYTES,
|
||||
modalProxyAuthHeaders,
|
||||
modalWebFunctionUrl,
|
||||
} from '@/tools/modal/utils'
|
||||
import { transformTable } from '@/tools/shared/table'
|
||||
import type { HttpMethod, ToolConfig } from '@/tools/types'
|
||||
|
||||
/** Methods Modal's proxy forwards without a request body. */
|
||||
const BODYLESS_METHODS = new Set<HttpMethod>(['GET', 'HEAD'])
|
||||
|
||||
function resolveMethod(params: ModalCallFunctionParams): HttpMethod {
|
||||
const method = params.method?.toString().trim().toUpperCase()
|
||||
return (method || 'POST') as HttpMethod
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a body the function labelled `application/json`. A Web Function runs
|
||||
* arbitrary user code, so a mislabelled body is its bug to see — surfacing the
|
||||
* raw text beats failing the whole call with a bare `SyntaxError`.
|
||||
*/
|
||||
function parseJsonBody(text: string): unknown {
|
||||
if (!text) return text
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
export const modalCallFunctionTool: ToolConfig<ModalCallFunctionParams, ModalCallFunctionResponse> =
|
||||
{
|
||||
id: 'modal_call_function',
|
||||
name: 'Modal Call Function',
|
||||
description: 'Invoke a deployed Modal Web Function or Server over HTTPS',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
url: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Public URL of the deployed Modal Web Function or Server (e.g., https://your-workspace--your-app-your-function.modal.run)',
|
||||
},
|
||||
method: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'POST',
|
||||
visibility: 'user-or-llm',
|
||||
description: 'HTTP method to use: GET, POST, PUT, PATCH, DELETE, or HEAD',
|
||||
},
|
||||
body: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'JSON request body sent to the function',
|
||||
},
|
||||
queryParams: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Query parameters to append to the URL as key-value pairs',
|
||||
},
|
||||
headers: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Additional request headers as key-value pairs',
|
||||
},
|
||||
tokenId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Modal proxy token ID (wk-...), required for authenticated functions',
|
||||
},
|
||||
tokenSecret: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Modal proxy token secret (ws-...), required for authenticated functions',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
appendModalQueryParams(
|
||||
modalWebFunctionUrl(params.url),
|
||||
transformTable(params.queryParams ?? null)
|
||||
),
|
||||
method: (params) => resolveMethod(params),
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: '*/*',
|
||||
...transformTable(params.headers ?? null),
|
||||
...modalProxyAuthHeaders(params),
|
||||
}
|
||||
|
||||
const hasBody = params.body !== undefined && !BODYLESS_METHODS.has(resolveMethod(params))
|
||||
if (hasBody && !headers['Content-Type'] && !headers['content-type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
return headers
|
||||
},
|
||||
body: (params) => {
|
||||
if (params.body === undefined || BODYLESS_METHODS.has(resolveMethod(params)))
|
||||
return undefined
|
||||
if (typeof params.body === 'string') return params.body
|
||||
return params.body as Record<string, unknown>
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractModalError(response, 'Modal function call failed'))
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[key] = value
|
||||
})
|
||||
|
||||
const text = await readResponseTextWithLimit(response, {
|
||||
maxBytes: MAX_MODAL_RESPONSE_BODY_BYTES,
|
||||
label: 'Modal function response body',
|
||||
allowNoBodyFallback: true,
|
||||
})
|
||||
const isJson = (response.headers.get('content-type') ?? '').includes('application/json')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
data: isJson ? parseJsonBody(text) : text,
|
||||
status: response.status,
|
||||
headers,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
data: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Body returned by the function — parsed JSON when it responds with application/json, otherwise the raw text',
|
||||
},
|
||||
status: { type: 'number', description: 'HTTP status code of the response' },
|
||||
headers: { type: 'json', description: 'Response headers as key-value pairs' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { modalChatCompletionTool } from '@/tools/modal/chat_completion'
|
||||
import type { ModalChatCompletionParams } from '@/tools/modal/types'
|
||||
import { MODAL_SHARED_INFERENCE_URL } from '@/tools/modal/utils'
|
||||
|
||||
const buildUrl = modalChatCompletionTool.request.url as (
|
||||
params: ModalChatCompletionParams
|
||||
) => string
|
||||
const buildBody = modalChatCompletionTool.request.body as (
|
||||
params: ModalChatCompletionParams
|
||||
) => Record<string, unknown>
|
||||
const transform = modalChatCompletionTool.transformResponse!
|
||||
|
||||
const baseParams: ModalChatCompletionParams = {
|
||||
model: 'my-endpoint.us-west.modal.direct',
|
||||
content: 'hello',
|
||||
tokenId: 'wk-1',
|
||||
tokenSecret: 'ws-2',
|
||||
}
|
||||
|
||||
describe('modalChatCompletionTool endpoint resolution', () => {
|
||||
it('falls back to the shared inference host when no endpoint is given', () => {
|
||||
expect(buildUrl(baseParams)).toBe(`${MODAL_SHARED_INFERENCE_URL}/v1/chat/completions`)
|
||||
})
|
||||
|
||||
it('treats a blank or whitespace endpoint the same as an omitted one', () => {
|
||||
expect(buildUrl({ ...baseParams, endpointUrl: '' })).toBe(
|
||||
`${MODAL_SHARED_INFERENCE_URL}/v1/chat/completions`
|
||||
)
|
||||
expect(buildUrl({ ...baseParams, endpointUrl: ' ' })).toBe(
|
||||
`${MODAL_SHARED_INFERENCE_URL}/v1/chat/completions`
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a dedicated endpoint when one is supplied', () => {
|
||||
expect(buildUrl({ ...baseParams, endpointUrl: 'https://mine.us-east.modal.direct' })).toBe(
|
||||
'https://mine.us-east.modal.direct/v1/chat/completions'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('modalChatCompletionTool body', () => {
|
||||
it('prepends the system prompt as a system message only when one is set', () => {
|
||||
expect(buildBody(baseParams).messages).toEqual([{ role: 'user', content: 'hello' }])
|
||||
expect(buildBody({ ...baseParams, systemPrompt: 'be terse' }).messages).toEqual([
|
||||
{ role: 'system', content: 'be terse' },
|
||||
{ role: 'user', content: 'hello' },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps the sampling controls onto their OpenAI wire names', () => {
|
||||
const body = buildBody({ ...baseParams, maxTokens: 256, temperature: 0, topP: 0.9 })
|
||||
expect(body).toMatchObject({ max_tokens: 256, temperature: 0, top_p: 0.9 })
|
||||
})
|
||||
|
||||
it('omits sampling controls that were never set', () => {
|
||||
const body = buildBody(baseParams)
|
||||
expect(body).not.toHaveProperty('max_tokens')
|
||||
expect(body).not.toHaveProperty('temperature')
|
||||
expect(body).not.toHaveProperty('top_p')
|
||||
})
|
||||
})
|
||||
|
||||
describe('modalChatCompletionTool transformResponse', () => {
|
||||
it('extracts the completion, model, finish reason, and usage', async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
model: 'Qwen/Qwen3.5-4B',
|
||||
choices: [{ message: { content: 'hi there' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
|
||||
await expect(transform(response, baseParams)).resolves.toMatchObject({
|
||||
success: true,
|
||||
output: {
|
||||
content: 'hi there',
|
||||
model: 'Qwen/Qwen3.5-4B',
|
||||
finishReason: 'stop',
|
||||
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('nulls usage an engine omitted instead of reporting zeros', async () => {
|
||||
const response = new Response(JSON.stringify({ choices: [{ message: { content: 'hi' } }] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
|
||||
await expect(transform(response, baseParams)).resolves.toMatchObject({
|
||||
output: {
|
||||
content: 'hi',
|
||||
model: 'my-endpoint.us-west.modal.direct',
|
||||
finishReason: null,
|
||||
usage: { prompt_tokens: null, completion_tokens: null, total_tokens: null },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('raises the endpoint error on a rejected proxy token', async () => {
|
||||
const response = new Response(JSON.stringify({ error: 'invalid proxy auth credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
await expect(transform(response, baseParams)).rejects.toThrow(
|
||||
'Modal chat completion failed (status 401): invalid proxy auth credentials'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
|
||||
import type {
|
||||
ModalChatCompletionApiResponse,
|
||||
ModalChatCompletionParams,
|
||||
ModalChatCompletionResponse,
|
||||
} from '@/tools/modal/types'
|
||||
import {
|
||||
extractModalError,
|
||||
MAX_MODAL_RESPONSE_BODY_BYTES,
|
||||
MODAL_SHARED_INFERENCE_URL,
|
||||
modalOpenAiUrl,
|
||||
modalProxyAuthHeaders,
|
||||
toOptionalNumber,
|
||||
} from '@/tools/modal/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const modalChatCompletionTool: ToolConfig<
|
||||
ModalChatCompletionParams,
|
||||
ModalChatCompletionResponse
|
||||
> = {
|
||||
id: 'modal_chat_completion',
|
||||
name: 'Modal Chat Completion',
|
||||
description: 'Generate a chat completion from a model served by a Modal Endpoint',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
endpointUrl: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: MODAL_SHARED_INFERENCE_URL,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'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: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Model to generate with — the base model repo ID for a dedicated endpoint, or the endpoint hostname for a Shared Endpoint',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The user message content to send to the model',
|
||||
},
|
||||
systemPrompt: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'System prompt to guide the model behavior',
|
||||
},
|
||||
maxTokens: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of tokens to generate',
|
||||
},
|
||||
temperature: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Sampling temperature (e.g., 0 for deterministic, 0.7 for creative)',
|
||||
},
|
||||
topP: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Nucleus sampling probability mass between 0 and 1',
|
||||
},
|
||||
tokenId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Modal proxy token ID (wk-...)',
|
||||
},
|
||||
tokenSecret: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Modal proxy token secret (ws-...)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
modelInput: {
|
||||
mode: 'project',
|
||||
select: (params) => ({
|
||||
systemPrompt: params.systemPrompt,
|
||||
content: params.content,
|
||||
}),
|
||||
},
|
||||
url: (params) =>
|
||||
modalOpenAiUrl(params.endpointUrl?.trim() || MODAL_SHARED_INFERENCE_URL, '/chat/completions'),
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/json',
|
||||
...modalProxyAuthHeaders(params, { required: true }),
|
||||
}),
|
||||
body: (params) => {
|
||||
const messages: Array<{ role: string; content: string }> = []
|
||||
if (params.systemPrompt) {
|
||||
messages.push({ role: 'system', content: params.systemPrompt })
|
||||
}
|
||||
messages.push({ role: 'user', content: params.content })
|
||||
|
||||
const body: Record<string, unknown> = { model: params.model, messages }
|
||||
|
||||
const maxTokens = toOptionalNumber(params.maxTokens)
|
||||
if (maxTokens !== undefined) body.max_tokens = maxTokens
|
||||
const temperature = toOptionalNumber(params.temperature)
|
||||
if (temperature !== undefined) body.temperature = temperature
|
||||
const topP = toOptionalNumber(params.topP)
|
||||
if (topP !== undefined) body.top_p = topP
|
||||
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response, params) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractModalError(response, 'Modal chat completion failed'))
|
||||
}
|
||||
|
||||
const data = await readResponseJsonWithLimit<ModalChatCompletionApiResponse>(response, {
|
||||
maxBytes: MAX_MODAL_RESPONSE_BODY_BYTES,
|
||||
label: 'Modal chat completion response body',
|
||||
})
|
||||
const choice = data?.choices?.[0]
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: choice?.message?.content ?? '',
|
||||
model: data?.model ?? params?.model ?? '',
|
||||
finishReason: choice?.finish_reason ?? null,
|
||||
usage: {
|
||||
prompt_tokens: data?.usage?.prompt_tokens ?? null,
|
||||
completion_tokens: data?.usage?.completion_tokens ?? null,
|
||||
total_tokens: data?.usage?.total_tokens ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Generated text content' },
|
||||
model: { type: 'string', description: 'Model that produced the completion' },
|
||||
finishReason: {
|
||||
type: 'string',
|
||||
description: 'Why generation stopped (e.g., stop, length)',
|
||||
optional: true,
|
||||
},
|
||||
usage: {
|
||||
type: 'object',
|
||||
description: 'Token usage reported by the endpoint',
|
||||
properties: {
|
||||
prompt_tokens: {
|
||||
type: 'number',
|
||||
description: 'Number of tokens in the prompt',
|
||||
optional: true,
|
||||
},
|
||||
completion_tokens: {
|
||||
type: 'number',
|
||||
description: 'Number of tokens in the completion',
|
||||
optional: true,
|
||||
},
|
||||
total_tokens: {
|
||||
type: 'number',
|
||||
description: 'Total number of tokens used',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { modalCallFunctionTool } from '@/tools/modal/call_function'
|
||||
export { modalChatCompletionTool } from '@/tools/modal/chat_completion'
|
||||
export { modalListModelsTool } from '@/tools/modal/list_models'
|
||||
@@ -0,0 +1,85 @@
|
||||
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
|
||||
import type {
|
||||
ModalListModelsApiResponse,
|
||||
ModalListModelsParams,
|
||||
ModalListModelsResponse,
|
||||
} from '@/tools/modal/types'
|
||||
import {
|
||||
extractModalError,
|
||||
MAX_MODAL_RESPONSE_BODY_BYTES,
|
||||
MODAL_MODEL_OUTPUT_PROPERTIES,
|
||||
MODAL_SHARED_INFERENCE_URL,
|
||||
mapModalModel,
|
||||
modalOpenAiUrl,
|
||||
modalProxyAuthHeaders,
|
||||
} from '@/tools/modal/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const modalListModelsTool: ToolConfig<ModalListModelsParams, ModalListModelsResponse> = {
|
||||
id: 'modal_list_models',
|
||||
name: 'Modal List Models',
|
||||
description: 'List the model IDs a Modal proxy token can reach on an endpoint',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
endpointUrl: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: MODAL_SHARED_INFERENCE_URL,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Endpoint URL to query. Defaults to https://inference.us-west.modal.direct, which lists every Shared Endpoint the token can reach',
|
||||
},
|
||||
tokenId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Modal proxy token ID (wk-...)',
|
||||
},
|
||||
tokenSecret: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Modal proxy token secret (ws-...)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
modalOpenAiUrl(params.endpointUrl?.trim() || MODAL_SHARED_INFERENCE_URL, '/models'),
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
Accept: 'application/json',
|
||||
...modalProxyAuthHeaders(params, { required: true }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractModalError(response, 'Failed to list Modal models'))
|
||||
}
|
||||
|
||||
const data = await readResponseJsonWithLimit<ModalListModelsApiResponse>(response, {
|
||||
maxBytes: MAX_MODAL_RESPONSE_BODY_BYTES,
|
||||
label: 'Modal models response body',
|
||||
})
|
||||
const models = Array.isArray(data?.data) ? data.data.map(mapModalModel) : []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
models,
|
||||
count: models.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
models: {
|
||||
type: 'array',
|
||||
description: 'Models the token can reach on the endpoint',
|
||||
items: { type: 'object', properties: MODAL_MODEL_OUTPUT_PROPERTIES },
|
||||
},
|
||||
count: { type: 'number', description: 'Number of models returned' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { HttpMethod, TableRow, ToolResponse } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Modal proxy-token pair. The token ID starts with `wk-` and the secret with
|
||||
* `ws-`; both are created with `modal workspace proxy-tokens create`.
|
||||
*/
|
||||
export interface ModalProxyTokenParams {
|
||||
tokenId?: string
|
||||
tokenSecret?: string
|
||||
}
|
||||
|
||||
export interface ModalCallFunctionParams extends ModalProxyTokenParams {
|
||||
url: string
|
||||
method?: HttpMethod
|
||||
queryParams?: TableRow[] | Record<string, string> | string
|
||||
headers?: TableRow[] | Record<string, string> | string
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export interface ModalChatCompletionParams extends ModalProxyTokenParams {
|
||||
endpointUrl: string
|
||||
model: string
|
||||
content: string
|
||||
systemPrompt?: string
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
topP?: number
|
||||
}
|
||||
|
||||
export interface ModalListModelsParams extends ModalProxyTokenParams {
|
||||
endpointUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire shapes served by a Modal Endpoint's OpenAI-compatible `/v1` API. Every
|
||||
* field is optional because the payload comes from whichever inference engine
|
||||
* backs the endpoint — the readers stay defensive, and these types exist so a
|
||||
* future change to that mapping is caught by the compiler.
|
||||
*/
|
||||
export interface ModalApiModel {
|
||||
id?: string | null
|
||||
object?: string | null
|
||||
created?: number | null
|
||||
owned_by?: string | null
|
||||
}
|
||||
|
||||
export interface ModalListModelsApiResponse {
|
||||
data?: ModalApiModel[] | null
|
||||
}
|
||||
|
||||
export interface ModalChatCompletionApiResponse {
|
||||
model?: string | null
|
||||
choices?: Array<{
|
||||
message?: { content?: string | null } | null
|
||||
finish_reason?: string | null
|
||||
}> | null
|
||||
usage?: {
|
||||
prompt_tokens?: number | null
|
||||
completion_tokens?: number | null
|
||||
total_tokens?: number | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface ModalCallFunctionResponse extends ToolResponse {
|
||||
output: {
|
||||
data: unknown
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModalChatCompletionResponse extends ToolResponse {
|
||||
output: {
|
||||
content: string
|
||||
model: string
|
||||
finishReason: string | null
|
||||
usage: {
|
||||
prompt_tokens: number | null
|
||||
completion_tokens: number | null
|
||||
total_tokens: number | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModalModelSummary {
|
||||
id: string
|
||||
object: string | null
|
||||
created: number | null
|
||||
ownedBy: string | null
|
||||
}
|
||||
|
||||
export interface ModalListModelsResponse extends ToolResponse {
|
||||
output: {
|
||||
models: ModalModelSummary[]
|
||||
count: number
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendModalQueryParams,
|
||||
extractModalError,
|
||||
modalOpenAiUrl,
|
||||
modalProxyAuthHeaders,
|
||||
modalWebFunctionUrl,
|
||||
} from '@/tools/modal/utils'
|
||||
|
||||
describe('modalProxyAuthHeaders', () => {
|
||||
it('sends the token pair as Modal-Key/Modal-Secret so Authorization stays free', () => {
|
||||
expect(modalProxyAuthHeaders({ tokenId: 'wk-123', tokenSecret: 'ws-456' })).toEqual({
|
||||
'Modal-Key': 'wk-123',
|
||||
'Modal-Secret': 'ws-456',
|
||||
})
|
||||
})
|
||||
|
||||
it('trims pasted whitespace off both halves of the pair', () => {
|
||||
expect(modalProxyAuthHeaders({ tokenId: ' wk-123 ', tokenSecret: '\tws-456\n' })).toEqual({
|
||||
'Modal-Key': 'wk-123',
|
||||
'Modal-Secret': 'ws-456',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits auth entirely when the pair is absent, for unauthenticated Web Functions', () => {
|
||||
expect(modalProxyAuthHeaders({})).toEqual({})
|
||||
expect(modalProxyAuthHeaders({ tokenId: 'wk-123' })).toEqual({})
|
||||
expect(modalProxyAuthHeaders({ tokenId: 'wk-123', tokenSecret: ' ' })).toEqual({})
|
||||
})
|
||||
|
||||
it('fails loudly when a half is missing on an endpoint that always authenticates', () => {
|
||||
expect(() => modalProxyAuthHeaders({ tokenId: 'wk-123' }, { required: true })).toThrow(
|
||||
/token ID and token secret are required/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('modalOpenAiUrl', () => {
|
||||
it('appends the /v1 root the endpoint serves its API under', () => {
|
||||
expect(modalOpenAiUrl('https://my-endpoint.us-west.modal.direct', '/chat/completions')).toBe(
|
||||
'https://my-endpoint.us-west.modal.direct/v1/chat/completions'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not double the /v1 when the pasted URL already carries it', () => {
|
||||
expect(modalOpenAiUrl('https://inference.us-west.modal.direct/v1', '/models')).toBe(
|
||||
'https://inference.us-west.modal.direct/v1/models'
|
||||
)
|
||||
})
|
||||
|
||||
it('tolerates trailing slashes from a copied dashboard URL', () => {
|
||||
expect(modalOpenAiUrl('https://inference.us-west.modal.direct//', '/models')).toBe(
|
||||
'https://inference.us-west.modal.direct/v1/models'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a cleartext URL rather than leaking the proxy token', () => {
|
||||
expect(() => modalOpenAiUrl('http://my-endpoint.modal.direct', '/models')).toThrow(/https/)
|
||||
})
|
||||
|
||||
it('rejects a missing or relative URL', () => {
|
||||
expect(() => modalOpenAiUrl(undefined, '/models')).toThrow(/required/)
|
||||
expect(() => modalOpenAiUrl('my-endpoint.modal.direct', '/models')).toThrow(/absolute URL/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('modalWebFunctionUrl', () => {
|
||||
it('keeps the function path and drops the trailing slash', () => {
|
||||
expect(modalWebFunctionUrl('https://acme--app-fn.modal.run/predict/')).toBe(
|
||||
'https://acme--app-fn.modal.run/predict'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a cleartext function URL', () => {
|
||||
expect(() => modalWebFunctionUrl('http://acme--app-fn.modal.run')).toThrow(/https/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendModalQueryParams', () => {
|
||||
it('returns the URL untouched when there is nothing to append', () => {
|
||||
expect(appendModalQueryParams('https://acme--app-fn.modal.run', {})).toBe(
|
||||
'https://acme--app-fn.modal.run'
|
||||
)
|
||||
})
|
||||
|
||||
it('encodes keys and values and joins onto an existing query string', () => {
|
||||
expect(appendModalQueryParams('https://acme--app-fn.modal.run?a=1', { 'b c': 'd&e' })).toBe(
|
||||
'https://acme--app-fn.modal.run?a=1&b%20c=d%26e'
|
||||
)
|
||||
})
|
||||
|
||||
it('skips blank keys and null values so an empty table row adds nothing', () => {
|
||||
expect(
|
||||
appendModalQueryParams('https://acme--app-fn.modal.run', {
|
||||
'': 'orphan',
|
||||
keep: 'yes',
|
||||
drop: null,
|
||||
})
|
||||
).toBe('https://acme--app-fn.modal.run?keep=yes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractModalError', () => {
|
||||
it('reads the proxy shape returned by a rejected token', async () => {
|
||||
const response = new Response(JSON.stringify({ error: 'invalid proxy auth credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
await expect(extractModalError(response, 'Modal chat completion failed')).resolves.toBe(
|
||||
'Modal chat completion failed (status 401): invalid proxy auth credentials'
|
||||
)
|
||||
})
|
||||
|
||||
it('reads the nested OpenAI-compatible shape an inference server returns', async () => {
|
||||
const response = new Response(JSON.stringify({ error: { message: 'model not found' } }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
await expect(extractModalError(response, 'Modal chat completion failed')).resolves.toBe(
|
||||
'Modal chat completion failed (status 404): model not found'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the raw body when a Web Function errors with plain text', async () => {
|
||||
const response = new Response('Traceback: boom', { status: 500 })
|
||||
await expect(extractModalError(response, 'Modal function call failed')).resolves.toBe(
|
||||
'Modal function call failed (status 500): Traceback: boom'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the status alone when the error carries no body', async () => {
|
||||
const response = new Response(null, { status: 503 })
|
||||
await expect(extractModalError(response, 'Modal function call failed')).resolves.toBe(
|
||||
'Modal function call failed (status 503)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
|
||||
import type { ModalApiModel, ModalModelSummary, ModalProxyTokenParams } from '@/tools/modal/types'
|
||||
|
||||
/**
|
||||
* Shared Endpoints are reachable through this host and routed on the OpenAI
|
||||
* `model` field, so the model ID is the endpoint's own hostname.
|
||||
*/
|
||||
export const MODAL_SHARED_INFERENCE_URL = 'https://inference.us-west.modal.direct'
|
||||
|
||||
export const MAX_MODAL_RESPONSE_BODY_BYTES = 10 * 1024 * 1024
|
||||
|
||||
const MAX_MODAL_ERROR_BODY_BYTES = 64 * 1024
|
||||
|
||||
/**
|
||||
* Builds the proxy-token headers Modal's request proxy authenticates against.
|
||||
*
|
||||
* Uses the `Modal-Key`/`Modal-Secret` pair rather than the equivalent combined
|
||||
* `Authorization: Bearer wk-<id>.ws-<secret>` form, so a web function that
|
||||
* validates its own bearer token keeps the `Authorization` header free.
|
||||
*/
|
||||
export function modalProxyAuthHeaders(
|
||||
params: ModalProxyTokenParams,
|
||||
options: { required?: boolean } = {}
|
||||
): Record<string, string> {
|
||||
const tokenId = params.tokenId?.trim()
|
||||
const tokenSecret = params.tokenSecret?.trim()
|
||||
|
||||
if (!tokenId || !tokenSecret) {
|
||||
if (options.required) {
|
||||
throw new Error(
|
||||
'Modal token ID and token secret are required. Create a proxy token with `modal workspace proxy-tokens create`.'
|
||||
)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
return { 'Modal-Key': tokenId, 'Modal-Secret': tokenSecret }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an absolute Modal URL and strips its trailing slashes. Modal
|
||||
* terminates TLS for `.modal.run`, `.modal.direct`, and custom domains alike,
|
||||
* so a cleartext URL is always a misconfiguration that would leak the token.
|
||||
*/
|
||||
function parseModalUrl(rawUrl: string | undefined, label: string): URL {
|
||||
const trimmed = rawUrl?.trim().replace(/\/+$/, '')
|
||||
if (!trimmed) {
|
||||
throw new Error(`${label} is required`)
|
||||
}
|
||||
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(trimmed)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`${label} must be an absolute URL (e.g. https://your-workspace--your-app-your-function.modal.run)`
|
||||
)
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new Error(`${label} must use https`)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
/** Resolves the URL of a deployed Modal Web Function or Server. */
|
||||
export function modalWebFunctionUrl(rawUrl: string): string {
|
||||
return parseModalUrl(rawUrl, 'Modal function URL').toString().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an OpenAI-compatible path on a Modal Endpoint. Accepts the endpoint
|
||||
* root or a URL that already ends in `/v1`, so a value copied from the
|
||||
* dashboard and one copied from `modal endpoint list` resolve identically.
|
||||
*/
|
||||
export function modalOpenAiUrl(rawUrl: string | undefined, path: string): string {
|
||||
const parsed = parseModalUrl(rawUrl, 'Modal endpoint URL')
|
||||
const base = parsed.toString().replace(/\/+$/, '')
|
||||
const root = base.endsWith('/v1') ? base : `${base}/v1`
|
||||
return `${root}${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends query parameters to a URL, skipping empty keys so a blank table row
|
||||
* cannot introduce a stray `?=` into the request.
|
||||
*/
|
||||
export function appendModalQueryParams(url: string, queryParams: Record<string, unknown>): string {
|
||||
const entries = Object.entries(queryParams).filter(
|
||||
([key, value]) => key.trim() !== '' && value !== undefined && value !== null
|
||||
)
|
||||
if (entries.length === 0) return url
|
||||
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
const query = entries
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join('&')
|
||||
return `${url}${separator}${query}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a readable message from a Modal proxy or endpoint error response.
|
||||
* The proxy returns `{ "error": "..." }`; the OpenAI-compatible inference
|
||||
* servers behind an Endpoint return `{ "error": { "message": "..." } }`.
|
||||
*/
|
||||
export async function extractModalError(response: Response, fallback: string): Promise<string> {
|
||||
const prefix = `${fallback} (status ${response.status})`
|
||||
|
||||
let text: string
|
||||
try {
|
||||
text = await readResponseTextWithLimit(response, {
|
||||
maxBytes: MAX_MODAL_ERROR_BODY_BYTES,
|
||||
label: 'Modal error response body',
|
||||
allowNoBodyFallback: true,
|
||||
})
|
||||
} catch {
|
||||
return prefix
|
||||
}
|
||||
|
||||
if (!text.trim()) return prefix
|
||||
|
||||
try {
|
||||
const data = JSON.parse(text)
|
||||
if (typeof data?.error === 'string') return `${prefix}: ${data.error}`
|
||||
if (typeof data?.error?.message === 'string') return `${prefix}: ${data.error.message}`
|
||||
if (typeof data?.detail === 'string') return `${prefix}: ${data.detail}`
|
||||
if (typeof data?.message === 'string') return `${prefix}: ${data.message}`
|
||||
} catch {
|
||||
return `${prefix}: ${truncate(text.trim(), 500)}`
|
||||
}
|
||||
|
||||
return `${prefix}: ${truncate(text.trim(), 500)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces an optional user- or LLM-provided value to a number, treating
|
||||
* empty/missing values as undefined.
|
||||
*/
|
||||
export function toOptionalNumber(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
const num = Number(value)
|
||||
return Number.isNaN(num) ? undefined : num
|
||||
}
|
||||
|
||||
/** Maps a raw OpenAI-compatible model entry to the normalized summary shape. */
|
||||
export function mapModalModel(model: ModalApiModel | null | undefined): ModalModelSummary {
|
||||
return {
|
||||
id: model?.id ?? '',
|
||||
object: model?.object ?? null,
|
||||
created: model?.created ?? null,
|
||||
ownedBy: model?.owned_by ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export const MODAL_MODEL_OUTPUT_PROPERTIES = {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Model ID. For a Shared Endpoint this is the endpoint hostname',
|
||||
},
|
||||
object: { type: 'string', description: 'Object type reported by the endpoint', optional: true },
|
||||
created: { type: 'number', description: 'Creation timestamp in epoch seconds', optional: true },
|
||||
ownedBy: { type: 'string', description: 'Owner reported by the endpoint', optional: true },
|
||||
} as const
|
||||
@@ -2955,6 +2955,7 @@ import {
|
||||
mintlifyTriggerUpdateTool,
|
||||
} from '@/tools/mintlify'
|
||||
import { mistralParserTool, mistralParserV2Tool, mistralParserV3Tool } from '@/tools/mistral'
|
||||
import { modalCallFunctionTool, modalChatCompletionTool, modalListModelsTool } from '@/tools/modal'
|
||||
import {
|
||||
mondayArchiveItemTool,
|
||||
mondayChangeColumnValueTool,
|
||||
@@ -10546,4 +10547,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
mintlify_get_searches: mintlifyGetSearchesTool,
|
||||
mintlify_get_views: mintlifyGetViewsTool,
|
||||
mintlify_get_visitors: mintlifyGetVisitorsTool,
|
||||
modal_call_function: modalCallFunctionTool,
|
||||
modal_chat_completion: modalChatCompletionTool,
|
||||
modal_list_models: modalListModelsTool,
|
||||
}
|
||||
|
||||
@@ -14226,6 +14226,37 @@
|
||||
"integrationType": "ai",
|
||||
"tags": ["document-processing", "ocr"]
|
||||
},
|
||||
{
|
||||
"type": "modal",
|
||||
"slug": "modal",
|
||||
"name": "Modal",
|
||||
"description": "Call deployed Modal functions and endpoints",
|
||||
"longDescription": "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.",
|
||||
"bgColor": "#000000",
|
||||
"iconName": "ModalIcon",
|
||||
"docsUrl": "https://docs.sim.ai/integrations/modal",
|
||||
"operations": [
|
||||
{
|
||||
"name": "Call Function",
|
||||
"description": "Invoke a deployed Modal Web Function or Server over HTTPS"
|
||||
},
|
||||
{
|
||||
"name": "Chat Completion",
|
||||
"description": "Generate a chat completion from a model served by a Modal Endpoint"
|
||||
},
|
||||
{
|
||||
"name": "List Models",
|
||||
"description": "List the model IDs a Modal proxy token can reach on an endpoint"
|
||||
}
|
||||
],
|
||||
"operationCount": 3,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "api-key",
|
||||
"category": "tools",
|
||||
"integrationType": "ai",
|
||||
"tags": ["llm", "cloud", "agentic"]
|
||||
},
|
||||
{
|
||||
"type": "monday",
|
||||
"slug": "monday",
|
||||
|
||||
Reference in New Issue
Block a user