Просмотр исходного кода

feat(core): restore Modal model discovery

Aiden Cline 1 день назад
Родитель
Сommit
76287477dc

+ 2 - 0
packages/core/src/plugin/provider.ts

@@ -16,6 +16,7 @@ import { GroqPlugin } from "./provider/groq.js"
 import { KiloPlugin } from "./provider/kilo.js"
 import { LLMGatewayPlugin } from "./provider/llmgateway.js"
 import { MistralPlugin } from "./provider/mistral.js"
+import { ModalPlugin } from "./provider/modal.js"
 import { NvidiaPlugin } from "./provider/nvidia.js"
 import { OpenAIPlugin } from "./provider/openai.js"
 import { SnowflakeCortexPlugin } from "./provider/snowflake-cortex.js"
@@ -49,6 +50,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
   KiloPlugin,
   LLMGatewayPlugin,
   MistralPlugin,
+  ModalPlugin,
   NvidiaPlugin,
   OpencodePlugin,
   SnowflakeCortexPlugin,

+ 176 - 0
packages/core/src/plugin/provider/modal.ts

@@ -0,0 +1,176 @@
+import { Money } from "@opencode-ai/schema/money"
+import { define } from "@opencode-ai/plugin/effect/plugin"
+import { Effect, Schema, Semaphore, Stream } from "effect"
+import { Bus } from "../../bus.js"
+import { Catalog } from "../../catalog.js"
+import { Integration } from "../../integration.js"
+import { Model } from "../../model.js"
+import { Provider } from "../../provider.js"
+
+const providerID = Provider.ID.make("modal")
+
+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_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)
+
+export const ModalPlugin = define({
+  id: "opencode.provider.modal",
+  effect: Effect.fn(function* (ctx) {
+    const bus = yield* Bus.Service
+    const catalog = yield* Catalog.Service
+    const loading = Semaphore.makeUnsafe(1)
+    let templates: Map<Model.ID, Model.Info> | undefined
+    let models: Map<Model.ID, Model.Info> | undefined
+
+    const load = Effect.fn("ModalPlugin.load")(function* () {
+      const existing =
+        templates ??
+        new Map(
+          (yield* catalog.model.all())
+            .filter((model) => model.providerID === providerID)
+            .map((model) => [model.id, model]),
+        )
+      templates = existing
+      const connection = yield* ctx.integration.connection.active("modal")
+      const credential = connection
+        ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
+        : undefined
+      const provider = yield* catalog.provider.get(providerID)
+      const baseURL = typeof provider?.settings?.baseURL === "string" ? provider.settings.baseURL : undefined
+      if (credential?.type !== "key" || !baseURL) {
+        models = new Map()
+        return
+      }
+
+      models = yield* Effect.tryPromise({
+        try: () => discover(baseURL, credential.key, existing),
+        catch: (cause) => cause,
+      }).pipe(
+        Effect.catch((cause) =>
+          Effect.logWarning("failed to sync Modal models", { cause }).pipe(Effect.as(new Map<Model.ID, Model.Info>())),
+        ),
+      )
+    })
+
+    yield* ctx.catalog.transform((draft) => {
+      if (!models) return
+      const provider = draft.provider.get(providerID)
+      if (!provider) return
+      for (const id of provider.models.keys()) {
+        if (!models.has(Model.ID.make(id))) draft.model.remove(providerID, Model.ID.make(id))
+      }
+      for (const [id, model] of models) {
+        draft.model.update(providerID, id, (item) => Object.assign(item, structuredClone(model)))
+      }
+    })
+    const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
+    yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
+      Stream.filter((event) => event.data.integrationID === Integration.ID.make("modal")),
+      Stream.runForEach(refresh),
+      Effect.forkScoped({ startImmediately: true }),
+    )
+    yield* refresh().pipe(Effect.forkScoped)
+  }),
+})
+
+async function discover(baseURL: string, apiKey: string, templates: ReadonlyMap<Model.ID, Model.Info>) {
+  const response = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
+    headers: { Authorization: `Bearer ${apiKey}` },
+    signal: AbortSignal.timeout(3_000),
+  })
+  if (!response.ok) throw new Error(`Failed to fetch Modal models: ${response.status}`)
+
+  return new Map(
+    decode(await response.json()).data.map((item) => {
+      const id = Model.ID.make(item.id)
+      const template = templates.get(Model.ID.make(item.base_model_id ?? item.hugging_face_id ?? item.id))
+      return [id, build(id, item, baseURL, template)]
+    }),
+  )
+}
+
+function build(id: Model.ID, item: (typeof Response.Type)["data"][number], baseURL: string, template?: Model.Info) {
+  const fallback: Model.Info = template ?? Model.Info.make(Model.Info.default(providerID, id))
+  const baseCost = fallback.cost[0]
+  const variants = item.reasoning_options?.flatMap((option) =>
+    option.values.map((value) => {
+      const effort = value ?? "none"
+      return {
+        id: Model.VariantID.make(effort),
+        settings: { reasoningEffort: effort },
+      }
+    }),
+  )
+  return Model.Info.make({
+    ...structuredClone(fallback),
+    id,
+    modelID: id,
+    providerID,
+    name: item.name ?? fallback.name,
+    compatibility: Model.compatibility(item.interleaved) ?? fallback.compatibility,
+    package: fallback.package ?? Provider.aisdk("@ai-sdk/openai-compatible"),
+    settings: Provider.mergeOverlay(fallback.settings, { baseURL }),
+    capabilities: {
+      tools: item.supported_features?.includes("tools") ?? fallback.capabilities.tools,
+      input: item.input_modalities ? [...item.input_modalities] : [...fallback.capabilities.input],
+      output: item.output_modalities ? [...item.output_modalities] : [...fallback.capabilities.output],
+    },
+    variants: variants ?? [...fallback.variants],
+    cost: [
+      {
+        input: price(item.pricing?.prompt, baseCost?.input ?? Money.USDPerMillionTokens.zero),
+        output: price(item.pricing?.completion, baseCost?.output ?? Money.USDPerMillionTokens.zero),
+        cache: {
+          read: price(item.pricing?.input_cache_read, baseCost?.cache.read ?? Money.USDPerMillionTokens.zero),
+          write: baseCost?.cache.write ?? Money.USDPerMillionTokens.zero,
+        },
+      },
+    ],
+    limit: {
+      context: item.context_length ?? fallback.limit.context,
+      input: fallback.limit.input,
+      output: item.max_output_length ?? fallback.limit.output,
+    },
+    status: fallback.status,
+    enabled: fallback.enabled,
+  })
+}
+
+function price(value: string | number | undefined, fallback: number) {
+  if (value === undefined) return Money.USDPerMillionTokens.make(fallback)
+  const parsed = Number(value)
+  return Money.USDPerMillionTokens.make(Number.isFinite(parsed) ? parsed * 1_000_000 : fallback)
+}

+ 143 - 0
packages/core/test/plugin/provider-modal.test.ts

@@ -0,0 +1,143 @@
+import { Catalog } from "@opencode-ai/core/catalog"
+import { Integration } from "@opencode-ai/core/integration"
+import { Model } from "@opencode-ai/core/model"
+import { Plugin } from "@opencode-ai/core/plugin"
+import { PluginHost } from "@opencode-ai/core/plugin/host"
+import { ModalPlugin } from "@opencode-ai/core/plugin/provider/modal"
+import { Provider } from "@opencode-ai/core/provider"
+import { State } from "@opencode-ai/core/state"
+import { Money } from "@opencode-ai/schema/money"
+import { expect } from "bun:test"
+import { Effect } from "effect"
+import { testEffect } from "../lib/effect"
+import { PluginTestLayer } from "./fixture"
+
+const it = testEffect(PluginTestLayer)
+const providerID = Provider.ID.make("modal")
+const integrationID = Integration.ID.make("modal")
+const baseModelID = Model.ID.make("thinkingmachines/Inkling-NVFP4")
+const runtimeModelID = Model.ID.make("workspace--inkling.us-west.modal.direct")
+
+function eventually<A>(
+  effect: Effect.Effect<A>,
+  predicate: (value: A) => boolean,
+  remaining = 1000,
+): Effect.Effect<A, Error> {
+  return Effect.gen(function* () {
+    const value = yield* effect
+    if (predicate(value)) return value
+    if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
+    yield* Effect.promise(() => Bun.sleep(1))
+    return yield* eventually(effect, predicate, remaining - 1)
+  })
+}
+
+const setup = Effect.fn(function* (baseURL: string, key?: string) {
+  const catalog = yield* Catalog.Service
+  const integrations = yield* Integration.Service
+  yield* integrations.transform((draft) => {
+    draft.method.update({ integrationID, method: { type: "key" } })
+  })
+  if (key) yield* integrations.connection.key({ integrationID, key })
+  yield* State.batch(
+    Effect.gen(function* () {
+      yield* catalog.transform((draft) => {
+        draft.provider.update(providerID, (provider) => {
+          provider.name = "Modal"
+          provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
+          provider.settings = { baseURL }
+          provider.integrationID = integrationID
+        })
+        draft.model.update(providerID, baseModelID, (model) => {
+          model.name = "Inkling"
+          model.family = Model.Family.make("ling")
+          model.compatibility = { reasoningField: "reasoning_content" }
+          model.capabilities = { tools: true, input: ["text", "image", "audio"], output: ["text"] }
+          model.variants = [{ id: Model.VariantID.make("fallback"), settings: { reasoningEffort: "fallback" } }]
+          model.cost = [
+            {
+              input: Money.USDPerMillionTokens.make(1),
+              output: Money.USDPerMillionTokens.make(4),
+              cache: {
+                read: Money.USDPerMillionTokens.make(0.2),
+                write: Money.USDPerMillionTokens.zero,
+              },
+            },
+          ]
+          model.limit = { context: 128_000, output: 8_192 }
+          model.time = { released: Date.parse("2026-07-15") }
+        })
+      })
+      yield* ModalPlugin.effect(yield* PluginHost.make(yield* Plugin.Service))
+    }),
+  )
+})
+
+it.live("discovers Modal workspace models", () =>
+  Effect.gen(function* () {
+    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: runtimeModelID,
+              base_model_id: baseModelID,
+              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_features: ["tools", "reasoning"],
+              reasoning_options: [{ type: "effort", values: ["none", "low", "high"] }],
+              interleaved: { field: "reasoning_content" },
+            },
+          ],
+        })
+      },
+    })
+    yield* setup(`${server.url}v1`, "test-token")
+
+    const models = yield* eventually(
+      (yield* Catalog.Service).model
+        .all()
+        .pipe(Effect.map((models) => models.filter((model) => model.providerID === providerID))),
+      (models) => models.some((model) => model.id === runtimeModelID),
+    )
+    expect(requests).toEqual([{ authorization: "Bearer test-token", path: "/v1/models" }])
+    expect(models).toHaveLength(1)
+    expect(models[0]).toMatchObject({
+      id: runtimeModelID,
+      modelID: runtimeModelID,
+      name: "Thinking Machines: Inkling",
+      family: "ling",
+      compatibility: { reasoningField: "reasoning_content" },
+      settings: { baseURL: `${server.url}v1` },
+      capabilities: { tools: true, input: ["text", "image", "audio"], output: ["text"] },
+      variants: [
+        { id: "none", settings: { reasoningEffort: "none" } },
+        { id: "low", settings: { reasoningEffort: "low" } },
+        { id: "high", settings: { reasoningEffort: "high" } },
+      ],
+      cost: [{ input: 1.2, output: 5, cache: { read: 0.27, write: 0 } }],
+      limit: { context: 1_048_576, output: 262_144 },
+    })
+  }),
+)
+
+it.live("hides static Modal models when discovery fails", () =>
+  Effect.gen(function* () {
+    using server = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 503 }) })
+    yield* setup(`${server.url}v1`, "test-token")
+    const models = yield* eventually(
+      (yield* Catalog.Service).model
+        .all()
+        .pipe(Effect.map((models) => models.filter((model) => model.providerID === providerID))),
+      (models) => models.length === 0,
+    )
+    expect(models).toEqual([])
+  }),
+)