mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(core): add Modal live model backfill plugin (#47136)
This commit is contained in:
committed by
GitHub
Unverified
parent
b2fb2c5e36
commit
5a9931280e
@@ -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<Model.ID, Model.Info>()
|
||||
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<string, Model.Info["variants"][number]>()
|
||||
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()]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Model.ID, Model.Info>
|
||||
} = {}
|
||||
|
||||
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)
|
||||
@@ -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<Model.Info> = {}) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user