From 76287477dcc255759919bff65ef4cf3fc40fcbef Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 16 Aug 2026 04:17:18 +0000 Subject: [PATCH] feat(core): restore Modal model discovery --- packages/core/src/plugin/provider.ts | 2 + packages/core/src/plugin/provider/modal.ts | 176 ++++++++++++++++++ .../core/test/plugin/provider-modal.test.ts | 143 ++++++++++++++ 3 files changed, 321 insertions(+) create mode 100644 packages/core/src/plugin/provider/modal.ts create mode 100644 packages/core/test/plugin/provider-modal.test.ts diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 6025e70ac8a..e27bbf92097 100644 --- a/packages/core/src/plugin/provider.ts +++ b/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, diff --git a/packages/core/src/plugin/provider/modal.ts b/packages/core/src/plugin/provider/modal.ts new file mode 100644 index 00000000000..84bc793361f --- /dev/null +++ b/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 | undefined + let models: Map | 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())), + ), + ) + }) + + 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) { + 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) +} diff --git a/packages/core/test/plugin/provider-modal.test.ts b/packages/core/test/plugin/provider-modal.test.ts new file mode 100644 index 00000000000..1ccd1f26ca2 --- /dev/null +++ b/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( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 1000, +): Effect.Effect { + 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([]) + }), +)