mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 18:30:00 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 655ab67157 |
@@ -12,7 +12,6 @@ type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly provider?: string
|
||||
readonly baseURL: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
@@ -20,8 +19,6 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
@@ -34,11 +31,11 @@ export const routes = [OpenAICompatibleChat.route]
|
||||
|
||||
export const configure = (input: GenericModelOptions) => {
|
||||
const provider = input.provider ?? "openai-compatible"
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, queryParams, ...rest } = input
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = OpenAICompatibleChat.route.with({
|
||||
...rest,
|
||||
provider,
|
||||
endpoint: { baseURL, query: queryParams },
|
||||
endpoint: { baseURL },
|
||||
auth: AuthOptions.bearer(input, []),
|
||||
})
|
||||
return {
|
||||
@@ -78,8 +75,6 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
}).model(modelID)
|
||||
|
||||
export const baseten = define(profiles.baseten)
|
||||
|
||||
@@ -64,24 +64,6 @@ describe("provider package entrypoints", () => {
|
||||
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
|
||||
})
|
||||
|
||||
test("maps OpenAI-compatible package settings onto the executable model", async () => {
|
||||
const OpenAICompatible = await import("@opencode-ai/ai/providers/openai-compatible")
|
||||
const selected = OpenAICompatible.model("custom-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://provider.example.test/v1",
|
||||
provider: "example",
|
||||
queryParams: { version: "preview" },
|
||||
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.endpoint).toMatchObject({
|
||||
baseURL: "https://provider.example.test/v1",
|
||||
query: { version: "preview" },
|
||||
})
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
|
||||
})
|
||||
|
||||
test("maps package settings onto the executable model", () => {
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
|
||||
@@ -68,10 +68,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
}),
|
||||
Spec.make("debug", {
|
||||
description: "Debugging and troubleshooting tools",
|
||||
commands: [
|
||||
Spec.make("agents", { description: "List all agents" }),
|
||||
Spec.make("config", { description: "Show resolved configuration" }),
|
||||
],
|
||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||
}),
|
||||
Spec.make("console", {
|
||||
description: "Manage OpenCode Console access",
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.config,
|
||||
Effect.fn("cli.debug.config")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(JSON.stringify(entries, null, 2) + EOL)
|
||||
}),
|
||||
)
|
||||
@@ -22,7 +22,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
onStart: (reason, previousVersion) => {
|
||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||
process.stderr.write(
|
||||
|
||||
@@ -10,11 +10,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
||||
yield* Effect.promise(async () => validateMiniTerminal())
|
||||
const serverURL = Option.getOrUndefined(input.server)
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: serverURL,
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
})
|
||||
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
||||
const config = yield* Config.Service
|
||||
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
|
||||
@@ -22,7 +22,6 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
},
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
config: () => import("./commands/handlers/debug/config"),
|
||||
},
|
||||
console: {
|
||||
login: () => import("./commands/handlers/console/login"),
|
||||
|
||||
@@ -42,10 +42,9 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
||||
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
||||
}
|
||||
|
||||
const mismatch = args.mismatch ?? "ignore"
|
||||
const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })
|
||||
const options = yield* ServiceConfig.options()
|
||||
return {
|
||||
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, mismatch),
|
||||
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"),
|
||||
service: managedService(options),
|
||||
} satisfies Resolved
|
||||
})
|
||||
|
||||
@@ -98,12 +98,12 @@ const paths = Effect.gen(function* () {
|
||||
}
|
||||
})
|
||||
|
||||
export const options = Effect.fnUntraced(function* (input: { readonly checkVersion?: boolean } = {}) {
|
||||
export const options = Effect.fnUntraced(function* () {
|
||||
const { file, legacyRegistrationFiles } = yield* paths
|
||||
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
|
||||
return {
|
||||
file,
|
||||
version: input.checkVersion ? OPENCODE_VERSION : undefined,
|
||||
version: OPENCODE_VERSION,
|
||||
command: [...selfCommand(), "serve", "--service"],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
|
||||
describe("debug config command", () => {
|
||||
test("is included in troubleshooting help", async () => {
|
||||
const [debug, config] = await Promise.all([cli(["debug", "--help"]), cli(["debug", "config", "--help"])])
|
||||
|
||||
expect(debug.exitCode).toBe(0)
|
||||
expect(debug.stdout).toContain("config")
|
||||
expect(debug.stdout).toContain("Show resolved configuration")
|
||||
expect(config.exitCode).toBe(0)
|
||||
expect(config.stdout).toContain("opencode debug config [flags]")
|
||||
})
|
||||
|
||||
test("prints config entries from the invoking directory without reordering permissions", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-debug-config-"))
|
||||
const project = path.join(import.meta.dir, "..")
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const entries = [
|
||||
{
|
||||
type: "document",
|
||||
path: path.join(project, "opencode.json"),
|
||||
info: {
|
||||
permissions: [
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "file", path: path.join(project, "opencode.json") },
|
||||
]
|
||||
let requested: URL | undefined
|
||||
const authorization: Array<string | null> = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") {
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
}
|
||||
requested = url
|
||||
authorization.push(request.headers.get("authorization"))
|
||||
return Response.json(entries)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await fs.writeFile(
|
||||
registration,
|
||||
JSON.stringify({ version: OPENCODE_VERSION, url: server.url.toString(), pid: process.pid, password: "secret" }),
|
||||
)
|
||||
const result = await cli(["debug", "config"], project, { XDG_STATE_HOME: path.join(root, "state") })
|
||||
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(JSON.parse(result.stdout)).toEqual(entries)
|
||||
expect(requested?.pathname).toBe("/api/config")
|
||||
expect(requested?.searchParams.get("location[directory]")).toBe(project)
|
||||
expect(authorization).toEqual([`Basic ${btoa("opencode:secret")}`])
|
||||
} finally {
|
||||
server.stop(true)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function cli(args: string[], cwd = path.join(import.meta.dir, ".."), env?: Record<string, string>) {
|
||||
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
return { stdout, stderr, exitCode }
|
||||
}
|
||||
@@ -55,17 +55,3 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("service options only require a matching version when requested", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-options-"))
|
||||
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||
const runPromise = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
|
||||
|
||||
try {
|
||||
expect((await runPromise(ServiceConfig.options())).version).toBeUndefined()
|
||||
expect((await runPromise(ServiceConfig.options({ checkVersion: true }))).version).toBe(OPENCODE_VERSION)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "@opencode-ai/protocol/client"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||
@@ -49,7 +48,6 @@ const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmi
|
||||
const effectTypeReferences = [
|
||||
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
|
||||
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
|
||||
...namespaceTypes("Config", "@opencode-ai/schema/config", Config),
|
||||
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
|
||||
...namespaceTypes("Event", "@opencode-ai/schema/event", Event),
|
||||
...namespaceTypes("EventLog", "@opencode-ai/schema/event-log", EventLog),
|
||||
|
||||
@@ -38,7 +38,6 @@ import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { Config } from "@opencode-ai/schema/config"
|
||||
|
||||
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
||||
@@ -1596,16 +1595,6 @@ export interface WebsearchApi<E = never> {
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint29_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
||||
|
||||
export interface ConfigApi<E = never> {
|
||||
readonly get: ConfigGetOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
readonly health: HealthApi<E>
|
||||
readonly server: ServerApi<E>
|
||||
@@ -1636,5 +1625,4 @@ export interface AppApi<E = never> {
|
||||
readonly debug: DebugApi<E>
|
||||
readonly migration: MigrationApi<E>
|
||||
readonly websearch: WebsearchApi<E>
|
||||
readonly config: ConfigApi<E>
|
||||
}
|
||||
|
||||
@@ -220,8 +220,6 @@ import type {
|
||||
Endpoint28_0Output,
|
||||
Endpoint28_1Input,
|
||||
Endpoint28_1Output,
|
||||
Endpoint29_0Input,
|
||||
Endpoint29_0Output,
|
||||
} from "../api/api.js"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -1243,13 +1241,6 @@ const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
||||
query: Endpoint28_1(raw),
|
||||
})
|
||||
|
||||
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
|
||||
preserveEffect<Endpoint29_0Output>()(
|
||||
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
server: adaptGroup1(raw["server.server"]),
|
||||
@@ -1280,7 +1271,6 @@ const adaptClient = (raw: RawClient) => ({
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
websearch: adaptGroup28(raw["server.websearch"]),
|
||||
config: adaptGroup29(raw["server.config"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -8,7 +8,6 @@ export type {
|
||||
AppApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
ConfigApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
@@ -21,7 +20,6 @@ export type {
|
||||
} from "./api.js"
|
||||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Command } from "@opencode-ai/schema/command"
|
||||
export { Config } from "@opencode-ai/schema/config"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { Event } from "@opencode-ai/schema/event"
|
||||
export { EventLog } from "@opencode-ai/schema/event-log"
|
||||
|
||||
@@ -53,7 +53,6 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
|
||||
const contenders = new Set<Contender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
@@ -83,18 +82,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
const registration = yield* registered(options.file, true)
|
||||
const info = registration.info
|
||||
const service = registration.service
|
||||
if (registration.timedOut && info !== undefined) {
|
||||
timeouts = {
|
||||
info,
|
||||
count: timeouts !== undefined && same(timeouts.info, info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
yield* announce("missing")
|
||||
yield* evict(info, options)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
if (service !== undefined) {
|
||||
spawnDelay = 5_000
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
@@ -195,10 +182,6 @@ type LocalService = {
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||
return (yield* probeResult(info, allowLegacy)).service
|
||||
})
|
||||
|
||||
const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
@@ -206,53 +189,39 @@ const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(2_000)
|
||||
const result = yield* Effect.promise(() =>
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/api/health", info.url), {
|
||||
headers: headers(endpoint),
|
||||
signal,
|
||||
})
|
||||
.then(async (response) => ({ response, body: (await response.json()) as unknown }))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
const body = result.value.body
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}),
|
||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
if (response === undefined) return undefined
|
||||
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
const health = decodeHealth(body)
|
||||
if (Option.isSome(health)) {
|
||||
if (health.value.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && health.value.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
if (health.value.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: health.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
info,
|
||||
endpoint,
|
||||
version: health.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService
|
||||
}
|
||||
if (
|
||||
!allowLegacy ||
|
||||
Option.isNone(decodeLegacyHealth(body)) ||
|
||||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
||||
)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
return undefined
|
||||
return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
|
||||
})
|
||||
|
||||
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
|
||||
const info = yield* read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(yield* probeResult(info, allowLegacy)) }
|
||||
if (info === undefined) return { info: undefined, service: undefined }
|
||||
return { info, service: yield* probe(info, allowLegacy) }
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
@@ -280,19 +249,6 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll))
|
||||
})
|
||||
|
||||
const kill = Effect.fnUntraced(function* (service: LocalService, options: { readonly file?: string }) {
|
||||
const requested = yield* requestStop(service)
|
||||
if (requested === "rejected") return
|
||||
|
||||
@@ -2,7 +2,6 @@ type Client = ReturnType<typeof import("./generated/client.js").make>
|
||||
|
||||
export type AgentApi = Client["agent"]
|
||||
export type CommandApi = Client["command"]
|
||||
export type ConfigApi = Client["config"]
|
||||
export type EventApi = Client["event"]
|
||||
export type IntegrationApi = Client["integration"]
|
||||
export type ModelApi = Client["model"]
|
||||
|
||||
@@ -216,8 +216,6 @@ import type {
|
||||
WebsearchProvidersOutput,
|
||||
WebsearchQueryInput,
|
||||
WebsearchQueryOutput,
|
||||
ConfigGetInput,
|
||||
ConfigGetOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -1813,20 +1811,6 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
config: {
|
||||
get: (input?: ConfigGetInput, requestOptions?: RequestOptions) =>
|
||||
request<ConfigGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/config`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1698,180 +1698,6 @@ export type AgentInfo = {
|
||||
permissions: PermissionRuleset
|
||||
}
|
||||
|
||||
export type ConfigEntry =
|
||||
| {
|
||||
type: "document"
|
||||
path?: string | null
|
||||
info: {
|
||||
$schema?: string | null
|
||||
shell?: string | null
|
||||
model?: string | { providerID: string; model: string; variant?: string | null } | null
|
||||
default_agent?: string | null
|
||||
autoupdate?: boolean | "notify" | null
|
||||
share?: "manual" | "auto" | "disabled" | null
|
||||
enterprise?: { url?: string | null } | null
|
||||
username?: string | null
|
||||
permissions?: PermissionRuleset | null
|
||||
agents?: {
|
||||
[x: string]: {
|
||||
model?: string | { providerID: string; model: string; variant?: string | null } | null
|
||||
request?: { headers?: { [x: string]: string } | null; body?: { [x: string]: JsonValue } | null } | null
|
||||
system?: string | null
|
||||
description?: string | null
|
||||
mode?: "subagent" | "primary" | "all" | null
|
||||
hidden?: boolean | null
|
||||
color?: string | null
|
||||
steps?: number | null
|
||||
disabled?: boolean | null
|
||||
permissions?: PermissionRuleset | null
|
||||
}
|
||||
} | null
|
||||
snapshots?: boolean | null
|
||||
watcher?: { ignore?: Array<string> | null } | null
|
||||
formatter?:
|
||||
| boolean
|
||||
| {
|
||||
[x: string]: {
|
||||
disabled?: boolean | null
|
||||
command?: Array<string> | null
|
||||
environment?: { [x: string]: string } | null
|
||||
extensions?: Array<string> | null
|
||||
}
|
||||
}
|
||||
| null
|
||||
lsp?:
|
||||
| boolean
|
||||
| {
|
||||
[x: string]:
|
||||
| { disabled: true }
|
||||
| {
|
||||
command: Array<string>
|
||||
extensions?: Array<string> | null
|
||||
disabled?: boolean | null
|
||||
env?: { [x: string]: string } | null
|
||||
initialization?: { [x: string]: JsonValue } | null
|
||||
}
|
||||
}
|
||||
| null
|
||||
media?: {
|
||||
image?: {
|
||||
auto_resize?: boolean | null
|
||||
max_width?: number | null
|
||||
max_height?: number | null
|
||||
max_base64_bytes?: number | null
|
||||
} | null
|
||||
} | null
|
||||
tool_output?: { max_lines?: number | null; max_bytes?: number | null } | null
|
||||
mcp?: {
|
||||
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
|
||||
servers?: {
|
||||
[x: string]:
|
||||
| {
|
||||
type: "local"
|
||||
command: Array<string>
|
||||
cwd?: string | null
|
||||
environment?: { [x: string]: string } | null
|
||||
disabled?: boolean | null
|
||||
codemode?: boolean | null
|
||||
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
|
||||
}
|
||||
| {
|
||||
type: "remote"
|
||||
url: string
|
||||
headers?: { [x: string]: string } | null
|
||||
oauth?:
|
||||
| {
|
||||
client_id?: string | null
|
||||
client_secret?: string | null
|
||||
scope?: string | null
|
||||
callback_port?: number | null
|
||||
redirect_uri?: string | null
|
||||
}
|
||||
| false
|
||||
| null
|
||||
disabled?: boolean | null
|
||||
codemode?: boolean | null
|
||||
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
|
||||
}
|
||||
} | null
|
||||
} | null
|
||||
compaction?: { auto?: boolean | null; keep?: { tokens?: number | null } | null; buffer?: number | null } | null
|
||||
skills?: Array<string> | null
|
||||
commands?: {
|
||||
[x: string]: {
|
||||
template: string
|
||||
description?: string | null
|
||||
agent?: string | null
|
||||
model?: string | { providerID: string; model: string; variant?: string | null } | null
|
||||
subtask?: boolean | null
|
||||
}
|
||||
} | null
|
||||
instructions?: Array<string> | null
|
||||
references?: {
|
||||
[x: string]:
|
||||
| string
|
||||
| { repository: string; branch?: string | null; description?: string | null; hidden?: boolean | null }
|
||||
| { path: string; description?: string | null; hidden?: boolean | null }
|
||||
} | null
|
||||
websearch?: { provider: string } | null
|
||||
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } | null }> | null
|
||||
warming?: boolean | { prompt?: string | null; interval?: string | null; duration?: string | null } | null
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
name?: string | null
|
||||
env?: Array<string> | null
|
||||
package?: string | null
|
||||
settings?: { [x: string]: JsonValue } | null
|
||||
headers?: { [x: string]: string } | null
|
||||
body?: { [x: string]: JsonValue } | null
|
||||
models?: {
|
||||
[x: string]: {
|
||||
modelID?: string | null
|
||||
family?: string | null
|
||||
name?: string | null
|
||||
compatibility?: ModelCompatibility | null
|
||||
package?: string | null
|
||||
settings?: { [x: string]: JsonValue } | null
|
||||
headers?: { [x: string]: string } | null
|
||||
body?: { [x: string]: JsonValue } | null
|
||||
capabilities?: ModelCapabilities | null
|
||||
variants?: Array<{
|
||||
id: string
|
||||
settings?: { [x: string]: JsonValue } | null
|
||||
headers?: { [x: string]: string } | null
|
||||
body?: { [x: string]: JsonValue } | null
|
||||
}> | null
|
||||
cost?:
|
||||
| {
|
||||
tier?: { type: "context"; size: number } | null
|
||||
input: MoneyUSDPerMillionTokens
|
||||
output: MoneyUSDPerMillionTokens
|
||||
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
|
||||
}
|
||||
| Array<{
|
||||
tier?: { type: "context"; size: number } | null
|
||||
input: MoneyUSDPerMillionTokens
|
||||
output: MoneyUSDPerMillionTokens
|
||||
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
|
||||
}>
|
||||
| null
|
||||
disabled?: boolean | null
|
||||
limit?: { context?: number | null; input?: number | null; output?: number | null } | null
|
||||
}
|
||||
} | null
|
||||
}
|
||||
} | null
|
||||
experimental?: {
|
||||
subagent_depth?: number | null
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }> | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
| { type: "directory"; path: string }
|
||||
| { type: "file"; path: string }
|
||||
| { type: "agents"; path: string }
|
||||
| { type: "claude"; path: string }
|
||||
|
||||
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
|
||||
|
||||
export type SessionPendingUser = {
|
||||
@@ -4767,11 +4593,3 @@ export type WebsearchQueryOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: { providerID: string; results: Array<WebSearchResult> }
|
||||
}
|
||||
|
||||
export type ConfigGetInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ConfigGetOutput = Array<ConfigEntry>
|
||||
|
||||
@@ -3,7 +3,6 @@ export type {
|
||||
AgentApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
ConfigApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
|
||||
@@ -34,7 +34,6 @@ async function discoverLocal(options: DiscoverOptions) {
|
||||
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const deadline = Date.now() + 120_000
|
||||
const contenders = new Set<Contender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
@@ -63,19 +62,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, true)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
info: registration.info,
|
||||
count:
|
||||
timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
await evict(registration.info, options)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
spawnDelay = 5_000
|
||||
@@ -159,10 +145,6 @@ type LocalService = {
|
||||
}
|
||||
|
||||
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
|
||||
return (await probeResult(info, allowLegacy)).service
|
||||
}
|
||||
|
||||
async function probeResult(info: Info, allowLegacy = false) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
@@ -170,48 +152,30 @@ async function probeResult(info: Info, allowLegacy = false) {
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(2_000)
|
||||
const result = await fetch(new URL("/api/health", info.url), {
|
||||
const response = await fetch(new URL("/api/health", info.url), {
|
||||
headers: headers(endpoint),
|
||||
signal,
|
||||
})
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: (await response.json()) as ServiceHealth | { readonly healthy: true },
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
const body = result.value.body
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}).catch(() => undefined)
|
||||
const body = (await response?.json().catch(() => undefined)) as ServiceHealth | { readonly healthy: true } | undefined
|
||||
if (body !== undefined && "version" in body && "pid" in body) {
|
||||
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && body.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
if (body.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && body.version !== info.version) return undefined
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: body.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
info,
|
||||
endpoint,
|
||||
version: body.version,
|
||||
state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
}
|
||||
}
|
||||
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
if (!allowLegacy || body?.healthy !== true) return undefined
|
||||
return { info, endpoint, state: "ready", legacy: true }
|
||||
}
|
||||
|
||||
async function registered(file?: string, allowLegacy = false) {
|
||||
const info = await read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(await probeResult(info, allowLegacy)) }
|
||||
if (info === undefined) return { info: undefined, service: undefined }
|
||||
return { info, service: await probe(info, allowLegacy) }
|
||||
}
|
||||
|
||||
async function find(options: { readonly file?: string }) {
|
||||
@@ -245,18 +209,6 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function evict(info: Info, options: { readonly file?: string }) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
signal(info.pid, "SIGTERM")
|
||||
if (await waitUntilStopped(info.pid)) return
|
||||
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function kill(service: LocalService, options: { readonly file?: string }) {
|
||||
const requested = await requestStop(service)
|
||||
if (requested === "rejected") return
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
@@ -11,7 +10,6 @@ const Client = await import("../src/effect")
|
||||
|
||||
test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
expect(Client.Agent).toBe(Agent)
|
||||
expect(Client.Config).toBe(Config)
|
||||
expect(Client.Model).toBe(Model)
|
||||
expect(Client.Session).toBe(Session)
|
||||
})
|
||||
|
||||
@@ -47,10 +47,6 @@ const server = Bun.serve({
|
||||
}
|
||||
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "hanging") {
|
||||
await appendFile(registration + ".requests", process.pid + "\n")
|
||||
return new Promise<Response>(() => {})
|
||||
}
|
||||
if (mode === "modern" && requests === 1) {
|
||||
await writeFile(registration + ".first-request", "")
|
||||
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
|
||||
|
||||
@@ -70,32 +70,6 @@ test("reports a failed registered service", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
})
|
||||
processes.push(existing)
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
})
|
||||
const replacement = await Bun.file(registration).json()
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
}, 20_000)
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
const registration = await setup("graceful")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
@@ -33,7 +33,6 @@ test("exposes every standard HTTP API group", () => {
|
||||
"vcs",
|
||||
"debug",
|
||||
"websearch",
|
||||
"config",
|
||||
])
|
||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
|
||||
@@ -51,34 +50,6 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("config.get returns ordered config entries for a location", async () => {
|
||||
let request: Request | undefined
|
||||
const entries = [
|
||||
{
|
||||
type: "document" as const,
|
||||
path: "/tmp/project/opencode.json",
|
||||
info: {
|
||||
permissions: [
|
||||
{ action: "shell", resource: "*", effect: "ask" as const },
|
||||
{ action: "shell", resource: "git status", effect: "allow" as const },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "file" as const, path: "/tmp/project/opencode.json" },
|
||||
]
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input) => {
|
||||
request = input instanceof Request ? input : new Request(input)
|
||||
return Response.json(entries)
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.config.get({ location: { directory: "/tmp/project" } })).toEqual(entries)
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
})
|
||||
|
||||
test("websearch.query uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -70,30 +70,6 @@ test("reports a failed registered service without spawning", async () => {
|
||||
expect(process.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "hanging")
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await run(
|
||||
Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
}),
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
}, 20_000)
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface Mapping {
|
||||
|
||||
export interface MapInput {
|
||||
readonly packageName: string | undefined
|
||||
readonly providerID: string
|
||||
readonly settings: Readonly<Record<string, unknown>>
|
||||
readonly modelID: string
|
||||
}
|
||||
@@ -52,8 +51,6 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
...mapGoogleOptions(input.settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/openai-compatible":
|
||||
return mapOpenAICompatible(input, baseSettings)
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return mapOpenRouter(input.settings, baseSettings)
|
||||
case "@ai-sdk/xai":
|
||||
@@ -66,25 +63,6 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function mapOpenAICompatible(
|
||||
input: MapInput,
|
||||
baseSettings: Readonly<Record<string, unknown>>,
|
||||
): Mapping | undefined {
|
||||
if (typeof baseSettings.baseURL !== "string") return undefined
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: {
|
||||
baseURL: baseSettings.baseURL,
|
||||
...mapAPIKey(input.settings),
|
||||
provider: input.providerID,
|
||||
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
|
||||
...mapOpenAIOptions(input.settings),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
|
||||
|
||||
+148
-22
@@ -5,16 +5,8 @@ import path from "path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
Directory,
|
||||
Document,
|
||||
File,
|
||||
Info,
|
||||
type Entry,
|
||||
Event,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Credential } from "./credential"
|
||||
import { Bus } from "./bus"
|
||||
@@ -23,11 +15,141 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
import { ConfigMedia } from "./config/media"
|
||||
import { ConfigCompaction } from "./config/compaction"
|
||||
import { ConfigCommand } from "./config/command"
|
||||
import { ConfigExperimental } from "./config/experimental"
|
||||
import { ConfigFormatter } from "./config/formatter"
|
||||
import { ConfigLSP } from "./config/lsp"
|
||||
import { ConfigMCP } from "./config/mcp"
|
||||
import { ConfigModel } from "./config/model"
|
||||
import { ConfigPlugin } from "./config/plugin"
|
||||
import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigWebSearch } from "./config/websearch"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
import { ConfigWarming } from "./config/warming"
|
||||
import { ConfigV1 } from "./v1/config/config"
|
||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||
import { WellKnown } from "./wellknown"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
description: "JSON schema reference for configuration validation",
|
||||
}),
|
||||
shell: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default shell to use for terminal and shell tool execution",
|
||||
}),
|
||||
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
|
||||
description: "Default model to use when no session or agent model is selected",
|
||||
}),
|
||||
default_agent: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: "Automatically update or notify when a new version is available",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
}),
|
||||
enterprise: Schema.Struct({
|
||||
url: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: "Enterprise sharing service configuration",
|
||||
}),
|
||||
username: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
|
||||
description: "Ordered tool permission rules applied to agent tool use",
|
||||
}),
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||
description: "Named built-in agent overrides and custom agent definitions",
|
||||
}),
|
||||
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Enable snapshots used for undo and revert behavior",
|
||||
}),
|
||||
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
|
||||
description: "Filesystem watcher configuration",
|
||||
}),
|
||||
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in formatters or configure formatter overrides",
|
||||
}),
|
||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in language servers or configure server overrides",
|
||||
}),
|
||||
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||
description: "Media processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
}),
|
||||
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
|
||||
description: "MCP server configuration",
|
||||
}),
|
||||
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
|
||||
description: "Conversation compaction behavior",
|
||||
}),
|
||||
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs to discover skills from",
|
||||
}),
|
||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
|
||||
description: "Named slash command definitions",
|
||||
}),
|
||||
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs supplying ambient instructions",
|
||||
}),
|
||||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
|
||||
description: "Keep recently active sessions warm with transient model requests (default: false)",
|
||||
}),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||
experimental: ConfigExperimental.Info.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
||||
type: Schema.Literal("document"),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
info: Info,
|
||||
}) {}
|
||||
|
||||
export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
||||
type: Schema.Literal("directory"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class File extends Schema.Class<File>("Config.File")({
|
||||
type: Schema.Literal("file"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
|
||||
type: Schema.Literal("agents"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.ClaudeDirectory")({
|
||||
type: Schema.Literal("claude"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export type Entry = Document | Directory | File | AgentsDirectory | ClaudeDirectory
|
||||
|
||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||
return entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
@@ -174,17 +296,21 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
|
||||
// We load certain files from a few other folders in the ecosystem
|
||||
const claude = [
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude"),
|
||||
]),
|
||||
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
|
||||
...((yield* fs.isDir(globalClaudeDirectory))
|
||||
? [new ClaudeDirectory({ type: "claude", path: globalClaudeDirectory })]
|
||||
: []),
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".claude")
|
||||
.map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })),
|
||||
]
|
||||
const agents = [
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents"),
|
||||
]),
|
||||
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||
...((yield* fs.isDir(globalAgentsDirectory))
|
||||
? [new AgentsDirectory({ type: "agents", path: globalAgentsDirectory })]
|
||||
: []),
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".agents")
|
||||
.map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })),
|
||||
]
|
||||
|
||||
const directories = [
|
||||
globalDirectory,
|
||||
@@ -233,13 +359,13 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
|
||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||
return [
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...claude,
|
||||
...agents,
|
||||
...(supplementary[0] ?? []),
|
||||
...explicit,
|
||||
...direct,
|
||||
...supplementary.slice(1).flat(),
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...content,
|
||||
]
|
||||
})
|
||||
@@ -282,7 +408,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* reconcile(next)
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export * as ConfigAgent from "./agent.js"
|
||||
export * as ConfigAgent from "./agent"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
import { ConfigModel } from "./model.js"
|
||||
import { ConfigProvider } from "./provider.js"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { ConfigProvider } from "./provider"
|
||||
import { ConfigModel } from "./model"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigCommand from "./command.js"
|
||||
export * as ConfigCommand from "./command"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ConfigModel } from "./model.js"
|
||||
import { ConfigModel } from "./model"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Command")({
|
||||
template: Schema.String,
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigCompaction from "./compaction.js"
|
||||
export * as ConfigCompaction from "./compaction"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema.js"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
|
||||
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
|
||||
tokens: NonNegativeInt.pipe(Schema.optional),
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
export * as ConfigExperimental from "./experimental.js"
|
||||
export * as ConfigExperimental from "./experimental"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema.js"
|
||||
import { ConfigPolicy } from "./policy.js"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { ConfigPolicy } from "./policy"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
||||
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigFormatter from "./formatter.js"
|
||||
export * as ConfigFormatter from "./formatter"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigLSP from "./lsp.js"
|
||||
export * as ConfigLSP from "./lsp"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export * as ConfigMCP from "./mcp.js"
|
||||
export * as ConfigMCP from "./mcp"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Mcp } from "../mcp.js"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
|
||||
// The MCP server config is a public wire contract (used by the mcp.add route), so it lives in
|
||||
// @opencode-ai/schema and is re-exported here.
|
||||
export const Timeout = Mcp.TimeoutConfig
|
||||
export type Timeout = Mcp.TimeoutConfig
|
||||
export const Local = Mcp.LocalConfig
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigMedia from "./media.js"
|
||||
export * as ConfigMedia from "./media"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
||||
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigModel from "./model.js"
|
||||
export * as ConfigModel from "./model"
|
||||
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { Model } from "../model.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
|
||||
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
|
||||
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigPlugin from "./plugin.js"
|
||||
export * as ConfigPlugin from "./plugin"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Agent } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigAgent } from "../agent"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
@@ -25,7 +24,7 @@ const legacySources = [
|
||||
const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
|
||||
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
|
||||
type PathAction =
|
||||
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
||||
| typeof ReadTool.name
|
||||
@@ -64,13 +63,13 @@ export const Plugin = define({
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Document => document !== undefined),
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as Document[] }
|
||||
const loaded = { documents: [] as Config.Document[] }
|
||||
const reload = load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.agent.reload()),
|
||||
@@ -140,7 +139,7 @@ export const Plugin = define({
|
||||
|
||||
// Matches anything at or under <root>/{agent,agents,mode,modes}. No file-suffix
|
||||
// check: directory-level events such as renames carry no per-file paths.
|
||||
function isAgentSource(entries: Entry[], file: string) {
|
||||
function isAgentSource(entries: Config.Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
@@ -209,5 +208,5 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
||||
}),
|
||||
)
|
||||
if (!info) return
|
||||
return new Document({ type: "document", path: file.filepath, info })
|
||||
return new Config.Document({ type: "document", path: file.filepath, info })
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Command } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigCommand } from "../command"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
|
||||
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
||||
@@ -28,7 +27,7 @@ export const Plugin = define({
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as { commands: Info["commands"] }[] }
|
||||
const loaded = { documents: [] as { commands: Config.Info["commands"] }[] }
|
||||
const reload = load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
@@ -76,7 +75,7 @@ const sourceDirectories = ["command", "commands"] as const
|
||||
|
||||
// Matches anything at or under <root>/{command,commands}. No file-suffix check:
|
||||
// directory-level events such as renames carry no per-file paths.
|
||||
function isCommandSource(entries: Entry[], file: string) {
|
||||
function isCommandSource(entries: Config.Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ConfigPolicyPlugin from "./policy"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Wildcard } from "../../util/wildcard"
|
||||
@@ -14,7 +13,7 @@ export const Plugin = define({
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
// User-global policy takes priority over policy authored by a repository.
|
||||
const policies = loaded.entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||
for (const record of catalog.provider.list()) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
@@ -108,8 +107,8 @@ export const Plugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
function configuredProviders(entries: readonly Entry[]) {
|
||||
function configuredProviders(entries: readonly Config.Entry[]) {
|
||||
return entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((file) => Object.entries(file.info.providers ?? {}))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
||||
import path from "path"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -20,7 +19,7 @@ export const Plugin = define({
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.reference.transform((draft) => {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
|
||||
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigPolicy from "./policy.js"
|
||||
export * as ConfigPolicy from "./policy"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigProvider from "./provider.js"
|
||||
export * as ConfigProvider from "./provider"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Money } from "../money.js"
|
||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model"
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigReference from "./reference.js"
|
||||
export * as ConfigReference from "./reference"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigToolOutput from "./tool-output.js"
|
||||
export * as ConfigToolOutput from "./tool-output"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
|
||||
max_lines: PositiveInt.pipe(Schema.optional),
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigWarming from "./warming.js"
|
||||
export * as ConfigWarming from "./warming"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigWatcher from "./watcher.js"
|
||||
export * as ConfigWatcher from "./watcher"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigWebSearch from "./websearch.js"
|
||||
export * as ConfigWebSearch from "./websearch"
|
||||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Schema } from "effect"
|
||||
import { WebSearch } from "../websearch.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
|
||||
provider: WebSearch.ID,
|
||||
@@ -3,7 +3,6 @@ export * as LocationWatcher from "./location-watcher"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import path from "path"
|
||||
import { Config } from "../config"
|
||||
import { Bus } from "../bus"
|
||||
@@ -42,7 +41,7 @@ const layer = Layer.effect(
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
const home = Protected.isHome(location.directory)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
ToolSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
||||
|
||||
@@ -3,12 +3,11 @@ export * as MCP from "./index"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { createHash } from "node:crypto"
|
||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Config } from "../config"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { Credential } from "../credential"
|
||||
import { Bus } from "../bus"
|
||||
import { Form } from "../form"
|
||||
@@ -179,7 +178,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
|
||||
|
||||
const documents = (yield* config.entries()).filter((entry): entry is Document => entry.type === "document")
|
||||
const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
||||
// Global MCP timeout defaults, later config files overriding earlier ones.
|
||||
const timeout = Object.assign(
|
||||
{},
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprot
|
||||
import { createServer } from "node:http"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { OauthCallbackPage } from "../oauth/page"
|
||||
import type { Integration } from "../integration"
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { LanguageModel } from "@opencode-ai/ai"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
|
||||
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
@@ -16,7 +18,6 @@ import { Credential } from "./credential"
|
||||
import { Integration } from "./integration"
|
||||
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { PluginHooks } from "./plugin/hooks"
|
||||
import { Provider } from "./provider"
|
||||
|
||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||
@@ -134,11 +135,6 @@ export const withVariant = (
|
||||
export interface Dependencies {
|
||||
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
|
||||
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
|
||||
readonly resolveProvider?: (input: {
|
||||
readonly model: Info
|
||||
readonly credential?: Credential.Value
|
||||
readonly settings: Record<string, unknown>
|
||||
}) => Effect.Effect<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export const fromCatalogModel = (
|
||||
@@ -148,6 +144,8 @@ export const fromCatalogModel = (
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError> => {
|
||||
const resolved = produce(model, (draft) => {
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
||||
})
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
@@ -166,11 +164,21 @@ export const fromCatalogModel = (
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (
|
||||
Provider.isAISDK(resolved.package) &&
|
||||
packageName === "@ai-sdk/openai-compatible" &&
|
||||
typeof resolved.settings?.baseURL === "string"
|
||||
) {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||
const mapping = Provider.isAISDK(resolved.package)
|
||||
? AISDKNative.map({
|
||||
packageName,
|
||||
providerID: resolved.providerID,
|
||||
settings: configured,
|
||||
modelID: resolved.modelID ?? resolved.id,
|
||||
})
|
||||
@@ -249,25 +257,7 @@ export const resolveModel = (
|
||||
variant: VariantID | undefined,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) =>
|
||||
withVariant(model, variant).pipe(
|
||||
Effect.flatMap((model) => {
|
||||
if (!dependencies?.resolveProvider) return fromCatalogModel(model, credential, dependencies)
|
||||
return dependencies
|
||||
.resolveProvider({ model, credential, settings: { ...model.settings, ...credential?.metadata } })
|
||||
.pipe(
|
||||
Effect.flatMap((settings) =>
|
||||
fromCatalogModel(
|
||||
produce(model, (draft) => {
|
||||
draft.settings = settings
|
||||
}),
|
||||
credential,
|
||||
dependencies,
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
) => withVariant(model, variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)))
|
||||
|
||||
export const supported = (model: Info) => Boolean(model.package)
|
||||
|
||||
@@ -279,7 +269,6 @@ export const layer = Layer.effect(
|
||||
const integrations = yield* Integration.Service
|
||||
const npm = yield* Npm.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
|
||||
const provider = yield* catalog.provider.get(selected.providerID)
|
||||
const connection = yield* integrations.connection.active(
|
||||
@@ -292,8 +281,6 @@ export const layer = Layer.effect(
|
||||
{
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
resolveProvider: (input) =>
|
||||
hooks.trigger("provider", "resolve", input).pipe(Effect.map((event) => event.settings)),
|
||||
},
|
||||
)
|
||||
return {
|
||||
@@ -331,5 +318,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node, PluginHooks.node],
|
||||
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as PluginHooks from "./hooks"
|
||||
|
||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||
import type { ProviderHooks } from "@opencode-ai/plugin/effect/provider"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
@@ -11,7 +10,6 @@ import { State } from "../state"
|
||||
|
||||
export interface Domains {
|
||||
readonly aisdk: AISDKHooks
|
||||
readonly provider: ProviderHooks
|
||||
readonly session: SessionHooks
|
||||
readonly shell: ShellHooks
|
||||
readonly tool: ToolHooks
|
||||
|
||||
@@ -273,9 +273,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
plugin: {
|
||||
list: () => response(plugin.list()),
|
||||
},
|
||||
provider: {
|
||||
hook: (name, callback) => hooks.register("provider", name, callback),
|
||||
},
|
||||
reference: {
|
||||
list: () => response(reference.list()),
|
||||
reload: reference.reload,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Permission } from "../permission"
|
||||
import { Reference } from "../reference"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { WebSearchPreference } from "../websearch-preference"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
@@ -89,6 +90,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const read = yield* ReadToolFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const websearchPreference = yield* WebSearchPreference.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const shell = yield* Shell.Service
|
||||
@@ -121,6 +123,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(ReadToolFileSystem.Service, read),
|
||||
Context.make(Reference.Service, reference),
|
||||
Context.make(WebSearch.Service, websearch),
|
||||
Context.make(WebSearchPreference.Service, websearchPreference),
|
||||
Context.make(Ripgrep.Service, ripgrep),
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(Shell.Service, shell),
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as PluginPromise from "./promise"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
|
||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
@@ -57,6 +58,62 @@ export function fromPromise(plugin: Plugin) {
|
||||
}),
|
||||
)
|
||||
|
||||
function sessionHook<Name extends keyof SessionHooks>(
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
): Promise<Registration>
|
||||
function sessionHook(
|
||||
...registration: {
|
||||
[Name in keyof SessionHooks]: [
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
]
|
||||
}[keyof SessionHooks]
|
||||
) {
|
||||
if (registration[0] !== "http")
|
||||
return register(
|
||||
host.session.hook(registration[0], (event) =>
|
||||
Effect.promise(() => Promise.resolve(registration[1](event))),
|
||||
),
|
||||
)
|
||||
return register(
|
||||
host.session.hook("http", (event) => {
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const output: SessionHttp = {
|
||||
...event,
|
||||
use: (item) => {
|
||||
middlewares.push(item)
|
||||
},
|
||||
}
|
||||
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.forEach(
|
||||
middlewares,
|
||||
(item) =>
|
||||
event.use((input, next) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => {
|
||||
const inputSignal = AbortSignal.any([signal, input.signal])
|
||||
return Promise.resolve(
|
||||
item(new Request(input, { signal: inputSignal }), (request) => {
|
||||
const requestSignal = AbortSignal.any([signal, request.signal])
|
||||
return Effect.runPromiseWith(
|
||||
context,
|
||||
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
|
||||
}),
|
||||
)
|
||||
},
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
}),
|
||||
),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const context2: Context = {
|
||||
app: host.app,
|
||||
options: host.options,
|
||||
@@ -84,10 +141,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
transform: transform(host.catalog),
|
||||
reload: () => run(host.catalog.reload()),
|
||||
},
|
||||
provider: {
|
||||
hook: (name, callback) =>
|
||||
register(host.provider.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
command: {
|
||||
list: (input) => run(host.command.list(input)),
|
||||
transform: transform(host.command),
|
||||
@@ -269,8 +322,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
hook: sessionHook,
|
||||
create: (input) =>
|
||||
run(
|
||||
host.session.create(
|
||||
|
||||
@@ -14,34 +14,72 @@ export const CloudflareWorkersAIPlugin = define({
|
||||
if (!item) return
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (!Provider.isAISDK(provider.package)) return
|
||||
const baseURL = resolveBaseURL(provider.settings ?? {})
|
||||
if (baseURL) provider.settings = { ...provider.settings, baseURL }
|
||||
provider.headers = Provider.mergeHeaders(provider.headers, {
|
||||
"User-Agent": `${App.useragent(ctx.app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
})
|
||||
if (typeof provider.settings?.baseURL === "string") return
|
||||
const accountId = resolveAccountId(provider.settings ?? {})
|
||||
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
|
||||
})
|
||||
})
|
||||
yield* ctx.provider.hook(
|
||||
"resolve",
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
const baseURL = resolveBaseURL(evt.settings)
|
||||
if (baseURL) evt.settings.baseURL = baseURL
|
||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
|
||||
const accountId = resolveAccountId(evt.options)
|
||||
if (!hasWorkersEndpoint(evt.model) && !accountId) return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible(
|
||||
sdkOptions(
|
||||
{
|
||||
...evt.options,
|
||||
baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
|
||||
},
|
||||
ctx.app,
|
||||
) as any,
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function resolveBaseURL(options: Record<string, unknown>) {
|
||||
const baseURL = stringOption(options, "baseURL")
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
|
||||
if (!accountId) return baseURL
|
||||
if (baseURL) return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", encodeURIComponent(accountId))
|
||||
return workersEndpoint(accountId)
|
||||
function resolveAccountId(options: Record<string, unknown>) {
|
||||
return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
|
||||
}
|
||||
|
||||
function workersEndpoint(accountId: string) {
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/v1`
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
|
||||
}
|
||||
|
||||
function hasWorkersEndpoint(model: {
|
||||
readonly package?: string
|
||||
readonly settings?: Readonly<Record<string, unknown>>
|
||||
}) {
|
||||
return Provider.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
|
||||
}
|
||||
|
||||
function sdkOptions(options: Record<string, any>, app: App.Info) {
|
||||
return {
|
||||
...options,
|
||||
baseURL: expandAccountId(options.baseURL),
|
||||
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
|
||||
headers: {
|
||||
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
...options.headers,
|
||||
},
|
||||
name: providerID,
|
||||
}
|
||||
}
|
||||
|
||||
function expandAccountId(baseURL: unknown) {
|
||||
if (typeof baseURL !== "string") return baseURL
|
||||
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}")
|
||||
}
|
||||
|
||||
function stringOption(options: Record<string, unknown>, key: string) {
|
||||
|
||||
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
|
||||
}
|
||||
draft.cost = []
|
||||
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
||||
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("http.request", (evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||
const url = new URL(evt.request.url)
|
||||
evt.request.headers.set("originator", "opencode")
|
||||
evt.request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return
|
||||
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
||||
yield* ctx.session.hook("http", (evt) =>
|
||||
evt.use((request, next) => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
|
||||
const url = new URL(request.url)
|
||||
request.headers.set("originator", "opencode")
|
||||
request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return next(request)
|
||||
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
@@ -10,6 +9,7 @@ import { Agent } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Command } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { ConfigPlugin } from "../config/plugin"
|
||||
import { Credential } from "../credential"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
@@ -39,6 +39,7 @@ import { Skill } from "../skill"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { Tool } from "../tool"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { WebSearchPreference } from "../websearch-preference"
|
||||
import { WellKnown } from "../wellknown"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
@@ -83,15 +84,15 @@ function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Config.Entry[]) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
entries.filter((entry): entry is Config.Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
@@ -208,7 +209,7 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
function isPluginSource(entries: readonly Config.Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
@@ -243,7 +244,7 @@ const layer = Layer.effect(
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
|
||||
entries: readonly Entry[],
|
||||
entries: readonly Config.Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
@@ -345,6 +346,7 @@ export const node = makeLocationNode({
|
||||
Tool.node,
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
WebSearchPreference.node,
|
||||
WellKnown.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "../config"
|
||||
import { Bus } from "../bus"
|
||||
@@ -149,9 +148,9 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
return ""
|
||||
}
|
||||
|
||||
const settings = (documents: readonly Entry[]) => {
|
||||
const settings = (documents: readonly Config.Entry[]) => {
|
||||
const configured = documents
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
||||
return {
|
||||
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
@@ -229,31 +230,44 @@ export const layer = Layer.effect(
|
||||
const options: StreamOptions = {
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
let latest = request
|
||||
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const web = yield* HttpClientRequest.toWeb(request)
|
||||
yield* hooks.trigger("session", "http", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: yield* HttpClientRequest.toWeb(request),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
})
|
||||
let sent = HttpClientRequest.fromWeb(before.request)
|
||||
if (before.request.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||
before.request.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
const response = yield* handler(sent)
|
||||
const after = yield* hooks.trigger("session", "http.response", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: before.request,
|
||||
response: new Response(
|
||||
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
||||
{ status: response.status, headers: response.headers },
|
||||
),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
const send = (input: Request) =>
|
||||
Effect.gen(function* () {
|
||||
let sent = HttpClientRequest.fromWeb(input)
|
||||
if (input.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
|
||||
input.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
latest = sent
|
||||
const response = yield* handler(sent)
|
||||
const body = [204, 205, 304].includes(response.status)
|
||||
? null
|
||||
: yield* Stream.toReadableStreamEffect(response.stream)
|
||||
const output = new Response(body, { status: response.status, headers: response.headers })
|
||||
origins.set(output, sent)
|
||||
return output
|
||||
})
|
||||
const dispatch = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
send,
|
||||
)
|
||||
const response = yield* dispatch(web)
|
||||
const origin = origins.get(response) ?? latest
|
||||
return HttpClientResponse.fromWeb(origin, response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
|
||||
@@ -4,9 +4,9 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../../form"
|
||||
import { KV } from "../../kv"
|
||||
import { Permission } from "../../permission"
|
||||
import { WebSearch } from "../../websearch"
|
||||
import { WebSearchPreference } from "../../websearch-preference"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
@@ -28,7 +28,7 @@ export const Plugin = {
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const kv = yield* KV.Service
|
||||
const preference = yield* WebSearchPreference.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
@@ -52,65 +52,77 @@ export const Plugin = {
|
||||
const result = yield* ctx.websearch.query(input).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return Effect.gen(function* () {
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
return preference
|
||||
.synchronized(
|
||||
Effect.gen(function* () {
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const stored = yield* preference.get()
|
||||
if (stored === false) return yield* new WebSearch.DisabledError()
|
||||
if (typeof stored === "string" && providers.some((provider) => provider.id === stored))
|
||||
return yield* Effect.void
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
value: "allow",
|
||||
label: `Allow web search via ${defaultProvider.name}`,
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: `Allow web search via ${defaultProvider.name}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (response.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* kv.set("websearch:provider", false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
response.answer.choice === "choose"
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: providers.map((provider) => ({ value: provider.id, label: provider.name })),
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined
|
||||
if (selection?.status === "cancelled") return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* kv.set("websearch:provider", providerID)
|
||||
return yield* ctx.websearch.query(input)
|
||||
})
|
||||
})
|
||||
if (response.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* preference.set(false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
response.answer.choice === "choose"
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined
|
||||
if (selection?.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
return yield* preference.set(providerID)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.andThen(ctx.websearch.query(input)))
|
||||
}),
|
||||
)
|
||||
const output = {
|
||||
@@ -140,7 +152,7 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name]
|
||||
if ((yield* preference.get()) === false) delete event.tools[name]
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigV1 from "./config"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
|
||||
import { ConfigReference } from "../../config/reference"
|
||||
import { ConfigAgentV1 } from "./agent"
|
||||
import { ConfigAttachmentV1 } from "./attachment"
|
||||
import { ConfigCommandV1 } from "./command"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export * as WebSearchPreference from "./websearch-preference"
|
||||
|
||||
import { Context, Effect, Layer, Semaphore } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { KV } from "./kv"
|
||||
|
||||
const key = "websearch:provider"
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<KV.Value | undefined>
|
||||
readonly set: (value: KV.Value) => Effect.Effect<void>
|
||||
readonly synchronized: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/WebSearchPreference") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
return Service.of({
|
||||
get: () => kv.get(key),
|
||||
set: (value) => kv.set(key, value),
|
||||
synchronized: (effect) => lock.withPermit(effect),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node] })
|
||||
@@ -4,8 +4,8 @@ import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { KV } from "./kv"
|
||||
import { State } from "./state"
|
||||
import { WebSearchPreference } from "./websearch-preference"
|
||||
|
||||
export const ID = WebSearch.ID
|
||||
export type ID = WebSearch.ID
|
||||
@@ -75,7 +75,7 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const kv = yield* KV.Service
|
||||
const preference = yield* WebSearchPreference.Service
|
||||
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
@@ -98,7 +98,7 @@ const layer = Layer.effect(
|
||||
const data = state.get()
|
||||
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
|
||||
if (configured) return configured
|
||||
const stored = yield* kv.get("websearch:provider")
|
||||
const stored = yield* preference.get()
|
||||
if (stored === false) return yield* new DisabledError()
|
||||
if (typeof stored !== "string") return
|
||||
return data.providers.get(ID.make(stored))
|
||||
@@ -140,5 +140,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, KV.node],
|
||||
deps: [Bus.node, WebSearchPreference.node],
|
||||
})
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
|
||||
|
||||
const map = (
|
||||
packageName: string,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
modelID = "test-model",
|
||||
providerID = "test-provider",
|
||||
) => AISDKNative.map({ packageName, providerID, settings, modelID })
|
||||
const map = (packageName: string, settings: Readonly<Record<string, unknown>>, modelID = "test-model") =>
|
||||
AISDKNative.map({ packageName, settings, modelID })
|
||||
|
||||
describe("AISDKNative", () => {
|
||||
test("maps both models.dev Bedrock packages to native providers", () => {
|
||||
@@ -45,52 +41,6 @@ describe("AISDKNative", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("maps Cloudflare Workers AI to the generic OpenAI-compatible provider", () => {
|
||||
expect(
|
||||
map(
|
||||
"@ai-sdk/openai-compatible",
|
||||
{
|
||||
accountId: "account/id",
|
||||
apiKey: "secret",
|
||||
baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1",
|
||||
headers: { "x-custom": "value" },
|
||||
queryParams: { version: "preview" },
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
"@cf/model",
|
||||
"cloudflare-workers-ai",
|
||||
),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1",
|
||||
provider: "cloudflare-workers-ai",
|
||||
queryParams: { version: "preview" },
|
||||
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||
},
|
||||
headers: { "x-custom": "value" },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps generic OpenAI-compatible providers to the native package", () => {
|
||||
expect(
|
||||
map("@ai-sdk/openai-compatible", {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://provider.example/v1",
|
||||
reasoningEffort: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://provider.example/v1",
|
||||
provider: "test-provider",
|
||||
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Bedrock provider and request options", () => {
|
||||
expect(
|
||||
map(
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Effect, Fiber, Schema, Stream } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Directory, Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -20,7 +19,7 @@ import { testEffect } from "../lib/effect"
|
||||
import { agentHost, host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
|
||||
...Agent.Info.default(Agent.ID.make("test")).permissions,
|
||||
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
|
||||
@@ -72,7 +71,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
const entries = [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
|
||||
@@ -93,7 +92,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
@@ -154,7 +153,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const entries = [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
agents: {
|
||||
@@ -174,7 +173,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
agents: {
|
||||
@@ -219,7 +218,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
yield* agents.transform((editor) => editor.update(build, () => {}))
|
||||
|
||||
const entries = [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({ agents: { build: { disabled: true } } }),
|
||||
}),
|
||||
@@ -277,7 +276,7 @@ Use native v2 fields.`,
|
||||
const agents = yield* Agent.Service
|
||||
const global = yield* Global.Service
|
||||
const entries = [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({ agents: { reviewer: { description: "JSON description" } } }),
|
||||
}),
|
||||
@@ -426,7 +425,7 @@ Use native v2 fields.`,
|
||||
})
|
||||
|
||||
function directoryEntry(directory: string) {
|
||||
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
return new Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
}
|
||||
|
||||
function sourceCases() {
|
||||
@@ -523,7 +522,7 @@ function loadHomePermissions(home: string) {
|
||||
const build = Agent.ID.make("build")
|
||||
yield* agents.transform((editor) => editor.update(build, () => {}))
|
||||
const entries = [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode(
|
||||
ConfigMigrateV1.migrate({
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
@@ -35,7 +35,7 @@ const it = testEffect(
|
||||
[Location.node, testLocationLayer],
|
||||
]),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
it.live("loads inline and file-based commands in config order", () =>
|
||||
@@ -63,7 +63,7 @@ Review files`,
|
||||
|
||||
const command = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const update = yield* bus.publish(Event.Updated, {})
|
||||
const update = yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
const updates = yield* PubSub.unbounded<typeof update>()
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
@@ -77,11 +77,11 @@ Review files`,
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({ commands: { review: { template: "Inline review" } } }),
|
||||
}),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
|
||||
]),
|
||||
),
|
||||
)
|
||||
@@ -333,7 +333,7 @@ function watchReady(config: Config.Interface, directory: string) {
|
||||
}
|
||||
|
||||
function directoryEntry(directory: string) {
|
||||
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
return new Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
}
|
||||
|
||||
function sourceCases() {
|
||||
|
||||
@@ -4,9 +4,9 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigModel } from "@opencode-ai/schema/config/model"
|
||||
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
|
||||
import { ConfigModel } from "@opencode-ai/core/config/model"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -161,7 +161,7 @@ describe("Config", () => {
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
const changed = yield* bus
|
||||
.subscribe(Event.Updated)
|
||||
.subscribe(ConfigSchema.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
@@ -234,14 +234,14 @@ describe("Config", () => {
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
const removed = yield* bus
|
||||
.subscribe(Event.Updated)
|
||||
.subscribe(ConfigSchema.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => fs.rm(file))
|
||||
yield* Fiber.join(removed).pipe(Effect.timeout("5 seconds"))
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBeUndefined()
|
||||
|
||||
const recreated = yield* bus
|
||||
.subscribe(Event.Updated)
|
||||
.subscribe(ConfigSchema.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "two" })))
|
||||
yield* Fiber.join(recreated).pipe(Effect.timeout("5 seconds"))
|
||||
@@ -273,7 +273,7 @@ describe("Config", () => {
|
||||
const test = yield* Config.Test
|
||||
expect(yield* config.entries()).toEqual([])
|
||||
|
||||
const entry = new Document({ type: "document", info: new Info({}) })
|
||||
const entry = new Config.Document({ type: "document", info: new Config.Info({}) })
|
||||
yield* test.setEntries([entry])
|
||||
expect(yield* config.entries()).toEqual([entry])
|
||||
|
||||
@@ -289,16 +289,16 @@ describe("Config", () => {
|
||||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
}),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
|
||||
new Document({ type: "document", info: new Info({}) }),
|
||||
new Document({
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
|
||||
new Config.Document({ type: "document", info: new Config.Info({}) }),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
info: new Config.Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -372,7 +372,7 @@ describe("Config", () => {
|
||||
const bus = yield* Bus.Service
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
|
||||
const updated = yield* bus
|
||||
.subscribe(Event.Updated)
|
||||
.subscribe(ConfigSchema.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
key = "next"
|
||||
@@ -418,7 +418,7 @@ describe("Config", () => {
|
||||
Schema.encodeUnknownSync(Schema.UnknownFromJsonString)(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
@@ -661,45 +661,13 @@ describe("Config", () => {
|
||||
const entries = yield* config.entries()
|
||||
|
||||
expect(entries).toEqual([
|
||||
new Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
|
||||
])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("deduplicates global ecosystem directories found during upward discovery", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(global, "home")
|
||||
const project = path.join(home, "project")
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.mkdir(path.join(home, ".claude"), { recursive: true }),
|
||||
fs.mkdir(path.join(home, ".agents"), { recursive: true }),
|
||||
fs.mkdir(project, { recursive: true }),
|
||||
]),
|
||||
)
|
||||
const entries = yield* Config.Service.use((config) => config.entries()).pipe(
|
||||
Effect.provide(testLayer(project, global)),
|
||||
)
|
||||
|
||||
expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
|
||||
AbsolutePath.make(path.join(home, ".claude")),
|
||||
])
|
||||
expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
|
||||
AbsolutePath.make(path.join(home, ".agents")),
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not watch ecosystem config roots", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -761,7 +729,7 @@ describe("Config", () => {
|
||||
expect(documents).toHaveLength(2)
|
||||
expect(documents.map((document) => document.type)).toEqual(["document", "document"])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
|
||||
expect(documents[0]).toBeInstanceOf(Document)
|
||||
expect(documents[0]).toBeInstanceOf(Config.Document)
|
||||
expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
|
||||
expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
|
||||
|
||||
@@ -1209,7 +1177,7 @@ describe("Config", () => {
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents).toHaveLength(1)
|
||||
expect(documents[0]?.info).toBeInstanceOf(Info)
|
||||
expect(documents[0]?.info).toBeInstanceOf(Config.Info)
|
||||
expect(documents[0]?.info.shell).toBe("/bin/zsh")
|
||||
expect(documents[0]?.info.default_agent).toBe("reviewer")
|
||||
expect(documents[0]?.info.snapshots).toBe(false)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigModel } from "@opencode-ai/schema/config/model"
|
||||
import { ConfigModel } from "@opencode-ai/core/config/model"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigPolicyPlugin } from "@opencode-ai/core/config/plugin/policy"
|
||||
@@ -12,10 +12,10 @@ import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
experimental: {
|
||||
@@ -24,7 +24,7 @@ const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const addPlugin = Effect.fn(function* (entries: Entry[]) {
|
||||
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
|
||||
@@ -78,7 +78,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
|
||||
|
||||
yield* test.setEntries([policies({ effect: "allow", resource: "openai" })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* waitUntil(catalog.provider.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)))
|
||||
}).pipe(Effect.provide(Config.testLayer([policies({ effect: "deny", resource: "openai" })]))),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
@@ -15,7 +14,7 @@ import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* (entries: Entry[]) {
|
||||
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
|
||||
@@ -47,7 +46,7 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
)
|
||||
}
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("defaults custom models to agent capabilities", () =>
|
||||
@@ -56,7 +55,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.make("custom")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const entries = [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -91,7 +90,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
})
|
||||
})
|
||||
const entries = [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -130,7 +129,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.opencode
|
||||
const modelID = Model.ID.make("alpha-gpt-next")
|
||||
const entries = [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -179,7 +178,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.opencode
|
||||
const modelID = Model.ID.make("alpha-gpt-next")
|
||||
const entries = [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -190,7 +189,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -224,7 +223,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.make("custom")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const entries = [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/first",
|
||||
@@ -256,7 +255,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/default",
|
||||
@@ -290,7 +289,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
@@ -22,7 +22,7 @@ import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
describe("config plugin reloads", () => {
|
||||
@@ -54,7 +54,7 @@ describe("config plugin reloads", () => {
|
||||
|
||||
yield* test.setEntries([config("second")])
|
||||
yield* Effect.yieldNow
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* waitUntil(
|
||||
Effect.gen(function* () {
|
||||
return (
|
||||
@@ -83,7 +83,7 @@ describe("config plugin reloads", () => {
|
||||
})
|
||||
|
||||
function config(name: string) {
|
||||
return new Document({
|
||||
return new Config.Document({
|
||||
type: "document",
|
||||
path: document,
|
||||
info: decode({
|
||||
|
||||
@@ -2,7 +2,6 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AgentsDirectory, ClaudeDirectory, Directory, Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -13,7 +12,7 @@ import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigSkillPlugin.Plugin", () => {
|
||||
it.effect("registers configured skill directories and URLs", () =>
|
||||
@@ -44,10 +43,10 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
|
||||
new Document({
|
||||
new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
|
||||
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Duration, Schema } from "effect"
|
||||
import { Info } from "@opencode-ai/schema/config"
|
||||
import { Config } from "../../src/config"
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("config warming", () => {
|
||||
test("accepts boolean enablement", () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { Config } from "../src/config"
|
||||
import { Formatter } from "../src/formatter"
|
||||
import { Location } from "../src/location"
|
||||
@@ -14,16 +13,16 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
type ConfigInput = typeof Config.Info.Encoded
|
||||
|
||||
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
|
||||
info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
|
||||
}),
|
||||
]
|
||||
return AppNodeBuilder.build(Formatter.node, [
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Generate } from "@opencode-ai/core/generate"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { ID, Info, Ref } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
@@ -66,13 +65,9 @@ const aisdk = Layer.mock(AISDK.Service, {
|
||||
},
|
||||
model: () => Effect.succeed(runtime),
|
||||
})
|
||||
const hooks = Layer.mock(PluginHooks.Service, {
|
||||
register: () => Effect.die("unused"),
|
||||
trigger: (_domain, _name, event) => Effect.succeed(event),
|
||||
})
|
||||
const client = TestLLM.clientLayer.pipe(Layer.provide(TestLLM.layer({ fallback: TestLLM.text("OK", "generate") })))
|
||||
|
||||
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk, hooks)))
|
||||
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
|
||||
const resolverIt = testEffect(resolver)
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -163,9 +162,9 @@ function resourceMcpLayer(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Config.testLayer([
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
info: new Config.Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
servers: {
|
||||
resources:
|
||||
|
||||
@@ -131,56 +131,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes Cloudflare Workers AI through the generic OpenAI-compatible provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.resolveModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
providerID: Provider.ID.make("cloudflare-workers-ai"),
|
||||
modelID: "@cf/meta/llama-3.1-8b-instruct",
|
||||
settings: {
|
||||
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
queryParams: { version: "preview" },
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "account/id" } }),
|
||||
{
|
||||
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
|
||||
resolveProvider: (input) =>
|
||||
Effect.succeed({
|
||||
...input.settings,
|
||||
baseURL: String(input.settings.baseURL).replace(
|
||||
"${CLOUDFLARE_ACCOUNT_ID}",
|
||||
encodeURIComponent(String(input.settings.accountId)),
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://example.com",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route.id).toBe("openai-compatible-chat")
|
||||
expect(String(resolved.provider)).toBe("cloudflare-workers-ai")
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1")
|
||||
expect(resolved.route.endpoint.query).toEqual({ version: "preview" })
|
||||
expect(resolved.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
|
||||
expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } })
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
expect(prepared.body).toMatchObject({
|
||||
reasoning_effort: "high",
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
expect(prepared.body).not.toHaveProperty("accountId")
|
||||
expect(headers.authorization).toBe("Bearer secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
@@ -201,9 +151,6 @@ describe("ModelResolver", () => {
|
||||
http: { body: { custom_extension: { enabled: true } } },
|
||||
},
|
||||
})
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
expect(prepared.body.max_output_tokens).toBeUndefined()
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -406,7 +353,7 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps key credential metadata out of request bodies", () =>
|
||||
it.effect("prefers stored credentials over configured auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -426,7 +373,7 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
|
||||
expect(headers.authorization).toBe("Bearer stored-secret")
|
||||
expect(resolved.route.defaults.http?.body).toEqual({})
|
||||
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -29,9 +29,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
aisdk: overrides.aisdk ?? {
|
||||
hook: () => Effect.die("unused aisdk.hook"),
|
||||
},
|
||||
provider: overrides.provider ?? {
|
||||
hook: () => Effect.die("unused provider.hook"),
|
||||
},
|
||||
catalog: overrides.catalog ?? {
|
||||
provider: {
|
||||
list: () => Effect.die("unused catalog.provider.list"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import { host as testHost } from "./host"
|
||||
@@ -223,45 +223,102 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts promise session HTTP request and response hooks", () =>
|
||||
it.effect("adapts promise session HTTP hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const bodies: string[] = []
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-http",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use(async (request, next) => {
|
||||
request.headers.set("x-hook", "promise")
|
||||
await next(request)
|
||||
const response = await next(request)
|
||||
return new Response(`${await response.text()}-response`)
|
||||
})
|
||||
})
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use(async (request, next) => {
|
||||
const response = await next(request)
|
||||
return new Response(`${await response.text()}-outer`)
|
||||
})
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
const event: PluginHooks.Domains["session"]["http"] = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "http", event)
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
(input: Request) =>
|
||||
Effect.promise(() => input.text()).pipe(
|
||||
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
|
||||
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
|
||||
),
|
||||
)
|
||||
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
|
||||
|
||||
expect(bodies).toEqual(["payload", "payload"])
|
||||
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-http",
|
||||
id: "promise-session-http-interrupt",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http.request", (event) => {
|
||||
event.request = new Request("https://provider.test/changed", event.request)
|
||||
event.request.headers.set("x-hook", "promise")
|
||||
})
|
||||
await ctx.session.hook("http.response", async (event) => {
|
||||
event.response = new Response(`${await event.response.text()}-response`, {
|
||||
status: event.response.status,
|
||||
})
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use((request, next) => next(request))
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||
const started = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
const event: PluginHooks.Domains["session"]["http"] = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
}
|
||||
|
||||
const request = yield* hooks.trigger("session", "http.request", {
|
||||
...context,
|
||||
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
|
||||
})
|
||||
const response = yield* hooks.trigger("session", "http.response", {
|
||||
...context,
|
||||
request: request.request,
|
||||
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
|
||||
})
|
||||
yield* hooks.trigger("session", "http", event)
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
() =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||
),
|
||||
)
|
||||
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
expect(request.request.url).toBe("https://provider.test/changed")
|
||||
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
|
||||
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { ID, Info } from "@opencode-ai/core/model"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -15,7 +15,9 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
yield* CloudflareWorkersAIPlugin.effect(yield* PluginHost.make(plugin))
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* CloudflareWorkersAIPlugin.effect(host)
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
@@ -23,130 +25,243 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(value: string | undefined, effect: () => Effect.Effect<A, E, R>) {
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
if (value === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
else process.env.CLOUDFLARE_ACCOUNT_ID = value
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
else process.env.CLOUDFLARE_ACCOUNT_ID = previous
|
||||
}),
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
|
||||
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
|
||||
modelID,
|
||||
)
|
||||
}
|
||||
|
||||
type CloudflareConfig = {
|
||||
url: (input: { path: string; modelId: string }) => string
|
||||
headers: () => Record<string, string> | Promise<Record<string, string>>
|
||||
}
|
||||
|
||||
function cloudflareURL(sdk: unknown, modelID = "@cf/model") {
|
||||
return cloudflareLanguage(sdk, modelID).config.url({ path: "/chat/completions", modelId: modelID })
|
||||
}
|
||||
|
||||
function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
|
||||
return cloudflareLanguage(sdk, modelID).config.headers()
|
||||
}
|
||||
|
||||
describe("CloudflareWorkersAIPlugin", () => {
|
||||
it.effect("resolves the account environment variable into the native endpoint", () =>
|
||||
withEnv("account/id", () =>
|
||||
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) =>
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.package = Provider.aisdk("test-provider")
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
|
||||
const sdk = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
|
||||
modelID: Model.ID.make("@cf/model"),
|
||||
package: provider.package,
|
||||
settings: provider.settings,
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
|
||||
})
|
||||
expect(provider).toMatchObject({
|
||||
package: "aisdk:test-provider",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
|
||||
})
|
||||
expect(sdk.sdk).toBeDefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(required(yield* catalog.provider.get(providerID))).toMatchObject({
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/account%2Fid/ai/v1" },
|
||||
headers: { "User-Agent": expect.stringContaining("cloudflare-workers-ai") },
|
||||
it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.package = Provider.aisdk("test-provider")
|
||||
provider.settings = { ...provider.settings, baseURL: "https://proxy.example/v1" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
|
||||
package: "aisdk:test-provider",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("resolves an account ID from provider settings", () =>
|
||||
withEnv(undefined, () =>
|
||||
it.effect("allows a configured baseURL without account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) =>
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { accountId: "configured/account" }
|
||||
}),
|
||||
)
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
|
||||
"https://api.cloudflare.com/client/v4/accounts/configured%2Faccount/ai/v1",
|
||||
)
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
|
||||
modelID: Model.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
|
||||
})
|
||||
expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("resolves an account ID from credential metadata before native routing", () =>
|
||||
withEnv(undefined, () =>
|
||||
it.effect("uses env account ID over configured account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("provider", "resolve", {
|
||||
model: Info.make({
|
||||
id: ID.make("model"),
|
||||
modelID: ID.make("@cf/model"),
|
||||
providerID,
|
||||
name: "Model",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: {},
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 8_192 },
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.package = Provider.aisdk("test-provider")
|
||||
provider.settings = { ...provider.settings, accountId: "configured-acct" }
|
||||
}),
|
||||
credential: Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "stored/account" } }),
|
||||
settings: {
|
||||
accountId: "stored/account",
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
|
||||
package: "aisdk:test-provider",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
|
||||
modelID: Model.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
name: "cloudflare-workers-ai",
|
||||
apiKey: "auth-key",
|
||||
baseURL: "https://proxy.example/v1",
|
||||
headers: { custom: "header" },
|
||||
},
|
||||
})
|
||||
const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk)))
|
||||
expect(headers.authorization).toBe("Bearer env-key")
|
||||
expect(headers.custom).toBe("header")
|
||||
expect(headers["user-agent"]).toMatch(/^opencode\/.* cloudflare-workers-ai \(.+\) ai-sdk\/openai-compatible\//)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("expands account ID vars in endpoint URLs", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
|
||||
modelID: Model.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
name: "cloudflare-workers-ai",
|
||||
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
},
|
||||
})
|
||||
|
||||
expect(event.settings.baseURL).toBe(
|
||||
"https://api.cloudflare.com/client/v4/accounts/stored%2Faccount/ai/v1",
|
||||
expect(cloudflareURL(result.sdk)).toBe(
|
||||
"https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("expands account placeholders and preserves configured endpoints", () =>
|
||||
withEnv("env-account", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) =>
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = {
|
||||
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
|
||||
"https://api.cloudflare.com/client/v4/accounts/env-account/ai/v1",
|
||||
)
|
||||
}),
|
||||
),
|
||||
it.effect("selects languageModel with the API model ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("alias")),
|
||||
modelID: Model.ID.make("@cf/api-model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
expect(result.language).toBeDefined()
|
||||
expect(calls).toEqual(["languageModel:@cf/api-model"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a custom endpoint when an account ID is configured", () =>
|
||||
withEnv(undefined, () =>
|
||||
it.effect("does not create an SDK for non OpenAI-compatible packages", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((draft) =>
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { accountId: "configured-account", baseURL: "https://proxy.example/v1" }
|
||||
}),
|
||||
)
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe("https://proxy.example/v1")
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
|
||||
modelID: Model.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/anthropic",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
}),
|
||||
package: "@ai-sdk/anthropic",
|
||||
options: { name: "cloudflare-workers-ai" },
|
||||
})
|
||||
expect(result.sdk).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -30,13 +31,26 @@ function required<T>(value: T | undefined): T {
|
||||
}
|
||||
|
||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
request: new Request(url, { method: "POST", body: "{}" }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
})
|
||||
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
|
||||
const request = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
(input: Request) => {
|
||||
const headers = new Headers(input.headers)
|
||||
headers.set("x-seen-url", input.url)
|
||||
return Effect.succeed(new Response(null, { headers }))
|
||||
},
|
||||
)
|
||||
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
|
||||
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
|
||||
})
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
@@ -126,7 +140,7 @@ describe("OpenAIPlugin", () => {
|
||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(eligible.cost).toEqual([])
|
||||
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||
false,
|
||||
@@ -135,14 +149,14 @@ describe("OpenAIPlugin", () => {
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
||||
context: 400_000,
|
||||
context: 272_000,
|
||||
input: 272_000,
|
||||
output: 64_000,
|
||||
})
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -214,7 +213,7 @@ const configuredIt = testEffect(
|
||||
entries: () =>
|
||||
Effect.succeed(
|
||||
configuredShell
|
||||
? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })]
|
||||
? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })]
|
||||
: [],
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -255,7 +255,6 @@ describe("SessionRunnerLLM recorded", () => {
|
||||
describe("SessionModelRequest HTTP bridge", () => {
|
||||
const bodies: Uint8Array[] = []
|
||||
const methods: string[] = []
|
||||
const headers: Array<string | undefined> = []
|
||||
const response = [
|
||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
||||
@@ -269,7 +268,6 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
methods.push(request.method)
|
||||
bodies.push(request.body.body.slice())
|
||||
headers.push(request.headers["x-hook"])
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
||||
@@ -277,16 +275,14 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
const httpIt = testEffect(
|
||||
const retryIt = testEffect(
|
||||
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
||||
)
|
||||
|
||||
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
|
||||
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
|
||||
Effect.gen(function* () {
|
||||
bodies.length = 0
|
||||
methods.length = 0
|
||||
headers.length = 0
|
||||
const seen: string[] = []
|
||||
const agents = yield* Agent.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -301,20 +297,13 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
catalog: catalogHost(catalog),
|
||||
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
||||
})
|
||||
yield* pluginHost.session.hook("http.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push("request")
|
||||
event.request.headers.set("x-hook", "effect")
|
||||
}),
|
||||
)
|
||||
yield* pluginHost.session.hook("http.response", (event) =>
|
||||
Effect.gen(function* () {
|
||||
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
|
||||
event.response = new Response(
|
||||
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
|
||||
event.response,
|
||||
)
|
||||
}),
|
||||
yield* pluginHost.session.hook("http", (event) =>
|
||||
event.use((request, next) =>
|
||||
Effect.gen(function* () {
|
||||
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
|
||||
return yield* next(request)
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
||||
const { db } = yield* Database.Service
|
||||
@@ -342,15 +331,10 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
|
||||
yield* session.resume(retrySessionID)
|
||||
|
||||
expect(methods).toEqual(["POST"])
|
||||
expect(headers).toEqual(["effect"])
|
||||
expect(seen).toEqual(["request", "response:200:effect"])
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(methods).toEqual(["POST", "POST"])
|
||||
expect(bodies).toHaveLength(2)
|
||||
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
||||
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "Hooked!" }],
|
||||
})
|
||||
expect(bodies[1]).toEqual(bodies[0])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -49,10 +49,9 @@ import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/plugin/question"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
|
||||
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Info as ToolInfo } from "@opencode-ai/schema/tool"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import {
|
||||
InstructionStateTable,
|
||||
SessionPendingTable,
|
||||
@@ -229,7 +228,7 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, ToolInfo>>, options?: Tool.Options) =>
|
||||
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
|
||||
registry.transform((draft) =>
|
||||
Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })),
|
||||
)
|
||||
@@ -335,9 +334,9 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
||||
})
|
||||
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const config = Config.testLayer([
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
info: new Config.Info({
|
||||
compaction: new ConfigCompaction.Info({
|
||||
buffer: 3_000,
|
||||
keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
|
||||
|
||||
@@ -2,8 +2,7 @@ import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { ConfigMedia } from "@opencode-ai/core/config/media"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -430,9 +429,9 @@ describe("ReadTool", () => {
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
info: new Config.Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
|
||||
}),
|
||||
@@ -473,9 +472,9 @@ describe("ReadTool", () => {
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
info: new Config.Info({
|
||||
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
|
||||
}),
|
||||
}),
|
||||
@@ -512,9 +511,9 @@ describe("ReadTool", () => {
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Document({
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
info: new Config.Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { WebSearchPreference } from "@opencode-ai/core/websearch-preference"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { WebSearchTool } from "@opencode-ai/core/tool/plugin/websearch"
|
||||
@@ -24,7 +25,7 @@ const webSearchToolNode = makeLocationNode({
|
||||
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
|
||||
}),
|
||||
),
|
||||
deps: [Tool.node, Permission.node, WebSearch.node, Form.node, KV.node],
|
||||
deps: [Tool.node, Permission.node, WebSearch.node, WebSearchPreference.node, Form.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_websearch_test")
|
||||
@@ -39,6 +40,8 @@ const providers = [
|
||||
let providerRequired = false
|
||||
let formResponse: Form.TerminalState = { status: "cancelled" }
|
||||
const formResponses: Form.TerminalState[] = []
|
||||
let queryBarrier: Deferred.Deferred<void> | undefined
|
||||
let synchronizedQueries = 0
|
||||
let result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
@@ -52,6 +55,8 @@ beforeEach(() => {
|
||||
providerRequired = false
|
||||
formResponse = { status: "cancelled" }
|
||||
formResponses.length = 0
|
||||
queryBarrier = undefined
|
||||
synchronizedQueries = 0
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
@@ -80,6 +85,11 @@ const websearch = Layer.succeed(
|
||||
Effect.gen(function* () {
|
||||
queries.push(input)
|
||||
const stored = values.get("websearch:provider")
|
||||
if (queryBarrier && synchronizedQueries < 5) {
|
||||
synchronizedQueries++
|
||||
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
|
||||
yield* Deferred.await(queryBarrier)
|
||||
}
|
||||
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
||||
if (typeof stored === "string")
|
||||
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
||||
@@ -316,6 +326,35 @@ describe("WebSearchTool registration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shares provider consent across concurrent searches", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
queryBarrier = yield* Deferred.make<void>()
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
const results = yield* Effect.all(
|
||||
Array.from({ length: 5 }, (_, index) =>
|
||||
executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `call-concurrent-${index}`,
|
||||
name: "websearch",
|
||||
input: { query: `effect ${index}` },
|
||||
},
|
||||
}),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(results.every((item) => item.status === "completed")).toBe(true)
|
||||
expect(formRequests).toHaveLength(1)
|
||||
expect(values.get("websearch:provider")).toBe("exa")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists the choice to disable web search", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
|
||||
@@ -44,12 +44,11 @@ export type SQLiteEffectSelectPrepare<
|
||||
TEffectHKT
|
||||
>
|
||||
|
||||
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
|
||||
export class SQLiteEffectSelectBuilder<
|
||||
out TSelection extends SelectedFields | undefined,
|
||||
out TRunResult,
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
out TBuilderMode extends "db" | "qb" = "db",
|
||||
TSelection extends SelectedFields | undefined,
|
||||
TRunResult,
|
||||
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
TBuilderMode extends "db" | "qb" = "db",
|
||||
> {
|
||||
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
|
||||
|
||||
|
||||
@@ -303,11 +303,10 @@ export class SQLiteEffectPreparedQuery<
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
|
||||
export abstract class SQLiteEffectSession<
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
out TRunResult = unknown,
|
||||
out TRelations extends AnyRelations = EmptyRelations,
|
||||
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
TRunResult = unknown,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
> {
|
||||
static readonly [entityKind]: string = "SQLiteEffectSession"
|
||||
|
||||
@@ -405,9 +404,9 @@ export abstract class SQLiteEffectSession<
|
||||
}
|
||||
|
||||
export abstract class SQLiteEffectTransaction<
|
||||
out TEffectHKT extends QueryEffectHKTBase,
|
||||
out TRunResult,
|
||||
out TRelations extends AnyRelations = EmptyRelations,
|
||||
TEffectHKT extends QueryEffectHKTBase,
|
||||
TRunResult,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
|
||||
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { CatalogDomain } from "./catalog.js"
|
||||
import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { ProviderDomain } from "./provider.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { ShellDomain } from "./shell.js"
|
||||
@@ -26,7 +25,6 @@ export interface Context {
|
||||
readonly event: EventDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly plugin: PluginApi<unknown>
|
||||
readonly provider: ProviderDomain
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
readonly shell: ShellDomain
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Credential } from "@opencode-ai/schema/credential"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface ProviderHooks {
|
||||
resolve: {
|
||||
readonly model: Model.Info
|
||||
readonly credential?: Credential.Value
|
||||
readonly settings: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProviderDomain {
|
||||
readonly hook: Hooks<ProviderHooks>
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Effect, JsonSchema } from "effect"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface SessionContext {
|
||||
@@ -15,25 +15,23 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionHttpRequest {
|
||||
export interface SessionHttp {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
request: Request
|
||||
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface SessionHttpResponse {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly request: Request
|
||||
response: Response
|
||||
}
|
||||
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Effect.Effect<Response, Error>
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly http: SessionHttp
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { CatalogDomain } from "./catalog.js"
|
||||
import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { ProviderDomain } from "./provider.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { ShellDomain } from "./shell.js"
|
||||
@@ -25,7 +24,6 @@ export interface Context {
|
||||
readonly event: EventDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly plugin: PluginApi
|
||||
readonly provider: ProviderDomain
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
readonly shell: ShellDomain
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Credential } from "@opencode-ai/schema/credential"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface ProviderHooks {
|
||||
resolve: {
|
||||
readonly model: Model.Info
|
||||
readonly credential?: Credential.Value
|
||||
readonly settings: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProviderDomain {
|
||||
readonly hook: Hooks<ProviderHooks>
|
||||
}
|
||||
@@ -15,25 +15,23 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionHttpRequest {
|
||||
export interface SessionHttp {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
request: Request
|
||||
readonly use: (middleware: SessionHttpMiddleware) => void
|
||||
}
|
||||
|
||||
export interface SessionHttpResponse {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly request: Request
|
||||
response: Response
|
||||
}
|
||||
export type SessionHttpHandler = (request: Request) => Promise<Response>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Promise<Response> | Response
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
readonly http: SessionHttp
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
+134
-1930
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user