Explorar o código

feat: discover Modal models (#39066)

Deven Navani hai 2 semanas
pai
achega
341c64cc97

+ 2 - 0
packages/opencode/src/plugin/index.ts

@@ -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,

+ 17 - 0
packages/opencode/src/plugin/modal/modal.ts

@@ -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 - 0
packages/opencode/src/plugin/modal/models.ts

@@ -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 - 0
packages/opencode/test/plugin/modal-models.test.ts

@@ -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({})
+})