mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2017378db0 | |||
| a48d44955e |
@@ -41,8 +41,8 @@
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -102,6 +102,17 @@ for (const item of targets) {
|
||||
const name = target.replace(binary, "cli")
|
||||
const executablePath = await compileExecutable(item)
|
||||
console.log(`building ${name}`)
|
||||
const compileTarget = {
|
||||
autoloadBunfig: false,
|
||||
autoloadDotenv: false,
|
||||
autoloadTsconfig: true,
|
||||
autoloadPackageJson: true,
|
||||
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
outfile: path.join(outdir, name, "bin", binary),
|
||||
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--no-warnings", "--"],
|
||||
windows: {},
|
||||
}
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
@@ -111,17 +122,7 @@ for (const item of targets) {
|
||||
minify: true,
|
||||
sourcemap: "inline",
|
||||
splitting: true,
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
autoloadDotenv: false,
|
||||
autoloadTsconfig: true,
|
||||
autoloadPackageJson: true,
|
||||
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
|
||||
executablePath,
|
||||
outfile: path.join(outdir, name, "bin", binary),
|
||||
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
|
||||
windows: {},
|
||||
},
|
||||
compile: compileTarget,
|
||||
define: {
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OPENCODE_CLI_NAME: `'${binary}'`,
|
||||
|
||||
@@ -16,7 +16,6 @@ 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"
|
||||
@@ -50,7 +49,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
KiloPlugin,
|
||||
LLMGatewayPlugin,
|
||||
MistralPlugin,
|
||||
ModalPlugin,
|
||||
NvidiaPlugin,
|
||||
OpencodePlugin,
|
||||
SnowflakeCortexPlugin,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
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([])
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user