feat: discover Modal models (#39066)

This commit is contained in:
Deven Navani
2026-07-29 21:06:19 -07:00
committed by GitHub
parent 320e6c91da
commit d16024fbfd
4 changed files with 350 additions and 0 deletions
+2
View File
@@ -13,6 +13,7 @@ import { CodexAuthPlugin } from "./openai/codex"
import { Session } from "@/session/session"
import { NamedError } from "@opencode-ai/core/util/error"
import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { ModalPlugin } from "./modal/modal"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
@@ -70,6 +71,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
}),
CopilotAuthPlugin,
ModalPlugin,
GitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
@@ -0,0 +1,17 @@
import type { Hooks } from "@opencode-ai/plugin"
import { ModalModels } from "./models"
export async function ModalPlugin(): Promise<Hooks> {
return {
provider: {
id: "modal",
async models(provider, ctx) {
const apiKey = ctx.auth?.type === "api" ? ctx.auth.key : process.env.MODAL_PROXY_TOKEN
const baseURL = Object.values(provider.models)[0]?.api.url
if (!apiKey || !baseURL) return {}
return ModalModels.get(baseURL, apiKey, provider.models).catch(() => ({}))
},
},
}
}
@@ -0,0 +1,133 @@
import type { Model } from "@opencode-ai/sdk/v2"
import { Schema } from "effect"
const reasoningOption = Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
})
const response = Schema.Struct({
data: Schema.Array(
Schema.Struct({
id: Schema.String,
base_model_id: Schema.optional(Schema.String),
hugging_face_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
input_modalities: Schema.optional(Schema.Array(Schema.String)),
output_modalities: Schema.optional(Schema.Array(Schema.String)),
context_length: Schema.optional(Schema.Number),
max_output_length: Schema.optional(Schema.Number),
pricing: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
}),
),
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
supported_features: Schema.optional(Schema.Array(Schema.String)),
reasoning_options: Schema.optional(Schema.Array(reasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
}),
),
})
const decode = Schema.decodeUnknownSync(response)
function price(value: string | number | undefined, fallback: number) {
if (value === undefined) return fallback
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed * 1_000_000 : fallback
}
export async function get(baseURL: string, apiKey: string, existing: Record<string, Model>) {
const data = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(3_000),
}).then(async (res) => {
if (!res.ok) throw new Error(`Failed to fetch Modal models: ${res.status}`)
return decode(await res.json())
})
return Object.fromEntries(
data.data.map((item) => {
const template = existing[item.base_model_id ?? item.hugging_face_id ?? item.id]
const model: Model = {
id: item.id,
providerID: "modal",
name: item.name ?? template?.name ?? item.id,
family: template?.family,
api: {
id: item.id,
url: baseURL,
npm: template?.api.npm ?? "@ai-sdk/openai-compatible",
},
status: template?.status ?? "active",
headers: { ...template?.headers },
options: { ...template?.options },
cost: {
input: price(item.pricing?.prompt, template?.cost.input ?? 0),
output: price(item.pricing?.completion, template?.cost.output ?? 0),
cache: {
read: price(item.pricing?.input_cache_read, template?.cost.cache.read ?? 0),
write: template?.cost.cache.write ?? 0,
},
},
limit: {
context: item.context_length ?? template?.limit.context ?? 0,
input: template?.limit.input,
output: item.max_output_length ?? template?.limit.output ?? 0,
},
capabilities: {
temperature:
item.supported_sampling_parameters?.includes("temperature") ?? template?.capabilities.temperature ?? false,
reasoning: item.supported_features?.includes("reasoning") ?? template?.capabilities.reasoning ?? false,
attachment:
item.input_modalities?.some((modality) => modality !== "text") ??
template?.capabilities.attachment ??
false,
toolcall: item.supported_features?.includes("tools") ?? template?.capabilities.toolcall ?? true,
input: {
text: item.input_modalities?.includes("text") ?? template?.capabilities.input.text ?? true,
audio: item.input_modalities?.includes("audio") ?? template?.capabilities.input.audio ?? false,
image: item.input_modalities?.includes("image") ?? template?.capabilities.input.image ?? false,
video: item.input_modalities?.includes("video") ?? template?.capabilities.input.video ?? false,
pdf: item.input_modalities?.includes("pdf") ?? template?.capabilities.input.pdf ?? false,
},
output: {
text: item.output_modalities?.includes("text") ?? template?.capabilities.output.text ?? true,
audio: item.output_modalities?.includes("audio") ?? template?.capabilities.output.audio ?? false,
image: item.output_modalities?.includes("image") ?? template?.capabilities.output.image ?? false,
video: item.output_modalities?.includes("video") ?? template?.capabilities.output.video ?? false,
pdf: item.output_modalities?.includes("pdf") ?? template?.capabilities.output.pdf ?? false,
},
interleaved: item.interleaved ?? template?.capabilities.interleaved ?? false,
},
release_date: template?.release_date ?? "",
}
model.variants =
item.reasoning_options === undefined
? template?.variants
: Object.fromEntries(
item.reasoning_options.flatMap((option) =>
option.values.map((value) => {
const effort = value ?? "none"
return [effort, { reasoningEffort: effort }]
}),
),
)
return [item.id, model]
}),
)
}
export * as ModalModels from "./models"