mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-29 11:54:42 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b85cf3d67f | |||
| 2b72a95502 | |||
| 68a40e2d21 | |||
| 57c02cd04b | |||
| 46515df4a3 | |||
| 51250e4346 | |||
| dbd47702b2 | |||
| a085bf62a4 | |||
| b605f355ca | |||
| 85e2b0a23a | |||
| 3dc187bf3f | |||
| ed3259a9b7 | |||
| db09cc842e |
@@ -47,7 +47,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Deploy update service
|
||||
if: github.ref_name == 'v2' || github.ref_name == 'beta'
|
||||
if: github.ref_name == 'v2'
|
||||
working-directory: packages/updates
|
||||
run: bun run deploy
|
||||
env:
|
||||
|
||||
@@ -575,6 +575,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
@@ -585,6 +586,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -971,7 +973,6 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -1123,7 +1124,6 @@
|
||||
"opentui-spinner": "0.0.7",
|
||||
"remeda": "2.26.0",
|
||||
"remend": "1.3.1",
|
||||
"resolve.exports": "2.0.3",
|
||||
"semver": "7.7.4",
|
||||
"shiki": "4.4.3",
|
||||
"solid-js": "1.9.15",
|
||||
@@ -5171,8 +5171,6 @@
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
|
||||
|
||||
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
||||
|
||||
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
|
||||
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
|
||||
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
|
||||
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
|
||||
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
|
||||
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
|
||||
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
|
||||
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"reserve-packages": "bun script/reserve-package-names.ts",
|
||||
"random": "echo 'Random script'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
@@ -93,7 +92,6 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"resolve.exports": "2.0.3",
|
||||
"sst": "4.13.1",
|
||||
"shiki": "4.4.3",
|
||||
"solid-list": "0.3.0",
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { Effect } from "effect"
|
||||
import { Duration, Effect, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Auth } from "../auth.js"
|
||||
import { render as renderEndpoint } from "../endpoint.js"
|
||||
import { Framing } from "../framing.js"
|
||||
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js"
|
||||
import * as ProviderShared from "../../protocols/shared.js"
|
||||
import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
DEFAULT_HTTP_TIMEOUT_MS,
|
||||
mergeJsonRecords,
|
||||
TransportError,
|
||||
type HttpContext,
|
||||
type HttpTimeout,
|
||||
type LLMRequest,
|
||||
type TransportOperation,
|
||||
} from "../../schema/index.js"
|
||||
import { RequestExecutor } from "../executor.js"
|
||||
|
||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||
@@ -87,17 +96,45 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
middleware: prepareInput.middleware,
|
||||
}
|
||||
}),
|
||||
execute: (prepared, _request, runtime) =>
|
||||
execute: (prepared, request, runtime) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* runtime.http.execute(prepared.request, prepared.middleware)
|
||||
const timeout = (operation: TransportOperation, message: string, http?: HttpContext) =>
|
||||
new AIError({
|
||||
reason: new TransportError({
|
||||
message,
|
||||
transport: "http",
|
||||
operation,
|
||||
code: "Timeout",
|
||||
url: prepared.request.url,
|
||||
http,
|
||||
}),
|
||||
})
|
||||
const response = yield* runtime.http.execute(prepared.request, prepared.middleware).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: timeoutDuration(request.http?.headerTimeout),
|
||||
orElse: () => Effect.fail(timeout("request", "Timed out waiting for response headers")),
|
||||
}),
|
||||
)
|
||||
const http = RequestExecutor.responseHttp(response)
|
||||
return {
|
||||
frames: prepared.framing.frame(RequestExecutor.responseStream(response)),
|
||||
http: RequestExecutor.responseHttp(response),
|
||||
frames: prepared.framing.frame(
|
||||
RequestExecutor.responseStream(response).pipe(
|
||||
Stream.timeoutOrElse({
|
||||
duration: timeoutDuration(request.http?.chunkTimeout),
|
||||
orElse: () => Stream.fail(timeout("read", "Timed out waiting for response data", http)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
http,
|
||||
body: prepared.framing.body,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
// `false` disables a timer; an unset value uses the shared default.
|
||||
const timeoutDuration = (value: HttpTimeout | undefined) =>
|
||||
value === false ? Duration.infinity : Duration.millis(value ?? DEFAULT_HTTP_TIMEOUT_MS)
|
||||
|
||||
export const sseJson = {
|
||||
id: "http-json/sse",
|
||||
with: <Body>() => httpJson<Body, string>({ framing: Framing.sse }),
|
||||
|
||||
@@ -43,10 +43,21 @@ export const mergeProviderOptions = (
|
||||
...items: ReadonlyArray<ProviderOptions | undefined>
|
||||
): ProviderOptions | undefined => mergeJsonRecords(...items)
|
||||
|
||||
/** Milliseconds for an HTTP timeout, or `false` to disable it. */
|
||||
export const HttpTimeout = Schema.Union([Schema.Number.check(Schema.isGreaterThan(0)), Schema.Literal(false)])
|
||||
export type HttpTimeout = Schema.Schema.Type<typeof HttpTimeout>
|
||||
|
||||
/** Default for `headerTimeout` and `chunkTimeout` when a request leaves them unset. */
|
||||
export const DEFAULT_HTTP_TIMEOUT_MS = 300_000
|
||||
|
||||
export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
|
||||
body: Schema.optional(JsonSchema),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
query: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
/** Time allowed for response headers to arrive. Defaults to five minutes. */
|
||||
headerTimeout: Schema.optional(HttpTimeout),
|
||||
/** Time allowed between streamed response chunks once headers have arrived. Defaults to five minutes. */
|
||||
chunkTimeout: Schema.optional(HttpTimeout),
|
||||
}) {}
|
||||
|
||||
export namespace HttpOptions {
|
||||
@@ -60,8 +71,10 @@ export const mergeHttpOptions = (...items: ReadonlyArray<HttpOptions | undefined
|
||||
const body = mergeJsonRecords(...items.map((item) => item?.body))
|
||||
const headers = mergeStringRecords(...items.map((item) => item?.headers))
|
||||
const query = mergeStringRecords(...items.map((item) => item?.query))
|
||||
if (!body && !headers && !query) return undefined
|
||||
return new HttpOptions({ body, headers, query })
|
||||
const headerTimeout = items.findLast((item) => item?.headerTimeout !== undefined)?.headerTimeout
|
||||
const chunkTimeout = items.findLast((item) => item?.chunkTimeout !== undefined)?.chunkTimeout
|
||||
if (!body && !headers && !query && headerTimeout === undefined && chunkTimeout === undefined) return undefined
|
||||
return new HttpOptions({ body, headers, query, headerTimeout, chunkTimeout })
|
||||
}
|
||||
|
||||
export class GenerationOptions extends Schema.Class<GenerationOptions>("LLM.GenerationOptions")({
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { HttpOptions, LLM, mergeHttpOptions } from "../src/index.js"
|
||||
import { LLMClient } from "../src/route.js"
|
||||
import { configure } from "../src/providers/openai.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
import { deltaChunk, finishChunk } from "./lib/openai-chunks.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
|
||||
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4.1-mini")
|
||||
const body = sseEvents(deltaChunk({ role: "assistant", content: "Hi" }), finishChunk("stop"))
|
||||
const SSE = { headers: { "content-type": "text/event-stream" } }
|
||||
|
||||
// Never produces headers; the header timer is the only way out.
|
||||
const silentServer = dynamicResponse(() => Effect.never)
|
||||
|
||||
// Sends one chunk, then stalls until `resume` releases the rest of the body.
|
||||
const stalledServer = Effect.gen(function* () {
|
||||
const stalled = yield* Deferred.make<void>()
|
||||
let resume = () => {}
|
||||
const released = new Promise<void>((resolve) => {
|
||||
resume = resolve
|
||||
})
|
||||
const encoder = new TextEncoder()
|
||||
const layer = dynamicResponse((input) =>
|
||||
Effect.sync(() =>
|
||||
input.respond(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(deltaChunk({ content: "Hi" }))}\n\n`))
|
||||
},
|
||||
async pull(controller) {
|
||||
Deferred.doneUnsafe(stalled, Effect.void)
|
||||
await released
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(finishChunk("stop"))}\n\ndata: [DONE]\n\n`))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
SSE,
|
||||
),
|
||||
),
|
||||
)
|
||||
return { layer, stalled, resume: () => resume() }
|
||||
})
|
||||
|
||||
const slowHeadersServer = dynamicResponse((input) =>
|
||||
Effect.sleep("10 minutes").pipe(Effect.as(input.respond(body, SSE))),
|
||||
)
|
||||
|
||||
describe("HTTP transport timeouts", () => {
|
||||
it.effect("fails when response headers take longer than five minutes", () =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(silentServer),
|
||||
Effect.flip,
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
transport: "http",
|
||||
operation: "request",
|
||||
code: "Timeout",
|
||||
})
|
||||
expect(error.reason.message).toContain("response headers")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails when the response body stalls for five minutes", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* stalledServer
|
||||
const fiber = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(server.layer),
|
||||
Effect.flip,
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(server.stalled)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", transport: "http", operation: "read", code: "Timeout" })
|
||||
expect(error.reason.http).toMatchObject({ status: 200 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies a configured header timeout", () =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Hello", http: { headerTimeout: 1_000 } }),
|
||||
).pipe(Effect.provide(silentServer), Effect.flip, Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 second")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", operation: "request", code: "Timeout" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables the header timeout with false", () =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Hello", http: { headerTimeout: false } }),
|
||||
).pipe(Effect.provide(slowHeadersServer), Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
const response = yield* Fiber.join(fiber)
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables the chunk timeout with false", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* stalledServer
|
||||
const fiber = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Hello", http: { chunkTimeout: false } }),
|
||||
).pipe(Effect.provide(server.layer), Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(server.stalled)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
server.resume()
|
||||
const response = yield* Fiber.join(fiber)
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges timeouts with later values winning", () =>
|
||||
Effect.sync(() => {
|
||||
const merged = mergeHttpOptions(
|
||||
new HttpOptions({ headerTimeout: 1_000, chunkTimeout: 2_000 }),
|
||||
new HttpOptions({ headers: { a: "b" } }),
|
||||
new HttpOptions({ chunkTimeout: false }),
|
||||
)
|
||||
|
||||
expect(merged).toMatchObject({ headers: { a: "b" }, headerTimeout: 1_000, chunkTimeout: false })
|
||||
expect(mergeHttpOptions(new HttpOptions({}), undefined)).toBeUndefined()
|
||||
expect(mergeHttpOptions(new HttpOptions({ headerTimeout: false }))?.headerTimeout).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -89,12 +89,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
}
|
||||
: undefined,
|
||||
packages: {
|
||||
resolve: (spec, install = true) =>
|
||||
runPromise(
|
||||
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
|
||||
Effect.map((result) => result.entrypoint),
|
||||
),
|
||||
),
|
||||
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
|
||||
},
|
||||
environment: requestedServer === undefined ? Env.session() : undefined,
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Effect } from "effect"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { resolveConfigPath } from "../mcp/add"
|
||||
@@ -16,9 +17,9 @@ export default Runtime.handler(
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
|
||||
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
|
||||
const npm = yield* Npm.Service
|
||||
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
|
||||
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
|
||||
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
|
||||
const installed = yield* npm.add(input.package)
|
||||
const entrypoints = Host.resolve(installed)
|
||||
const target = configurationTarget(entrypoints.server, entrypoints.tui)
|
||||
if (!target)
|
||||
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: str
|
||||
tuiTargets,
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
const installed = yield* npm.resolve(target)
|
||||
const outdated = yield* npm.check(target).pipe(Effect.exit)
|
||||
return {
|
||||
runtime: "TUI" as const,
|
||||
|
||||
@@ -9,8 +9,9 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
|
||||
import { discoverPluginTargets, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
@@ -22,27 +23,27 @@ export default Runtime.handler(
|
||||
const global = yield* Global.Service
|
||||
const info = yield* config.get()
|
||||
const discovered = yield* Effect.promise(() =>
|
||||
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
|
||||
localPluginDirectories(process.cwd(), global.config).then(discoverPluginTargets),
|
||||
)
|
||||
const npm = yield* Npm.Service
|
||||
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
|
||||
const configured = yield* Effect.forEach([...(info.plugins ?? []), ...discovered], (entry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
|
||||
return []
|
||||
const local = localSource(target, path.dirname(config.path))
|
||||
if (local) return [{ target: fileURLToPath(local), version: "local" }]
|
||||
if (local) {
|
||||
const directory = fileURLToPath(local)
|
||||
const entrypoints = Host.resolve({ directory })
|
||||
return entrypoints.tui ? [{ target: directory, version: "local" }] : []
|
||||
}
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
if (!installed.entrypoint) return []
|
||||
const installed = yield* npm.resolve(target)
|
||||
if (!Host.resolve(installed).tui) return []
|
||||
return [{ target, version: installed.version }]
|
||||
}),
|
||||
)
|
||||
const output = format(
|
||||
response.data,
|
||||
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
|
||||
input.builtin,
|
||||
)
|
||||
const output = format(response.data, configured.flat(), input.builtin)
|
||||
if (!output) {
|
||||
process.stdout.write("No plugins found" + EOL)
|
||||
return
|
||||
@@ -81,9 +82,7 @@ export function format(
|
||||
? plugin.source.target === item.target
|
||||
: plugin.source.type === "local" &&
|
||||
(plugin.source.path === item.target ||
|
||||
(plugin.features.tui &&
|
||||
path.dirname(plugin.source.path) ===
|
||||
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
|
||||
(plugin.features.tui && path.dirname(plugin.source.path) === item.target)),
|
||||
),
|
||||
)
|
||||
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
|
||||
|
||||
@@ -26,7 +26,7 @@ export default Runtime.handler(
|
||||
(item) =>
|
||||
(item.runtime === "Server"
|
||||
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
|
||||
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
|
||||
: npm.update(item.target).pipe(Effect.asVoid)
|
||||
).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((result) => ({ item, result })),
|
||||
|
||||
@@ -985,15 +985,6 @@ export type ReferenceUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type PluginAdded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "plugin.added"
|
||||
location?: LocationRef
|
||||
data: { id: string }
|
||||
}
|
||||
|
||||
export type PluginUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2299,7 +2290,6 @@ export type V2Event =
|
||||
| ReferenceUpdated
|
||||
| PermissionAsked
|
||||
| PermissionReplied
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| ProjectUpdated
|
||||
| WorktreeUpdated
|
||||
|
||||
@@ -83,10 +83,8 @@ type LocationData = {
|
||||
agent?: AgentInfo[]
|
||||
command?: CommandInfo[]
|
||||
integration?: IntegrationInfo[]
|
||||
mcp?: {
|
||||
server?: McpServer[]
|
||||
resource?: McpResource[]
|
||||
}
|
||||
mcpServer?: McpServer[]
|
||||
mcpResource?: McpResource[]
|
||||
model?: ModelInfo[]
|
||||
provider?: ProviderInfo[]
|
||||
reference?: ReferenceInfo[]
|
||||
@@ -125,8 +123,8 @@ export function locationKey(location: LocationRef) {
|
||||
return JSON.stringify([location.directory, location.workspaceID])
|
||||
}
|
||||
|
||||
function locationQuery(ref?: LocationRef) {
|
||||
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||
function locationQuery(ref: LocationRef) {
|
||||
return { directory: ref.directory, workspace: ref.workspaceID }
|
||||
}
|
||||
|
||||
function formRequestOptions(sessionID: string, ref?: LocationRef) {
|
||||
@@ -1273,6 +1271,41 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
}
|
||||
|
||||
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
|
||||
// effective location, and publishes under the server's canonical location; `alias`
|
||||
// also publishes under the requested key when the two differ.
|
||||
function locationResource<Field extends keyof LocationData>(
|
||||
field: Field,
|
||||
load: (location: ReturnType<typeof locationQuery>) => Promise<{ location: LocationRef; data: LocationData[Field] }>,
|
||||
options?: { alias?: boolean },
|
||||
) {
|
||||
const publish = (key: string, value: LocationData[Field]) => setStore("location", key, { [field]: value })
|
||||
return {
|
||||
list: (ref?: LocationRef) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
|
||||
sync: (ref?: LocationRef) => {
|
||||
const location = ref ?? defaultLocation()
|
||||
const id = locationKey(location)
|
||||
return sync.run(`location.${field}:${id}`, async () => {
|
||||
const response = await load(locationQuery(location))
|
||||
const key = locationKey(response.location)
|
||||
publish(key, response.data)
|
||||
if (options?.alias && key !== id) publish(id, response.data)
|
||||
})
|
||||
},
|
||||
invalidate: (ref?: LocationRef) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`),
|
||||
}
|
||||
}
|
||||
|
||||
const vcs = locationResource("vcs", (location) => api().vcs.get({ location }))
|
||||
const shells = locationResource("shell", async (location) => {
|
||||
const response = await api().shell.list({ location })
|
||||
const ref = { directory: response.location.directory, workspaceID: response.location.workspaceID }
|
||||
return {
|
||||
location: response.location,
|
||||
data: Object.fromEntries(response.data.map((info) => [info.id, { ...info, location: ref }])),
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
on: config.event.on,
|
||||
listen: config.event.listen,
|
||||
@@ -1740,7 +1773,7 @@ export function createData(config: CreateDataInput) {
|
||||
},
|
||||
shell: {
|
||||
list(location?: LocationRef) {
|
||||
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
|
||||
return Object.values(shells.list(location) ?? {})
|
||||
},
|
||||
listBySession(sessionID: string) {
|
||||
return Object.values(store.location)
|
||||
@@ -1752,31 +1785,8 @@ export function createData(config: CreateDataInput) {
|
||||
.map((data) => data.shell?.[id])
|
||||
.find((shell) => shell !== undefined)
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.shell:${id}`, async () => {
|
||||
const response = await api().shell.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
shell: Object.fromEntries(
|
||||
response.data.map((info) => [
|
||||
info.id,
|
||||
{
|
||||
...info,
|
||||
location: {
|
||||
directory: response.location.directory,
|
||||
workspaceID: response.location.workspaceID,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
sync: shells.sync,
|
||||
invalidate: shells.invalidate,
|
||||
},
|
||||
location: {
|
||||
info(ref?: LocationRef) {
|
||||
@@ -1831,162 +1841,20 @@ export function createData(config: CreateDataInput) {
|
||||
result.shell.invalidate(location)
|
||||
result.session.form.invalidate("global", location)
|
||||
},
|
||||
vcs: {
|
||||
info(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.vcs
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const location = ref ?? defaultLocation()
|
||||
return sync.run(`location.vcs:${locationKey(location)}`, async () => {
|
||||
const response = await api().vcs.get({ location: locationQuery(location) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], vcs: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.agent
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.agent:${id}`, async () => {
|
||||
const response = await api().agent.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], agent: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
command: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.command
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.command:${id}`, async () => {
|
||||
const response = await api().command.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], command: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.integration
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.integration:${id}`, async () => {
|
||||
const response = await api().integration.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], integration: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
|
||||
agent: locationResource("agent", (location) => api().agent.list({ location })),
|
||||
command: locationResource("command", (location) => api().command.list({ location })),
|
||||
integration: locationResource("integration", (location) => api().integration.list({ location })),
|
||||
mcp: {
|
||||
server: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.mcp.server:${id}`, async () => {
|
||||
const response = await api().mcp.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
mcp: { ...store.location[key]?.mcp, server: response.data },
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
resource: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.mcp.resource:${id}`, async () => {
|
||||
const response = await api().mcp.resource.catalog({
|
||||
location: locationQuery(ref ?? defaultLocation()),
|
||||
})
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
mcp: { ...store.location[key]?.mcp, resource: response.data.resources },
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.model
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.model:${id}`, async () => {
|
||||
const response = await api().model.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], model: response.data })
|
||||
if (key !== id) setStore("location", id, { ...store.location[id], model: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.provider
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.provider:${id}`, async () => {
|
||||
const response = await api().provider.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], provider: response.data })
|
||||
if (key !== id) setStore("location", id, { ...store.location[id], provider: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
reference: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.reference
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.reference:${id}`, async () => {
|
||||
const response = await api().reference.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], reference: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
|
||||
resource: locationResource("mcpResource", async (location) => {
|
||||
const response = await api().mcp.resource.catalog({ location })
|
||||
return { location: response.location, data: response.data.resources }
|
||||
}),
|
||||
},
|
||||
model: locationResource("model", (location) => api().model.list({ location }), { alias: true }),
|
||||
provider: locationResource("provider", (location) => api().provider.list({ location }), { alias: true }),
|
||||
reference: locationResource("reference", (location) => api().reference.list({ location })),
|
||||
websearch: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.websearch
|
||||
@@ -2001,22 +1869,7 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.skill
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.skill:${id}`, async () => {
|
||||
const response = await api().skill.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], skill: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
skill: locationResource("skill", (location) => api().skill.list({ location })),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -20378,44 +20378,6 @@
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugin.added": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^evt_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["plugin.added"]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugin.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23123,9 +23085,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/permission.v2.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/plugin.added"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/plugin.updated"
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"migration": "bun run script/migration.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"benchmark:location": "bun run script/benchmark-location.ts",
|
||||
"benchmark:location-memory": "bun run script/benchmark-location-memory.ts",
|
||||
"build": "bun run script/build.ts",
|
||||
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
|
||||
"test": "bun run script/test.ts",
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Measures the heap retained by Location service graphs and by the models.dev
|
||||
// catalog plugin. Everything runs against a temporary global directory with a
|
||||
// temporary home, an in-memory database, no filesystem watchers, and no network,
|
||||
// so it never touches a live server, database, or user configuration.
|
||||
//
|
||||
// bun run script/benchmark-location-memory.ts [--locations 6] [--plugins 8] [--json out.json]
|
||||
//
|
||||
// "retained" numbers are heapUsed after two forced GCs; "peak" numbers are the
|
||||
// highest heapUsed sampled without forcing GC and are reported separately.
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { heapStats } from "bun:jsc"
|
||||
import { Effect, Layer, Logger, Scope } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { Bus } from "../src/bus"
|
||||
import { Catalog } from "../src/catalog"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Integration } from "../src/integration"
|
||||
import { Location } from "../src/location"
|
||||
import { LocationServiceMap } from "../src/location-service-map"
|
||||
import { ModelsDev } from "../src/models-dev"
|
||||
import { Plugin } from "../src/plugin"
|
||||
import { ModelsDevPlugin } from "../src/plugin/models-dev"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
import { Watcher } from "../src/filesystem/watcher"
|
||||
import { location } from "../test/fixture/location"
|
||||
import { catalogHost, host, integrationHost } from "../test/plugin/host"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const flag = (name: string, fallback: number) => {
|
||||
const index = args.indexOf(`--${name}`)
|
||||
if (index === -1) return fallback
|
||||
const value = Number(args[index + 1])
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
console.error(`--${name} must be a positive integer`)
|
||||
process.exit(1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
const locationCount = flag("locations", 6)
|
||||
const pluginCount = flag("plugins", 8)
|
||||
const jsonIndex = args.indexOf("--json")
|
||||
const jsonPath = jsonIndex === -1 ? undefined : args[jsonIndex + 1]
|
||||
|
||||
type Sample = { heapUsed: number; rss: number; objects: number }
|
||||
|
||||
const sample = (): Sample => {
|
||||
Bun.gc(true)
|
||||
Bun.gc(true)
|
||||
const usage = process.memoryUsage()
|
||||
return { heapUsed: usage.heapUsed, rss: usage.rss, objects: heapStats().objectCount }
|
||||
}
|
||||
|
||||
const mib = (bytes: number) => (bytes / 1024 / 1024).toFixed(2).padStart(8)
|
||||
|
||||
const median = (values: ReadonlyArray<number>) => {
|
||||
const sorted = values.toSorted((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
const upper = sorted[middle] ?? 0
|
||||
return sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + upper) / 2 : upper
|
||||
}
|
||||
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-location-memory-")))
|
||||
const globalLayer = Global.layerWith({
|
||||
home: path.join(root, "home"),
|
||||
data: path.join(root, "data"),
|
||||
cache: path.join(root, "cache"),
|
||||
config: path.join(root, "config"),
|
||||
state: path.join(root, "state"),
|
||||
tmp: path.join(root, "tmp"),
|
||||
bin: path.join(root, "cache", "bin"),
|
||||
log: path.join(root, "data", "log"),
|
||||
repos: path.join(root, "data", "repos"),
|
||||
})
|
||||
const replacements = [
|
||||
Global.node.replace(globalLayer),
|
||||
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
]
|
||||
|
||||
// One full Location graph per directory, retained for the rest of the run, the
|
||||
// way a long-running server retains every directory a client has touched.
|
||||
const locationsProgram = Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const before = sample()
|
||||
const deltas: number[] = []
|
||||
const rss: number[] = []
|
||||
let previous = before
|
||||
for (let index = 0; index < locationCount; index++) {
|
||||
const directory = path.join(root, "projects", `location-${index}`)
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
const context = yield* locations
|
||||
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(directory) }))
|
||||
.pipe(Scope.provide(scope))
|
||||
const plugins = yield* Plugin.Service.pipe(Effect.provideContext(context))
|
||||
yield* plugins.awaitActivation
|
||||
const current = sample()
|
||||
deltas.push(current.heapUsed - previous.heapUsed)
|
||||
rss.push(current.rss)
|
||||
previous = current
|
||||
}
|
||||
const catalog = yield* Catalog.Service.pipe(
|
||||
Effect.provideContext(
|
||||
yield* locations
|
||||
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(path.join(root, "projects", `location-0`)) }))
|
||||
.pipe(Scope.provide(scope)),
|
||||
),
|
||||
)
|
||||
const models = yield* catalog.model.all()
|
||||
const providers = yield* catalog.provider.all()
|
||||
return {
|
||||
before,
|
||||
after: previous,
|
||||
deltas,
|
||||
rss,
|
||||
catalog: { providers: providers.length, models: models.length },
|
||||
}
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
// The models.dev plugin alone, against a real Catalog and Integration state per
|
||||
// instance, isolates the catalog-copy contribution from the rest of the graph.
|
||||
const pluginProgram = Effect.gen(function* () {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const snapshot = yield* modelsDev.get()
|
||||
const scope = yield* Scope.Scope
|
||||
const before = sample()
|
||||
const deltas: number[] = []
|
||||
let previous = before
|
||||
for (let index = 0; index < pluginCount; index++) {
|
||||
const directory = AbsolutePath.make(path.join(root, "plugins", `instance-${index}`))
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location(Location.Ref.make({ directory }))),
|
||||
)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
|
||||
Location.node.replace(locationLayer),
|
||||
...replacements,
|
||||
]),
|
||||
).pipe(Scope.provide(scope))
|
||||
const catalog = yield* Catalog.Service.pipe(Effect.provideContext(context))
|
||||
const integration = yield* Integration.Service.pipe(Effect.provideContext(context))
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integration) }),
|
||||
).pipe(Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideContext(context), Scope.provide(scope))
|
||||
yield* catalog.model.all()
|
||||
yield* integration.list()
|
||||
const current = sample()
|
||||
deltas.push(current.heapUsed - previous.heapUsed)
|
||||
previous = current
|
||||
}
|
||||
return {
|
||||
before,
|
||||
after: previous,
|
||||
deltas,
|
||||
snapshot: {
|
||||
providers: snapshot.length,
|
||||
models: snapshot.reduce((total, provider) => total + provider.models.length, 0),
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const plugin = yield* pluginProgram.pipe(
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, ModelsDev.node]), replacements)),
|
||||
)
|
||||
const locations = yield* locationsProgram.pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), replacements),
|
||||
),
|
||||
)
|
||||
return { plugin, locations }
|
||||
}).pipe(Effect.provide(Logger.layer([])))
|
||||
|
||||
const result = await Effect.runPromise(program)
|
||||
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined)
|
||||
|
||||
console.log(
|
||||
`models.dev snapshot: ${result.plugin.snapshot.providers} providers, ${result.plugin.snapshot.models} models`,
|
||||
)
|
||||
console.log(`ModelsDevPlugin instances: ${pluginCount}`)
|
||||
console.log(` retained heap per instance (MiB): ${result.plugin.deltas.map((delta) => mib(delta).trim()).join(", ")}`)
|
||||
console.log(` median per instance: ${mib(median(result.plugin.deltas))} MiB`)
|
||||
console.log(
|
||||
`Location graphs: ${locationCount} (catalog ${result.locations.catalog.providers} providers, ${result.locations.catalog.models} models each)`,
|
||||
)
|
||||
console.log(
|
||||
` retained heap per location (MiB): ${result.locations.deltas.map((delta) => mib(delta).trim()).join(", ")}`,
|
||||
)
|
||||
console.log(` median per location: ${mib(median(result.locations.deltas))} MiB`)
|
||||
console.log(
|
||||
` heapUsed before ${mib(result.locations.before.heapUsed)} MiB -> after ${mib(result.locations.after.heapUsed)} MiB`,
|
||||
)
|
||||
console.log(` rss before ${mib(result.locations.before.rss)} MiB -> after ${mib(result.locations.after.rss)} MiB`)
|
||||
|
||||
if (jsonPath) {
|
||||
await fs.mkdir(path.dirname(jsonPath), { recursive: true })
|
||||
await fs.writeFile(
|
||||
jsonPath,
|
||||
JSON.stringify(
|
||||
{ revision: process.env.OPENCODE_BENCH_REVISION, bun: Bun.version, locationCount, pluginCount, ...result },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
+28
-17
@@ -16,6 +16,7 @@ import type {
|
||||
SharedV3ProviderOptions,
|
||||
} from "@ai-sdk/provider"
|
||||
import {
|
||||
DEFAULT_HTTP_TIMEOUT_MS,
|
||||
FinishReason,
|
||||
LLMEvent,
|
||||
AIError,
|
||||
@@ -126,21 +127,28 @@ function prepareOptions(model: Info, pkg: string) {
|
||||
}
|
||||
|
||||
const customFetch = options.fetch
|
||||
const chunkTimeout = options.chunkTimeout
|
||||
const timeouts = Provider.timeouts(options)
|
||||
const chunkTimeout = timeouts.chunkTimeout ?? DEFAULT_HTTP_TIMEOUT_MS
|
||||
const headerTimeout = timeouts.headerTimeout ?? DEFAULT_HTTP_TIMEOUT_MS
|
||||
delete options.chunkTimeout
|
||||
delete options.headerTimeout
|
||||
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
const opts = { ...(init ?? {}) }
|
||||
const signals = [
|
||||
opts.signal,
|
||||
typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined,
|
||||
options.timeout !== undefined && options.timeout !== null && options.timeout !== false
|
||||
? AbortSignal.timeout(options.timeout)
|
||||
: undefined,
|
||||
].filter((item): item is AbortSignal | AbortController => item !== undefined && item !== null)
|
||||
const chunkAbortCtl = signals.find((item): item is AbortController => item instanceof AbortController)
|
||||
const abortSignals = signals.map((item) => (item instanceof AbortController ? item.signal : item))
|
||||
if (abortSignals.length === 1) opts.signal = abortSignals[0]
|
||||
if (abortSignals.length > 1) opts.signal = AbortSignal.any(abortSignals)
|
||||
const ctl = new AbortController()
|
||||
// Only covers the wait for response headers; wrapSSE takes over once the body streams.
|
||||
const headerTimer =
|
||||
headerTimeout === false
|
||||
? undefined
|
||||
: setTimeout(() => ctl.abort(new Error(HEADER_TIMEOUT_MESSAGE)), headerTimeout)
|
||||
opts.signal = AbortSignal.any(
|
||||
[
|
||||
opts.signal,
|
||||
ctl.signal,
|
||||
options.timeout !== undefined && options.timeout !== null && options.timeout !== false
|
||||
? AbortSignal.timeout(options.timeout)
|
||||
: undefined,
|
||||
].filter((item): item is AbortSignal => item !== undefined && item !== null),
|
||||
)
|
||||
|
||||
if (typeof opts.body === "string" && model.body !== undefined) {
|
||||
const decoded = Option.getOrUndefined(decodeJson(opts.body))
|
||||
@@ -152,14 +160,16 @@ function prepareOptions(model: Info, pkg: string) {
|
||||
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
|
||||
...opts,
|
||||
timeout: false,
|
||||
})
|
||||
if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res
|
||||
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
||||
}).finally(() => clearTimeout(headerTimer))
|
||||
if (chunkTimeout === false) return res
|
||||
return wrapSSE(res, chunkTimeout, ctl)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
const HEADER_TIMEOUT_MESSAGE = "Response headers timed out"
|
||||
|
||||
export class InitError extends Schema.TaggedError<InitError>()("AISDK.InitError", {
|
||||
providerID: Provider.ID,
|
||||
cause: Schema.Defect(),
|
||||
@@ -388,7 +398,7 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
|
||||
if (settings === undefined) return undefined
|
||||
const result = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
|
||||
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "headerTimeout", "timeout"].includes(key),
|
||||
),
|
||||
)
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
@@ -838,7 +848,7 @@ function llmError(error: unknown, operation: "request" | "read") {
|
||||
|
||||
// Runtime-generated network failure shapes. The codes mirror the AI SDK's own
|
||||
// Bun network error list in handleFetchError; the messages are undici's fetch
|
||||
// TypeError and stream termination strings plus our SSE chunk timeout error.
|
||||
// TypeError and stream termination strings plus our header and chunk timeout errors.
|
||||
// Unrecognized shapes still retry via the UnknownProvider default; this match
|
||||
// only adds transport semantics (continuation eligibility, display).
|
||||
const NETWORK_ERROR_CODES = new Set([
|
||||
@@ -856,6 +866,7 @@ const NETWORK_ERROR_MESSAGES = new Set([
|
||||
"terminated",
|
||||
"other side closed",
|
||||
"sse read timed out",
|
||||
HEADER_TIMEOUT_MESSAGE.toLowerCase(),
|
||||
])
|
||||
|
||||
const NativeErrorShape = Schema.Struct({
|
||||
|
||||
@@ -3,10 +3,11 @@ export * as ConfigPluginSource from "./source.js"
|
||||
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Config } from "../../config.js"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
import { Location } from "../../location.js"
|
||||
@@ -41,8 +42,8 @@ export const layer = Layer.effect(
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
|
||||
// Configured local plugin entrypoints can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Configured local plugins can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those targets directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
@@ -56,7 +57,10 @@ export const layer = Layer.effect(
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
const updates = yield* watcher.subscribe({
|
||||
path: operation.target,
|
||||
type: (yield* fs.isDir(operation.target)) ? "directory" : "file",
|
||||
})
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
@@ -148,24 +152,40 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
|
||||
return Option.none<Operation>()
|
||||
}
|
||||
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
|
||||
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
|
||||
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
|
||||
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
|
||||
return Option.none<Operation>()
|
||||
return Option.some<Operation>(operation)
|
||||
}),
|
||||
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.orElseSucceed(() => operation),
|
||||
)
|
||||
})
|
||||
return yield* Effect.forEach([...discovered, ...resolved], (operation) =>
|
||||
Effect.gen(function* () {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return [operation]
|
||||
if (!(yield* fs.existsSafe(operation.target))) return [operation]
|
||||
const directory = yield* fs.isDir(operation.target)
|
||||
const entrypoints: Host.Entrypoints = directory
|
||||
? yield* Effect.sync(() => Host.resolve({ directory: operation.target }))
|
||||
: { server: pathToFileURL(operation.target).href }
|
||||
if (!entrypoints.server) return []
|
||||
if (directory) {
|
||||
const root = yield* fs.resolve(operation.target)
|
||||
const server = yield* fs.resolve(fileURLToPath(entrypoints.server))
|
||||
if (!FSUtil.contains(root, server)) return []
|
||||
}
|
||||
const times = yield* Effect.forEach(
|
||||
[
|
||||
...Object.values(entrypoints)
|
||||
.filter((entry) => entry !== undefined)
|
||||
.map((entry) => fileURLToPath(entry)),
|
||||
path.join(directory ? operation.target : path.dirname(operation.target), "package.json"),
|
||||
],
|
||||
(entry) =>
|
||||
fs.stat(entry).pipe(
|
||||
Effect.map((info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()),
|
||||
Effect.orElseSucceed(() => 0),
|
||||
),
|
||||
)
|
||||
return [{ ...operation, mtime: Math.max(...times) }]
|
||||
}),
|
||||
).pipe(Effect.map((operations) => operations.flat()))
|
||||
})
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ModelResolver from "./model-resolver.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { HttpOptions, LanguageModel, mergeHttpOptions } from "@opencode-ai/ai"
|
||||
import { Auth } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema, Struct } from "effect"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
@@ -128,7 +128,10 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
const resolved = prepareRuntimeModel(model, credential)
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const configuration = credential?.type === "key" ? credential.configuration : undefined
|
||||
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
|
||||
const merged = { ...resolved.settings, ...credential?.metadata, ...configuration }
|
||||
// Timeouts are transport policy: they become route HTTP defaults rather than provider package settings.
|
||||
const timeouts = Provider.timeouts(merged)
|
||||
const configured = Struct.omit(merged, ["headerTimeout", "chunkTimeout"])
|
||||
const mapping = Provider.isAISDK(resolved.package)
|
||||
? AISDKNative.map({
|
||||
packageName,
|
||||
@@ -173,6 +176,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
compatibility: resolved.compatibility
|
||||
? Object.assign({}, runtime.compatibility, resolved.compatibility)
|
||||
: runtime.compatibility,
|
||||
defaults: { ...runtime.defaults, http: mergeHttpOptions(runtime.defaults?.http, new HttpOptions(timeouts)) },
|
||||
})
|
||||
},
|
||||
catch: () => unsupported(resolved),
|
||||
|
||||
@@ -52,7 +52,6 @@ const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
|
||||
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.exit,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,8 @@ export const ModelsDevPlugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
// The normalized snapshot is shared by every Location and only read here; the catalog
|
||||
// receives copies below, so retaining a second copy per Location is unnecessary.
|
||||
const loaded = { data: snapshots(yield* modelsDev.get()) }
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
for (const provider of loaded.data) {
|
||||
@@ -32,7 +34,7 @@ export const ModelsDevPlugin = define({
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
for (const provider of loaded.data) {
|
||||
catalog.provider.update(provider.info.id, (draft) => {
|
||||
Object.assign(draft, provider.info)
|
||||
Object.assign(draft, copy(provider.info))
|
||||
draft.integrationID = Integration.ID.make(provider.info.id)
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
@@ -65,15 +67,16 @@ function environmentNames(provider: ModelsDev.Snapshot) {
|
||||
}
|
||||
|
||||
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
return copy(data).filter(
|
||||
return data.filter(
|
||||
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
|
||||
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
|
||||
)
|
||||
}
|
||||
|
||||
// The catalog owns and mutates its model records, so every rebuild needs fresh copies of the
|
||||
// thousands of snapshot models. Snapshot data is plain JSON, and a direct copy is an order of
|
||||
// magnitude faster than structuredClone's general graph walk on the startup path.
|
||||
// The catalog owns and mutates its provider and model records in place, so every rebuild
|
||||
// needs fresh copies of the thousands of shared snapshot records. Snapshot data is plain
|
||||
// JSON, and a direct copy is an order of magnitude faster than structuredClone's general
|
||||
// graph walk on the startup path.
|
||||
function copy<T>(value: T): T {
|
||||
if (Array.isArray(value)) return value.map(copy) as T
|
||||
if (value !== null && typeof value === "object") {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export * as PluginModule from "./module.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import type { Generation } from "../plugin.js"
|
||||
import { PluginPromise } from "./promise.js"
|
||||
@@ -35,21 +35,27 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
options?: { readonly install?: boolean },
|
||||
) {
|
||||
const npm = yield* Npm.Service
|
||||
const local = path.isAbsolute(operation.target)
|
||||
const installed: Npm.EntryPoint = local
|
||||
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
|
||||
const npm = yield* Npm.Service
|
||||
const installed = local
|
||||
? undefined
|
||||
: options?.install === false
|
||||
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
|
||||
const entrypoint = installed.entrypoint
|
||||
? yield* npm.resolve(operation.target)
|
||||
: yield* npm.add(operation.target)
|
||||
// Legacy auto-discovery still admits standalone server sources. Configured
|
||||
// local plugins always arrive here as directories.
|
||||
const entrypoints: Host.Entrypoints =
|
||||
local && (yield* Effect.promise(() => stat(operation.target))).isFile()
|
||||
? { server: pathToFileURL(operation.target).href }
|
||||
: yield* Effect.sync(() => Host.resolve(installed ?? { directory: operation.target }))
|
||||
const entrypoint = entrypoints.server
|
||||
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
|
||||
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
|
||||
const target = typeof Bun !== "undefined" ? fileURLToPath(entrypoint).replaceAll("\\", "/") : entrypoint
|
||||
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => importModule(source))
|
||||
const mod = yield* Effect.promise(() => Host.load(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
@@ -60,45 +66,20 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
),
|
||||
)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
const features = local
|
||||
? yield* localFeatures(operation.target)
|
||||
: yield* Effect.all({
|
||||
tui: npm.resolve(operation.target, { subpaths: ["tui"] }),
|
||||
rpc: npm.resolve(operation.target, { subpaths: ["rpc"] }),
|
||||
}).pipe(
|
||||
Effect.map((resolved) => ({
|
||||
...(resolved.tui.entrypoint ? { tui: true as const } : {}),
|
||||
...(resolved.rpc.entrypoint ? { rpc: true as const } : {}),
|
||||
})),
|
||||
)
|
||||
return {
|
||||
id: plugin.id,
|
||||
features,
|
||||
revision: JSON.stringify([operation, installed.revision]),
|
||||
features: {
|
||||
...(entrypoints.tui ? { tui: true as const } : {}),
|
||||
...(entrypoints.rpc ? { rpc: true as const } : {}),
|
||||
},
|
||||
revision: JSON.stringify([operation, installed?.revision]),
|
||||
source: path.isAbsolute(operation.target)
|
||||
? { type: "local" as const, path: operation.target }
|
||||
? { type: "local" as const, path: fileURLToPath(entrypoint) }
|
||||
: {
|
||||
type: "package" as const,
|
||||
target: operation.target,
|
||||
...(installed.version ? { version: installed.version } : {}),
|
||||
...(installed?.version ? { version: installed.version } : {}),
|
||||
},
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies Generation
|
||||
})
|
||||
|
||||
function localFeatures(entrypoint: string) {
|
||||
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
|
||||
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
|
||||
Effect.map((entries) => {
|
||||
const names = new Set(
|
||||
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
|
||||
)
|
||||
const has = (name: string) =>
|
||||
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) => names.has(`${name}.${extension}`))
|
||||
return {
|
||||
...(has("tui") ? { tui: true as const } : {}),
|
||||
...(has("rpc") ? { rpc: true as const } : {}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Permission } from "../permission.js"
|
||||
const plan = Agent.ID.make("plan")
|
||||
|
||||
const enter = (directory: string) => `<system-reminder>
|
||||
You are in Plan mode. You may optionally create or update plan documents in:
|
||||
You are in Plan mode. Discuss the plan with the user directly in the conversation. Do not create or update plan files unless the user explicitly asks you to; when they do, write them only in:
|
||||
${directory}
|
||||
|
||||
Do not modify any other files or ask a subagent to do so.
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const loadSDKFactory = Effect.fnUntraced(function* (npm: Npm.Interface, packageName: string) {
|
||||
const installedPath = packageName.startsWith("file://")
|
||||
? packageName
|
||||
: (yield* npm.add(packageName).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) return yield* Effect.die(new Error(`Package ${packageName} has no import entrypoint`))
|
||||
: yield* npm.add(packageName).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((installed) => resolveModule(installed.name, installed.directory)),
|
||||
)
|
||||
|
||||
const mod = (yield* Effect.promise(() =>
|
||||
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
|
||||
)) as Record<string, unknown>
|
||||
const mod = (yield* Effect.promise(() => importModule(installedPath))) as Record<string, unknown>
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) return yield* Effect.die(new Error(`Package ${packageName} has no provider factory export`))
|
||||
return mod[match]
|
||||
|
||||
@@ -22,31 +22,12 @@ export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
|
||||
Effect.gen(function* () {
|
||||
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
|
||||
if (entry.type === "file" && source) return Option.some(entry.target)
|
||||
if (entry.type === "directory") return yield* entrypoint(fs, entry.target)
|
||||
if (entry.type === "directory") return Option.some(entry.target)
|
||||
if (entry.type !== "symlink") return Option.none<string>()
|
||||
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
|
||||
if (yield* fs.isDir(entry.target)) return yield* entrypoint(fs, entry.target)
|
||||
if (yield* fs.isDir(entry.target)) return Option.some(entry.target)
|
||||
return Option.none<string>()
|
||||
}),
|
||||
)
|
||||
return targets.flatMap(Option.toArray)
|
||||
})
|
||||
|
||||
export function entrypoint(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* fs.resolve(directory)
|
||||
return yield* Effect.findFirst(
|
||||
["index.ts", "index.js"].map((entry) => path.join(directory, entry)),
|
||||
(entry) =>
|
||||
fs
|
||||
.isFile(entry)
|
||||
.pipe(
|
||||
Effect.flatMap((exists) =>
|
||||
exists
|
||||
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as Provider from "./provider.js"
|
||||
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import type { ProviderPackageDefinition } from "@opencode-ai/ai"
|
||||
import { HttpTimeout, type ProviderPackageDefinition } from "@opencode-ai/ai"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import type { DeepMutable } from "./schema.js"
|
||||
@@ -95,8 +95,7 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (specifie
|
||||
const root = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier)
|
||||
const installed = yield* npm.add(root).pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause })))
|
||||
const entrypoint = yield* Effect.try({
|
||||
try: () =>
|
||||
specifier === root && installed.entrypoint ? installed.entrypoint : resolveModule(specifier, installed.directory),
|
||||
try: () => resolveModule(specifier, installed.directory),
|
||||
catch: (cause) => new LoadError({ package: specifier, cause }),
|
||||
})
|
||||
return yield* importPackage(specifier, entrypoint)
|
||||
@@ -137,6 +136,16 @@ export function mergeHeaders(
|
||||
)
|
||||
}
|
||||
|
||||
const decodeTimeout = Schema.decodeUnknownOption(HttpTimeout)
|
||||
|
||||
/** Read request timeouts from provider settings; invalid values are ignored so the default applies. */
|
||||
export function timeouts(settings: Readonly<Record<string, unknown>>) {
|
||||
return {
|
||||
headerTimeout: Option.getOrUndefined(decodeTimeout(settings.headerTimeout)),
|
||||
chunkTimeout: Option.getOrUndefined(decodeTimeout(settings.chunkTimeout)),
|
||||
}
|
||||
}
|
||||
|
||||
export const Request = Provider.Request
|
||||
export type Request = Provider.Request
|
||||
|
||||
|
||||
@@ -226,9 +226,8 @@ const applyModelHooks = (hooks: PluginHooks.Interface, scope: HookScope, request
|
||||
return LLMRequest.update(request, {
|
||||
model: route === request.model.route ? request.model : LanguageModel.update(request.model, { route }),
|
||||
http: new HttpOptions({
|
||||
body: request.http?.body,
|
||||
...request.http,
|
||||
headers: Object.keys(event.headers).length === 0 ? undefined : event.headers,
|
||||
query: request.http?.query,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -701,6 +701,45 @@ it.effect("does not treat SSE comment heartbeats as model progress", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with a retryable transport error when response headers time out", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const customFetch = Object.assign(
|
||||
(_input: Parameters<typeof fetch>[0], init?: RequestInit) =>
|
||||
new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true })
|
||||
}),
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = createOpenAICompatible({
|
||||
...event.options,
|
||||
name: String(event.options.name),
|
||||
baseURL: String(event.options.baseURL),
|
||||
})
|
||||
})
|
||||
const resolved = yield* aisdk.model(
|
||||
model("@ai-sdk/openai-compatible", {
|
||||
apiKey: "test",
|
||||
baseURL: "https://example.test/v1",
|
||||
headerTimeout: 25,
|
||||
fetch: customFetch,
|
||||
}),
|
||||
)
|
||||
const error = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
|
||||
Effect.provide(client),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
operation: "request",
|
||||
message: "Response headers timed out",
|
||||
})
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -247,6 +247,22 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lifts timeout settings onto native route HTTP defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/anthropic"), {
|
||||
settings: { baseURL: "https://anthropic.example/v1", headerTimeout: false, chunkTimeout: 60_000 },
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "secret" }),
|
||||
)
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
|
||||
expect(resolved.defaults?.http).toMatchObject({ headerTimeout: false, chunkTimeout: 60_000 })
|
||||
expect(resolved.defaults?.providerOptions).toBeUndefined()
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("Timeout")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("Npm.cacheKey", () => {
|
||||
})
|
||||
|
||||
describe("Npm.add", () => {
|
||||
test("resolves cached scoped package specs without reifying", async () => {
|
||||
test("locates cached scoped package specs without reifying", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const spec = "@fixture/provider@1.0.0"
|
||||
const directory = path.join(
|
||||
@@ -185,24 +185,7 @@ describe("Npm.add", () => {
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.directory).toBe(directory)
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
|
||||
test("falls back to the original spec when parsing fails", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const spec = "fixture provider"
|
||||
const directory = path.join(tmp.path, "cache", "npm", Npm.sanitize(spec), "1000", "node_modules", spec)
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(directory, { name: spec, exports: "./index.js" })
|
||||
await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n")
|
||||
|
||||
const entry = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.directory).toBe(directory)
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
expect(entry.name).toBe("@fixture/provider")
|
||||
})
|
||||
|
||||
test("reifies when package cache directory exists without the package installed", async () => {
|
||||
@@ -224,16 +207,16 @@ describe("Npm.add", () => {
|
||||
const entries = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
tui: yield* npm.add(spec, { subpaths: ["tui", ""] }),
|
||||
fallback: yield* npm.add(spec, { subpaths: ["missing", ""] }),
|
||||
added: yield* npm.add(spec),
|
||||
cached: yield* npm.add(spec),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entries.tui.entrypoint).toEndWith("/tui.js")
|
||||
expect(entries.fallback.entrypoint).toEndWith("/index.js")
|
||||
expect(await fs.stat(path.join(entries.added.directory, "package.json"))).toBeTruthy()
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
})
|
||||
|
||||
test.each(["unnamed", "named"])("installs and resolves %s Git packages with dependencies", async (kind) => {
|
||||
test.each(["unnamed", "named"])("installs and locates %s Git packages with dependencies", async (kind) => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const spec =
|
||||
@@ -250,7 +233,7 @@ describe("Npm.add", () => {
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entries.added.entrypoint).toBe(pathToFileURL(path.join(entries.added.directory, "index.js")).href)
|
||||
expect(entries.added.directory).toEndWith(path.join("node_modules", entries.added.name))
|
||||
expect(entries.added.version).toBe(fixture.commit)
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
expect(entries.resolved).toEqual(entries.added)
|
||||
@@ -271,7 +254,7 @@ describe("Npm.add", () => {
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
expect(entry.name).toBe("fixture-subdirectory-plugin")
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
@@ -319,19 +302,44 @@ describe("Npm.add", () => {
|
||||
expect(result.pinned).toContain("root: true")
|
||||
expect(result.current).toBeFalse()
|
||||
}, 30_000)
|
||||
|
||||
// Symlink creation needs elevated privileges on Windows.
|
||||
test.skipIf(win)("records Git revisions when the cache directory is reached through a symlink", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
await fs.mkdir(path.join(tmp.path, "cache"))
|
||||
await fs.symlink(path.join(tmp.path, "cache"), path.join(tmp.path, "link"))
|
||||
const mutable = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
|
||||
|
||||
const result = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const added = yield* npm.add(mutable)
|
||||
const current = yield* npm.check(mutable)
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
|
||||
await Bun.$`git -C ${fixture.repository} add .`
|
||||
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
|
||||
})
|
||||
return { added, current, outdated: yield* npm.check(mutable) }
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "link"))), Effect.runPromise)
|
||||
|
||||
expect(result.added.version).toBe(fixture.commit)
|
||||
expect(result.current).toBeFalse()
|
||||
expect(result.outdated).toBeTrue()
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
describe("Npm.resolve", () => {
|
||||
test("resolves a TUI entrypoint only when the package is already cached", async () => {
|
||||
test("locates a cached package without installing it", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const spec = "fixture-plugin@1.0.0"
|
||||
const directory = path.join(cache, "npm", Npm.sanitize(spec), "1000", "node_modules", "fixture-plugin")
|
||||
const missing = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
return yield* npm.resolve(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(missing.entrypoint).toBeUndefined()
|
||||
expect(missing.version).toBeUndefined()
|
||||
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(directory, {
|
||||
@@ -343,9 +351,9 @@ describe("Npm.resolve", () => {
|
||||
|
||||
const resolved = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
return yield* npm.resolve(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(resolved.entrypoint).toEndWith("/tui.js")
|
||||
expect(resolved.directory).toBe(directory)
|
||||
expect(resolved.version).toBe("1.0.0")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,10 +39,10 @@ import { emptyMcpLayer } from "../fixture/mcp"
|
||||
const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
add: (name) => Effect.succeed({ directory: "", name }),
|
||||
resolve: (name) => Effect.succeed({ directory: "", name }),
|
||||
check: () => Effect.succeed(false),
|
||||
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
update: (name) => Effect.succeed({ directory: "", name }),
|
||||
which: () => Effect.undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Exit, Layer, Scope } from "effect"
|
||||
import { Context, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -31,10 +32,245 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
|
||||
])
|
||||
const it = testEffect(layer)
|
||||
const real = testEffect(PluginTestLayer)
|
||||
const isolated = testEffect(Layer.empty)
|
||||
const models = (file: string) =>
|
||||
AppNodeBuilder.build(ModelsDev.node, [ModelsDev.node.replace(ModelsDev.configured({ file, fetch: false }))])
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
// One complete Location graph behind the production plugin host. Two of these stand in for
|
||||
// two Locations that share a single models.dev snapshot instance.
|
||||
const owner = Effect.gen(function* () {
|
||||
const context = yield* Layer.build(PluginTestLayer)
|
||||
const host = yield* PluginHost.make(Context.get(context, Plugin.Service)).pipe(Effect.provideContext(context))
|
||||
return {
|
||||
context,
|
||||
host,
|
||||
bus: Context.get(context, Bus.Service),
|
||||
catalog: Context.get(context, Catalog.Service),
|
||||
integration: Context.get(context, Integration.Service),
|
||||
}
|
||||
})
|
||||
|
||||
// Every nested overlay shape the normalized snapshot can carry, so in-place mutation of any
|
||||
// existing nested value is observable on the source object.
|
||||
const richSnapshot = (name = "Acme") => {
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const modelID = Model.ID.make("gpt-5.4")
|
||||
const snapshot = [
|
||||
{
|
||||
info: {
|
||||
id: providerID,
|
||||
name,
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://api.acme.test/v1", thinking: { type: "adaptive", display: "summarized" } },
|
||||
headers: { "x-acme": "provider" },
|
||||
body: { service_tier: "default", tags: ["stable"] },
|
||||
},
|
||||
environment: ["ACME_API_KEY", "ACME_HOST"],
|
||||
models: [
|
||||
{
|
||||
id: modelID,
|
||||
modelID,
|
||||
providerID,
|
||||
name: "GPT-5.4",
|
||||
family: Model.Family.make("gpt"),
|
||||
settings: { baseURL: "https://models.acme.test/v1", reasoning: { effort: "low" } },
|
||||
headers: { "x-mode": "fast" },
|
||||
body: { service_tier: "priority", options: { top_k: 1 }, stop: ["<end>"] },
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [
|
||||
{
|
||||
id: Model.VariantID.make("low"),
|
||||
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
body: { max_tokens: 1024 },
|
||||
},
|
||||
],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(2.5),
|
||||
output: Money.USDPerMillionTokens.make(15),
|
||||
cache: { read: Money.USDPerMillionTokens.make(0.25), write: Money.USDPerMillionTokens.zero },
|
||||
},
|
||||
{
|
||||
tier: { type: "context", size: 200_000 },
|
||||
input: Money.USDPerMillionTokens.make(5),
|
||||
output: Money.USDPerMillionTokens.make(22.5),
|
||||
cache: { read: Money.USDPerMillionTokens.make(0.5), write: Money.USDPerMillionTokens.zero },
|
||||
},
|
||||
],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]
|
||||
return { providerID, modelID, snapshot }
|
||||
}
|
||||
|
||||
describe("ModelsDevPlugin", () => {
|
||||
isolated.effect("shares one snapshot between Locations while each catalog mutates only its own copies", () =>
|
||||
Effect.gen(function* () {
|
||||
const { providerID, modelID, snapshot } = richSnapshot()
|
||||
const pristine = JSON.stringify(snapshot)
|
||||
const source = ModelsDev.Service.of({ get: () => Effect.succeed(snapshot), refresh: () => Effect.void })
|
||||
const first = yield* owner
|
||||
const second = yield* owner
|
||||
for (const each of [first, second])
|
||||
yield* ModelsDevPlugin.effect(each.host).pipe(
|
||||
Effect.provideService(ModelsDev.Service, source),
|
||||
Effect.provideContext(each.context),
|
||||
)
|
||||
|
||||
// The stored environment method must own its names array; the source array is shared.
|
||||
let names: readonly string[] | undefined
|
||||
yield* first.integration.transform((draft) => {
|
||||
names = draft.method
|
||||
.list(Integration.ID.make(providerID))
|
||||
.flatMap((method) => (method.type === "env" ? [method.names] : []))[0]
|
||||
})
|
||||
expect(names).toEqual(snapshot[0].environment)
|
||||
expect(names).not.toBe(snapshot[0].environment)
|
||||
|
||||
// Catalog-owned provider records are copies, not the source's nested objects.
|
||||
const stored = required(yield* first.catalog.provider.get(providerID))
|
||||
expect(stored.settings).toEqual(snapshot[0].info.settings)
|
||||
expect(stored.settings).not.toBe(snapshot[0].info.settings)
|
||||
expect(stored.headers).not.toBe(snapshot[0].info.headers)
|
||||
expect(stored.body).not.toBe(snapshot[0].info.body)
|
||||
|
||||
// A later plugin in the first Location mutates existing nested values in place through the
|
||||
// production host, the way the Bedrock, Azure, and config provider plugins do.
|
||||
const scope = yield* Scope.make()
|
||||
yield* first.host.catalog
|
||||
.transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
required(provider.settings).baseURL = "https://override.acme.test/v1"
|
||||
required(provider.settings).thinking.type = "disabled"
|
||||
required(provider.headers)["x-acme"] = "override"
|
||||
required(provider.body).service_tier = "priority"
|
||||
required(provider.body).tags.push("override")
|
||||
})
|
||||
catalog.model.update(providerID, modelID, (model) => {
|
||||
required(model.settings).reasoning.effort = "high"
|
||||
required(model.headers)["x-mode"] = "slow"
|
||||
required(model.body).options.top_k = 7
|
||||
required(model.body).stop.push("<stop>")
|
||||
const variant = required(model.variants[0])
|
||||
required(variant.settings).thinking.type = "disabled"
|
||||
required(variant.body).max_tokens = 4096
|
||||
model.capabilities.input.push("image")
|
||||
required(model.cost[0]).cache.read = Money.USDPerMillionTokens.make(9)
|
||||
required(required(model.cost[1]).tier).size = 1
|
||||
required(model.cost[1]).input = Money.USDPerMillionTokens.make(42)
|
||||
model.limit.context = 1
|
||||
model.time.released = 5
|
||||
})
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
|
||||
const mutatedProvider = required(yield* first.catalog.provider.get(providerID))
|
||||
const mutated = required(yield* first.catalog.model.get(providerID, modelID))
|
||||
expect(mutatedProvider.settings).toEqual({
|
||||
baseURL: "https://override.acme.test/v1",
|
||||
thinking: { type: "disabled", display: "summarized" },
|
||||
})
|
||||
expect(mutatedProvider.headers).toEqual({ "x-acme": "override" })
|
||||
expect(mutatedProvider.body).toEqual({ service_tier: "priority", tags: ["stable", "override"] })
|
||||
expect(mutated.settings).toEqual({
|
||||
baseURL: "https://models.acme.test/v1",
|
||||
thinking: { type: "disabled", display: "summarized" },
|
||||
reasoning: { effort: "high" },
|
||||
})
|
||||
expect(mutated.headers).toEqual({ "x-acme": "override", "x-mode": "slow" })
|
||||
expect(mutated.body).toEqual({
|
||||
service_tier: "priority",
|
||||
tags: ["stable", "override"],
|
||||
options: { top_k: 7 },
|
||||
stop: ["<end>", "<stop>"],
|
||||
})
|
||||
expect(mutated.variants).toEqual([
|
||||
{
|
||||
id: Model.VariantID.make("low"),
|
||||
settings: { thinking: { type: "disabled", display: "summarized" }, effort: "low" },
|
||||
body: { max_tokens: 4096 },
|
||||
},
|
||||
])
|
||||
expect(mutated.capabilities.input).toEqual(["text", "image"])
|
||||
expect(mutated.cost[0]?.cache.read).toBe(Money.USDPerMillionTokens.make(9))
|
||||
expect(mutated.cost[1]).toMatchObject({ tier: { type: "context", size: 1 }, input: 42 })
|
||||
expect(mutated.limit.context).toBe(1)
|
||||
expect(mutated.time.released).toBe(5)
|
||||
|
||||
// The sibling Location and the shared source are untouched.
|
||||
const sibling = required(yield* second.catalog.model.get(providerID, modelID))
|
||||
expect(sibling.settings).toEqual({
|
||||
baseURL: "https://models.acme.test/v1",
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
reasoning: { effort: "low" },
|
||||
})
|
||||
expect(sibling.headers).toEqual({ "x-acme": "provider", "x-mode": "fast" })
|
||||
expect(sibling.body).toEqual({
|
||||
service_tier: "priority",
|
||||
tags: ["stable"],
|
||||
options: { top_k: 1 },
|
||||
stop: ["<end>"],
|
||||
})
|
||||
expect(sibling.variants).toEqual(snapshot[0].models[0].variants)
|
||||
expect(sibling.capabilities.input).toEqual(["text"])
|
||||
expect(sibling.cost).toEqual(snapshot[0].models[0].cost)
|
||||
expect(sibling.limit).toEqual(snapshot[0].models[0].limit)
|
||||
expect(sibling.time).toEqual(snapshot[0].models[0].time)
|
||||
expect(required(yield* second.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings)
|
||||
expect(JSON.stringify(snapshot)).toBe(pristine)
|
||||
|
||||
// Removing the mutating transform rebuilds the first catalog from the shared source.
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* first.catalog.model.get(providerID, modelID)).toEqual(sibling)
|
||||
expect(required(yield* first.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings)
|
||||
expect(JSON.stringify(snapshot)).toBe(pristine)
|
||||
}),
|
||||
)
|
||||
|
||||
isolated.effect("replaces its snapshot reference on refresh without touching the sibling Location", () =>
|
||||
Effect.gen(function* () {
|
||||
const initial = richSnapshot("Acme")
|
||||
const refreshed = richSnapshot("Acme Refreshed")
|
||||
const pristine = JSON.stringify(initial.snapshot)
|
||||
const current = { snapshot: initial.snapshot }
|
||||
const source = ModelsDev.Service.of({
|
||||
get: () => Effect.sync(() => current.snapshot),
|
||||
refresh: () => Effect.void,
|
||||
})
|
||||
const first = yield* owner
|
||||
const second = yield* owner
|
||||
for (const each of [first, second])
|
||||
yield* ModelsDevPlugin.effect(each.host).pipe(
|
||||
Effect.provideService(ModelsDev.Service, source),
|
||||
Effect.provideContext(each.context),
|
||||
)
|
||||
expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme")
|
||||
|
||||
current.snapshot = refreshed.snapshot
|
||||
yield* first.bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
// Integration and catalog reloads are debounced sequentially.
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* TestClock.adjust("500 millis")
|
||||
|
||||
expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme Refreshed")
|
||||
expect(required(yield* second.catalog.provider.get(initial.providerID)).name).toBe("Acme")
|
||||
expect(JSON.stringify(initial.snapshot)).toBe(pristine)
|
||||
expect(JSON.stringify(refreshed.snapshot)).toBe(JSON.stringify(richSnapshot("Acme Refreshed").snapshot))
|
||||
}),
|
||||
)
|
||||
|
||||
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -92,6 +328,79 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the shared models.dev snapshot pristine while catalog transforms mutate records in place", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const modelID = Model.ID.make("gpt-5.4")
|
||||
const snapshot = [
|
||||
{
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://api.acme.test/v1" },
|
||||
headers: { "x-acme": "provider" },
|
||||
},
|
||||
environment: ["ACME_API_KEY"],
|
||||
models: [
|
||||
{
|
||||
id: modelID,
|
||||
modelID,
|
||||
providerID,
|
||||
name: "GPT-5.4",
|
||||
settings: { baseURL: "https://models.acme.test/v1" },
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 1_050_000, output: 128_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]
|
||||
const pristine = JSON.stringify(snapshot)
|
||||
// The plugin receives the same snapshot instance every Location shares.
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({
|
||||
catalog: catalogHost(catalog),
|
||||
integration: integrationHost(integrations),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
ModelsDev.Service,
|
||||
ModelsDev.Service.of({ get: () => Effect.succeed(snapshot), refresh: () => Effect.void }),
|
||||
),
|
||||
)
|
||||
|
||||
// Later plugins mutate nested provider and model records in place, as the Bedrock provider does.
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
if (provider.settings) provider.settings.baseURL = "https://override.acme.test/v1"
|
||||
if (provider.headers) provider.headers["x-acme"] = "override"
|
||||
})
|
||||
draft.model.update(providerID, modelID, (model) => {
|
||||
if (model.settings) model.settings.baseURL = "https://override.models.acme.test/v1"
|
||||
model.variants.push({ id: Model.VariantID.make("configured") })
|
||||
model.capabilities.input.push("image")
|
||||
})
|
||||
})
|
||||
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(provider?.settings?.baseURL).toBe("https://override.acme.test/v1")
|
||||
expect(provider?.headers).toEqual({ "x-acme": "override" })
|
||||
expect(model?.settings?.baseURL).toBe("https://override.models.acme.test/v1")
|
||||
expect(model?.variants).toEqual([{ id: Model.VariantID.make("configured") }])
|
||||
expect(model?.capabilities.input).toEqual(["text", "image"])
|
||||
expect(JSON.stringify(snapshot)).toBe(pristine)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects normalized models.dev snapshots into the catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -166,7 +166,7 @@ describe("plan plugin reminders", () => {
|
||||
const { persisted } = yield* run([agentSelected(plan, build), agentSelected(build, plan)])
|
||||
yield* settle(persisted, 2)
|
||||
expect(persisted[0]).toContain("You are in Plan mode")
|
||||
expect(persisted[0]).toContain("optionally create or update plan documents")
|
||||
expect(persisted[0]).toContain("Do not create or update plan files unless the user explicitly asks you to")
|
||||
expect(persisted[0]).toContain(planDirectory)
|
||||
expect(persisted[0]).toContain("Do not modify any other files")
|
||||
expect(persisted[1]).toContain("NO LONGER in Plan mode")
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Cause, Effect, Layer } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -16,16 +15,15 @@ import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
const fixtureProviderPath = fileURLToPath(fixtureProvider)
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node)))
|
||||
|
||||
function npmEntrypoint(entrypoint?: string) {
|
||||
function npmPackage(directory = "") {
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
add: (name) => Effect.succeed({ directory, name }),
|
||||
resolve: (name) => Effect.succeed({ directory, name }),
|
||||
check: () => Effect.succeed(false),
|
||||
update: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
update: (name) => Effect.succeed({ directory, name }),
|
||||
which: () => Effect.undefined,
|
||||
})
|
||||
}
|
||||
@@ -36,13 +34,16 @@ const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
|
||||
yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service)))
|
||||
})
|
||||
|
||||
function tempEntrypoint(source: string) {
|
||||
function tempPackage(source: string) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-dynamic-"))
|
||||
const entrypoint = path.join(directory, "provider.mjs")
|
||||
await Bun.write(entrypoint, source)
|
||||
return { directory, entrypoint }
|
||||
await Bun.write(path.join(directory, "provider.mjs"), source)
|
||||
await Bun.write(
|
||||
path.join(directory, "package.json"),
|
||||
JSON.stringify({ name: "fixture-provider", exports: "./provider.mjs" }),
|
||||
)
|
||||
return { directory }
|
||||
}),
|
||||
(tmp) => Effect.promise(() => fs.rm(tmp.directory, { recursive: true, force: true })),
|
||||
)
|
||||
@@ -86,27 +87,11 @@ describe("DynamicProviderPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("injects the provider ID as the SDK factory name", () =>
|
||||
it.live("loads npm packages through their resolved import entrypoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("custom-provider"), Model.ID.make("test-model")),
|
||||
modelID: Model.ID.make("test-model"),
|
||||
package: Provider.aisdk(fixtureProvider),
|
||||
}),
|
||||
package: fixtureProvider,
|
||||
options: { name: "custom-provider", marker: "dynamic" },
|
||||
})
|
||||
expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads npm packages through their resolved import entrypoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(npmEntrypoint(fixtureProviderPath))
|
||||
const tmp = yield* tempPackage(`export { createFixtureProvider } from ${JSON.stringify(fixtureProvider)}\n`)
|
||||
yield* addPlugin(npmPackage(tmp.directory))
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("npm-provider"), Model.ID.make("test-model")),
|
||||
@@ -123,7 +108,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(npmEntrypoint())
|
||||
yield* addPlugin(npmPackage())
|
||||
const exit = yield* aisdk
|
||||
.language(
|
||||
Model.Info.make({
|
||||
@@ -159,8 +144,8 @@ describe("DynamicProviderPlugin", () => {
|
||||
itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
|
||||
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
|
||||
const tmp = yield* tempPackage("export const notAProviderFactory = true\n")
|
||||
yield* addPlugin(npmPackage(tmp.directory))
|
||||
const exit = yield* aisdk
|
||||
.language(
|
||||
Model.Info.make({
|
||||
|
||||
@@ -14,10 +14,10 @@ import { PluginTestLayer } from "./fixture"
|
||||
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const npm = Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
add: (name) => Effect.succeed({ directory: "", name }),
|
||||
resolve: (name) => Effect.succeed({ directory: "", name }),
|
||||
check: () => Effect.succeed(false),
|
||||
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
update: (name) => Effect.succeed({ directory: "", name }),
|
||||
which: () => Effect.undefined,
|
||||
})
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/promise/index.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./host": "./src/host.ts",
|
||||
"./tui": "./src/tui/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
@@ -23,6 +25,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:"
|
||||
@@ -53,6 +56,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export * as Host from "./host.js"
|
||||
|
||||
import path from "node:path"
|
||||
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export interface Target {
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}
|
||||
|
||||
export interface Entrypoints {
|
||||
readonly server?: string
|
||||
readonly tui?: string
|
||||
readonly rpc?: string
|
||||
}
|
||||
|
||||
export function resolve(target: Target): Entrypoints {
|
||||
const entry = (subpaths: readonly string[]) => {
|
||||
for (const subpath of subpaths) {
|
||||
const specifier = target.name
|
||||
? [target.name, subpath].filter(Boolean).join("/")
|
||||
: path.resolve(target.directory, subpath || "index")
|
||||
try {
|
||||
return resolveModule(specifier, target.directory)
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!("code" in error) ||
|
||||
![
|
||||
"ENOENT",
|
||||
"ENOTDIR",
|
||||
"MODULE_NOT_FOUND",
|
||||
"ERR_MODULE_NOT_FOUND",
|
||||
"ERR_PACKAGE_PATH_NOT_EXPORTED",
|
||||
"ERR_UNSUPPORTED_DIR_IMPORT",
|
||||
].includes(String(error.code))
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return { server: entry(["server", ""]), tui: entry(["tui"]), rpc: entry(["rpc"]) }
|
||||
}
|
||||
|
||||
export function load(entrypoint: string): Promise<unknown> {
|
||||
return importModule(entrypoint)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Host } from "../src/host.js"
|
||||
|
||||
// Every entrypoint throws if evaluated: resolution must never execute plugins.
|
||||
const source = 'throw new Error("Plugin code must not run during resolution")'
|
||||
const name = "@fixture/plugin"
|
||||
|
||||
async function fixture(files: Record<string, string>, installed = false) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-host-"))
|
||||
const directory = installed ? path.join(root, "node_modules", name) : root
|
||||
await Promise.all(
|
||||
Object.entries(files).map(async ([file, content]) => {
|
||||
await mkdir(path.dirname(path.join(directory, file)), { recursive: true })
|
||||
await writeFile(path.join(directory, file), content)
|
||||
}),
|
||||
)
|
||||
return {
|
||||
target: { directory, ...(installed ? { name } : {}) },
|
||||
url: (file: string) => pathToFileURL(path.join(directory, file)).href,
|
||||
[Symbol.asyncDispose]: () => rm(root, { recursive: true, force: true }),
|
||||
}
|
||||
}
|
||||
|
||||
describe("Host.resolve", () => {
|
||||
it("resolves conventional entrypoints without package.json", async () => {
|
||||
await using plugin = await fixture({ "index.ts": source, "tui.tsx": source, "rpc.ts": source })
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: plugin.url("index.ts"),
|
||||
tui: plugin.url("tui.tsx"),
|
||||
rpc: plugin.url("rpc.ts"),
|
||||
})
|
||||
})
|
||||
|
||||
it("resolves a TUI-only directory without an index or package.json", async () => {
|
||||
await using plugin = await fixture({ "tui.tsx": source })
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: undefined,
|
||||
tui: plugin.url("tui.tsx"),
|
||||
rpc: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
for (const main of [undefined, "lib/backend.js"]) {
|
||||
it(`resolves packages without exports using ${main ?? "the default index"}`, async () => {
|
||||
await using plugin = await fixture(
|
||||
{
|
||||
"package.json": JSON.stringify({ name, main }),
|
||||
[main ?? "index.js"]: source,
|
||||
"tui.js": source,
|
||||
"rpc.js": source,
|
||||
},
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: plugin.url(main ?? "index.js"),
|
||||
tui: plugin.url("tui.js"),
|
||||
rpc: plugin.url("rpc.js"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it("resolves local conventional entrypoints with package.json but no exports", async () => {
|
||||
await using plugin = await fixture({
|
||||
"package.json": JSON.stringify({ name, type: "module" }),
|
||||
"index.js": source,
|
||||
"tui.js": source,
|
||||
"rpc.js": source,
|
||||
})
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: plugin.url("index.js"),
|
||||
tui: plugin.url("tui.js"),
|
||||
rpc: plugin.url("rpc.js"),
|
||||
})
|
||||
})
|
||||
|
||||
it("honors exports and prefers the explicit server entrypoint over the root", async () => {
|
||||
await using plugin = await fixture(
|
||||
{
|
||||
"package.json": JSON.stringify({
|
||||
name,
|
||||
exports: {
|
||||
".": "./dist/root.js",
|
||||
"./server": "./dist/backend.js",
|
||||
"./tui": "./dist/terminal.js",
|
||||
"./rpc": "./dist/contract.js",
|
||||
},
|
||||
}),
|
||||
"dist/root.js": source,
|
||||
"dist/backend.js": source,
|
||||
"dist/terminal.js": source,
|
||||
"dist/contract.js": source,
|
||||
},
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: plugin.url("dist/backend.js"),
|
||||
tui: plugin.url("dist/terminal.js"),
|
||||
rpc: plugin.url("dist/contract.js"),
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to the root export and uses import rather than require conditions", async () => {
|
||||
await using plugin = await fixture(
|
||||
{
|
||||
"package.json": JSON.stringify({
|
||||
name,
|
||||
exports: {
|
||||
".": { import: "./dist/server.mjs", require: "./dist/server.cjs" },
|
||||
"./tui": { import: "./dist/tui.mjs", require: "./dist/tui.cjs" },
|
||||
},
|
||||
}),
|
||||
"dist/server.mjs": source,
|
||||
"dist/server.cjs": source,
|
||||
"dist/tui.mjs": source,
|
||||
"dist/tui.cjs": source,
|
||||
},
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: plugin.url("dist/server.mjs"),
|
||||
tui: plugin.url("dist/tui.mjs"),
|
||||
rpc: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("supports TUI-only exports without falling back to unexported files", async () => {
|
||||
await using plugin = await fixture(
|
||||
{
|
||||
"package.json": JSON.stringify({ name, exports: { "./tui": "./dist/terminal.js" } }),
|
||||
"dist/terminal.js": source,
|
||||
"index.js": source,
|
||||
"server.js": source,
|
||||
"rpc.js": source,
|
||||
},
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: undefined,
|
||||
tui: plugin.url("dist/terminal.js"),
|
||||
rpc: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not return an exported entrypoint whose file is missing", async () => {
|
||||
await using plugin = await fixture(
|
||||
{ "package.json": JSON.stringify({ name, exports: { "./tui": "./missing.js" } }) },
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(Host.resolve(plugin.target), { server: undefined, tui: undefined, rpc: undefined })
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- Preserve the dependency direction: `@opencode-ai/schema <- @opencode-ai/protocol <- @opencode-ai/server`.
|
||||
- Schema values should be serializable contract definitions, not service implementations or runtime registries.
|
||||
- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.added` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema.
|
||||
- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.updated` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema.
|
||||
- The root barrel exports canonical current domain contracts. Specialized event modules, manifests, infrastructure modules, and V1 contracts use direct entrypoints instead of becoming first-class root exports.
|
||||
|
||||
## Current Versus V1
|
||||
|
||||
@@ -42,12 +42,8 @@ export const Info = Schema.Struct({
|
||||
state: State,
|
||||
}).annotate({ identifier: "Plugin.Info" })
|
||||
|
||||
const Added = ephemeral({
|
||||
type: "plugin.added",
|
||||
schema: { id: ID },
|
||||
})
|
||||
const Updated = ephemeral({
|
||||
type: "plugin.updated",
|
||||
schema: {},
|
||||
})
|
||||
export const Event = { Added, Updated, Definitions: inventory(Added, Updated) }
|
||||
export const Event = { Updated, Definitions: inventory(Updated) }
|
||||
|
||||
@@ -88,7 +88,7 @@ import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { newSessionLocation } from "./config/new-session-location"
|
||||
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context"
|
||||
import { localPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, Slot } from "./plugin/render"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
@@ -188,7 +188,7 @@ export type TuiInput = {
|
||||
updater?: {
|
||||
apply: (version: string) => Promise<void>
|
||||
}
|
||||
packages: PackageResolver
|
||||
packages: PackageSource
|
||||
environment?: Readonly<Record<string, string>>
|
||||
terminalHandoff?: () => Promise<
|
||||
| {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { DialogErrorDetails } from "../../component/dialog-error-details"
|
||||
import { Spinner } from "../../component/spinner"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
@@ -31,12 +32,17 @@ export function PluginsDialog(props: {
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const [detail, setDetail] = createSignal<Entry>()
|
||||
const [showInternal, setShowInternal] = createSignal(false)
|
||||
const [pending, setPending] = createSignal<readonly string[]>([])
|
||||
const [server, { refetch, mutate }] = createResource(
|
||||
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
|
||||
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
|
||||
)
|
||||
onMount(() => dialog.setSize("medium"))
|
||||
onCleanup(props.context.data.on("plugin.updated", () => void refetch()))
|
||||
const updating = (entry: Entry) =>
|
||||
pending().includes(entry.key) ||
|
||||
(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.updating === true)
|
||||
const updatable = (entry: Entry | undefined) => entry !== undefined && outdated(entry) && !updating(entry)
|
||||
const entries = createMemo<Entry[]>(() => {
|
||||
const builtins: Entry[] = props.plugins
|
||||
.registered()
|
||||
@@ -86,15 +92,16 @@ export function PluginsDialog(props: {
|
||||
value: entry.key,
|
||||
category: entry.runtime === "tui" ? "TUI" : "Server",
|
||||
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
|
||||
footer: footer(entry),
|
||||
footer: updating(entry) ? "updating" : footer(entry),
|
||||
footerColor:
|
||||
status(entry) === "failed"
|
||||
? props.context.theme.text.feedback.error.default
|
||||
: outdated(entry)
|
||||
? props.context.theme.text.feedback.info.default
|
||||
: props.context.theme.text.subdued,
|
||||
gutter:
|
||||
status(entry) === "failed"
|
||||
gutter: updating(entry)
|
||||
? (color) => <Spinner color={color} />
|
||||
: status(entry) === "failed"
|
||||
? () => <text fg={props.context.theme.text.feedback.error.default}>x</text>
|
||||
: undefined,
|
||||
}),
|
||||
@@ -131,20 +138,22 @@ export function PluginsDialog(props: {
|
||||
}
|
||||
const update = (entry: Entry | undefined) => {
|
||||
if (entry?.runtime !== "server" || entry.plugin.source.type !== "package" || !updatable(entry)) return
|
||||
const location = props.context.location ?? props.context.data.location.default()
|
||||
setPending((keys) => [...keys, entry.key])
|
||||
props.context.client.plugin
|
||||
.update({
|
||||
location: props.context.location ?? props.context.data.location.default(),
|
||||
location,
|
||||
targets: [entry.plugin.source.target],
|
||||
})
|
||||
.then(() =>
|
||||
props.context.ui.toast.show({ variant: "success", message: `Updated plugin ${label(entry, props.context)}` }),
|
||||
)
|
||||
.then(() => props.context.client.plugin.awaitActivation({ location }))
|
||||
.then(() => refetch())
|
||||
.catch((cause) => {
|
||||
props.context.ui.toast.show({
|
||||
variant: "error",
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
})
|
||||
})
|
||||
.finally(() => setPending((keys) => keys.filter((key) => key !== entry.key)))
|
||||
}
|
||||
// The server only re-checks package sources on startup and then caches the
|
||||
// result for a day, so a merge pushed after launch stays invisible until the
|
||||
@@ -287,21 +296,13 @@ function outdated(entry: Entry) {
|
||||
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.outdated === true
|
||||
}
|
||||
|
||||
function updating(entry: Entry) {
|
||||
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.updating === true
|
||||
}
|
||||
|
||||
function updatable(entry: Entry | undefined) {
|
||||
return entry !== undefined && outdated(entry) && !updating(entry)
|
||||
}
|
||||
|
||||
function footer(entry: Entry) {
|
||||
const details = [
|
||||
...(status(entry) === "active" ? [] : [status(entry)]),
|
||||
...(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.version
|
||||
? [displayVersion(entry.plugin.source.version)]
|
||||
: []),
|
||||
...(updating(entry) ? ["updating"] : outdated(entry) ? ["update available"] : []),
|
||||
...(outdated(entry) ? ["update available"] : []),
|
||||
]
|
||||
return details.length ? details.join(", ") : undefined
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
} from "solid-js"
|
||||
import path from "path"
|
||||
import { readFile, stat } from "fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { Page } from "@opencode-ai/plugin/tui/context"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { resolveSlots, type Claim } from "./structure"
|
||||
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
|
||||
import { isDeepEqual } from "remeda"
|
||||
@@ -30,11 +31,11 @@ import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource, tuiEntrypoint } from "./discovery"
|
||||
import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery"
|
||||
import { isMissingPath } from "../util/config-directories"
|
||||
|
||||
export interface PackageResolver {
|
||||
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
|
||||
export interface PackageSource {
|
||||
readonly prepare: (spec: string, install?: boolean) => Promise<Host.Target>
|
||||
}
|
||||
|
||||
type State =
|
||||
@@ -93,7 +94,7 @@ export function combineMarkdownRenderers(
|
||||
return createMarkdownCodeBlockRenderer(renderers)
|
||||
}
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
@@ -265,19 +266,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const reconcile = async () => {
|
||||
await Promise.all(props.directories.map(watcher.wait))
|
||||
const entries = [
|
||||
...(await discoverTuiPlugins(props.directories)).map((entry) => ({
|
||||
...(await discoverPluginTargets(props.directories)).map((entry) => ({
|
||||
entry,
|
||||
install: true,
|
||||
server: false,
|
||||
discovered: true,
|
||||
optional: true,
|
||||
})),
|
||||
...serverPlugins().map((plugin) => ({
|
||||
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
|
||||
install: false,
|
||||
server: true,
|
||||
discovered: false,
|
||||
optional: true,
|
||||
})),
|
||||
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false, discovered: false })),
|
||||
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, optional: false })),
|
||||
]
|
||||
|
||||
// Resolve: fold entries into one desired generation. A source that fails
|
||||
@@ -304,7 +303,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const local = localSource(target, directory)
|
||||
if (
|
||||
local &&
|
||||
!source.discovered &&
|
||||
(await stat(local).then(
|
||||
(info) => info.isFile(),
|
||||
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
|
||||
@@ -324,7 +322,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
}),
|
||||
)
|
||||
if (resolved.status === "unsupported") {
|
||||
if (source.server) continue
|
||||
if (source.optional) continue
|
||||
failures.push({ target, status: "unsupported" })
|
||||
continue
|
||||
}
|
||||
@@ -598,7 +596,7 @@ async function resolvePlugin(
|
||||
local: URL | undefined,
|
||||
options: Readonly<Record<string, any>> | undefined,
|
||||
previous: Registration | undefined,
|
||||
packages: PackageResolver,
|
||||
packages: PackageSource,
|
||||
install: boolean,
|
||||
sourceGeneration: (entrypoint: string) => Promise<number>,
|
||||
) {
|
||||
@@ -606,7 +604,8 @@ async function resolvePlugin(
|
||||
// version needs no re-resolution (which could otherwise hit npm).
|
||||
if (!local && previous && sameOptions(previous.options, options))
|
||||
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
|
||||
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
|
||||
const target = local ? { directory: fileURLToPath(local) } : await packages.prepare(spec, install)
|
||||
const entrypoint = Host.resolve(target).tui
|
||||
if (!entrypoint) return { status: "unsupported" as const }
|
||||
// Content remains stable across the several mtimes one save may expose to
|
||||
// filesystem watchers, while the generation keeps reverted modules fresh.
|
||||
@@ -615,7 +614,7 @@ async function resolvePlugin(
|
||||
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
|
||||
if (previous && previous.version === version && sameOptions(previous.options, options))
|
||||
return { status: "unchanged" as const, plugin: previous.plugin, version }
|
||||
const mod: { readonly default?: unknown } = await import(version)
|
||||
const mod = await Host.load(version)
|
||||
if (generation !== undefined) {
|
||||
const observed = await sourceGeneration(entrypoint)
|
||||
// In-place saves can change the file between hashing and import. Retry
|
||||
@@ -625,7 +624,8 @@ async function resolvePlugin(
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
|
||||
if (typeof mod !== "object" || mod === null || !("default" in mod) || !isPlugin(mod.default))
|
||||
throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
|
||||
return { status: "loaded" as const, plugin: mod.default, version }
|
||||
}
|
||||
}
|
||||
@@ -671,14 +671,6 @@ function snapshotOptions(options: Registration["options"]) {
|
||||
return options ? structuredClone(unwrap(options)) : undefined
|
||||
}
|
||||
|
||||
async function resolveLocal(url: URL) {
|
||||
const info = await stat(url)
|
||||
if (info.isFile()) return url.href
|
||||
if (!info.isDirectory()) return
|
||||
const entrypoint = await tuiEntrypoint(fileURLToPath(url))
|
||||
return entrypoint ? pathToFileURL(entrypoint).href : undefined
|
||||
}
|
||||
|
||||
function isPlugin(value: unknown): value is Plugin.Definition {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
|
||||
@@ -3,8 +3,6 @@ import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories"
|
||||
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cts", ".cjs"]
|
||||
|
||||
export async function localPluginDirectories(cwd: string, configDirectory: string) {
|
||||
const projectDirectory = await localProjectDirectory(cwd)
|
||||
const projectConfig = path.join(projectDirectory, ".opencode")
|
||||
@@ -21,7 +19,7 @@ export async function localPluginDirectories(cwd: string, configDirectory: strin
|
||||
return directories.filter((_, index) => exists[index]).map((directory) => path.join(directory, "plugins"))
|
||||
}
|
||||
|
||||
export async function discoverTuiPlugins(directories: string[]) {
|
||||
export async function discoverPluginTargets(directories: string[]) {
|
||||
return (
|
||||
await Promise.all(
|
||||
directories.map(async (directory) => {
|
||||
@@ -43,7 +41,7 @@ export async function discoverTuiPlugins(directories: string[]) {
|
||||
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
|
||||
))
|
||||
if (!isDirectory) return undefined
|
||||
return tuiEntrypoint(plugin)
|
||||
return plugin
|
||||
}),
|
||||
)
|
||||
).filter((entry): entry is string => entry !== undefined)
|
||||
@@ -52,14 +50,6 @@ export async function discoverTuiPlugins(directories: string[]) {
|
||||
).flat()
|
||||
}
|
||||
|
||||
export async function tuiEntrypoint(directory: string) {
|
||||
const files = await readdir(directory, { withFileTypes: true })
|
||||
const names = new Set(files.filter((file) => file.isFile() || file.isSymbolicLink()).map((file) => file.name))
|
||||
if (!extensions.some((extension) => names.has("index" + extension))) return undefined
|
||||
const tui = extensions.find((extension) => names.has("tui" + extension))
|
||||
return tui ? path.join(directory, "tui" + tui) : undefined
|
||||
}
|
||||
|
||||
export function localSource(spec: string, directory: string) {
|
||||
if (spec.startsWith("file://")) return new URL(spec)
|
||||
if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec))
|
||||
|
||||
@@ -239,7 +239,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: {},
|
||||
log: () => {},
|
||||
@@ -315,7 +315,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy" },
|
||||
log: () => {},
|
||||
@@ -389,7 +389,7 @@ test("session title generated while an untitled session is loading remains visib
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy" },
|
||||
log: () => {},
|
||||
@@ -775,7 +775,7 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
||||
log: () => {},
|
||||
@@ -1309,7 +1309,7 @@ test.each(["manual", "select"] as const)(
|
||||
}),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
args: { sessionID: session.id },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
log: () => {},
|
||||
@@ -1395,7 +1395,7 @@ async function createAppFixture(
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: input.args ?? {},
|
||||
log: () => {},
|
||||
|
||||
@@ -59,7 +59,7 @@ test.each([70, 120])(
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({ animations: false }), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: { sessionID },
|
||||
log: () => {},
|
||||
@@ -184,7 +184,7 @@ test.each(["first", "second"])(
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({ animations: false }), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: { sessionID },
|
||||
log: () => {},
|
||||
|
||||
@@ -103,7 +103,7 @@ test.each([
|
||||
}),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "ses_test" },
|
||||
log: () => {},
|
||||
|
||||
@@ -79,7 +79,7 @@ test.each([80, 120, 180])("session wheel scrolling preserves pane focus at width
|
||||
get: async () => ({ animations: false, session: { terminal: true }, tabs: { enabled: false } }),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
args: { sessionID: session.id },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
log: () => {},
|
||||
|
||||
@@ -41,7 +41,7 @@ test("stats shows only this year and returns after errors or success", async ()
|
||||
get: async () => ({ animations: false, tabs: { enabled: false } }),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: {},
|
||||
log: () => {},
|
||||
|
||||
@@ -16,6 +16,26 @@ The `/admin*` route must be protected by a Cloudflare Access self-hosted applica
|
||||
|
||||
The Worker has `workers_dev` and preview URLs disabled so the custom hostname is its only public entry point.
|
||||
|
||||
## Request logging
|
||||
|
||||
Every request reaching the Worker emits an unsampled event at request start to the shared production
|
||||
Cloudflare lake stream through the `EVENTS` Pipelines binding. Events use
|
||||
`source: "update"`, `type: "request"`, an ISO `timestamp`, and a `payload` containing
|
||||
the method, path, `useragent`, `ip` (from Cloudflare's `CF-Connecting-IP` header),
|
||||
country, and Cloudflare colo. Query strings, request bodies, cookies, and authorization
|
||||
headers are not included. Response status and duration are not recorded.
|
||||
|
||||
Delivery runs in `waitUntil` without delaying the response. Delivery failures are
|
||||
logged but do not fail requests or retry; this is not lossless audit logging.
|
||||
Requests blocked before reaching the Worker are not recorded.
|
||||
|
||||
The stream ID in `wrangler.jsonc` comes from the `lake.stream` output of the
|
||||
`anomalyco/platform/production` Pulumi stack. Update the binding if that stream is
|
||||
replaced. The stream is shared across release channels because the update service
|
||||
has a single public deployment.
|
||||
|
||||
## Publishing
|
||||
|
||||
GitHub Actions publishes artifacts through `POST /api/publish` using a short-lived OIDC token with audience `https://update.opencode.ai`. The Worker accepts only tokens signed by GitHub for repository ID `975734319`, owner ID `66570915`, and `.github/workflows/publish.yml` on configured publishing refs.
|
||||
|
||||
Apply migrations and deploy from this directory:
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import worker, { channelsForRef, resolveChannel, validGitHubClaims } from "./index"
|
||||
|
||||
const claims = {
|
||||
repository: "anomalyco/opencode",
|
||||
repository_id: "975734319",
|
||||
repository_owner_id: "66570915",
|
||||
workflow_ref: "anomalyco/opencode/.github/workflows/publish.yml@refs/heads/dev",
|
||||
ref: "refs/heads/dev",
|
||||
sha: "abc123",
|
||||
run_id: "123",
|
||||
run_attempt: "1",
|
||||
actor: "opencode-agent",
|
||||
}
|
||||
|
||||
describe("GitHub publish authorization", () => {
|
||||
test("allows the publish workflow from the repository", () => {
|
||||
expect(validGitHubClaims(claims)).toBe(true)
|
||||
expect(channelsForRef(claims.ref)).toEqual(["dev", "latest"])
|
||||
})
|
||||
|
||||
test("maps V2 development to the dev channel", () => {
|
||||
expect(channelsForRef("refs/heads/v2")).toEqual(["dev"])
|
||||
})
|
||||
|
||||
test("rejects another repository or workflow", () => {
|
||||
expect(validGitHubClaims({ ...claims, repository_id: "1" })).toBe(false)
|
||||
expect(
|
||||
validGitHubClaims({ ...claims, workflow_ref: "anomalyco/opencode/.github/workflows/other.yml@refs/heads/dev" }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects unconfigured refs", () => {
|
||||
const ref = "refs/heads/untrusted"
|
||||
expect(
|
||||
validGitHubClaims({ ...claims, ref, workflow_ref: `anomalyco/opencode/.github/workflows/publish.yml@${ref}` }),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("routes the retired next channel to beta", () => {
|
||||
expect(resolveChannel("next")).toBe("beta")
|
||||
expect(resolveChannel("dev")).toBe("dev")
|
||||
})
|
||||
|
||||
const artifact = {
|
||||
channel: "beta",
|
||||
name: "opencode",
|
||||
distribution: "darwin-arm64",
|
||||
version: "1.0.0",
|
||||
metadata: "{}",
|
||||
active: 1,
|
||||
time_created: 1,
|
||||
time_updated: 2,
|
||||
}
|
||||
|
||||
test.each([
|
||||
["/api/next", ["beta"], { channel: "beta", artifacts: [{ ...artifact, metadata: {}, active: true }] }],
|
||||
[
|
||||
"/api/next/opencode",
|
||||
["beta", "opencode"],
|
||||
{ channel: "beta", name: "opencode", artifacts: [{ ...artifact, metadata: {}, active: true }] },
|
||||
],
|
||||
[
|
||||
"/api/next/opencode/darwin-arm64",
|
||||
["beta", "opencode", "darwin-arm64"],
|
||||
{ ...artifact, metadata: {}, active: true },
|
||||
],
|
||||
])("routes GET %s", async (path, expectedBindings, expectedBody) => {
|
||||
const bindings: unknown[][] = []
|
||||
const statement = {
|
||||
bind(...values: unknown[]) {
|
||||
bindings.push(values)
|
||||
return statement
|
||||
},
|
||||
async all() {
|
||||
return { results: [artifact] }
|
||||
},
|
||||
async first() {
|
||||
return artifact
|
||||
},
|
||||
}
|
||||
const db = {
|
||||
prepare() {
|
||||
return statement
|
||||
},
|
||||
} as unknown as D1Database
|
||||
|
||||
const response = await worker.fetch(new Request(`https://update.opencode.ai${path}`), { DB: db })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toBe(JSON.stringify(expectedBody))
|
||||
expect(bindings).toEqual([expectedBindings])
|
||||
})
|
||||
|
||||
test.each(["/api", "/v1/dev", "/api/dev/opencode/darwin-arm64/extra", "/api/dev/opencode/darwin$arm64"])(
|
||||
"returns 404 for GET %s",
|
||||
async (path) => {
|
||||
const db = {
|
||||
prepare() {
|
||||
throw new Error("Invalid routes must not query the database")
|
||||
},
|
||||
} as unknown as D1Database
|
||||
|
||||
const response = await worker.fetch(new Request(`https://update.opencode.ai${path}`), { DB: db })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(await response.text()).toBe("Not found")
|
||||
},
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"
|
||||
import type { Pipeline } from "cloudflare:pipelines"
|
||||
|
||||
interface Env {
|
||||
DB: D1Database
|
||||
EVENTS: Pipeline
|
||||
}
|
||||
|
||||
type ArtifactRow = {
|
||||
@@ -31,8 +33,25 @@ const audience = "https://update.opencode.ai"
|
||||
const githubKeys = createRemoteJWKSet(new URL("https://token.actions.githubusercontent.com/.well-known/jwks"))
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
async fetch(request: Request, env: Env, ctx: Pick<ExecutionContext, "waitUntil">): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
ctx.waitUntil(
|
||||
env.EVENTS.send([
|
||||
{
|
||||
source: "update",
|
||||
type: "request",
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
useragent: request.headers.get("user-agent"),
|
||||
ip: request.headers.get("cf-connecting-ip"),
|
||||
cf_country: request.cf?.country,
|
||||
cf_colo: request.cf?.colo,
|
||||
},
|
||||
},
|
||||
]).catch(() => console.error("Failed to send update request event to the data lake")),
|
||||
)
|
||||
|
||||
if (url.pathname === "/") return json({ service: "opencode-updates" })
|
||||
if (url.pathname === "/admin" && request.method === "GET") return admin(request, env)
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
"compatibility_date": "2026-07-21",
|
||||
"workers_dev": false,
|
||||
"preview_urls": false,
|
||||
"pipelines": [
|
||||
{
|
||||
"binding": "EVENTS",
|
||||
// Shared lake stream from anomalyco/platform/production.
|
||||
"stream": "251a89241c3a461c9007f6b6f345ed8b",
|
||||
},
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "update.opencode.ai",
|
||||
|
||||
@@ -54,8 +54,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:"
|
||||
"pacote": "21.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
+40
-79
@@ -11,7 +11,6 @@ import { filesystem } from "./effect/app-node-platform.js"
|
||||
import { LayerNode } from "./effect/layer-node.js"
|
||||
import { makeRuntime } from "./effect/runtime.js"
|
||||
import { NpmConfig } from "./npm-config.js"
|
||||
import { resolveModule } from "#runtime-import"
|
||||
|
||||
export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()("NpmInstallFailedError", {
|
||||
add: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
@@ -19,24 +18,18 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export interface EntryPoint {
|
||||
export interface Package {
|
||||
readonly directory: string
|
||||
readonly entrypoint?: string
|
||||
readonly name: string
|
||||
readonly version?: string
|
||||
readonly revision?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
|
||||
readonly add: (pkg: string) => Effect.Effect<Package, InstallFailedError | EffectFlock.LockError>
|
||||
readonly resolve: (pkg: string) => Effect.Effect<Package>
|
||||
readonly check: (pkg: string) => Effect.Effect<boolean, InstallFailedError>
|
||||
readonly update: (
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly update: (pkg: string) => Effect.Effect<Package, InstallFailedError | EffectFlock.LockError>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
@@ -114,22 +107,6 @@ function gitSlug(pkg: string) {
|
||||
)
|
||||
}
|
||||
|
||||
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
|
||||
const entrypoint = subpaths
|
||||
.map((subpath) => {
|
||||
try {
|
||||
return resolveModule([name, subpath].filter(Boolean).join("/"), dir)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
.find((entrypoint) => entrypoint !== undefined)
|
||||
return {
|
||||
directory: dir,
|
||||
entrypoint,
|
||||
}
|
||||
}
|
||||
|
||||
interface ArboristNode {
|
||||
name: string
|
||||
path: string
|
||||
@@ -215,13 +192,7 @@ const layer = Layer.effect(
|
||||
if (revision) return revision
|
||||
}
|
||||
})
|
||||
const entry = Effect.fnUntraced(function* (
|
||||
root: string,
|
||||
name: string,
|
||||
dir: string,
|
||||
target: Target | undefined,
|
||||
subpaths?: readonly string[],
|
||||
) {
|
||||
const metadata = Effect.fnUntraced(function* (root: string, name: string, dir: string, target: Target | undefined) {
|
||||
const manifest = yield* afs
|
||||
.readJson(path.join(dir, "package.json"))
|
||||
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
|
||||
@@ -229,7 +200,8 @@ const layer = Layer.effect(
|
||||
const revision = target ? (yield* installedRevision(root, name, target)) ?? manifestVersion : undefined
|
||||
const version = target?.type === "git" ? revision : manifestVersion
|
||||
return {
|
||||
...resolveEntryPoint(name, dir, subpaths),
|
||||
directory: dir,
|
||||
name,
|
||||
...(version ? { version } : {}),
|
||||
...(revision ? { revision } : {}),
|
||||
}
|
||||
@@ -274,43 +246,47 @@ const layer = Layer.effect(
|
||||
pkg: string,
|
||||
target: Target | undefined,
|
||||
dir: string,
|
||||
subpaths: readonly string[] | undefined,
|
||||
update: boolean,
|
||||
) {
|
||||
yield* flock.acquire(`npm-install:${dir}`)
|
||||
const active = yield* current(dir)
|
||||
const name = yield* installedName(pkg, active ?? dir, target)
|
||||
if (active && !update && (yield* afs.existsSafe(path.join(active, "node_modules", name)))) {
|
||||
return yield* entry(active, name, path.join(active, "node_modules", name), target, subpaths)
|
||||
return yield* metadata(active, name, path.join(active, "node_modules", name), target)
|
||||
}
|
||||
|
||||
yield* mkdir(dir)
|
||||
const startedAt = yield* Clock.currentTimeMillis
|
||||
const staging = path.join(dir, `.staging-${startedAt}-${randomUUID()}`)
|
||||
// Arborist keys lockfile entries relative to the root's real path. When the cache
|
||||
// directory is reached through a symlink (macOS `/var` → `/private/var`, a linked
|
||||
// XDG cache), the keys become `../../…` paths that installedRevision never finds,
|
||||
// so Git checks report "not installed" and updates go undetected. Stage under the
|
||||
// resolved directory so the root path and real path agree.
|
||||
const root = yield* fs.realPath(dir).pipe(Effect.mapError((cause) => new InstallFailedError({ dir, cause })))
|
||||
const staging = path.join(root, `.staging-${startedAt}-${randomUUID()}`)
|
||||
const staged = yield* Effect.gen(function* () {
|
||||
const tree = yield* reify({ dir: staging, config: dir, add: [pkg], update })
|
||||
const installed = tree.edgesOut.values().next().value?.to
|
||||
const installedNameValue = installed?.name ?? (yield* installedName(pkg, staging, target))
|
||||
const result = yield* entry(
|
||||
const result = yield* metadata(
|
||||
staging,
|
||||
installedNameValue,
|
||||
installed?.path ?? path.join(staging, "node_modules", installedNameValue),
|
||||
target,
|
||||
// Resolve installed entrypoints after rename so Bun cannot hold the staging directory open on Windows.
|
||||
installed ? [] : subpaths,
|
||||
)
|
||||
if (!installed && !result.entrypoint) return yield* new InstallFailedError({ add: [pkg], dir: staging })
|
||||
if (!installed && !(yield* afs.isDir(result.directory)))
|
||||
return yield* new InstallFailedError({ add: [pkg], dir: staging })
|
||||
const links =
|
||||
process.platform === "win32"
|
||||
? Array.from(tree.inventory.values()).filter(
|
||||
(node) => node.isLink && FSUtil.contains(staging, node.path) && FSUtil.contains(staging, node.realpath),
|
||||
)
|
||||
: []
|
||||
return { name: installedNameValue, result, links }
|
||||
return { result, links }
|
||||
}).pipe(Effect.onError(() => remove(staging, dir).pipe(Effect.ignore)))
|
||||
|
||||
if (active) {
|
||||
const activeEntry = yield* entry(active, name, path.join(active, "node_modules", name), target, subpaths)
|
||||
const activeEntry = yield* metadata(active, name, path.join(active, "node_modules", name), target)
|
||||
if (activeEntry.revision && activeEntry.revision === staged.result.revision) {
|
||||
yield* remove(staging, dir)
|
||||
return activeEntry
|
||||
@@ -337,13 +313,7 @@ const layer = Layer.effect(
|
||||
).pipe(Effect.onError(() => remove(staging, dir).pipe(Effect.ignore)))
|
||||
}
|
||||
yield* rename(staging, generation, dir)
|
||||
return yield* entry(
|
||||
generation,
|
||||
staged.name,
|
||||
path.join(generation, "node_modules", staged.name),
|
||||
target,
|
||||
subpaths,
|
||||
)
|
||||
return { ...staged.result, directory: path.join(generation, "node_modules", staged.result.name) }
|
||||
})
|
||||
|
||||
const collect = Effect.fnUntraced(function* (dir: string) {
|
||||
@@ -367,26 +337,20 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const add = Effect.fn("Npm.add")(function* (
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) {
|
||||
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
||||
const target = yield* Effect.promise(() => parse(pkg))
|
||||
const dir = directory(pkg, target)
|
||||
return yield* install(pkg, target, dir, options?.subpaths, false)
|
||||
return yield* install(pkg, target, dir, false)
|
||||
}, Effect.scoped)
|
||||
|
||||
const resolve = Effect.fn("Npm.resolve")(function* (
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) {
|
||||
const resolve = Effect.fn("Npm.resolve")(function* (pkg: string) {
|
||||
const target = yield* Effect.promise(() => parse(pkg))
|
||||
const root = directory(pkg, target)
|
||||
const generation = yield* current(root)
|
||||
const name = yield* installedName(pkg, generation ?? root, target)
|
||||
const dir = path.join(generation ?? root, "node_modules", name)
|
||||
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
|
||||
return yield* entry(generation ?? root, name, dir, target, options?.subpaths)
|
||||
if (!(yield* afs.existsSafe(dir))) return { directory: dir, name }
|
||||
return yield* metadata(generation ?? root, name, dir, target)
|
||||
})
|
||||
|
||||
const check = Effect.fn("Npm.check")(function* (pkg: string) {
|
||||
@@ -415,22 +379,19 @@ const layer = Layer.effect(
|
||||
return installed !== available
|
||||
})
|
||||
|
||||
const update = Effect.fn("Npm.update")(
|
||||
function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
|
||||
const target = yield* Effect.promise(() => parse(pkg))
|
||||
const dir = directory(pkg, target)
|
||||
if (!target)
|
||||
return yield* new InstallFailedError({
|
||||
dir,
|
||||
cause: new Error("Package updates only support registry and Git package specs"),
|
||||
})
|
||||
if (!target.mutable) return yield* add(pkg, options)
|
||||
const installed = yield* install(pkg, target, dir, options?.subpaths, true)
|
||||
yield* collect(dir)
|
||||
return installed
|
||||
},
|
||||
Effect.scoped,
|
||||
)
|
||||
const update = Effect.fn("Npm.update")(function* (pkg: string) {
|
||||
const target = yield* Effect.promise(() => parse(pkg))
|
||||
const dir = directory(pkg, target)
|
||||
if (!target)
|
||||
return yield* new InstallFailedError({
|
||||
dir,
|
||||
cause: new Error("Package updates only support registry and Git package specs"),
|
||||
})
|
||||
if (!target.mutable) return yield* add(pkg)
|
||||
const installed = yield* install(pkg, target, dir, true)
|
||||
yield* collect(dir)
|
||||
return installed
|
||||
}, Effect.scoped)
|
||||
|
||||
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
|
||||
const target = yield* Effect.promise(() => parse(pkg))
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
export function importModule(specifier: string) {
|
||||
return import(specifier) as Promise<unknown>
|
||||
}
|
||||
|
||||
export function resolveModule(specifier: string, directory: string) {
|
||||
return import.meta.resolve(specifier, directory)
|
||||
const resolved = Bun.resolveSync(specifier, directory)
|
||||
return resolved.startsWith("node:") ? resolved : pathToFileURL(resolved).href
|
||||
}
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import { Script, constants } from "node:vm"
|
||||
import { createRequire, registerHooks } from "node:module"
|
||||
import { registerHooks } from "node:module"
|
||||
import { statSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { resolve, type Package } from "resolve.exports"
|
||||
|
||||
let conditions: readonly string[] = []
|
||||
const conditionHooks = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
conditions = context.conditions
|
||||
return nextResolve(specifier, context)
|
||||
},
|
||||
})
|
||||
await new Script('import("node:module")', {
|
||||
importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
|
||||
}).runInThisContext()
|
||||
conditionHooks.deregister()
|
||||
|
||||
export async function importModule(specifier: string) {
|
||||
const imported = (await new Script(`import(${JSON.stringify(specifier)})`, {
|
||||
@@ -30,10 +18,44 @@ export async function importModule(specifier: string) {
|
||||
}
|
||||
|
||||
export function resolveModule(specifier: string, directory: string) {
|
||||
const pkg = createRequire(import.meta.url)(path.join(directory, "package.json")) as Package
|
||||
const target = resolve(pkg, specifier, { conditions, unsafe: true })?.[0]
|
||||
if (target) return pathToFileURL(path.resolve(directory, target)).href
|
||||
const legacyTarget =
|
||||
specifier === pkg.name ? directory : path.resolve(directory, specifier.slice(pkg.name.length + 1))
|
||||
return pathToFileURL(createRequire(path.join(directory, "package.json")).resolve(legacyTarget)).href
|
||||
// Node only accepts import.meta.resolve's parent URL behind an experimental
|
||||
// flag. Scope this synchronous resolution to the caller's package directory
|
||||
// through the supported resolver hook instead.
|
||||
const hook = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
return nextResolve(specifier, { ...context, parentURL: pathToFileURL(path.join(directory, "package.json")).href })
|
||||
},
|
||||
})
|
||||
try {
|
||||
const resolve = (target: string) => {
|
||||
const resolved = import.meta.resolve(path.isAbsolute(target) ? pathToFileURL(target).href : target)
|
||||
if (resolved.startsWith("file:")) statSync(new URL(resolved))
|
||||
return resolved
|
||||
}
|
||||
try {
|
||||
return resolve(specifier)
|
||||
} catch (error) {
|
||||
if (path.extname(specifier) || !missing(error)) throw error
|
||||
// Node does not infer extensions for local files or legacy package
|
||||
// subpaths. Resolve each candidate natively so package exports still apply.
|
||||
for (const extension of [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cts", ".cjs"]) {
|
||||
try {
|
||||
return resolve(specifier + extension)
|
||||
} catch (cause) {
|
||||
if (!missing(cause)) throw cause
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
hook.deregister()
|
||||
}
|
||||
}
|
||||
|
||||
function missing(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
["ENOENT", "ENOTDIR", "ERR_MODULE_NOT_FOUND"].includes(String(error.code))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,6 +138,33 @@ body:
|
||||
}
|
||||
```
|
||||
|
||||
## Timeouts
|
||||
|
||||
Provider requests wait up to five minutes for response headers and up to five minutes between streamed response chunks.
|
||||
Set `settings.headerTimeout` or `settings.chunkTimeout` to a number of milliseconds to change a limit, or to `false` to
|
||||
disable it:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"settings": {
|
||||
"headerTimeout": 600000,
|
||||
"chunkTimeout": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Default | Purpose |
|
||||
| --------------- | -------- | ---------------------------------------------------------------------------- |
|
||||
| `headerTimeout` | `300000` | Time to wait for response headers. Stops once headers arrive. |
|
||||
| `chunkTimeout` | `300000` | Time allowed between streamed response chunks. Resets whenever data arrives. |
|
||||
|
||||
A request that times out fails with a transport error and follows the normal retry policy.
|
||||
|
||||
## Package
|
||||
|
||||
The `package` field selects the runtime used to communicate with a provider. For an OpenAI-compatible API, use the
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const packages = ["@opencode-ai/simulation", "@opencode-ai/server"]
|
||||
const publish = Bun.argv.includes("--publish")
|
||||
|
||||
if (!publish) {
|
||||
console.log("Package names to reserve:")
|
||||
for (const name of packages) console.log(`- ${name}`)
|
||||
console.log("\nRun `bun run reserve-packages --publish` to log in and publish reservation placeholders.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (run(["npm", "whoami"]) !== 0) {
|
||||
if (run(["npm", "login"]) !== 0) throw new Error("npm login failed")
|
||||
if (run(["npm", "whoami"]) !== 0) throw new Error("npm authentication failed")
|
||||
}
|
||||
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "opencode-package-reservations-"))
|
||||
try {
|
||||
for (const name of packages) {
|
||||
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`)
|
||||
if (response.ok) {
|
||||
console.log(`Skipping ${name}: already exists on npm`)
|
||||
continue
|
||||
}
|
||||
if (response.status !== 404) throw new Error(`Failed to check ${name}: npm returned ${response.status}`)
|
||||
|
||||
const target = path.join(directory, name.slice("@opencode-ai/".length))
|
||||
await mkdir(target, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(target, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name,
|
||||
version: "0.0.0-reserved",
|
||||
description: "Reserved for OpenCode",
|
||||
license: "MIT",
|
||||
repository: {
|
||||
type: "git",
|
||||
url: "git+https://github.com/anomalyco/opencode.git",
|
||||
},
|
||||
publishConfig: {
|
||||
access: "public",
|
||||
tag: "reserved",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
)
|
||||
await Bun.write(path.join(target, "README.md"), `# ${name}\n\nReserved for OpenCode.\n`)
|
||||
|
||||
console.log(`Reserving ${name}`)
|
||||
if (run(["npm", "publish", "--access", "public", "--tag", "reserved"], target) !== 0) {
|
||||
throw new Error(`Failed to reserve ${name}`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function run(command: string[], cwd?: string) {
|
||||
return Bun.spawnSync(command, {
|
||||
cwd,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}).exitCode
|
||||
}
|
||||
Reference in New Issue
Block a user