Skip to content

Commit 3312181

Browse files
icecrasher321claude
andcommitted
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>
1 parent da11e69 commit 3312181

11 files changed

Lines changed: 186 additions & 27 deletions

File tree

apps/docs/content/docs/en/integrations/modal.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Generate a chat completion from a model served by a Modal Endpoint
5050

5151
| Parameter | Type | Required | Description |
5252
| --------- | ---- | -------- | ----------- |
53-
| `endpointUrl` | string | Yes | Endpoint URL from the Modal dashboard or `modal endpoint list`. Use https://inference.us-west.modal.direct for Shared Endpoints |
53+
| `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 |
5454
| `model` | string | Yes | Model to generate with — the base model repo ID for a dedicated endpoint, or the endpoint hostname for a Shared Endpoint |
5555
| `content` | string | Yes | The user message content to send to the model |
5656
| `systemPrompt` | string | No | System prompt to guide the model behavior |

apps/sim/blocks/blocks/modal.test.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,19 @@ describe('ModalBlock', () => {
109109
expect(params).not.toHaveProperty('topP')
110110
})
111111

112-
it('leaves the endpoint URL unset on list models so the shared inference default applies', () => {
113-
expect(buildParams({ operation: 'list_models', endpointUrl: '' })).not.toHaveProperty(
114-
'endpointUrl'
115-
)
112+
it('leaves a blank endpoint URL unset so the shared inference default applies', () => {
113+
for (const operation of ['list_models', 'chat_completion']) {
114+
expect(buildParams({ operation, endpointUrl: '' })).not.toHaveProperty('endpointUrl')
115+
expect(
116+
buildParams({ operation, endpointUrl: 'https://my-endpoint.modal.direct' })
117+
).toMatchObject({ endpointUrl: 'https://my-endpoint.modal.direct' })
118+
}
119+
})
120+
121+
it('never marks the endpoint URL required, matching the skill that says to leave it empty', () => {
116122
expect(
117-
buildParams({ operation: 'list_models', endpointUrl: 'https://my-endpoint.modal.direct' })
118-
).toMatchObject({ endpointUrl: 'https://my-endpoint.modal.direct' })
123+
ModalBlock.subBlocks.find((subBlock) => subBlock.id === 'endpointUrl')?.required
124+
).toBeUndefined()
119125
})
120126

121127
it('requires the token pair only where Modal always authenticates', () => {

apps/sim/blocks/blocks/modal.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,6 @@ export const ModalBlock: BlockConfig = {
135135
type: 'short-input',
136136
placeholder: MODAL_SHARED_INFERENCE_URL,
137137
condition: { field: 'operation', value: ENDPOINT_OPERATIONS },
138-
required: { field: 'operation', value: 'chat_completion' },
139138
},
140139
{
141140
id: 'model',
@@ -216,7 +215,7 @@ export const ModalBlock: BlockConfig = {
216215
if (rest.requestHeaders) baseParams.headers = rest.requestHeaders
217216
break
218217
case 'chat_completion':
219-
baseParams.endpointUrl = rest.endpointUrl
218+
if (rest.endpointUrl) baseParams.endpointUrl = rest.endpointUrl
220219
baseParams.model = rest.model
221220
baseParams.content = rest.content
222221
if (rest.systemPrompt) baseParams.systemPrompt = rest.systemPrompt

apps/sim/tools/generated/tool-metadata.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/modal/call_function.test.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,16 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import { modalCallFunctionTool } from '@/tools/modal/call_function'
6+
import type { ModalCallFunctionParams } from '@/tools/modal/types'
67

78
const transform = modalCallFunctionTool.transformResponse!
8-
const buildUrl = modalCallFunctionTool.request.url as (params: Record<string, any>) => string
9+
const buildUrl = modalCallFunctionTool.request.url as (params: ModalCallFunctionParams) => string
910
const buildHeaders = modalCallFunctionTool.request.headers as (
10-
params: Record<string, any>
11+
params: ModalCallFunctionParams
1112
) => Record<string, string>
12-
const buildBody = modalCallFunctionTool.request.body as (
13-
params: Record<string, any>
14-
) => unknown | undefined
13+
const buildBody = modalCallFunctionTool.request.body as (params: ModalCallFunctionParams) => unknown
1514
const resolveMethod = modalCallFunctionTool.request.method as (
16-
params: Record<string, any>
15+
params: ModalCallFunctionParams
1716
) => string
1817

1918
describe('modalCallFunctionTool request', () => {

apps/sim/tools/modal/call_function.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export const modalCallFunctionTool: ToolConfig<ModalCallFunctionParams, ModalCal
111111
if (params.body === undefined || BODYLESS_METHODS.has(resolveMethod(params)))
112112
return undefined
113113
if (typeof params.body === 'string') return params.body
114-
return params.body as Record<string, any>
114+
return params.body as Record<string, unknown>
115115
},
116116
},
117117

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { modalChatCompletionTool } from '@/tools/modal/chat_completion'
6+
import type { ModalChatCompletionParams } from '@/tools/modal/types'
7+
import { MODAL_SHARED_INFERENCE_URL } from '@/tools/modal/utils'
8+
9+
const buildUrl = modalChatCompletionTool.request.url as (
10+
params: ModalChatCompletionParams
11+
) => string
12+
const buildBody = modalChatCompletionTool.request.body as (
13+
params: ModalChatCompletionParams
14+
) => Record<string, unknown>
15+
const transform = modalChatCompletionTool.transformResponse!
16+
17+
const baseParams: ModalChatCompletionParams = {
18+
model: 'my-endpoint.us-west.modal.direct',
19+
content: 'hello',
20+
tokenId: 'wk-1',
21+
tokenSecret: 'ws-2',
22+
}
23+
24+
describe('modalChatCompletionTool endpoint resolution', () => {
25+
it('falls back to the shared inference host when no endpoint is given', () => {
26+
expect(buildUrl(baseParams)).toBe(`${MODAL_SHARED_INFERENCE_URL}/v1/chat/completions`)
27+
})
28+
29+
it('treats a blank or whitespace endpoint the same as an omitted one', () => {
30+
expect(buildUrl({ ...baseParams, endpointUrl: '' })).toBe(
31+
`${MODAL_SHARED_INFERENCE_URL}/v1/chat/completions`
32+
)
33+
expect(buildUrl({ ...baseParams, endpointUrl: ' ' })).toBe(
34+
`${MODAL_SHARED_INFERENCE_URL}/v1/chat/completions`
35+
)
36+
})
37+
38+
it('uses a dedicated endpoint when one is supplied', () => {
39+
expect(buildUrl({ ...baseParams, endpointUrl: 'https://mine.us-east.modal.direct' })).toBe(
40+
'https://mine.us-east.modal.direct/v1/chat/completions'
41+
)
42+
})
43+
})
44+
45+
describe('modalChatCompletionTool body', () => {
46+
it('prepends the system prompt as a system message only when one is set', () => {
47+
expect(buildBody(baseParams).messages).toEqual([{ role: 'user', content: 'hello' }])
48+
expect(buildBody({ ...baseParams, systemPrompt: 'be terse' }).messages).toEqual([
49+
{ role: 'system', content: 'be terse' },
50+
{ role: 'user', content: 'hello' },
51+
])
52+
})
53+
54+
it('maps the sampling controls onto their OpenAI wire names', () => {
55+
const body = buildBody({ ...baseParams, maxTokens: 256, temperature: 0, topP: 0.9 })
56+
expect(body).toMatchObject({ max_tokens: 256, temperature: 0, top_p: 0.9 })
57+
})
58+
59+
it('omits sampling controls that were never set', () => {
60+
const body = buildBody(baseParams)
61+
expect(body).not.toHaveProperty('max_tokens')
62+
expect(body).not.toHaveProperty('temperature')
63+
expect(body).not.toHaveProperty('top_p')
64+
})
65+
})
66+
67+
describe('modalChatCompletionTool transformResponse', () => {
68+
it('extracts the completion, model, finish reason, and usage', async () => {
69+
const response = new Response(
70+
JSON.stringify({
71+
model: 'Qwen/Qwen3.5-4B',
72+
choices: [{ message: { content: 'hi there' }, finish_reason: 'stop' }],
73+
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
74+
}),
75+
{ status: 200, headers: { 'content-type': 'application/json' } }
76+
)
77+
78+
await expect(transform(response, baseParams)).resolves.toMatchObject({
79+
success: true,
80+
output: {
81+
content: 'hi there',
82+
model: 'Qwen/Qwen3.5-4B',
83+
finishReason: 'stop',
84+
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
85+
},
86+
})
87+
})
88+
89+
it('nulls usage an engine omitted instead of reporting zeros', async () => {
90+
const response = new Response(JSON.stringify({ choices: [{ message: { content: 'hi' } }] }), {
91+
status: 200,
92+
headers: { 'content-type': 'application/json' },
93+
})
94+
95+
await expect(transform(response, baseParams)).resolves.toMatchObject({
96+
output: {
97+
content: 'hi',
98+
model: 'my-endpoint.us-west.modal.direct',
99+
finishReason: null,
100+
usage: { prompt_tokens: null, completion_tokens: null, total_tokens: null },
101+
},
102+
})
103+
})
104+
105+
it('raises the endpoint error on a rejected proxy token', async () => {
106+
const response = new Response(JSON.stringify({ error: 'invalid proxy auth credentials' }), {
107+
status: 401,
108+
headers: { 'content-type': 'application/json' },
109+
})
110+
await expect(transform(response, baseParams)).rejects.toThrow(
111+
'Modal chat completion failed (status 401): invalid proxy auth credentials'
112+
)
113+
})
114+
})

apps/sim/tools/modal/chat_completion.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
2-
import type { ModalChatCompletionParams, ModalChatCompletionResponse } from '@/tools/modal/types'
2+
import type {
3+
ModalChatCompletionApiResponse,
4+
ModalChatCompletionParams,
5+
ModalChatCompletionResponse,
6+
} from '@/tools/modal/types'
37
import {
48
extractModalError,
59
MAX_MODAL_RESPONSE_BODY_BYTES,
10+
MODAL_SHARED_INFERENCE_URL,
611
modalOpenAiUrl,
712
modalProxyAuthHeaders,
813
toOptionalNumber,
@@ -21,10 +26,11 @@ export const modalChatCompletionTool: ToolConfig<
2126
params: {
2227
endpointUrl: {
2328
type: 'string',
24-
required: true,
29+
required: false,
30+
default: MODAL_SHARED_INFERENCE_URL,
2531
visibility: 'user-or-llm',
2632
description:
27-
'Endpoint URL from the Modal dashboard or `modal endpoint list`. Use https://inference.us-west.modal.direct for Shared Endpoints',
33+
'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',
2834
},
2935
model: {
3036
type: 'string',
@@ -85,7 +91,8 @@ export const modalChatCompletionTool: ToolConfig<
8591
content: params.content,
8692
}),
8793
},
88-
url: (params) => modalOpenAiUrl(params.endpointUrl, '/chat/completions'),
94+
url: (params) =>
95+
modalOpenAiUrl(params.endpointUrl?.trim() || MODAL_SHARED_INFERENCE_URL, '/chat/completions'),
8996
method: 'POST',
9097
headers: (params) => ({
9198
'Content-Type': 'application/json',
@@ -98,7 +105,7 @@ export const modalChatCompletionTool: ToolConfig<
98105
}
99106
messages.push({ role: 'user', content: params.content })
100107

101-
const body: Record<string, any> = { model: params.model, messages }
108+
const body: Record<string, unknown> = { model: params.model, messages }
102109

103110
const maxTokens = toOptionalNumber(params.maxTokens)
104111
if (maxTokens !== undefined) body.max_tokens = maxTokens
@@ -116,7 +123,7 @@ export const modalChatCompletionTool: ToolConfig<
116123
throw new Error(await extractModalError(response, 'Modal chat completion failed'))
117124
}
118125

119-
const data = await readResponseJsonWithLimit<any>(response, {
126+
const data = await readResponseJsonWithLimit<ModalChatCompletionApiResponse>(response, {
120127
maxBytes: MAX_MODAL_RESPONSE_BODY_BYTES,
121128
label: 'Modal chat completion response body',
122129
})

apps/sim/tools/modal/list_models.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
2-
import type { ModalListModelsParams, ModalListModelsResponse } from '@/tools/modal/types'
2+
import type {
3+
ModalListModelsApiResponse,
4+
ModalListModelsParams,
5+
ModalListModelsResponse,
6+
} from '@/tools/modal/types'
37
import {
48
extractModalError,
59
MAX_MODAL_RESPONSE_BODY_BYTES,
@@ -55,7 +59,7 @@ export const modalListModelsTool: ToolConfig<ModalListModelsParams, ModalListMod
5559
throw new Error(await extractModalError(response, 'Failed to list Modal models'))
5660
}
5761

58-
const data = await readResponseJsonWithLimit<any>(response, {
62+
const data = await readResponseJsonWithLimit<ModalListModelsApiResponse>(response, {
5963
maxBytes: MAX_MODAL_RESPONSE_BODY_BYTES,
6064
label: 'Modal models response body',
6165
})

apps/sim/tools/modal/types.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,36 @@ export interface ModalListModelsParams extends ModalProxyTokenParams {
3131
endpointUrl?: string
3232
}
3333

34+
/**
35+
* Wire shapes served by a Modal Endpoint's OpenAI-compatible `/v1` API. Every
36+
* field is optional because the payload comes from whichever inference engine
37+
* backs the endpoint — the readers stay defensive, and these types exist so a
38+
* future change to that mapping is caught by the compiler.
39+
*/
40+
export interface ModalApiModel {
41+
id?: string | null
42+
object?: string | null
43+
created?: number | null
44+
owned_by?: string | null
45+
}
46+
47+
export interface ModalListModelsApiResponse {
48+
data?: ModalApiModel[] | null
49+
}
50+
51+
export interface ModalChatCompletionApiResponse {
52+
model?: string | null
53+
choices?: Array<{
54+
message?: { content?: string | null } | null
55+
finish_reason?: string | null
56+
}> | null
57+
usage?: {
58+
prompt_tokens?: number | null
59+
completion_tokens?: number | null
60+
total_tokens?: number | null
61+
} | null
62+
}
63+
3464
export interface ModalCallFunctionResponse extends ToolResponse {
3565
output: {
3666
data: unknown

0 commit comments

Comments
 (0)