mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16f144465b | |||
| 478d0bd533 | |||
| ec95b27308 | |||
| be2f74d44a | |||
| 912a801060 | |||
| 0215498f63 | |||
| 1539bd6794 | |||
| 939da6a5e7 | |||
| 0f27ee7b4c | |||
| 51cef27579 |
@@ -12,6 +12,7 @@ type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly provider?: string
|
||||
readonly baseURL: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
@@ -19,6 +20,8 @@ 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"> &
|
||||
@@ -31,11 +34,11 @@ export const routes = [OpenAICompatibleChat.route]
|
||||
|
||||
export const configure = (input: GenericModelOptions) => {
|
||||
const provider = input.provider ?? "openai-compatible"
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, queryParams, ...rest } = input
|
||||
const route = OpenAICompatibleChat.route.with({
|
||||
...rest,
|
||||
provider,
|
||||
endpoint: { baseURL },
|
||||
endpoint: { baseURL, query: queryParams },
|
||||
auth: AuthOptions.bearer(input, []),
|
||||
})
|
||||
return {
|
||||
@@ -75,6 +78,8 @@ 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,6 +64,24 @@ 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,7 +68,10 @@ 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" })],
|
||||
commands: [
|
||||
Spec.make("agents", { description: "List all agents" }),
|
||||
Spec.make("config", { description: "Show resolved configuration" }),
|
||||
],
|
||||
}),
|
||||
Spec.make("console", {
|
||||
description: "Manage OpenCode Console access",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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,6 +22,7 @@ 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,7 +10,11 @@ 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 })
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: serverURL,
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
})
|
||||
const config = yield* Config.Service
|
||||
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
|
||||
@@ -22,6 +22,7 @@ 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,9 +42,10 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
||||
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
||||
}
|
||||
|
||||
const options = yield* ServiceConfig.options()
|
||||
const mismatch = args.mismatch ?? "ignore"
|
||||
const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })
|
||||
return {
|
||||
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"),
|
||||
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, mismatch),
|
||||
service: managedService(options),
|
||||
} satisfies Resolved
|
||||
})
|
||||
|
||||
@@ -98,12 +98,12 @@ const paths = Effect.gen(function* () {
|
||||
}
|
||||
})
|
||||
|
||||
export const options = Effect.fnUntraced(function* () {
|
||||
export const options = Effect.fnUntraced(function* (input: { readonly checkVersion?: boolean } = {}) {
|
||||
const { file, legacyRegistrationFiles } = yield* paths
|
||||
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
|
||||
return {
|
||||
file,
|
||||
version: OPENCODE_VERSION,
|
||||
version: input.checkVersion ? OPENCODE_VERSION : undefined,
|
||||
command: [...selfCommand(), "serve", "--service"],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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,3 +55,17 @@ 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,6 +8,7 @@ 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"
|
||||
@@ -48,6 +49,7 @@ 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,6 +38,7 @@ 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>
|
||||
@@ -1595,6 +1596,16 @@ 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>
|
||||
@@ -1625,4 +1636,5 @@ export interface AppApi<E = never> {
|
||||
readonly debug: DebugApi<E>
|
||||
readonly migration: MigrationApi<E>
|
||||
readonly websearch: WebsearchApi<E>
|
||||
readonly config: ConfigApi<E>
|
||||
}
|
||||
|
||||
@@ -220,6 +220,8 @@ import type {
|
||||
Endpoint28_0Output,
|
||||
Endpoint28_1Input,
|
||||
Endpoint28_1Output,
|
||||
Endpoint29_0Input,
|
||||
Endpoint29_0Output,
|
||||
} from "../api/api.js"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -1241,6 +1243,13 @@ 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"]),
|
||||
@@ -1271,6 +1280,7 @@ 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,6 +8,7 @@ export type {
|
||||
AppApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
ConfigApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
@@ -20,6 +21,7 @@ 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,6 +53,7 @@ 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
|
||||
@@ -82,6 +83,18 @@ 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)
|
||||
@@ -182,6 +195,10 @@ 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:
|
||||
@@ -189,39 +206,53 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
const signal = AbortSignal.timeout(2_000)
|
||||
const result = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", info.url), {
|
||||
headers: headers(endpoint),
|
||||
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))
|
||||
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
|
||||
const health = decodeHealth(body)
|
||||
if (Option.isSome(health)) {
|
||||
if (health.value.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||
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 }
|
||||
return {
|
||||
info,
|
||||
endpoint,
|
||||
version: health.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: health.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
if (
|
||||
!allowLegacy ||
|
||||
Option.isNone(decodeLegacyHealth(body)) ||
|
||||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
||||
)
|
||||
return undefined
|
||||
return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
})
|
||||
|
||||
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
|
||||
const info = yield* read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined }
|
||||
return { info, service: yield* probe(info, allowLegacy) }
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(yield* probeResult(info, allowLegacy)) }
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
@@ -249,6 +280,19 @@ 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,6 +2,7 @@ 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,6 +216,8 @@ import type {
|
||||
WebsearchProvidersOutput,
|
||||
WebsearchQueryInput,
|
||||
WebsearchQueryOutput,
|
||||
ConfigGetInput,
|
||||
ConfigGetOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -1811,6 +1813,20 @@ 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,6 +1698,180 @@ 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 = {
|
||||
@@ -4593,3 +4767,11 @@ 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,6 +3,7 @@ export type {
|
||||
AgentApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
ConfigApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
|
||||
@@ -34,6 +34,7 @@ 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
|
||||
@@ -62,6 +63,19 @@ 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
|
||||
@@ -145,6 +159,10 @@ 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:
|
||||
@@ -152,30 +170,48 @@ async function probe(info: Info, allowLegacy = false): Promise<LocalService | un
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const response = await fetch(new URL("/api/health", info.url), {
|
||||
const signal = AbortSignal.timeout(2_000)
|
||||
const result = await fetch(new URL("/api/health", info.url), {
|
||||
headers: headers(endpoint),
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}).catch(() => undefined)
|
||||
const body = (await response?.json().catch(() => undefined)) as ServiceHealth | { readonly healthy: true } | undefined
|
||||
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
|
||||
if (body !== undefined && "version" in body && "pid" in body) {
|
||||
if (body.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && body.version !== info.version) return undefined
|
||||
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && body.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
info,
|
||||
endpoint,
|
||||
version: body.version,
|
||||
state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: body.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
if (!allowLegacy || body?.healthy !== true) return undefined
|
||||
return { info, endpoint, state: "ready", legacy: true }
|
||||
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function registered(file?: string, allowLegacy = false) {
|
||||
const info = await read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined }
|
||||
return { info, service: await probe(info, allowLegacy) }
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(await probeResult(info, allowLegacy)) }
|
||||
}
|
||||
|
||||
async function find(options: { readonly file?: string }) {
|
||||
@@ -209,6 +245,18 @@ 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,6 +1,7 @@
|
||||
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"
|
||||
@@ -10,6 +11,7 @@ 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,6 +47,10 @@ 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,6 +70,32 @@ 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,6 +33,7 @@ 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"])
|
||||
@@ -50,6 +51,34 @@ 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,6 +70,30 @@ 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,6 +12,7 @@ export interface Mapping {
|
||||
|
||||
export interface MapInput {
|
||||
readonly packageName: string | undefined
|
||||
readonly providerID: string
|
||||
readonly settings: Readonly<Record<string, unknown>>
|
||||
readonly modelID: string
|
||||
}
|
||||
@@ -51,6 +52,8 @@ 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":
|
||||
@@ -63,6 +66,33 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function mapOpenAICompatible(
|
||||
input: MapInput,
|
||||
baseSettings: Readonly<Record<string, unknown>>,
|
||||
): Mapping | undefined {
|
||||
const accountId =
|
||||
input.providerID === "cloudflare-workers-ai" && typeof input.settings.accountId === "string"
|
||||
? input.settings.accountId
|
||||
: undefined
|
||||
const baseURL =
|
||||
typeof baseSettings.baseURL === "string" && accountId
|
||||
? baseSettings.baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", encodeURIComponent(accountId))
|
||||
: baseSettings.baseURL
|
||||
if (typeof baseURL !== "string") return undefined
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: {
|
||||
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 {
|
||||
|
||||
+22
-148
@@ -5,8 +5,16 @@ 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 { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
Directory,
|
||||
Document,
|
||||
File,
|
||||
Info,
|
||||
type Entry,
|
||||
Event,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Credential } from "./credential"
|
||||
import { Bus } from "./bus"
|
||||
@@ -15,141 +23,11 @@ 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")
|
||||
@@ -296,21 +174,17 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
|
||||
// We load certain files from a few other folders in the ecosystem
|
||||
const claude = [
|
||||
...((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) })),
|
||||
]
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude"),
|
||||
]),
|
||||
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
|
||||
const agents = [
|
||||
...((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) })),
|
||||
]
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents"),
|
||||
]),
|
||||
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||
|
||||
const directories = [
|
||||
globalDirectory,
|
||||
@@ -359,13 +233,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,
|
||||
]
|
||||
})
|
||||
@@ -408,7 +282,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* reconcile(next)
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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"
|
||||
@@ -24,7 +25,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(Config.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Info)
|
||||
type PathAction =
|
||||
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
||||
| typeof ReadTool.name
|
||||
@@ -63,13 +64,13 @@ export const Plugin = define({
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
documents.filter((document): document is Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as Config.Document[] }
|
||||
const loaded = { documents: [] as Document[] }
|
||||
const reload = load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.agent.reload()),
|
||||
@@ -139,7 +140,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: Config.Entry[], file: string) {
|
||||
function isAgentSource(entries: Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
@@ -208,5 +209,5 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
||||
}),
|
||||
)
|
||||
if (!info) return
|
||||
return new Config.Document({ type: "document", path: file.filepath, info })
|
||||
return new Document({ type: "document", path: file.filepath, info })
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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)
|
||||
@@ -27,7 +28,7 @@ export const Plugin = define({
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as { commands: Config.Info["commands"] }[] }
|
||||
const loaded = { documents: [] as { commands: Info["commands"] }[] }
|
||||
const reload = load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
@@ -75,7 +76,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: Config.Entry[], file: string) {
|
||||
function isCommandSource(entries: Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -13,7 +14,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 Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||
for (const record of catalog.provider.list()) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -107,8 +108,8 @@ export const Plugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
function configuredProviders(entries: readonly Config.Entry[]) {
|
||||
function configuredProviders(entries: readonly Entry[]) {
|
||||
return entries
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((file) => Object.entries(file.info.providers ?? {}))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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"
|
||||
@@ -19,7 +20,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 Config.Document => entry.type === "document")) {
|
||||
for (const doc of loaded.entries.filter((entry): entry is 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
|
||||
|
||||
@@ -3,6 +3,7 @@ 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"
|
||||
@@ -41,7 +42,7 @@ const layer = Layer.effect(
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is 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 "../config/mcp"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
||||
|
||||
@@ -3,11 +3,12 @@ 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"
|
||||
@@ -178,7 +179,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 Config.Document => entry.type === "document")
|
||||
const documents = (yield* config.entries()).filter((entry): entry is 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 "../config/mcp"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { OauthCallbackPage } from "../oauth/page"
|
||||
import type { Integration } from "../integration"
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ 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"
|
||||
@@ -146,6 +144,7 @@ export const fromCatalogModel = (
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
||||
if (draft.providerID === "cloudflare-workers-ai" && draft.body) delete draft.body.accountId
|
||||
})
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
@@ -164,21 +163,11 @@ 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,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ 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"
|
||||
@@ -58,62 +57,6 @@ 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,
|
||||
@@ -322,7 +265,8 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: sessionHook,
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
create: (input) =>
|
||||
run(
|
||||
host.session.create(
|
||||
|
||||
@@ -14,38 +14,20 @@ export const CloudflareWorkersAIPlugin = define({
|
||||
if (!item) return
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (!Provider.isAISDK(provider.package)) return
|
||||
if (typeof provider.settings?.baseURL === "string") return
|
||||
const accountId = resolveAccountId(provider.settings ?? {})
|
||||
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
|
||||
if (accountId)
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
baseURL:
|
||||
typeof provider.settings?.baseURL === "string"
|
||||
? provider.settings.baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", encodeURIComponent(accountId))
|
||||
: workersEndpoint(accountId),
|
||||
}
|
||||
provider.headers = Provider.mergeHeaders(provider.headers, {
|
||||
"User-Agent": `${App.useragent(ctx.app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
})
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -54,32 +36,7 @@ function resolveAccountId(options: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
function workersEndpoint(accountId: string) {
|
||||
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}")
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
function stringOption(options: Record<string, unknown>, key: string) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { shouldUseResponsesApi } from "@opencode-ai/ai/providers/github-copilot"
|
||||
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
@@ -140,14 +141,6 @@ const oauth = (app: App.Info) => ({
|
||||
}),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||
// which still need the chat-completions endpoint.
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (!match) return false
|
||||
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
||||
}
|
||||
|
||||
export const GithubCopilotPlugin = define({
|
||||
id: "opencode.provider.github-copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
@@ -269,7 +262,7 @@ export const GithubCopilotPlugin = define({
|
||||
return
|
||||
}
|
||||
const id = evt.model.modelID ?? evt.model.id
|
||||
evt.language = shouldUseResponses(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
|
||||
evt.language = shouldUseResponsesApi(id) ? evt.sdk.responses(id) : evt.sdk.chat(id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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: 272_000, input: 272_000 }
|
||||
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
||||
})
|
||||
}
|
||||
})
|
||||
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))
|
||||
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)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
@@ -9,7 +10,6 @@ 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"
|
||||
@@ -83,15 +83,15 @@ function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Config.Entry[]) {
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Config.Directory => entry.type === "directory"),
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
@@ -208,7 +208,7 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Config.Entry[], file: string) {
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
@@ -243,7 +243,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 Config.Entry[],
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -148,9 +149,9 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
return ""
|
||||
}
|
||||
|
||||
const settings = (documents: readonly Config.Entry[]) => {
|
||||
const settings = (documents: readonly Entry[]) => {
|
||||
const configured = documents
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
||||
return {
|
||||
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
|
||||
|
||||
@@ -2,7 +2,6 @@ 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"
|
||||
@@ -230,44 +229,31 @@ export const layer = Layer.effect(
|
||||
const options: StreamOptions = {
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
let latest = request
|
||||
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const web = yield* HttpClientRequest.toWeb(request)
|
||||
yield* hooks.trigger("session", "http", {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
request: yield* HttpClientRequest.toWeb(request),
|
||||
})
|
||||
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)
|
||||
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)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
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") =>
|
||||
AISDKNative.map({ packageName, settings, modelID })
|
||||
const map = (
|
||||
packageName: string,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
modelID = "test-model",
|
||||
providerID = "test-provider",
|
||||
) => AISDKNative.map({ packageName, providerID, settings, modelID })
|
||||
|
||||
describe("AISDKNative", () => {
|
||||
test("maps both models.dev Bedrock packages to native providers", () => {
|
||||
@@ -41,6 +45,52 @@ 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/${CLOUDFLARE_ACCOUNT_ID}/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,6 +5,7 @@ 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"
|
||||
@@ -19,7 +20,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(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(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" },
|
||||
@@ -71,7 +72,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
|
||||
@@ -92,7 +93,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
@@ -153,7 +154,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
agents: {
|
||||
@@ -173,7 +174,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
agents: {
|
||||
@@ -218,7 +219,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
yield* agents.transform((editor) => editor.update(build, () => {}))
|
||||
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ agents: { build: { disabled: true } } }),
|
||||
}),
|
||||
@@ -276,7 +277,7 @@ Use native v2 fields.`,
|
||||
const agents = yield* Agent.Service
|
||||
const global = yield* Global.Service
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ agents: { reviewer: { description: "JSON description" } } }),
|
||||
}),
|
||||
@@ -425,7 +426,7 @@ Use native v2 fields.`,
|
||||
})
|
||||
|
||||
function directoryEntry(directory: string) {
|
||||
return new Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
}
|
||||
|
||||
function sourceCases() {
|
||||
@@ -522,7 +523,7 @@ function loadHomePermissions(home: string) {
|
||||
const build = Agent.ID.make("build")
|
||||
yield* agents.transform((editor) => editor.update(build, () => {}))
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new 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 { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Directory, Document, Event, Info } 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(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(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(ConfigSchema.Event.Updated, {})
|
||||
const update = yield* bus.publish(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 Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ commands: { review: { template: "Inline review" } } }),
|
||||
}),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
|
||||
new 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 Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
return new 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 { 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 { 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 { 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(ConfigSchema.Event.Updated)
|
||||
.subscribe(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(ConfigSchema.Event.Updated)
|
||||
.subscribe(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(ConfigSchema.Event.Updated)
|
||||
.subscribe(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 Config.Document({ type: "document", info: new Config.Info({}) })
|
||||
const entry = new Document({ type: "document", info: new 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 Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
}),
|
||||
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({
|
||||
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({
|
||||
type: "document",
|
||||
info: new Config.Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
info: new 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(ConfigSchema.Event.Updated)
|
||||
.subscribe(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(Config.Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
@@ -661,13 +661,45 @@ describe("Config", () => {
|
||||
const entries = yield* config.entries()
|
||||
|
||||
expect(entries).toEqual([
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
|
||||
new 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()),
|
||||
@@ -729,7 +761,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(Config.Document)
|
||||
expect(documents[0]).toBeInstanceOf(Document)
|
||||
expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
|
||||
expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
|
||||
|
||||
@@ -1177,7 +1209,7 @@ describe("Config", () => {
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents).toHaveLength(1)
|
||||
expect(documents[0]?.info).toBeInstanceOf(Config.Info)
|
||||
expect(documents[0]?.info).toBeInstanceOf(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/core/config/model"
|
||||
import { ConfigModel } from "@opencode-ai/schema/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 { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Document, Event, Info, type Entry } 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(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
experimental: {
|
||||
@@ -24,7 +24,7 @@ const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
|
||||
const addPlugin = Effect.fn(function* (entries: 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(ConfigSchema.Event.Updated, {})
|
||||
yield* bus.publish(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,5 +1,6 @@
|
||||
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"
|
||||
@@ -14,7 +15,7 @@ import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
|
||||
const addPlugin = Effect.fn(function* (entries: Entry[]) {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
|
||||
@@ -46,7 +47,7 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
)
|
||||
}
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("defaults custom models to agent capabilities", () =>
|
||||
@@ -55,7 +56,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.make("custom")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -90,7 +91,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
})
|
||||
})
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -129,7 +130,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.opencode
|
||||
const modelID = Model.ID.make("alpha-gpt-next")
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -178,7 +179,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.opencode
|
||||
const modelID = Model.ID.make("alpha-gpt-next")
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -189,7 +190,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
@@ -223,7 +224,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
const providerID = Provider.ID.make("custom")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/first",
|
||||
@@ -255,7 +256,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/default",
|
||||
@@ -289,7 +290,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Document, Event, Info } 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(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(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(ConfigSchema.Event.Updated, {})
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* waitUntil(
|
||||
Effect.gen(function* () {
|
||||
return (
|
||||
@@ -83,7 +83,7 @@ describe("config plugin reloads", () => {
|
||||
})
|
||||
|
||||
function config(name: string) {
|
||||
return new Config.Document({
|
||||
return new Document({
|
||||
type: "document",
|
||||
path: document,
|
||||
info: decode({
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -12,7 +13,7 @@ import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigSkillPlugin.Plugin", () => {
|
||||
it.effect("registers configured skill directories and URLs", () =>
|
||||
@@ -43,10 +44,10 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
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({
|
||||
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({
|
||||
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 { Config } from "../../src/config"
|
||||
import { Info } from "@opencode-ai/schema/config"
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("config warming", () => {
|
||||
test("accepts boolean enablement", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -13,16 +14,16 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
type ConfigInput = typeof Config.Info.Encoded
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
|
||||
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
|
||||
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
|
||||
}),
|
||||
]
|
||||
return AppNodeBuilder.build(Formatter.node, [
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/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"
|
||||
@@ -162,9 +163,9 @@ function resourceMcpLayer(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Config.testLayer([
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
info: new Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
servers: {
|
||||
resources:
|
||||
|
||||
@@ -131,6 +131,45 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes Cloudflare Workers AI through the generic OpenAI-compatible provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
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",
|
||||
},
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "secret", metadata: { accountId: "account/id" } }),
|
||||
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
|
||||
)
|
||||
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"), {
|
||||
@@ -151,6 +190,9 @@ 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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { DateTime, Effect, 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, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import { host as testHost } from "./host"
|
||||
@@ -223,102 +223,45 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts promise session HTTP hooks", () =>
|
||||
it.effect("adapts promise session HTTP request and response 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.request", (event) => {
|
||||
event.request = new Request("https://provider.test/changed", event.request)
|
||||
event.request.headers.set("x-hook", "promise")
|
||||
})
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use(async (request, next) => {
|
||||
const response = await next(request)
|
||||
return new Response(`${await response.text()}-outer`)
|
||||
await ctx.session.hook("http.response", async (event) => {
|
||||
event.response = new Response(`${await event.response.text()}-response`, {
|
||||
status: event.response.status,
|
||||
})
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
const event: PluginHooks.Domains["session"]["http"] = {
|
||||
const context = {
|
||||
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" }))
|
||||
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"),
|
||||
})
|
||||
|
||||
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-interrupt",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use((request, next) => next(request))
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
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)
|
||||
}),
|
||||
}
|
||||
|
||||
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(yield* Deferred.isDone(interrupted)).toBeTrue()
|
||||
expect(request.request.url).toBe("https://provider.test/changed")
|
||||
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
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 { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -15,9 +12,7 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* CloudflareWorkersAIPlugin.effect(host)
|
||||
yield* CloudflareWorkersAIPlugin.effect(yield* PluginHost.make(plugin))
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
@@ -25,243 +20,96 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
function withEnv<A, E, R>(value: string | undefined, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
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
|
||||
})
|
||||
const previous = process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
if (value === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
else process.env.CLOUDFLARE_ACCOUNT_ID = value
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
else process.env.CLOUDFLARE_ACCOUNT_ID = previous
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||
|
||||
describe("CloudflareWorkersAIPlugin", () => {
|
||||
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((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()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
|
||||
it.effect("resolves the account environment variable into the native endpoint", () =>
|
||||
withEnv("account/id", () =>
|
||||
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* catalog.transform((draft) =>
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
}),
|
||||
)
|
||||
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" },
|
||||
|
||||
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("allows a configured baseURL without account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, 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://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("uses env account ID over configured account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
|
||||
it.effect("resolves an account ID from provider settings", () =>
|
||||
withEnv(undefined, () =>
|
||||
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, accountId: "configured-acct" }
|
||||
yield* catalog.transform((draft) =>
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { accountId: "configured/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(cloudflareURL(result.sdk)).toBe(
|
||||
"https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions",
|
||||
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe(
|
||||
"https://api.cloudflare.com/client/v4/accounts/configured%2Faccount/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("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("does not create an SDK for non OpenAI-compatible packages", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
it.effect("preserves a custom endpoint without an account ID", () =>
|
||||
withEnv(undefined, () =>
|
||||
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/anthropic",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
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://proxy.example/v1" }
|
||||
}),
|
||||
package: "@ai-sdk/anthropic",
|
||||
options: { name: "cloudflare-workers-ai" },
|
||||
})
|
||||
expect(result.sdk).toBeUndefined()
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(providerID)).settings?.baseURL).toBe("https://proxy.example/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
|
||||
@@ -31,26 +30,13 @@ function required<T>(value: T | undefined): T {
|
||||
}
|
||||
|
||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
||||
yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
request: new Request(url, { method: "POST", body: "{}" }),
|
||||
})
|
||||
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()) }
|
||||
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
|
||||
})
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
@@ -140,7 +126,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: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.limit).toEqual({ context: 400_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,
|
||||
@@ -149,14 +135,14 @@ describe("OpenAIPlugin", () => {
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
||||
context: 272_000,
|
||||
context: 400_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: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(gpt56.limit).toEqual({ context: 400_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,6 +1,7 @@
|
||||
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"
|
||||
@@ -213,7 +214,7 @@ const configuredIt = testEffect(
|
||||
entries: () =>
|
||||
Effect.succeed(
|
||||
configuredShell
|
||||
? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })]
|
||||
? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })]
|
||||
: [],
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -255,6 +255,7 @@ 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"}]}',
|
||||
@@ -268,6 +269,7 @@ 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" } }),
|
||||
@@ -275,14 +277,16 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
const retryIt = testEffect(
|
||||
const httpIt = testEffect(
|
||||
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
||||
)
|
||||
|
||||
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
|
||||
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
|
||||
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
|
||||
@@ -297,13 +301,20 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
catalog: catalogHost(catalog),
|
||||
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
||||
})
|
||||
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* 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* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
||||
const { db } = yield* Database.Service
|
||||
@@ -331,10 +342,15 @@ describe("SessionModelRequest HTTP bridge", () => {
|
||||
|
||||
yield* session.resume(retrySessionID)
|
||||
|
||||
expect(methods).toEqual(["POST", "POST"])
|
||||
expect(bodies).toHaveLength(2)
|
||||
expect(methods).toEqual(["POST"])
|
||||
expect(headers).toEqual(["effect"])
|
||||
expect(seen).toEqual(["request", "response:200:effect"])
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
||||
expect(bodies[1]).toEqual(bodies[0])
|
||||
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "Hooked!" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -49,9 +49,10 @@ 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 { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import type { Info as ToolInfo } from "@opencode-ai/schema/tool"
|
||||
import {
|
||||
InstructionStateTable,
|
||||
SessionPendingTable,
|
||||
@@ -228,7 +229,7 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
|
||||
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, ToolInfo>>, options?: Tool.Options) =>
|
||||
registry.transform((draft) =>
|
||||
Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })),
|
||||
)
|
||||
@@ -334,9 +335,9 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
||||
})
|
||||
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const config = Config.testLayer([
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
info: new Info({
|
||||
compaction: new ConfigCompaction.Info({
|
||||
buffer: 3_000,
|
||||
keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
|
||||
|
||||
@@ -2,7 +2,8 @@ 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 { ConfigMedia } from "@opencode-ai/core/config/media"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/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"
|
||||
@@ -429,9 +430,9 @@ describe("ReadTool", () => {
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
|
||||
}),
|
||||
@@ -472,9 +473,9 @@ describe("ReadTool", () => {
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
|
||||
}),
|
||||
}),
|
||||
@@ -511,9 +512,9 @@ describe("ReadTool", () => {
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Config.Document({
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
|
||||
}),
|
||||
|
||||
@@ -44,11 +44,12 @@ export type SQLiteEffectSelectPrepare<
|
||||
TEffectHKT
|
||||
>
|
||||
|
||||
// Explicit variance prevents comparisons from recursively scanning Drizzle's conditional select types.
|
||||
export class SQLiteEffectSelectBuilder<
|
||||
TSelection extends SelectedFields | undefined,
|
||||
TRunResult,
|
||||
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
TBuilderMode extends "db" | "qb" = "db",
|
||||
out TSelection extends SelectedFields | undefined,
|
||||
out TRunResult,
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
out TBuilderMode extends "db" | "qb" = "db",
|
||||
> {
|
||||
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
|
||||
|
||||
|
||||
@@ -303,10 +303,11 @@ export class SQLiteEffectPreparedQuery<
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit variance prevents comparisons from recursively scanning the full Drizzle query-builder graph.
|
||||
export abstract class SQLiteEffectSession<
|
||||
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
TRunResult = unknown,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
out TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
|
||||
out TRunResult = unknown,
|
||||
out TRelations extends AnyRelations = EmptyRelations,
|
||||
> {
|
||||
static readonly [entityKind]: string = "SQLiteEffectSession"
|
||||
|
||||
@@ -404,9 +405,9 @@ export abstract class SQLiteEffectSession<
|
||||
}
|
||||
|
||||
export abstract class SQLiteEffectTransaction<
|
||||
TEffectHKT extends QueryEffectHKTBase,
|
||||
TRunResult,
|
||||
TRelations extends AnyRelations = EmptyRelations,
|
||||
out TEffectHKT extends QueryEffectHKTBase,
|
||||
out TRunResult,
|
||||
out TRelations extends AnyRelations = EmptyRelations,
|
||||
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
|
||||
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
|
||||
|
||||
|
||||
@@ -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 { Effect, JsonSchema } from "effect"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface SessionContext {
|
||||
@@ -15,23 +15,25 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionHttp {
|
||||
export interface SessionHttpRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
|
||||
request: Request
|
||||
}
|
||||
|
||||
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Effect.Effect<Response, Error>
|
||||
export interface SessionHttpResponse {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly request: Request
|
||||
response: Response
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly http: SessionHttp
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -15,23 +15,25 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionHttp {
|
||||
export interface SessionHttpRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly use: (middleware: SessionHttpMiddleware) => void
|
||||
request: Request
|
||||
}
|
||||
|
||||
export type SessionHttpHandler = (request: Request) => Promise<Response>
|
||||
|
||||
export type SessionHttpMiddleware = (
|
||||
request: Request,
|
||||
next: SessionHttpHandler,
|
||||
) => Promise<Response> | Response
|
||||
export interface SessionHttpResponse {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly request: Request
|
||||
response: Response
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly http: SessionHttp
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
+1933
-137
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ import { ProjectGroup } from "./groups/project.js"
|
||||
import { ProjectCopyGroup } from "./groups/project-copy.js"
|
||||
import { VcsGroup } from "./groups/vcs.js"
|
||||
import { MigrationGroup } from "./groups/migration.js"
|
||||
import { ConfigGroup } from "./groups/config.js"
|
||||
|
||||
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
|
||||
@@ -53,6 +54,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProjectCopyGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
|
||||
|
||||
type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService> =
|
||||
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
|
||||
@@ -175,6 +177,7 @@ const makeApiFromGroup = <
|
||||
.add(DebugGroup)
|
||||
.add(MigrationGroup)
|
||||
.add(WebSearchGroup.middleware(locationMiddleware))
|
||||
.add(ConfigGroup.middleware(locationMiddleware))
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode HttpApi",
|
||||
|
||||
@@ -62,6 +62,7 @@ export const groupNames = {
|
||||
"server.project": "project",
|
||||
"server.projectCopy": "projectCopy",
|
||||
"server.vcs": "vcs",
|
||||
"server.config": "config",
|
||||
} as const
|
||||
|
||||
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
export const ConfigGroup = HttpApiGroup.make("server.config")
|
||||
.add(
|
||||
HttpApiEndpoint.get("config.get", "/api/config", {
|
||||
query: LocationQuery,
|
||||
success: Schema.Array(Config.Entry),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.config.get",
|
||||
summary: "Get configuration",
|
||||
description:
|
||||
"Return configuration documents and discovery sources for the requested location, from lowest to highest priority.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "config", description: "Location-scoped configuration routes." }))
|
||||
@@ -1,6 +1,142 @@
|
||||
export * as Config from "./config.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { ConfigAgent } from "./config/agent.js"
|
||||
import { ConfigMedia } from "./config/media.js"
|
||||
import { ConfigCompaction } from "./config/compaction.js"
|
||||
import { ConfigCommand } from "./config/command.js"
|
||||
import { ConfigExperimental } from "./config/experimental.js"
|
||||
import { ConfigFormatter } from "./config/formatter.js"
|
||||
import { ConfigLSP } from "./config/lsp.js"
|
||||
import { ConfigMCP } from "./config/mcp.js"
|
||||
import { ConfigModel } from "./config/model.js"
|
||||
import { ConfigPlugin } from "./config/plugin.js"
|
||||
import { ConfigProvider } from "./config/provider.js"
|
||||
import { ConfigReference } from "./config/reference.js"
|
||||
import { ConfigWebSearch } from "./config/websearch.js"
|
||||
import { ConfigToolOutput } from "./config/tool-output.js"
|
||||
import { ConfigWatcher } from "./config/watcher.js"
|
||||
import { ConfigWarming } from "./config/warming.js"
|
||||
|
||||
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 const Entry = Schema.Union([Document, Directory, File, AgentsDirectory, ClaudeDirectory]).annotate({
|
||||
identifier: "Config.Entry",
|
||||
})
|
||||
export type Entry = typeof Entry.Type
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "config.updated",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export * as ConfigAgent from "./agent"
|
||||
export * as ConfigAgent from "./agent.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { ConfigProvider } from "./provider"
|
||||
import { ConfigModel } from "./model"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
import { ConfigModel } from "./model.js"
|
||||
import { ConfigProvider } from "./provider.js"
|
||||
|
||||
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigCommand from "./command"
|
||||
export * as ConfigCommand from "./command.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ConfigModel } from "./model"
|
||||
import { ConfigModel } from "./model.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Command")({
|
||||
template: Schema.String,
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigCompaction from "./compaction"
|
||||
export * as ConfigCompaction from "./compaction.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { NonNegativeInt } from "../schema.js"
|
||||
|
||||
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"
|
||||
export * as ConfigExperimental from "./experimental.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { ConfigPolicy } from "./policy"
|
||||
import { NonNegativeInt } from "../schema.js"
|
||||
import { ConfigPolicy } from "./policy.js"
|
||||
|
||||
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"
|
||||
export * as ConfigFormatter from "./formatter.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigLSP from "./lsp"
|
||||
export * as ConfigLSP from "./lsp.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
export * as ConfigMCP from "./mcp"
|
||||
export * as ConfigMCP from "./mcp.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Mcp } from "../mcp.js"
|
||||
|
||||
// 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"
|
||||
export * as ConfigMedia from "./media.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
|
||||
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"
|
||||
export * as ConfigModel from "./model.js"
|
||||
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Model } from "../model.js"
|
||||
import { Provider } from "../provider.js"
|
||||
|
||||
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
|
||||
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigPlugin from "./plugin"
|
||||
export * as ConfigPlugin from "./plugin.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigPolicy from "./policy"
|
||||
export * as ConfigPolicy from "./policy.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigProvider from "./provider"
|
||||
export * as ConfigProvider from "./provider.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model"
|
||||
import { Money } from "../money.js"
|
||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigReference from "./reference"
|
||||
export * as ConfigReference from "./reference.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigToolOutput from "./tool-output"
|
||||
export * as ConfigToolOutput from "./tool-output.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
|
||||
max_lines: PositiveInt.pipe(Schema.optional),
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigWarming from "./warming"
|
||||
export * as ConfigWarming from "./warming.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigWatcher from "./watcher"
|
||||
export * as ConfigWatcher from "./watcher.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigWebSearch from "./websearch"
|
||||
export * as ConfigWebSearch from "./websearch.js"
|
||||
|
||||
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,
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Config } from "../src/config.js"
|
||||
import { AbsolutePath } from "../src/schema.js"
|
||||
|
||||
describe("Config.Entry", () => {
|
||||
test("round-trips every configuration entry type", () => {
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: "/project/opencode.json",
|
||||
info: new Config.Info({
|
||||
permissions: [
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
new Config.Document({ type: "document", info: new Config.Info({ shell: "/bin/zsh" }) }),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make("/project/.opencode") }),
|
||||
new Config.File({ type: "file", path: AbsolutePath.make("/project/opencode.json") }),
|
||||
new Config.AgentsDirectory({ type: "agents", path: AbsolutePath.make("/project/.agents") }),
|
||||
new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/project/.claude") }),
|
||||
]
|
||||
|
||||
const encoded = Schema.encodeSync(Schema.Array(Config.Entry))(entries)
|
||||
const decoded = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(encoded)
|
||||
|
||||
expect(decoded).toEqual(entries)
|
||||
expect(decoded[0]).toBeInstanceOf(Config.Document)
|
||||
expect(decoded[1]).not.toHaveProperty("path")
|
||||
expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "file", "agents", "claude"])
|
||||
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
])
|
||||
})
|
||||
|
||||
test("has a stable public identifier", () => {
|
||||
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry")
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ export { ClientError } from "@opencode-ai/client/effect"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/client/effect"
|
||||
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 { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Location as CoreLocation } from "@opencode-ai/core/location"
|
||||
import { SessionPending as CoreSessionPending } from "@opencode-ai/core/session/pending"
|
||||
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
@@ -24,6 +25,7 @@ const CoreSession = await import("@opencode-ai/core/session")
|
||||
|
||||
test("re-exports canonical contracts directly from Schema", () => {
|
||||
expect(SDK.Agent).toBe(Agent)
|
||||
expect(SDK.Config).toBe(Config)
|
||||
expect(SDK.Model).toBe(Model)
|
||||
expect(SDK.WebSearch).toBe(WebSearch)
|
||||
expect(SDK.Session).toBe(Session)
|
||||
@@ -32,6 +34,7 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||
"Agent",
|
||||
"ClientError",
|
||||
"Command",
|
||||
"Config",
|
||||
"Credential",
|
||||
"FileSystem",
|
||||
"Integration",
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ProjectCopyHandler } from "./handlers/project-copy"
|
||||
import { VcsHandler } from "./handlers/vcs"
|
||||
import { EventFeed } from "./event-feed"
|
||||
import { MigrationHandler } from "./handlers/migration"
|
||||
import { ConfigHandler } from "./handlers/config"
|
||||
|
||||
export const handlers = Layer.mergeAll(
|
||||
HealthHandler,
|
||||
@@ -60,4 +61,5 @@ export const handlers = Layer.mergeAll(
|
||||
ReferenceHandler,
|
||||
ProjectCopyHandler,
|
||||
VcsHandler,
|
||||
ConfigHandler,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
|
||||
export const ConfigHandler = HttpApiBuilder.group(Api, "server.config", (handlers) =>
|
||||
handlers.handle("config.get", () => Config.Service.use((config) => config.entries())),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live("returns ordered config entries for the requested directory", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir("opencode-config-endpoint-")),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const config = path.join(project, "opencode.json")
|
||||
yield* Effect.promise(() => Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
config,
|
||||
JSON.stringify({
|
||||
permissions: [
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: global },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const url = new URL("/api/config", HttpServer.formatAddress(server.address))
|
||||
url.searchParams.set("location[directory]", project)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
||||
)
|
||||
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
|
||||
yield* Effect.promise(() => response.json()),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(Array.isArray(entries)).toBe(true)
|
||||
const document = entries.find(
|
||||
(entry): entry is Config.Document => entry.type === "document" && entry.path === config,
|
||||
)
|
||||
expect(document?.info.permissions).toEqual([
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
])
|
||||
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
+16
-8
@@ -246,19 +246,27 @@ Runtime hooks intercept live operations:
|
||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
|
||||
| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch |
|
||||
| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds |
|
||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
||||
|
||||
HTTP hooks can modify requests, inspect responses, retry, or return a
|
||||
response without calling the provider. It applies to native models; AI SDK
|
||||
models do not currently pass through this hook.
|
||||
HTTP hooks can modify requests and responses. They apply to native models; AI
|
||||
SDK models do not currently pass through these hooks. Request and response
|
||||
bodies are one-shot streams. Use `clone()` when you intentionally need a
|
||||
separate reader, but be aware that its slower branch may buffer data. To inspect
|
||||
or modify chunks while preserving streaming, replace the body with one piped
|
||||
through a `TransformStream`.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("http", (event) => {
|
||||
event.use((request, next) => {
|
||||
request.headers.set("x-session-id", event.sessionID)
|
||||
return next(request)
|
||||
await ctx.session.hook("http.request", (event) => {
|
||||
event.request.headers.set("x-session-id", event.sessionID)
|
||||
})
|
||||
|
||||
await ctx.session.hook("http.response", (event) => {
|
||||
event.response = new Response(event.response.body, {
|
||||
status: event.response.status,
|
||||
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" },
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
+1933
-137
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