Skip to content

Commit 341c64c

Browse files
authored
feat: discover Modal models (#39066)
1 parent 1e17856 commit 341c64c

4 files changed

Lines changed: 350 additions & 0 deletions

File tree

packages/opencode/src/plugin/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { CodexAuthPlugin } from "./openai/codex"
1313
import { Session } from "@/session/session"
1414
import { NamedError } from "@opencode-ai/core/util/error"
1515
import { CopilotAuthPlugin } from "./github-copilot/copilot"
16+
import { ModalPlugin } from "./modal/modal"
1617
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
1718
import { PoeAuthPlugin } from "opencode-poe-auth"
1819
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
@@ -70,6 +71,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
7071
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
7172
}),
7273
CopilotAuthPlugin,
74+
ModalPlugin,
7375
GitlabAuthPlugin,
7476
PoeAuthPlugin,
7577
CloudflareWorkersAuthPlugin,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Hooks } from "@opencode-ai/plugin"
2+
import { ModalModels } from "./models"
3+
4+
export async function ModalPlugin(): Promise<Hooks> {
5+
return {
6+
provider: {
7+
id: "modal",
8+
async models(provider, ctx) {
9+
const apiKey = ctx.auth?.type === "api" ? ctx.auth.key : process.env.MODAL_PROXY_TOKEN
10+
const baseURL = Object.values(provider.models)[0]?.api.url
11+
if (!apiKey || !baseURL) return {}
12+
13+
return ModalModels.get(baseURL, apiKey, provider.models).catch(() => ({}))
14+
},
15+
},
16+
}
17+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type { Model } from "@opencode-ai/sdk/v2"
2+
import { Schema } from "effect"
3+
4+
const reasoningOption = Schema.Struct({
5+
type: Schema.Literal("effort"),
6+
values: Schema.Array(Schema.NullOr(Schema.String)),
7+
})
8+
9+
const response = Schema.Struct({
10+
data: Schema.Array(
11+
Schema.Struct({
12+
id: Schema.String,
13+
base_model_id: Schema.optional(Schema.String),
14+
hugging_face_id: Schema.optional(Schema.String),
15+
name: Schema.optional(Schema.String),
16+
input_modalities: Schema.optional(Schema.Array(Schema.String)),
17+
output_modalities: Schema.optional(Schema.Array(Schema.String)),
18+
context_length: Schema.optional(Schema.Number),
19+
max_output_length: Schema.optional(Schema.Number),
20+
pricing: Schema.optional(
21+
Schema.Struct({
22+
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
23+
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
24+
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
25+
}),
26+
),
27+
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
28+
supported_features: Schema.optional(Schema.Array(Schema.String)),
29+
reasoning_options: Schema.optional(Schema.Array(reasoningOption)),
30+
interleaved: Schema.optional(
31+
Schema.Union([
32+
Schema.Boolean,
33+
Schema.Struct({
34+
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
35+
}),
36+
]),
37+
),
38+
}),
39+
),
40+
})
41+
42+
const decode = Schema.decodeUnknownSync(response)
43+
44+
function price(value: string | number | undefined, fallback: number) {
45+
if (value === undefined) return fallback
46+
const parsed = Number(value)
47+
return Number.isFinite(parsed) ? parsed * 1_000_000 : fallback
48+
}
49+
50+
export async function get(baseURL: string, apiKey: string, existing: Record<string, Model>) {
51+
const data = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
52+
headers: {
53+
Authorization: `Bearer ${apiKey}`,
54+
},
55+
signal: AbortSignal.timeout(3_000),
56+
}).then(async (res) => {
57+
if (!res.ok) throw new Error(`Failed to fetch Modal models: ${res.status}`)
58+
return decode(await res.json())
59+
})
60+
61+
return Object.fromEntries(
62+
data.data.map((item) => {
63+
const template = existing[item.base_model_id ?? item.hugging_face_id ?? item.id]
64+
const model: Model = {
65+
id: item.id,
66+
providerID: "modal",
67+
name: item.name ?? template?.name ?? item.id,
68+
family: template?.family,
69+
api: {
70+
id: item.id,
71+
url: baseURL,
72+
npm: template?.api.npm ?? "@ai-sdk/openai-compatible",
73+
},
74+
status: template?.status ?? "active",
75+
headers: { ...template?.headers },
76+
options: { ...template?.options },
77+
cost: {
78+
input: price(item.pricing?.prompt, template?.cost.input ?? 0),
79+
output: price(item.pricing?.completion, template?.cost.output ?? 0),
80+
cache: {
81+
read: price(item.pricing?.input_cache_read, template?.cost.cache.read ?? 0),
82+
write: template?.cost.cache.write ?? 0,
83+
},
84+
},
85+
limit: {
86+
context: item.context_length ?? template?.limit.context ?? 0,
87+
input: template?.limit.input,
88+
output: item.max_output_length ?? template?.limit.output ?? 0,
89+
},
90+
capabilities: {
91+
temperature:
92+
item.supported_sampling_parameters?.includes("temperature") ?? template?.capabilities.temperature ?? false,
93+
reasoning: item.supported_features?.includes("reasoning") ?? template?.capabilities.reasoning ?? false,
94+
attachment:
95+
item.input_modalities?.some((modality) => modality !== "text") ??
96+
template?.capabilities.attachment ??
97+
false,
98+
toolcall: item.supported_features?.includes("tools") ?? template?.capabilities.toolcall ?? true,
99+
input: {
100+
text: item.input_modalities?.includes("text") ?? template?.capabilities.input.text ?? true,
101+
audio: item.input_modalities?.includes("audio") ?? template?.capabilities.input.audio ?? false,
102+
image: item.input_modalities?.includes("image") ?? template?.capabilities.input.image ?? false,
103+
video: item.input_modalities?.includes("video") ?? template?.capabilities.input.video ?? false,
104+
pdf: item.input_modalities?.includes("pdf") ?? template?.capabilities.input.pdf ?? false,
105+
},
106+
output: {
107+
text: item.output_modalities?.includes("text") ?? template?.capabilities.output.text ?? true,
108+
audio: item.output_modalities?.includes("audio") ?? template?.capabilities.output.audio ?? false,
109+
image: item.output_modalities?.includes("image") ?? template?.capabilities.output.image ?? false,
110+
video: item.output_modalities?.includes("video") ?? template?.capabilities.output.video ?? false,
111+
pdf: item.output_modalities?.includes("pdf") ?? template?.capabilities.output.pdf ?? false,
112+
},
113+
interleaved: item.interleaved ?? template?.capabilities.interleaved ?? false,
114+
},
115+
release_date: template?.release_date ?? "",
116+
}
117+
model.variants =
118+
item.reasoning_options === undefined
119+
? template?.variants
120+
: Object.fromEntries(
121+
item.reasoning_options.flatMap((option) =>
122+
option.values.map((value) => {
123+
const effort = value ?? "none"
124+
return [effort, { reasoningEffort: effort }]
125+
}),
126+
),
127+
)
128+
return [item.id, model]
129+
}),
130+
)
131+
}
132+
133+
export * as ModalModels from "./models"
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { expect, test } from "bun:test"
2+
import type { Model, Provider } from "@opencode-ai/sdk/v2"
3+
import { ModalPlugin } from "@/plugin/modal/modal"
4+
5+
const BASE_MODEL_ID = "thinkingmachines/Inkling-NVFP4"
6+
const RUNTIME_MODEL_ID = "workspace--inkling.us-west.modal.direct"
7+
const FALLBACK_RUNTIME_MODEL_ID = "workspace--inkling-fallback.us-west.modal.direct"
8+
9+
function makeProvider(baseURL: string): Provider {
10+
const template: Model = {
11+
id: BASE_MODEL_ID,
12+
providerID: "modal",
13+
name: "Inkling",
14+
family: "ling",
15+
api: {
16+
id: BASE_MODEL_ID,
17+
url: baseURL,
18+
npm: "@ai-sdk/openai-compatible",
19+
},
20+
status: "active",
21+
headers: {},
22+
options: {},
23+
cost: {
24+
input: 1,
25+
output: 4,
26+
cache: {
27+
read: 0.2,
28+
write: 0,
29+
},
30+
},
31+
limit: {
32+
context: 128_000,
33+
output: 8_192,
34+
},
35+
capabilities: {
36+
temperature: true,
37+
reasoning: true,
38+
attachment: true,
39+
toolcall: true,
40+
input: {
41+
text: true,
42+
audio: true,
43+
image: true,
44+
video: false,
45+
pdf: false,
46+
},
47+
output: {
48+
text: true,
49+
audio: false,
50+
image: false,
51+
video: false,
52+
pdf: false,
53+
},
54+
interleaved: {
55+
field: "reasoning_content",
56+
},
57+
},
58+
release_date: "2026-07-15",
59+
variants: {
60+
fallback: {
61+
reasoningEffort: "fallback",
62+
},
63+
},
64+
}
65+
66+
return {
67+
id: "modal",
68+
name: "Modal",
69+
source: "api",
70+
env: ["MODAL_PROXY_TOKEN"],
71+
options: {},
72+
models: {
73+
[template.id]: template,
74+
},
75+
}
76+
}
77+
78+
test("discovers Modal workspace models", async () => {
79+
const requests: Array<{ authorization: string | null; path: string }> = []
80+
using server = Bun.serve({
81+
port: 0,
82+
fetch(request) {
83+
requests.push({
84+
authorization: request.headers.get("authorization"),
85+
path: new URL(request.url).pathname,
86+
})
87+
return Response.json({
88+
data: [
89+
{
90+
id: RUNTIME_MODEL_ID,
91+
base_model_id: BASE_MODEL_ID,
92+
name: "Thinking Machines: Inkling",
93+
input_modalities: ["text", "image", "audio"],
94+
output_modalities: ["text"],
95+
context_length: 1_048_576,
96+
max_output_length: 262_144,
97+
pricing: {
98+
prompt: "0.0000012",
99+
completion: "0.000005",
100+
input_cache_read: "0.00000027",
101+
},
102+
supported_sampling_parameters: ["temperature"],
103+
supported_features: ["tools", "reasoning"],
104+
reasoning_options: [
105+
{
106+
type: "effort",
107+
values: ["none", "low", "medium", "high", "xhigh", "max"],
108+
},
109+
],
110+
interleaved: {
111+
field: "reasoning_content",
112+
},
113+
},
114+
{
115+
id: FALLBACK_RUNTIME_MODEL_ID,
116+
base_model_id: BASE_MODEL_ID,
117+
},
118+
],
119+
})
120+
},
121+
})
122+
const provider = makeProvider(`${server.url}v1`)
123+
124+
const plugin = await ModalPlugin()
125+
const models = await plugin.provider!.models!(provider, {
126+
auth: {
127+
type: "api",
128+
key: "test-token",
129+
},
130+
})
131+
const model = models[RUNTIME_MODEL_ID]
132+
133+
expect(requests).toEqual([
134+
{
135+
authorization: "Bearer test-token",
136+
path: "/v1/models",
137+
},
138+
])
139+
expect(Object.keys(models)).toEqual([RUNTIME_MODEL_ID, FALLBACK_RUNTIME_MODEL_ID])
140+
expect(model.api).toEqual({
141+
id: RUNTIME_MODEL_ID,
142+
url: `${server.url}v1`,
143+
npm: "@ai-sdk/openai-compatible",
144+
})
145+
expect(model.family).toBe("ling")
146+
expect(model.capabilities.interleaved).toEqual({ field: "reasoning_content" })
147+
expect(model.capabilities.input).toEqual({
148+
text: true,
149+
audio: true,
150+
image: true,
151+
video: false,
152+
pdf: false,
153+
})
154+
expect(model.cost).toEqual({
155+
input: 1.2,
156+
output: 5,
157+
cache: {
158+
read: 0.27,
159+
write: 0,
160+
},
161+
})
162+
expect(model.limit).toEqual({
163+
context: 1_048_576,
164+
output: 262_144,
165+
})
166+
expect(model.variants).toEqual({
167+
none: { reasoningEffort: "none" },
168+
low: { reasoningEffort: "low" },
169+
medium: { reasoningEffort: "medium" },
170+
high: { reasoningEffort: "high" },
171+
xhigh: { reasoningEffort: "xhigh" },
172+
max: { reasoningEffort: "max" },
173+
})
174+
expect(models[FALLBACK_RUNTIME_MODEL_ID].variants).toEqual({
175+
fallback: {
176+
reasoningEffort: "fallback",
177+
},
178+
})
179+
})
180+
181+
test("hides Modal models when discovery fails", async () => {
182+
using server = Bun.serve({
183+
port: 0,
184+
fetch() {
185+
return new Response(null, { status: 503 })
186+
},
187+
})
188+
189+
const plugin = await ModalPlugin()
190+
const models = await plugin.provider!.models!(makeProvider(`${server.url}v1`), {
191+
auth: {
192+
type: "api",
193+
key: "test-token",
194+
},
195+
})
196+
197+
expect(models).toEqual({})
198+
})

0 commit comments

Comments
 (0)