mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b9892f0f7 | |||
| 58deb52dcd |
@@ -27,7 +27,6 @@ const requestedTarget = process.argv.find((arg) => arg.startsWith("--target="))?
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const skipWebUi = process.argv.includes("--skip-web-ui")
|
||||
const solidPlugin = createSolidTransformPlugin()
|
||||
const releaseAssets = new Map<string, Promise<Map<string, string>>>()
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@@ -162,13 +161,7 @@ async function compileExecutable(item: (typeof allTargets)[number]) {
|
||||
if (!release) return
|
||||
|
||||
const platform = item.os === "win32" ? "windows" : item.os
|
||||
const name = [
|
||||
"bun",
|
||||
platform,
|
||||
item.arch === "arm64" ? "aarch64" : item.arch,
|
||||
item.abi,
|
||||
item.avx2 === false ? "baseline" : undefined,
|
||||
]
|
||||
const name = ["bun", platform, item.arch, item.abi, item.avx2 === false ? "baseline" : undefined]
|
||||
.filter(Boolean)
|
||||
.join("-")
|
||||
const cache = path.join(outdir, ".bun", release)
|
||||
@@ -177,13 +170,7 @@ async function compileExecutable(item: (typeof allTargets)[number]) {
|
||||
|
||||
await mkdir(cache, { recursive: true })
|
||||
const archive = path.join(cache, `${name}.zip`)
|
||||
const assets = await compileReleaseAssets(release)
|
||||
const url = assets.get(`${name}.zip`)
|
||||
if (!url) throw new Error(`Bun release ${release} does not include ${name}.zip`)
|
||||
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "application/octet-stream", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
})
|
||||
const response = await fetch(`https://github.com/oven-sh/bun/releases/download/${release}/${name}.zip`)
|
||||
if (!response.ok) throw new Error(`Failed to download ${name} from Bun release ${release}: ${response.status}`)
|
||||
await Bun.write(archive, response)
|
||||
await $`unzip -oq ${archive} -d ${cache}`
|
||||
@@ -191,38 +178,6 @@ async function compileExecutable(item: (typeof allTargets)[number]) {
|
||||
return executable
|
||||
}
|
||||
|
||||
function compileReleaseAssets(release: string) {
|
||||
const existing = releaseAssets.get(release)
|
||||
if (existing) return existing
|
||||
const pending = fetch(`https://api.github.com/repos/oven-sh/bun/releases/tags/${release}?cache=${Date.now()}`)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`Failed to resolve Bun release ${release}: ${response.status}`)
|
||||
const data: unknown = await response.json()
|
||||
if (typeof data !== "object" || data === null || !("assets" in data) || !Array.isArray(data.assets)) {
|
||||
throw new Error(`Bun release ${release} returned invalid metadata`)
|
||||
}
|
||||
return new Map(
|
||||
data.assets
|
||||
.filter(
|
||||
(asset): asset is { name: string; url: string } =>
|
||||
typeof asset === "object" &&
|
||||
asset !== null &&
|
||||
"name" in asset &&
|
||||
typeof asset.name === "string" &&
|
||||
"url" in asset &&
|
||||
typeof asset.url === "string",
|
||||
)
|
||||
.map((asset) => [asset.name, asset.url]),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
releaseAssets.delete(release)
|
||||
throw error
|
||||
})
|
||||
releaseAssets.set(release, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
function targetName(item: (typeof allTargets)[number]) {
|
||||
return [
|
||||
binary,
|
||||
|
||||
@@ -82,7 +82,7 @@ export const Plugin = define({
|
||||
.pipe(
|
||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isAgentSource(entries, update.path))),
|
||||
)
|
||||
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
|
||||
const configUpdates = ctx.event.subscribe("config.updated")
|
||||
yield* Stream.merge(sourceChanges, configUpdates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach(() => reload),
|
||||
|
||||
@@ -43,7 +43,7 @@ export const Plugin = define({
|
||||
Effect.map(config.entries(), (entries) => isCommandSource(entries, update.path)),
|
||||
),
|
||||
)
|
||||
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
|
||||
const configUpdates = ctx.event.subscribe("config.updated")
|
||||
yield* Stream.merge(sourceChanges, configUpdates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach(() => reload),
|
||||
|
||||
@@ -22,8 +22,7 @@ export const Plugin = define({
|
||||
if (policy?.effect === "deny") catalog.provider.remove(record.provider.id)
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
yield* ctx.event.subscribe("config.updated").pipe(
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
|
||||
@@ -97,8 +97,7 @@ export const Plugin = define({
|
||||
}
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
yield* ctx.event.subscribe("config.updated").pipe(
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
|
||||
@@ -49,8 +49,7 @@ export const Plugin = define({
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
yield* ctx.event.subscribe("config.updated").pipe(
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
|
||||
@@ -180,8 +180,7 @@ export const Plugin = define({
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
for (const skill of loaded.skills) draft.add(skill)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
yield* ctx.event.subscribe("config.updated").pipe(
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
|
||||
@@ -14,8 +14,7 @@ export const Plugin = define({
|
||||
if (selection === false) websearch.default.set(false)
|
||||
if (selection) websearch.default.set(selection.provider)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
yield* ctx.event.subscribe("config.updated").pipe(
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
|
||||
@@ -59,6 +59,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
||||
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
const subscribe: Plugin.Context["event"]["subscribe"] = (type?: EventManifest.ServerEvent["type"]) => {
|
||||
if (type === undefined) return bus.subscribe().pipe(Stream.filter(EventManifest.isServer))
|
||||
const definition = EventManifest.Server.get(type)
|
||||
if (!definition) return Stream.fail(new Error(`Unknown plugin event type: ${type}`))
|
||||
return bus.subscribe(definition).pipe(Stream.filter(EventManifest.isServer))
|
||||
}
|
||||
|
||||
return {
|
||||
app,
|
||||
@@ -180,7 +186,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
}),
|
||||
},
|
||||
event: {
|
||||
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
||||
subscribe,
|
||||
},
|
||||
integration: {
|
||||
list: () => response(integration.list()),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -150,10 +150,6 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
|
||||
if (!current.pending) return undefined
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (!force && current.publishedAt === undefined) {
|
||||
current.publishedAt = now
|
||||
return undefined
|
||||
}
|
||||
if (!force && current.publishedAt !== undefined && now - current.publishedAt < deltaBatchInterval)
|
||||
return undefined
|
||||
yield* delta(id, current.pending, current.ordinal)
|
||||
|
||||
@@ -20,24 +20,55 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
|
||||
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
|
||||
|
||||
describe("Plugin", () => {
|
||||
it.live("exposes public events through the plugin context", () =>
|
||||
it.live("selects one public event type through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const bus = yield* Bus.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const received = yield* host.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const received = yield* host.event
|
||||
.subscribe("config.updated")
|
||||
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
|
||||
expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("exposes all public events through a wildcard plugin subscription", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const bus = yield* Bus.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const received = yield* host.event
|
||||
.subscribe()
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
|
||||
expect(Array.from(yield* Fiber.join(received), (event) => event.type)).toEqual([
|
||||
"plugin.updated",
|
||||
"config.updated",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unknown runtime plugin event types", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const subscribe = host.event.subscribe as unknown as (type: string) => Stream.Stream<never, Error>
|
||||
|
||||
const failure = yield* subscribe("unknown.event").pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expect(failure.message).toBe("Unknown plugin event type: unknown.event")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces plugins by ID and version", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { DateTime, Effect, Schema, Stream } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -18,6 +18,8 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import type { PluginEventType } from "@opencode-ai/plugin/effect/event"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -27,6 +29,28 @@ import { host as testHost } from "./host"
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("fromPromise", () => {
|
||||
it.effect("forwards a selected event type", () =>
|
||||
Effect.gen(function* () {
|
||||
let selected: string | undefined
|
||||
const subscribe: EffectPlugin.Context["event"]["subscribe"] = (type?: PluginEventType) => {
|
||||
selected = type
|
||||
return Stream.empty
|
||||
}
|
||||
const host = testHost({ event: { subscribe } })
|
||||
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-event-subscribe",
|
||||
setup: (ctx) => {
|
||||
ctx.event.subscribe("config.updated")
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
|
||||
expect(selected).toBe("config.updated")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts session creation through the protocol schema", () =>
|
||||
Effect.gen(function* () {
|
||||
let seen: unknown
|
||||
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
@@ -217,13 +217,16 @@ it.effect("batches text deltas and flushes pending text before the terminal even
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
expect(published.filter((event) => event.type === "session.text.delta")).toHaveLength(0)
|
||||
expect(published.filter((event) => event.type === "session.text.delta").map((event) => event.data)).toMatchObject([
|
||||
{ delta: "one" },
|
||||
])
|
||||
yield* TestClock.adjust("99 millis")
|
||||
expect(published.filter((event) => event.type === "session.text.delta")).toHaveLength(0)
|
||||
expect(published.filter((event) => event.type === "session.text.delta")).toHaveLength(1)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
yield* publisher.publish(LLMEvent.textDelta({ id: "text", text: " four" }))
|
||||
expect(published.filter((event) => event.type === "session.text.delta").map((event) => event.data)).toMatchObject([
|
||||
{ delta: "one two three four" },
|
||||
{ delta: "one" },
|
||||
{ delta: " two three four" },
|
||||
])
|
||||
|
||||
yield* publisher.publish(LLMEvent.textDelta({ id: "text", text: " five" }))
|
||||
@@ -250,7 +253,7 @@ it.effect("batches reasoning deltas and flushes pending reasoning before the ter
|
||||
|
||||
expect(
|
||||
published.filter((event) => event.type === "session.reasoning.delta").map((event) => event.data),
|
||||
).toMatchObject([{ delta: "one two three" }])
|
||||
).toMatchObject([{ delta: "one" }, { delta: " two three" }])
|
||||
expect(published.slice(-2).map((event) => event.type)).toEqual([
|
||||
"session.reasoning.delta",
|
||||
"session.reasoning.ended.1",
|
||||
|
||||
@@ -767,7 +767,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) =>
|
||||
yield* admit(session, prompt)
|
||||
const bus = yield* Bus.Service
|
||||
const live = fixture.delta
|
||||
? yield* bus.subscribe(fixture.delta).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
? yield* bus.subscribe(fixture.delta).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
: undefined
|
||||
yield* Effect.yieldNow
|
||||
yield* TestLLM.push(fixture.completeEvents)
|
||||
@@ -785,7 +785,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) =>
|
||||
: []
|
||||
if (live) {
|
||||
const streamed = Array.from(yield* Fiber.join(live))
|
||||
expect(streamed).toHaveLength(1)
|
||||
expect(streamed).toHaveLength(2)
|
||||
expect(
|
||||
streamed
|
||||
.map((event) => {
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import type { EventApi } from "@opencode-ai/client/effect/api"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/effect"
|
||||
import type { Stream } from "effect"
|
||||
|
||||
export interface EventDomain extends Pick<EventApi<unknown>, "subscribe"> {}
|
||||
export type PluginEvent = Exclude<OpenCodeEvent, { readonly type: "server.connected" }>
|
||||
export type PluginEventType = PluginEvent["type"]
|
||||
|
||||
export interface EventSubscribe {
|
||||
(): Stream.Stream<PluginEvent, unknown>
|
||||
(type: PluginEventType): Stream.Stream<PluginEvent, unknown>
|
||||
}
|
||||
|
||||
export interface EventDomain extends Omit<EventApi<unknown>, "subscribe"> {
|
||||
readonly subscribe: EventSubscribe
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, SchemaAST, Scope, Stream } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { define } from "../effect/plugin.js"
|
||||
import type { PluginEventType } from "./event.js"
|
||||
import type { Context, Plugin } from "./plugin.js"
|
||||
import type { Info } from "./tool.js"
|
||||
|
||||
@@ -149,13 +150,15 @@ export function fromPromise(plugin: Plugin) {
|
||||
reload: () => run(host.command.reload()),
|
||||
},
|
||||
event: {
|
||||
subscribe: () =>
|
||||
Stream.toAsyncIterable(
|
||||
host.event.subscribe().pipe(
|
||||
subscribe: (type?: PluginEventType) => {
|
||||
const events = type === undefined ? host.event.subscribe() : host.event.subscribe(type)
|
||||
return Stream.toAsyncIterable(
|
||||
events.pipe(
|
||||
Stream.mapEffect((event) => Schema.encodeUnknownEffect(OpenCodeEvent)(event)),
|
||||
Stream.map((event) => event as unknown as PromiseEvent),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { EventApi } from "@opencode-ai/client/promise/api"
|
||||
|
||||
export interface EventDomain extends Pick<EventApi, "subscribe"> {}
|
||||
export type PluginEvent = Exclude<OpenCodeEvent, { readonly type: "server.connected" }>
|
||||
export type PluginEventType = PluginEvent["type"]
|
||||
|
||||
export interface EventSubscribe {
|
||||
(): AsyncIterable<PluginEvent>
|
||||
(type: PluginEventType): AsyncIterable<PluginEvent>
|
||||
}
|
||||
|
||||
export interface EventDomain extends Omit<EventApi, "subscribe"> {
|
||||
readonly subscribe: EventSubscribe
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Context as EffectContext } from "../src/effect/plugin.js"
|
||||
import type { Context as PromiseContext } from "../src/promise/plugin.js"
|
||||
|
||||
function effectSubscriptions(ctx: EffectContext) {
|
||||
ctx.event.subscribe()
|
||||
ctx.event.subscribe("config.updated")
|
||||
// @ts-expect-error server.connected is a network-only marker
|
||||
ctx.event.subscribe("server.connected")
|
||||
// @ts-expect-error plugin subscriptions select at most one event type
|
||||
ctx.event.subscribe(["config.updated"])
|
||||
}
|
||||
|
||||
function promiseSubscriptions(ctx: PromiseContext) {
|
||||
ctx.event.subscribe()
|
||||
ctx.event.subscribe("config.updated")
|
||||
// @ts-expect-error server.connected is a network-only marker
|
||||
ctx.event.subscribe("server.connected")
|
||||
// @ts-expect-error plugin subscriptions select at most one event type
|
||||
ctx.event.subscribe(["config.updated"])
|
||||
}
|
||||
|
||||
test("event subscription types support wildcard and one public event", () => {
|
||||
expect(effectSubscriptions).toBeFunction()
|
||||
expect(promiseSubscriptions).toBeFunction()
|
||||
})
|
||||
+8
@@ -184,6 +184,14 @@ and plugin options.
|
||||
| `ctx.event` | `subscribe` to the current public server event stream |
|
||||
| `ctx.options` | Readonly options from the matching config object |
|
||||
|
||||
Event subscriptions can receive every plugin-visible public event, or select
|
||||
one event type:
|
||||
|
||||
```ts
|
||||
ctx.event.subscribe()
|
||||
ctx.event.subscribe("config.updated")
|
||||
```
|
||||
|
||||
### Transform hooks
|
||||
|
||||
Transform hooks let a plugin modify how OpenCode is configured. Use them to add
|
||||
|
||||
Reference in New Issue
Block a user