diff --git a/packages/core/src/modal/models.ts b/packages/core/src/modal/models.ts new file mode 100644 index 00000000000..16714968148 --- /dev/null +++ b/packages/core/src/modal/models.ts @@ -0,0 +1,141 @@ +export * as ModalModels from "./models.js" + +import { Money } from "@opencode-ai/schema/money" +import { Option, Schema } from "effect" +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 RemoteModel = 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 Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) }) +const decodeResponse = Schema.decodeUnknownSync(Response) +const decodeModel = Schema.decodeUnknownOption(RemoteModel) + +type RemoteModel = typeof RemoteModel.Type + +export async function get(baseURL: string, apiKey: string, existing: readonly 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}`) + + // Decode each item tolerantly so one malformed entry cannot discard the + // whole inventory. A malformed envelope still fails the fetch. + const remote = decodeResponse(await response.json()).data.flatMap((raw) => { + const model = Option.getOrUndefined(decodeModel(raw)) + return model ? [model] : [] + }) + const templates = new Map(existing.map((model) => [model.id, model])) + const result = new Map() + for (const item of remote) { + const template = templates.get(Model.ID.make(item.base_model_id ?? item.hugging_face_id ?? item.id)) + const id = Model.ID.make(item.id) + result.set(id, build(id, item, baseURL, template)) + } + return result +} + +function price(value: string | number | undefined, fallback: Money.USDPerMillionTokens) { + if (value === undefined) return fallback + const parsed = Number(value) * 1_000_000 + return Number.isFinite(parsed) ? Money.USDPerMillionTokens.make(parsed) : fallback +} + +function limit(value: number | undefined, fallback: number) { + const parsed = value === undefined ? fallback : Math.trunc(value) + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback +} + +function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Model.Info) { + const cost = previous?.cost[0] + const input = previous?.limit.input + return Model.Info.make({ + ...Model.Info.default(providerID, id), + id, + modelID: Model.ID.make(remote.id), + providerID, + name: remote.name ?? previous?.name ?? remote.id, + family: previous?.family, + compatibility: + remote.interleaved === undefined + ? previous?.compatibility + : (Model.compatibility(remote.interleaved) ?? previous?.compatibility), + package: Provider.aisdk("@ai-sdk/openai-compatible"), + settings: Provider.mergeOverlay(previous?.settings, { baseURL }), + headers: previous?.headers, + body: previous?.body, + capabilities: { + tools: remote.supported_features?.includes("tools") ?? previous?.capabilities.tools ?? true, + input: remote.input_modalities ?? previous?.capabilities.input ?? ["text"], + output: remote.output_modalities ?? previous?.capabilities.output ?? ["text"], + }, + variants: remote.reasoning_options === undefined ? (previous?.variants ?? []) : variants(remote), + time: previous?.time ?? { released: 0 }, + cost: [ + { + input: price(remote.pricing?.prompt, cost?.input ?? Money.USDPerMillionTokens.zero), + output: price(remote.pricing?.completion, cost?.output ?? Money.USDPerMillionTokens.zero), + cache: { + read: price(remote.pricing?.input_cache_read, cost?.cache.read ?? Money.USDPerMillionTokens.zero), + write: cost?.cache.write ?? Money.USDPerMillionTokens.zero, + }, + }, + ], + status: previous?.status ?? "active", + enabled: previous?.enabled ?? true, + limit: { + context: limit(remote.context_length, previous?.limit.context ?? 0), + ...(input === undefined ? {} : { input }), + output: limit(remote.max_output_length, previous?.limit.output ?? 0), + }, + }) +} + +function variants(remote: RemoteModel): Model.Info["variants"] { + const seen = new Map() + for (const option of remote.reasoning_options ?? []) { + for (const value of option.values) { + const effort = value ?? "none" + if (!seen.has(effort)) + seen.set(effort, { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } }) + } + } + return [...seen.values()] +} diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 067cf85bf49..18ef4e9310d 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -15,6 +15,7 @@ import { KiloPlugin } from "./provider/kilo.js" import { LLMGatewayPlugin } from "./provider/llmgateway.js" import { LMStudioPlugin } from "./provider/lmstudio.js" import { MistralPlugin } from "./provider/mistral.js" +import { ModalPlugin } from "./provider/modal.js" import { NvidiaPlugin } from "./provider/nvidia.js" import { OllamaPlugin } from "./provider/ollama.js" import { OpenAIPlugin } from "./provider/openai.js" @@ -48,6 +49,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ LLMGatewayPlugin, LMStudioPlugin, MistralPlugin, + ModalPlugin, NvidiaPlugin, OllamaPlugin, OpencodePlugin, diff --git a/packages/core/src/plugin/provider/modal.ts b/packages/core/src/plugin/provider/modal.ts new file mode 100644 index 00000000000..38e7f1ad589 --- /dev/null +++ b/packages/core/src/plugin/provider/modal.ts @@ -0,0 +1,67 @@ +import { Effect, Semaphore, Stream } from "effect" +import { define } from "@opencode-ai/plugin/effect/plugin" +import { Bus } from "../../bus.js" +import { Catalog } from "../../catalog.js" +import { Credential } from "../../credential.js" +import { Integration } from "../../integration.js" +import { ModalModels } from "../../modal/models.js" +import { Model } from "../../model.js" +import { Provider } from "../../provider.js" +import type { PluginInternal } from "../internal.js" + +const providerID = Provider.ID.make("modal") + +export const ModalPlugin = define({ + id: "opencode.provider.modal", + effect: Effect.fn(function* (ctx) { + const catalog = yield* Catalog.Service + const bus = yield* Bus.Service + const loading = Semaphore.makeUnsafe(1) + const loaded: { + baseURL?: string + models?: Map + } = {} + + const load = Effect.fn("ModalPlugin.load")(function* () { + const connection = yield* ctx.integration.connection.active("modal") + const credential = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined)) + : undefined + const apiKey = credential?.type === "key" ? credential.key : process.env.MODAL_PROXY_TOKEN + const provider = yield* catalog.provider.get(providerID) + const baseURL = typeof provider?.settings?.baseURL === "string" ? provider.settings.baseURL : undefined + if (!apiKey || !baseURL) { + loaded.baseURL = undefined + loaded.models = undefined + return + } + loaded.baseURL = baseURL + const existing = (yield* catalog.model.all()).filter((model) => model.providerID === providerID) + loaded.models = yield* Effect.tryPromise({ + try: () => ModalModels.get(baseURL, apiKey, existing), + catch: (cause) => cause, + }).pipe( + Effect.catch((cause) => Effect.logWarning("failed to sync Modal models", { cause }).pipe(Effect.as(undefined))), + ) + }) + + yield* ctx.catalog.transform((evt) => { + const item = evt.provider.get(providerID) + if (!item) return + if (!loaded.models) return + for (const id of item.models.keys()) { + if (!loaded.models.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id) + } + for (const [id, model] of loaded.models) { + evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model))) + } + }) + const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) + yield* bus.subscribe(Credential.Event.Switched).pipe( + Stream.filter((event) => event.data.integrationID === Integration.ID.make("modal")), + Stream.runForEach(refresh), + Effect.forkScoped({ startImmediately: true }), + ) + yield* refresh().pipe(Effect.forkScoped) + }), +} satisfies PluginInternal.InternalPlugin) diff --git a/packages/core/test/modal/models.test.ts b/packages/core/test/modal/models.test.ts new file mode 100644 index 00000000000..a74e9d15ed1 --- /dev/null +++ b/packages/core/test/modal/models.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test" +import { ModalModels } from "@opencode-ai/core/modal/models" +import { Model } from "@opencode-ai/core/model" +import { Provider } from "@opencode-ai/core/provider" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { Money } from "@opencode-ai/schema/money" + +const providerID = Provider.ID.make("modal") + +test("modal plugin is registered", () => { + expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.modal") +}) + +function template(id: string, overrides: Partial = {}) { + return Model.Info.make({ + ...Model.Info.default(providerID, Model.ID.make(id)), + name: `${id} catalog`, + family: Model.Family.make("catalog-family"), + ...overrides, + }) +} + +test("maps live Modal models onto catalog templates", async () => { + const server = Bun.serve({ + port: 0, + fetch: (request) => { + expect(request.headers.get("Authorization")).toBe("Bearer test-key") + expect(new URL(request.url).pathname).toBe("/v1/models") + return Response.json({ + data: [ + { + id: "live-model", + base_model_id: "base-model", + name: "Live Model", + input_modalities: ["text", "image"], + output_modalities: ["text"], + context_length: 128000, + max_output_length: 8192, + pricing: { prompt: "0.000001", completion: 0.000002, input_cache_read: "0.0000002" }, + supported_sampling_parameters: ["temperature"], + supported_features: ["tools", "reasoning"], + reasoning_options: [{ type: "effort", values: ["low", "high", null] }], + interleaved: { field: "reasoning_content" }, + }, + { + id: "standalone", + context_length: 64000, + }, + { id: "malformed", context_length: "huge" }, + ], + }) + }, + }) + + try { + const base = template("base-model") + const stale = template("stale") + const models = await ModalModels.get(`${server.url.origin}/v1`, "test-key", [base, stale]) + + expect(models.has(Model.ID.make("stale"))).toBe(false) + expect(models.has(Model.ID.make("malformed"))).toBe(false) + + const model = models.get(Model.ID.make("live-model")) + expect(model?.name).toBe("Live Model") + expect(model?.family).toBe(Model.Family.make("catalog-family")) + expect(model?.providerID).toBe(providerID) + expect(model?.modelID).toBe(Model.ID.make("live-model")) + expect(model?.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible")) + expect(model?.settings).toMatchObject({ baseURL: `${server.url.origin}/v1` }) + expect(model?.compatibility).toMatchObject({ reasoningField: "reasoning_content" }) + expect(model?.capabilities).toMatchObject({ tools: true, input: ["text", "image"], output: ["text"] }) + expect(model?.cost[0]?.input).toBe(Money.USDPerMillionTokens.make(1)) + expect(model?.cost[0]?.output).toBe(Money.USDPerMillionTokens.make(2)) + expect(Number(model?.cost[0]?.cache.read)).toBeCloseTo(0.2, 10) + expect(model?.cost[0]?.cache.write).toBe(Money.USDPerMillionTokens.zero) + expect(model?.limit).toMatchObject({ context: 128000, output: 8192 }) + expect(model?.variants.map((variant) => variant.id)).toEqual([ + Model.VariantID.make("low"), + Model.VariantID.make("high"), + Model.VariantID.make("none"), + ]) + expect(model?.variants[0]?.settings).toMatchObject({ reasoningEffort: "low" }) + expect(model?.status).toBe("active") + + const fresh = models.get(Model.ID.make("standalone")) + expect(fresh?.name).toBe("standalone") + expect(fresh?.family).toBeUndefined() + expect(fresh?.capabilities).toMatchObject({ tools: true, input: ["text"], output: ["text"] }) + expect(fresh?.variants).toEqual([]) + expect(fresh?.limit).toMatchObject({ context: 64000, output: 0 }) + } finally { + await server.stop(true) + } +}) + +test("keeps template cost and limits when the proxy omits them", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => + Response.json({ + data: [{ id: "sparse", hugging_face_id: "hf-base" }], + }), + }) + + try { + const base = template("hf-base", { + cost: [ + { + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(10), + cache: { read: Money.USDPerMillionTokens.make(1), write: Money.USDPerMillionTokens.make(2) }, + }, + ], + limit: { context: 1000, input: 500, output: 250 }, + }) + const models = await ModalModels.get(server.url.origin, "test-key", [base]) + const model = models.get(Model.ID.make("sparse")) + expect(model?.name).toBe("hf-base catalog") + expect(model?.cost[0]).toMatchObject({ input: 5, output: 10, cache: { read: 1, write: 2 } }) + expect(model?.limit).toMatchObject({ context: 1000, input: 500, output: 250 }) + } finally { + await server.stop(true) + } +}) + +test("throws on proxy failure so the plugin can fail soft", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response("nope", { status: 500 }), + }) + + try { + await expect(ModalModels.get(server.url.origin, "test-key", [])).rejects.toThrow() + } finally { + await server.stop(true) + } +})