Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 76287477dc feat(core): restore Modal model discovery 2026-08-16 04:17:18 +00:00
6 changed files with 327 additions and 24 deletions
+2
View File
@@ -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
View File
@@ -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)
}
@@ -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([])
}),
)
+5 -12
View File
@@ -10,7 +10,6 @@ import { useClient } from "./client"
import { RGBA } from "@opentui/core"
import { readJson, writeJsonAtomic } from "../util/persistence"
import {
availableModelVariant,
createModelPreferenceRepository,
cycleModelVariant,
modelPreferenceKey,
@@ -222,14 +221,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
const model = newSessionModel()
if (!model) return
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
return {
...model,
variant: availableModelVariant(
preferences.variant[modelPreferenceKey(model)],
info?.variants?.map((item) => item.id) ?? [],
),
}
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
})
const currentModel = createMemo(() => {
@@ -270,12 +262,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (route.data.type === "session") {
const sessionID = route.data.sessionID
const current = sessionSelection(sessionID)
const preferred =
const preferred = normalizeModelVariant(
current?.providerID === model.providerID && current.modelID === model.modelID
? current.variant
: preferences.variant[modelPreferenceKey(model)]
: preferences.variant[modelPreferenceKey(model)],
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
const variant = availableModelVariant(preferred, info?.variants?.map((item) => item.id) ?? [])
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
setSessionDraft(sessionID, { ...model, variant })
return true
}
-5
View File
@@ -39,11 +39,6 @@ export function normalizeModelVariant(value: string | undefined) {
return value === "default" ? undefined : value
}
export function availableModelVariant(value: string | undefined, variants: string[]) {
const variant = normalizeModelVariant(value)
return variant && variants.includes(variant) ? variant : undefined
}
export function modelPreferenceKey(model: ModelPreferenceModel) {
return `${model.providerID}/${model.modelID}`
}
+1 -7
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { availableModelVariant, createModelPreferenceRepository, decodeModelPreference } from "../src/model-preference"
import { createModelPreferenceRepository, decodeModelPreference } from "../src/model-preference"
import { tmpdir } from "./fixture/fixture"
test("repairs known model preferences and preserves unrelated fields", () => {
@@ -19,12 +19,6 @@ test("repairs known model preferences and preserves unrelated fields", () => {
})
})
test("drops a saved variant that is no longer available", () => {
expect(availableModelVariant("medium", [])).toBeUndefined()
expect(availableModelVariant("medium", ["low", "high"])).toBeUndefined()
expect(availableModelVariant("medium", ["low", "medium", "high"])).toBe("medium")
})
test("atomically serializes patches and variant updates", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "model.json")