feat: discover Modal models (#39066)
This commit is contained in:
parent
1e17856ba4
commit
341c64cc97
4 changed files with 350 additions and 0 deletions
|
|
@ -13,6 +13,7 @@ import { CodexAuthPlugin } from "./openai/codex"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
||||||
|
import { ModalPlugin } from "./modal/modal"
|
||||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||||
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
||||||
|
|
@ -70,6 +71,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
|
||||||
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
|
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
|
||||||
}),
|
}),
|
||||||
CopilotAuthPlugin,
|
CopilotAuthPlugin,
|
||||||
|
ModalPlugin,
|
||||||
GitlabAuthPlugin,
|
GitlabAuthPlugin,
|
||||||
PoeAuthPlugin,
|
PoeAuthPlugin,
|
||||||
CloudflareWorkersAuthPlugin,
|
CloudflareWorkersAuthPlugin,
|
||||||
|
|
|
||||||
17
packages/opencode/src/plugin/modal/modal.ts
Normal file
17
packages/opencode/src/plugin/modal/modal.ts
Normal file
|
|
@ -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(() => ({}))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
133
packages/opencode/src/plugin/modal/models.ts
Normal file
133
packages/opencode/src/plugin/modal/models.ts
Normal file
|
|
@ -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"
|
||||||
198
packages/opencode/test/plugin/modal-models.test.ts
Normal file
198
packages/opencode/test/plugin/modal-models.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import type { Model, Provider } from "@opencode-ai/sdk/v2"
|
||||||
|
import { ModalPlugin } from "@/plugin/modal/modal"
|
||||||
|
|
||||||
|
const BASE_MODEL_ID = "thinkingmachines/Inkling-NVFP4"
|
||||||
|
const RUNTIME_MODEL_ID = "workspace--inkling.us-west.modal.direct"
|
||||||
|
const FALLBACK_RUNTIME_MODEL_ID = "workspace--inkling-fallback.us-west.modal.direct"
|
||||||
|
|
||||||
|
function makeProvider(baseURL: string): Provider {
|
||||||
|
const template: Model = {
|
||||||
|
id: BASE_MODEL_ID,
|
||||||
|
providerID: "modal",
|
||||||
|
name: "Inkling",
|
||||||
|
family: "ling",
|
||||||
|
api: {
|
||||||
|
id: BASE_MODEL_ID,
|
||||||
|
url: baseURL,
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
headers: {},
|
||||||
|
options: {},
|
||||||
|
cost: {
|
||||||
|
input: 1,
|
||||||
|
output: 4,
|
||||||
|
cache: {
|
||||||
|
read: 0.2,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 128_000,
|
||||||
|
output: 8_192,
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
temperature: true,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: true,
|
||||||
|
toolcall: true,
|
||||||
|
input: {
|
||||||
|
text: true,
|
||||||
|
audio: true,
|
||||||
|
image: true,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
text: true,
|
||||||
|
audio: false,
|
||||||
|
image: false,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
|
},
|
||||||
|
interleaved: {
|
||||||
|
field: "reasoning_content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
release_date: "2026-07-15",
|
||||||
|
variants: {
|
||||||
|
fallback: {
|
||||||
|
reasoningEffort: "fallback",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: "modal",
|
||||||
|
name: "Modal",
|
||||||
|
source: "api",
|
||||||
|
env: ["MODAL_PROXY_TOKEN"],
|
||||||
|
options: {},
|
||||||
|
models: {
|
||||||
|
[template.id]: template,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("discovers Modal workspace models", async () => {
|
||||||
|
const requests: Array<{ authorization: string | null; path: string }> = []
|
||||||
|
using server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
requests.push({
|
||||||
|
authorization: request.headers.get("authorization"),
|
||||||
|
path: new URL(request.url).pathname,
|
||||||
|
})
|
||||||
|
return Response.json({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: RUNTIME_MODEL_ID,
|
||||||
|
base_model_id: BASE_MODEL_ID,
|
||||||
|
name: "Thinking Machines: Inkling",
|
||||||
|
input_modalities: ["text", "image", "audio"],
|
||||||
|
output_modalities: ["text"],
|
||||||
|
context_length: 1_048_576,
|
||||||
|
max_output_length: 262_144,
|
||||||
|
pricing: {
|
||||||
|
prompt: "0.0000012",
|
||||||
|
completion: "0.000005",
|
||||||
|
input_cache_read: "0.00000027",
|
||||||
|
},
|
||||||
|
supported_sampling_parameters: ["temperature"],
|
||||||
|
supported_features: ["tools", "reasoning"],
|
||||||
|
reasoning_options: [
|
||||||
|
{
|
||||||
|
type: "effort",
|
||||||
|
values: ["none", "low", "medium", "high", "xhigh", "max"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
interleaved: {
|
||||||
|
field: "reasoning_content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: FALLBACK_RUNTIME_MODEL_ID,
|
||||||
|
base_model_id: BASE_MODEL_ID,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const provider = makeProvider(`${server.url}v1`)
|
||||||
|
|
||||||
|
const plugin = await ModalPlugin()
|
||||||
|
const models = await plugin.provider!.models!(provider, {
|
||||||
|
auth: {
|
||||||
|
type: "api",
|
||||||
|
key: "test-token",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const model = models[RUNTIME_MODEL_ID]
|
||||||
|
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{
|
||||||
|
authorization: "Bearer test-token",
|
||||||
|
path: "/v1/models",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(Object.keys(models)).toEqual([RUNTIME_MODEL_ID, FALLBACK_RUNTIME_MODEL_ID])
|
||||||
|
expect(model.api).toEqual({
|
||||||
|
id: RUNTIME_MODEL_ID,
|
||||||
|
url: `${server.url}v1`,
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
})
|
||||||
|
expect(model.family).toBe("ling")
|
||||||
|
expect(model.capabilities.interleaved).toEqual({ field: "reasoning_content" })
|
||||||
|
expect(model.capabilities.input).toEqual({
|
||||||
|
text: true,
|
||||||
|
audio: true,
|
||||||
|
image: true,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
|
})
|
||||||
|
expect(model.cost).toEqual({
|
||||||
|
input: 1.2,
|
||||||
|
output: 5,
|
||||||
|
cache: {
|
||||||
|
read: 0.27,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(model.limit).toEqual({
|
||||||
|
context: 1_048_576,
|
||||||
|
output: 262_144,
|
||||||
|
})
|
||||||
|
expect(model.variants).toEqual({
|
||||||
|
none: { reasoningEffort: "none" },
|
||||||
|
low: { reasoningEffort: "low" },
|
||||||
|
medium: { reasoningEffort: "medium" },
|
||||||
|
high: { reasoningEffort: "high" },
|
||||||
|
xhigh: { reasoningEffort: "xhigh" },
|
||||||
|
max: { reasoningEffort: "max" },
|
||||||
|
})
|
||||||
|
expect(models[FALLBACK_RUNTIME_MODEL_ID].variants).toEqual({
|
||||||
|
fallback: {
|
||||||
|
reasoningEffort: "fallback",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hides Modal models when discovery fails", async () => {
|
||||||
|
using server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch() {
|
||||||
|
return new Response(null, { status: 503 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const plugin = await ModalPlugin()
|
||||||
|
const models = await plugin.provider!.models!(makeProvider(`${server.url}v1`), {
|
||||||
|
auth: {
|
||||||
|
type: "api",
|
||||||
|
key: "test-token",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(models).toEqual({})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue