mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 21:21:18 -04:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3ec18bfd9 | |||
| c53f4cfb09 | |||
| 45a49ae32a | |||
| 238ce304a9 | |||
| cf606660fb | |||
| 759695d87c | |||
| f14724dfb1 | |||
| cc53db4406 | |||
| fa055143ea | |||
| 875b28658f | |||
| 0e022036fb | |||
| f0ae3b9569 | |||
| 3b5837d354 | |||
| e1ff217e44 | |||
| ecda3779fa | |||
| a3e69a967b | |||
| 6359623e24 |
@@ -188,12 +188,15 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"effect",
|
||||
"solid-js",
|
||||
],
|
||||
},
|
||||
"packages/codemode": {
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { adaptServerEvent, coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
let starts = 0
|
||||
const start = () => starts++
|
||||
|
||||
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
|
||||
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
|
||||
|
||||
expect(starts).toBe(1)
|
||||
})
|
||||
})
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
|
||||
describe("adaptServerEvent", () => {
|
||||
test("preserves current permission requests", () => {
|
||||
@@ -43,48 +31,3 @@ describe("adaptServerEvent", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("current event buffering", () => {
|
||||
const delta = (id: string, value: string, ordinal = 0) =>
|
||||
adaptServerEvent({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||
} as OpenCodeEvent)
|
||||
|
||||
test("merges adjacent text deltas for the same message and ordinal", () => {
|
||||
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
expect(result[0]?.properties).toMatchObject({ delta: "hello world" })
|
||||
})
|
||||
|
||||
test("coalesces current tool input deltas by tool ID", () => {
|
||||
const current = (eventID: string, id: string, delta: string) =>
|
||||
adaptServerEvent({
|
||||
id: eventID,
|
||||
created: 1,
|
||||
type: "session.tool.input.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||
} as OpenCodeEvent)
|
||||
const result = coalesceServerEvents([
|
||||
current("evt_1", "call_1", "{"),
|
||||
current("evt_2", "call_1", "}"),
|
||||
current("evt_3", "call_2", "[]"),
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||
expect(result[1]?.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||
})
|
||||
|
||||
test("preserves boundaries between distinct delta streams", () => {
|
||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||
|
||||
expect(coalesceServerEvents(events).map((event) => event.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
||||
import type { Event } from "@/types"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type Accessor, onCleanup } from "solid-js"
|
||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { ServerConnection } from "./servers"
|
||||
@@ -12,78 +11,15 @@ import { ServerScope } from "@/utils/server-scope"
|
||||
import { useServer } from "./server"
|
||||
|
||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||
type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||
>
|
||||
|
||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: ServerEvent[]) {
|
||||
const output: ServerEvent[] = []
|
||||
events.forEach((event) => {
|
||||
const current = currentDelta(event.current)
|
||||
if (current) {
|
||||
const previous = output[output.length - 1]
|
||||
const prior = currentDelta(previous?.current)
|
||||
if (
|
||||
previous &&
|
||||
prior &&
|
||||
prior.location?.directory === current.location?.directory &&
|
||||
currentDeltaKey(prior) === currentDeltaKey(current)
|
||||
) {
|
||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||
const data =
|
||||
current.type === "session.compaction.delta"
|
||||
? { ...current.data, text: fragment }
|
||||
: { ...current.data, delta: fragment }
|
||||
output[output.length - 1] = {
|
||||
...event,
|
||||
properties: data,
|
||||
current: { ...current, data } as CurrentDelta,
|
||||
} as ServerEvent
|
||||
return
|
||||
}
|
||||
output.push(event)
|
||||
return
|
||||
}
|
||||
output.push(event)
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
event?.type === "session.reasoning.delta" ||
|
||||
event?.type === "session.tool.input.delta" ||
|
||||
event?.type === "session.compaction.delta"
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
function currentDeltaKey(event: CurrentDelta) {
|
||||
if (event.type === "session.tool.input.delta")
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||
}
|
||||
|
||||
function currentDeltaFragment(event: CurrentDelta) {
|
||||
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
||||
}
|
||||
|
||||
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
||||
if (!event.persisted) return
|
||||
start()
|
||||
}
|
||||
|
||||
type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
|
||||
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
|
||||
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||
export type ServerConnectionStatus = ClientConnectionStatus
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
scope: ServerScope
|
||||
@@ -105,227 +41,38 @@ type ServerSDKBase = {
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
const eventFetch = (() => {
|
||||
if (!platform.fetch || !server) return
|
||||
try {
|
||||
const url = new URL(server.http.url)
|
||||
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
||||
if (url.protocol === "http:" && !loopback) return platform.fetch
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
})()
|
||||
|
||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const emitter = createGlobalEmitter<ServerEventMap>()
|
||||
const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>()
|
||||
|
||||
const FLUSH_FRAME_MS = 16
|
||||
const STREAM_YIELD_MS = 8
|
||||
const CONNECT_TIMEOUT_MS = 2_000
|
||||
const RECONNECT_DELAY_MS = 1_000
|
||||
|
||||
let queue: ServerEvent[] = []
|
||||
let buffer: ServerEvent[] = []
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
function flush() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => {
|
||||
emitter.emit(event.type, event)
|
||||
const directory = event.current?.location?.directory
|
||||
if (directory) locations.emit(directory, event)
|
||||
})
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer) return
|
||||
const elapsed = Date.now() - last
|
||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||
}
|
||||
|
||||
function publish(event: OpenCodeEvent) {
|
||||
queue.push(adaptServerEvent(event))
|
||||
schedule()
|
||||
}
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
let attempt: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: ServerConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({ status: "connecting", attempt: 0 })
|
||||
|
||||
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
|
||||
let connectedAt: number | undefined
|
||||
|
||||
// Bound the initial handshake and tie this request to the stream lifetime.
|
||||
const request = new AbortController()
|
||||
const cancel = () => request.abort(signal.reason)
|
||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
|
||||
try {
|
||||
// Open the event stream and validate its initial handshake.
|
||||
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (first.done) {
|
||||
const error =
|
||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
||||
return { error, connectedAt }
|
||||
}
|
||||
if (first.value.type !== "server.connected")
|
||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||
|
||||
// Publish the connected state before forwarding live events.
|
||||
clearTimeout(timeout)
|
||||
publish(first.value)
|
||||
connectedAt = Date.now()
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
|
||||
// Forward events until the stream closes or this connection is cancelled.
|
||||
let yielded = Date.now()
|
||||
while (!signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||
publish(event.value)
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0, signal)
|
||||
}
|
||||
return { error: undefined, connectedAt }
|
||||
} catch (error) {
|
||||
return { error, connectedAt }
|
||||
} finally {
|
||||
request.abort()
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", cancel)
|
||||
}
|
||||
}
|
||||
|
||||
async function runStream(active: number) {
|
||||
let retries = 0
|
||||
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
|
||||
const controller = new AbortController()
|
||||
attempt = controller
|
||||
const onAbort = () => controller.abort()
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
const result = await connect(controller.signal)
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) {
|
||||
if (attempt === controller) attempt = undefined
|
||||
return
|
||||
}
|
||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
|
||||
retries += 1
|
||||
const message =
|
||||
result.error === undefined
|
||||
? undefined
|
||||
: result.error instanceof Error
|
||||
? result.error.message
|
||||
: String(result.error)
|
||||
console.info("[global-sdk] event stream disconnected", {
|
||||
url: server.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
attempt: retries,
|
||||
error: message,
|
||||
})
|
||||
setConnection({ status: "reconnecting", attempt: retries, error: message })
|
||||
await wait(RECONNECT_DELAY_MS, controller.signal)
|
||||
if (attempt === controller) attempt = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return run
|
||||
started = true
|
||||
const active = ++generation
|
||||
const previous = run
|
||||
const current = (async () => {
|
||||
if (previous) await previous
|
||||
await runStream(active)
|
||||
})().finally(() => {
|
||||
if (run !== current) return
|
||||
run = undefined
|
||||
flush()
|
||||
})
|
||||
run = current
|
||||
return run
|
||||
}
|
||||
|
||||
function stop() {
|
||||
started = false
|
||||
generation++
|
||||
attempt?.abort()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(window, "pagehide", stop)
|
||||
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
||||
void start()
|
||||
const connection = createClientConnection(api, {
|
||||
flushInterval: 16,
|
||||
pageLifecycle: true,
|
||||
onEvent(event) {
|
||||
const adapted = adaptServerEvent(event)
|
||||
emitter.emit(adapted.type, adapted)
|
||||
const directory = event.location?.directory
|
||||
if (directory) locations.emit(directory, adapted)
|
||||
},
|
||||
log: {
|
||||
info(message, data) {
|
||||
if (message !== "event stream disconnected") return
|
||||
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
abort.abort()
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
queue = []
|
||||
buffer = []
|
||||
emitter.clear()
|
||||
locations.clear()
|
||||
})
|
||||
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
url: server.http.url,
|
||||
api,
|
||||
connection: {
|
||||
status: () => connection.status,
|
||||
attempt: () => connection.attempt,
|
||||
error: () => connection.error,
|
||||
},
|
||||
connection,
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"./promise": "./src/promise/index.ts",
|
||||
"./promise/api": "./src/promise/api.ts",
|
||||
"./service": "./src/promise/service.ts",
|
||||
"./solid": "./src/solid/index.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./effect/api": "./src/effect/api.ts",
|
||||
"./effect/service": "./src/effect/service.ts"
|
||||
@@ -36,11 +37,15 @@
|
||||
"@opencode-ai/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101"
|
||||
"effect": "4.0.0-beta.101",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"effect": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -49,6 +54,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "../promise"
|
||||
|
||||
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||
export type ClientConnectionEvent = {
|
||||
readonly type: "client.connection"
|
||||
readonly created: number
|
||||
readonly data: {
|
||||
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
|
||||
readonly attempt: number
|
||||
readonly error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ClientConnectionOptions = {
|
||||
readonly reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
|
||||
readonly onEvent: (event: OpenCodeEvent) => void
|
||||
readonly flushInterval?: number
|
||||
readonly pageLifecycle?: boolean
|
||||
readonly log?: {
|
||||
readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void
|
||||
readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void
|
||||
}
|
||||
}
|
||||
|
||||
const connectTimeout = 2_000
|
||||
const reconnectDelay = 1_000
|
||||
const connectionHistoryLimit = 50
|
||||
|
||||
export function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
|
||||
const abort = new AbortController()
|
||||
const history: ClientConnectionEvent[] = []
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: ClientConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({ status: "connecting", attempt: 0 })
|
||||
let api = initialApi
|
||||
let pending: OpenCodeEvent[] = []
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let stream: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
|
||||
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
|
||||
if (history.length > connectionHistoryLimit) history.shift()
|
||||
}
|
||||
|
||||
function publish(event: OpenCodeEvent) {
|
||||
pending.push(event)
|
||||
if (flushTimer) return
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = undefined
|
||||
const events = pending
|
||||
pending = []
|
||||
batch(() => events.forEach(options.onEvent))
|
||||
}, options.flushInterval ?? 10)
|
||||
}
|
||||
|
||||
async function connect(signal: AbortSignal, attempt: number) {
|
||||
let connectedAt: number | undefined
|
||||
const request = new AbortController()
|
||||
const cancel = () => request.abort(signal.reason)
|
||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
|
||||
try {
|
||||
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
|
||||
options.log?.info?.("event stream connecting", { attempt })
|
||||
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (first.done)
|
||||
return {
|
||||
error:
|
||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
|
||||
connectedAt,
|
||||
}
|
||||
if (first.value.type !== "server.connected")
|
||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||
|
||||
clearTimeout(timeout)
|
||||
record("connected", attempt)
|
||||
connectedAt = Date.now()
|
||||
options.log?.info?.("event stream connected")
|
||||
publish(first.value)
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
|
||||
while (!signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||
if ("durable" in event.value)
|
||||
options.log?.debug?.("event", {
|
||||
type: event.value.type,
|
||||
aggregateID: event.value.durable.aggregateID,
|
||||
seq: event.value.durable.seq,
|
||||
})
|
||||
publish(event.value)
|
||||
}
|
||||
return { error: undefined, connectedAt }
|
||||
} catch (error) {
|
||||
return { error, connectedAt }
|
||||
} finally {
|
||||
request.abort()
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", cancel)
|
||||
}
|
||||
}
|
||||
|
||||
async function runStream(active: number) {
|
||||
let attempt = 0
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
setConnection({ status: attempt === 0 ? "connecting" : "reconnecting", attempt })
|
||||
const controller = new AbortController()
|
||||
stream = controller
|
||||
const cancel = () => controller.abort(abort.signal.reason)
|
||||
abort.signal.addEventListener("abort", cancel)
|
||||
const result = await connect(controller.signal, attempt)
|
||||
abort.signal.removeEventListener("abort", cancel)
|
||||
if (abort.signal.aborted || !started || generation !== active) return
|
||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= reconnectDelay) attempt = 0
|
||||
attempt += 1
|
||||
const message = errorMessage(result.error)
|
||||
record("disconnected", attempt, message)
|
||||
options.log?.info?.("event stream disconnected", { attempt, error: message })
|
||||
setConnection({ status: "reconnecting", attempt, error: message })
|
||||
|
||||
if (options.reconnect) {
|
||||
const next = await options.reconnect(controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted)
|
||||
options.log?.info?.("server resolution failed", { attempt, error: errorMessage(error) })
|
||||
})
|
||||
if (abort.signal.aborted || controller.signal.aborted || !started || generation !== active) return
|
||||
if (next) {
|
||||
api = next
|
||||
if (attempt === 1) continue
|
||||
}
|
||||
}
|
||||
await wait(reconnectDelay, controller.signal)
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return run
|
||||
started = true
|
||||
const active = ++generation
|
||||
const previous = run
|
||||
const current = (async () => {
|
||||
if (previous) await previous
|
||||
await runStream(active)
|
||||
})().finally(() => {
|
||||
if (run !== current) return
|
||||
run = undefined
|
||||
})
|
||||
run = current
|
||||
return run
|
||||
}
|
||||
|
||||
function stop() {
|
||||
started = false
|
||||
generation += 1
|
||||
stream?.abort()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (options.pageLifecycle) {
|
||||
const pagehide = () => stop()
|
||||
const pageshow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) void start()
|
||||
}
|
||||
window.addEventListener("pagehide", pagehide)
|
||||
window.addEventListener("pageshow", pageshow)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pagehide", pagehide)
|
||||
window.removeEventListener("pageshow", pageshow)
|
||||
})
|
||||
}
|
||||
void start()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
abort.abort()
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
pending = []
|
||||
})
|
||||
|
||||
return {
|
||||
status: () => connection.status,
|
||||
attempt: () => connection.attempt,
|
||||
error: () => connection.error,
|
||||
internal: {
|
||||
history: () => history.slice(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error === undefined) return undefined
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
export * from "./data"
|
||||
export * from "./connection"
|
||||
@@ -1,10 +1,43 @@
|
||||
import { Effect } from "effect"
|
||||
import { sql } from "drizzle-orm"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const previousV2Marker = "20260730195856_optional_session_title"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260804233008_loose_psylocke",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
// This marker identifies the completed pre-split V2 lineage. Its V2 tables
|
||||
// are canonical, so rename them in place instead of replaying the V1 squash.
|
||||
if (yield* tx.get(sql`SELECT id FROM migration WHERE id = ${previousV2Marker}`)) {
|
||||
const v1Only = yield* tx.get(sql`
|
||||
SELECT 1
|
||||
FROM message
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM session_message WHERE session_message.session_id = message.session_id
|
||||
)
|
||||
LIMIT 1
|
||||
`)
|
||||
if (v1Only) return yield* Effect.die(new Error("Previous V2 database contains V1-only session history"))
|
||||
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_project_idx\`;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_workspace_idx\`;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_parent_idx\`;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_time_suspended_idx\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session\` RENAME TO \`session_v2\`;`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||
)
|
||||
yield* tx.run(`DROP TABLE IF EXISTS \`data_migration\`;`)
|
||||
yield* tx.run(`DROP TABLE IF EXISTS \`session_context_epoch\`;`)
|
||||
yield* tx.run(`DROP TABLE IF EXISTS \`session_input\`;`)
|
||||
return
|
||||
}
|
||||
|
||||
yield* tx.run(`
|
||||
CREATE TABLE IF NOT EXISTS \`kv\` (
|
||||
\`key\` text PRIMARY KEY,
|
||||
|
||||
@@ -33,7 +33,7 @@ const layer = Layer.effect(
|
||||
` Workspace root folder: ${location.project.directory}`,
|
||||
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
||||
` Prefer ${global.tmp} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
|
||||
"</env>",
|
||||
].join("\n"),
|
||||
),
|
||||
|
||||
@@ -15,8 +15,10 @@ import { GoogleVertexPlugin } from "./provider/google-vertex.js"
|
||||
import { GroqPlugin } from "./provider/groq.js"
|
||||
import { KiloPlugin } from "./provider/kilo.js"
|
||||
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
|
||||
import { LMStudioPlugin } from "./provider/lmstudio.js"
|
||||
import { MistralPlugin } from "./provider/mistral.js"
|
||||
import { NvidiaPlugin } from "./provider/nvidia.js"
|
||||
import { OllamaPlugin } from "./provider/ollama.js"
|
||||
import { OpenAIPlugin } from "./provider/openai.js"
|
||||
import { SnowflakeCortexPlugin } from "./provider/snowflake-cortex.js"
|
||||
import { OpenAICompatiblePlugin } from "./provider/openai-compatible.js"
|
||||
@@ -27,6 +29,7 @@ import { SapAICorePlugin } from "./provider/sap-ai-core.js"
|
||||
import { TogetherAIPlugin } from "./provider/togetherai.js"
|
||||
import { VercelPlugin } from "./provider/vercel.js"
|
||||
import { VenicePlugin } from "./provider/venice.js"
|
||||
import { VLLMPlugin } from "./provider/vllm.js"
|
||||
import { XAIPlugin } from "./provider/xai.js"
|
||||
import { ZenmuxPlugin } from "./provider/zenmux.js"
|
||||
import type { PluginInternal } from "./internal.js"
|
||||
@@ -48,8 +51,10 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
GroqPlugin,
|
||||
KiloPlugin,
|
||||
LLMGatewayPlugin,
|
||||
LMStudioPlugin,
|
||||
MistralPlugin,
|
||||
NvidiaPlugin,
|
||||
OllamaPlugin,
|
||||
OpencodePlugin,
|
||||
SnowflakeCortexPlugin,
|
||||
OpenAICompatiblePlugin,
|
||||
@@ -60,6 +65,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
TogetherAIPlugin,
|
||||
VercelPlugin,
|
||||
VenicePlugin,
|
||||
VLLMPlugin,
|
||||
XAIPlugin,
|
||||
ZenmuxPlugin,
|
||||
DynamicProviderPlugin,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Config } from "../../config.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const providerID = "lmstudio"
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
type: Schema.Literals(["llm", "embedding"]),
|
||||
key: Schema.String,
|
||||
display_name: Schema.String,
|
||||
architecture: Schema.NullOr(Schema.String).pipe(Schema.optional),
|
||||
loaded_instances: Schema.Array(
|
||||
Schema.Struct({
|
||||
config: Schema.Struct({ context_length: Schema.Int }),
|
||||
}),
|
||||
),
|
||||
max_context_length: Schema.Int,
|
||||
capabilities: Schema.Struct({
|
||||
vision: Schema.Boolean,
|
||||
trained_for_tool_use: Schema.Boolean,
|
||||
}).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Response = Schema.Struct({ models: Schema.Array(RemoteModel) })
|
||||
const discovery = new Map<string, { checked: number; apiKey?: string; models?: (typeof RemoteModel.Type)[] }>()
|
||||
const discoveryLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input = "30 seconds") {
|
||||
return define({
|
||||
id: "opencode.provider.lmstudio",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const config = yield* Config.Service
|
||||
const source = { current: configured(yield* config.entries(), origin) }
|
||||
const loaded = { models: [] as (typeof RemoteModel.Type)[], hash: "[]" }
|
||||
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
if (loaded.models.length === 0) return
|
||||
integrations.remove(providerID)
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
if (loaded.models.length === 0) return
|
||||
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
|
||||
catalog.model.remove(providerID, model.id)
|
||||
}
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = "LM Studio"
|
||||
provider.activation = "enabled"
|
||||
provider.package = "@opencode-ai/ai/providers/openai-compatible"
|
||||
provider.settings = {
|
||||
baseURL: source.current.baseURL,
|
||||
provider: providerID,
|
||||
apiKey: source.current.apiKey ?? "",
|
||||
}
|
||||
provider.integrationID = undefined
|
||||
})
|
||||
for (const item of loaded.models) {
|
||||
catalog.model.update(providerID, item.key, (model) => {
|
||||
model.modelID = Model.ID.make(item.key)
|
||||
model.name = item.display_name || item.key
|
||||
model.family = item.architecture ? Model.Family.make(item.architecture) : undefined
|
||||
model.capabilities = {
|
||||
tools: item.capabilities?.trained_for_tool_use ?? false,
|
||||
input: ["text", ...(item.capabilities?.vision ? ["image"] : [])],
|
||||
output: ["text"],
|
||||
}
|
||||
model.limit = {
|
||||
context:
|
||||
item.loaded_instances.length === 0
|
||||
? item.max_context_length
|
||||
: Math.min(...item.loaded_instances.map((instance) => instance.config.context_length)),
|
||||
output: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const discover = Effect.fn("LMStudioPlugin.discover")(function* () {
|
||||
const current = source.current
|
||||
if (!current.endpoint) return undefined
|
||||
return yield* discoveryLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const cached = discovery.get(current.endpoint)
|
||||
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
|
||||
return { source: current, models: cached.models }
|
||||
discovery.set(current.endpoint, {
|
||||
checked: Date.now(),
|
||||
apiKey: current.apiKey,
|
||||
models: cached && cached.apiKey === current.apiKey ? cached.models : undefined,
|
||||
})
|
||||
const request = current.apiKey
|
||||
? HttpClientRequest.get(current.endpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(current.apiKey),
|
||||
)
|
||||
: HttpClientRequest.get(current.endpoint).pipe(HttpClientRequest.acceptJson)
|
||||
const response = yield* http
|
||||
.execute(request)
|
||||
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
|
||||
const models = response.models
|
||||
.filter((model) => model.type === "llm" && model.key.length > 0)
|
||||
.toSorted((a, b) => a.key.localeCompare(b.key))
|
||||
discovery.set(current.endpoint, { checked: Date.now(), apiKey: current.apiKey, models })
|
||||
return { source: current, models }
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("LMStudioPlugin.refresh")(function* () {
|
||||
const result = yield* discover()
|
||||
if (!result?.models || result.source !== source.current) return
|
||||
const hash = JSON.stringify(result.models)
|
||||
if (hash === loaded.hash) return
|
||||
loaded.models = result.models
|
||||
loaded.hash = hash
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
})
|
||||
|
||||
// Keep the last successful inventory through transient outages instead of flickering model availability.
|
||||
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
|
||||
const reload = Effect.fn("LMStudioPlugin.reload")(function* () {
|
||||
const next = configured(yield* config.entries(), origin)
|
||||
if (
|
||||
next.baseURL === source.current.baseURL &&
|
||||
next.apiKey === source.current.apiKey &&
|
||||
next.endpoint === source.current.endpoint
|
||||
)
|
||||
return
|
||||
source.current = next
|
||||
loaded.models = []
|
||||
loaded.hash = "[]"
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
yield* refresh().pipe(Effect.ignore)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
}
|
||||
|
||||
export const LMStudioPlugin = make()
|
||||
|
||||
function configured(entries: readonly Entry[], origin: string) {
|
||||
const settings = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) => {
|
||||
const settings = entry.info.providers?.[providerID]?.settings
|
||||
return settings ? [settings] : []
|
||||
})
|
||||
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
|
||||
const baseURL = (
|
||||
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
|
||||
).replace(/\/+$/, "")
|
||||
const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
|
||||
if (!URL.canParse(baseURL)) return { baseURL, apiKey }
|
||||
const url = new URL(baseURL)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return { baseURL, apiKey }
|
||||
const prefix = url.pathname.endsWith("/v1") ? url.pathname.slice(0, -3) : url.pathname.replace(/\/+$/, "")
|
||||
url.pathname = `${prefix}/api/v1/models`
|
||||
url.search = ""
|
||||
url.hash = ""
|
||||
return { baseURL, apiKey, endpoint: url.toString() }
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Config } from "../../config.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const providerID = "ollama"
|
||||
|
||||
const Details = Schema.Struct({
|
||||
parent_model: Schema.String.pipe(Schema.optional),
|
||||
format: Schema.String,
|
||||
family: Schema.String,
|
||||
families: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
parameter_size: Schema.String,
|
||||
quantization_level: Schema.String,
|
||||
})
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
name: Schema.String,
|
||||
model: Schema.String,
|
||||
remote_model: Schema.String.pipe(Schema.optional),
|
||||
remote_host: Schema.String.pipe(Schema.optional),
|
||||
modified_at: Schema.String,
|
||||
size: Schema.Int,
|
||||
digest: Schema.String,
|
||||
details: Details,
|
||||
})
|
||||
|
||||
const TagsResponse = Schema.Struct({ models: Schema.Array(RemoteModel) })
|
||||
const ShowRequest = Schema.Struct({ model: Schema.String })
|
||||
const ShowResponse = Schema.Struct({
|
||||
parameters: Schema.String.pipe(Schema.optional),
|
||||
license: Schema.String.pipe(Schema.optional),
|
||||
modified_at: Schema.String.pipe(Schema.optional),
|
||||
details: Details.pipe(Schema.optional),
|
||||
template: Schema.String.pipe(Schema.optional),
|
||||
capabilities: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
model_info: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
type DiscoveredModel = typeof RemoteModel.Type & { show: typeof ShowResponse.Type }
|
||||
type Discovery = {
|
||||
checked: number
|
||||
apiKey?: string
|
||||
models?: DiscoveredModel[]
|
||||
shows: Map<string, { digest: string; info: typeof ShowResponse.Type }>
|
||||
}
|
||||
|
||||
const discovery = new Map<string, Discovery>()
|
||||
const discoveryLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export function make(origin = "http://127.0.0.1:11434", interval: Duration.Input = "30 seconds") {
|
||||
return define({
|
||||
id: "opencode.provider.ollama",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const config = yield* Config.Service
|
||||
const source = { current: configured(yield* config.entries(), origin) }
|
||||
const loaded = { models: [] as DiscoveredModel[], hash: "[]" }
|
||||
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
if (loaded.models.length === 0) return
|
||||
integrations.remove(providerID)
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
if (loaded.models.length === 0) return
|
||||
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
|
||||
catalog.model.remove(providerID, model.id)
|
||||
}
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = "Ollama"
|
||||
provider.activation = "enabled"
|
||||
provider.package = "@opencode-ai/ai/providers/openai-compatible"
|
||||
provider.settings = {
|
||||
baseURL: source.current.baseURL,
|
||||
provider: providerID,
|
||||
apiKey: source.current.apiKey ?? "",
|
||||
}
|
||||
provider.integrationID = undefined
|
||||
})
|
||||
for (const item of loaded.models) {
|
||||
catalog.model.update(providerID, item.model, (model) => {
|
||||
model.modelID = Model.ID.make(item.model)
|
||||
model.name = item.name || item.model
|
||||
model.family = item.show.details?.family
|
||||
? Model.Family.make(item.show.details.family)
|
||||
: item.details.family
|
||||
? Model.Family.make(item.details.family)
|
||||
: undefined
|
||||
model.capabilities = {
|
||||
tools: item.show.capabilities?.includes("tools") ?? false,
|
||||
input: ["text", ...(item.show.capabilities?.includes("vision") ? ["image"] : [])],
|
||||
output: ["text"],
|
||||
}
|
||||
model.limit = {
|
||||
context:
|
||||
Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
|
||||
key.endsWith(".context_length") && typeof value === "number" && value > 0 ? [value] : [],
|
||||
)[0] ?? 0,
|
||||
output: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const discover = Effect.fn("OllamaPlugin.discover")(function* () {
|
||||
const current = source.current
|
||||
if (!current.tagsEndpoint || !current.showEndpoint) return undefined
|
||||
return yield* discoveryLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const cached = discovery.get(current.tagsEndpoint)
|
||||
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
|
||||
return { source: current, models: cached.models }
|
||||
const previous: Discovery =
|
||||
cached && cached.apiKey === current.apiKey
|
||||
? cached
|
||||
: { checked: 0, apiKey: current.apiKey, shows: new Map() }
|
||||
discovery.set(current.tagsEndpoint, { ...previous, checked: Date.now(), apiKey: current.apiKey })
|
||||
const tagsRequest = current.apiKey
|
||||
? HttpClientRequest.get(current.tagsEndpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(current.apiKey),
|
||||
)
|
||||
: HttpClientRequest.get(current.tagsEndpoint).pipe(HttpClientRequest.acceptJson)
|
||||
const response = yield* http
|
||||
.execute(tagsRequest)
|
||||
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(TagsResponse)), Effect.timeout("1 second"))
|
||||
const summaries = response.models
|
||||
.filter((model) => model.model.length > 0)
|
||||
.toSorted((a, b) => a.model.localeCompare(b.model))
|
||||
const shows = new Map<string, { digest: string; info: typeof ShowResponse.Type }>()
|
||||
const models = yield* Effect.forEach(
|
||||
summaries,
|
||||
(model) =>
|
||||
Effect.gen(function* () {
|
||||
const saved = previous.shows.get(model.model)
|
||||
const info =
|
||||
saved?.digest === model.digest
|
||||
? saved.info
|
||||
: yield* HttpClientRequest.post(current.showEndpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
current.apiKey ? HttpClientRequest.bearerToken(current.apiKey) : (request) => request,
|
||||
HttpClientRequest.schemaBodyJson(ShowRequest)({ model: model.model }),
|
||||
Effect.flatMap(http.execute),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(ShowResponse)),
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
shows.set(model.model, { digest: model.digest, info })
|
||||
return { ...model, show: info }
|
||||
}).pipe(Effect.catch(() => Effect.succeed(undefined))),
|
||||
{ concurrency: 4 },
|
||||
)
|
||||
const filtered = models.filter(
|
||||
(model): model is DiscoveredModel =>
|
||||
model !== undefined && (model.show.capabilities?.includes("completion") ?? false),
|
||||
)
|
||||
discovery.set(current.tagsEndpoint, {
|
||||
checked: Date.now(),
|
||||
apiKey: current.apiKey,
|
||||
models: filtered,
|
||||
shows,
|
||||
})
|
||||
return { source: current, models: filtered }
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("OllamaPlugin.refresh")(function* () {
|
||||
const result = yield* discover()
|
||||
if (!result?.models || result.source !== source.current) return
|
||||
const hash = JSON.stringify(result.models)
|
||||
if (hash === loaded.hash) return
|
||||
loaded.models = result.models
|
||||
loaded.hash = hash
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
})
|
||||
|
||||
// Keep the last successful inventory through transient outages instead of flickering model availability.
|
||||
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
|
||||
const reload = Effect.fn("OllamaPlugin.reload")(function* () {
|
||||
const next = configured(yield* config.entries(), origin)
|
||||
if (
|
||||
next.baseURL === source.current.baseURL &&
|
||||
next.apiKey === source.current.apiKey &&
|
||||
next.tagsEndpoint === source.current.tagsEndpoint
|
||||
)
|
||||
return
|
||||
source.current = next
|
||||
loaded.models = []
|
||||
loaded.hash = "[]"
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
yield* refresh().pipe(Effect.ignore)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
}
|
||||
|
||||
export const OllamaPlugin = make()
|
||||
|
||||
function configured(entries: readonly Entry[], origin: string) {
|
||||
const settings = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) => {
|
||||
const settings = entry.info.providers?.[providerID]?.settings
|
||||
return settings ? [settings] : []
|
||||
})
|
||||
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
|
||||
const baseURL = (
|
||||
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
|
||||
).replace(/\/+$/, "")
|
||||
const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
|
||||
if (!URL.canParse(baseURL)) return { baseURL, apiKey }
|
||||
const url = new URL(baseURL)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return { baseURL, apiKey }
|
||||
const prefix = url.pathname.endsWith("/v1") ? url.pathname.slice(0, -3) : url.pathname.replace(/\/+$/, "")
|
||||
url.pathname = `${prefix}/api/tags`
|
||||
url.search = ""
|
||||
url.hash = ""
|
||||
const tagsEndpoint = url.toString()
|
||||
url.pathname = `${prefix}/api/show`
|
||||
return { baseURL, apiKey, tagsEndpoint, showEndpoint: url.toString() }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Config } from "../../config.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const providerID = "vllm"
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
id: Schema.String,
|
||||
owned_by: Schema.String,
|
||||
max_model_len: Schema.NullOr(Schema.Int),
|
||||
})
|
||||
|
||||
const Response = Schema.Struct({ data: Schema.Array(RemoteModel) })
|
||||
const discovery = new Map<string, { checked: number; apiKey?: string; models?: (typeof RemoteModel.Type)[] }>()
|
||||
const discoveryLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export function make(origin = "http://127.0.0.1:8000", interval: Duration.Input = "30 seconds") {
|
||||
return define({
|
||||
id: "opencode.provider.vllm",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const config = yield* Config.Service
|
||||
const source = { current: configured(yield* config.entries(), origin) }
|
||||
const loaded = { models: [] as (typeof RemoteModel.Type)[], hash: "[]" }
|
||||
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
if (loaded.models.length === 0) return
|
||||
integrations.remove(providerID)
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
if (loaded.models.length === 0) return
|
||||
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
|
||||
catalog.model.remove(providerID, model.id)
|
||||
}
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = "vLLM"
|
||||
provider.package = "@opencode-ai/ai/providers/openai-compatible"
|
||||
provider.settings = {
|
||||
baseURL: source.current.baseURL,
|
||||
provider: providerID,
|
||||
apiKey: source.current.apiKey ?? "",
|
||||
}
|
||||
provider.integrationID = undefined
|
||||
provider.activation = "enabled"
|
||||
})
|
||||
for (const item of loaded.models) {
|
||||
catalog.model.update(providerID, item.id, (model) => {
|
||||
model.modelID = Model.ID.make(item.id)
|
||||
model.name = item.id
|
||||
// Tool calling depends on vLLM server flags and parsers that model discovery does not report.
|
||||
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
|
||||
model.limit = { context: item.max_model_len ?? 0, output: 0 }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const discover = Effect.fn("VLLMPlugin.discover")(function* () {
|
||||
const current = source.current
|
||||
if (!current.healthEndpoint || !current.modelsEndpoint) return undefined
|
||||
return yield* discoveryLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = `${current.healthEndpoint}\n${current.modelsEndpoint}`
|
||||
const cached = discovery.get(endpoint)
|
||||
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
|
||||
return { source: current, models: cached.models }
|
||||
discovery.set(endpoint, {
|
||||
checked: Date.now(),
|
||||
apiKey: current.apiKey,
|
||||
models: cached && cached.apiKey === current.apiKey ? cached.models : undefined,
|
||||
})
|
||||
const request = (endpoint: string) =>
|
||||
current.apiKey
|
||||
? HttpClientRequest.get(endpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(current.apiKey),
|
||||
)
|
||||
: HttpClientRequest.get(endpoint).pipe(HttpClientRequest.acceptJson)
|
||||
yield* http.execute(request(current.healthEndpoint)).pipe(Effect.timeout("1 second"))
|
||||
const response = yield* http
|
||||
.execute(request(current.modelsEndpoint))
|
||||
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
|
||||
const models = response.data
|
||||
.filter((model) => model.owned_by === providerID && model.id.length > 0)
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
discovery.set(endpoint, { checked: Date.now(), apiKey: current.apiKey, models })
|
||||
return { source: current, models }
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("VLLMPlugin.refresh")(function* () {
|
||||
const result = yield* discover()
|
||||
if (!result?.models || result.source !== source.current) return
|
||||
const hash = JSON.stringify(result.models)
|
||||
if (hash === loaded.hash) return
|
||||
loaded.models = result.models
|
||||
loaded.hash = hash
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
})
|
||||
|
||||
// Keep the last successful inventory through transient outages instead of flickering model availability.
|
||||
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
|
||||
const reload = Effect.fn("VLLMPlugin.reload")(function* () {
|
||||
const next = configured(yield* config.entries(), origin)
|
||||
if (
|
||||
next.baseURL === source.current.baseURL &&
|
||||
next.apiKey === source.current.apiKey &&
|
||||
next.healthEndpoint === source.current.healthEndpoint &&
|
||||
next.modelsEndpoint === source.current.modelsEndpoint
|
||||
)
|
||||
return
|
||||
source.current = next
|
||||
loaded.models = []
|
||||
loaded.hash = "[]"
|
||||
yield* ctx.integration.reload()
|
||||
yield* ctx.catalog.reload()
|
||||
yield* refresh().pipe(Effect.ignore)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
}
|
||||
|
||||
export const VLLMPlugin = make()
|
||||
|
||||
function configured(entries: readonly Entry[], origin: string) {
|
||||
const settings = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) => {
|
||||
const settings = entry.info.providers?.[providerID]?.settings
|
||||
return settings ? [settings] : []
|
||||
})
|
||||
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
|
||||
const baseURL = (
|
||||
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
|
||||
).replace(/\/+$/, "")
|
||||
const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
|
||||
if (!URL.canParse(baseURL)) return { baseURL, apiKey }
|
||||
const models = new URL(baseURL)
|
||||
if (models.protocol !== "http:" && models.protocol !== "https:") return { baseURL, apiKey }
|
||||
models.pathname = `${models.pathname.replace(/\/+$/, "")}/models`
|
||||
models.search = ""
|
||||
models.hash = ""
|
||||
const health = new URL(baseURL)
|
||||
const path = health.pathname.replace(/\/+$/, "")
|
||||
const prefix = path.endsWith("/v1") ? path.slice(0, -3) : path
|
||||
health.pathname = `${prefix}/health`
|
||||
health.search = ""
|
||||
health.hash = ""
|
||||
return { baseURL, apiKey, healthEndpoint: health.toString(), modelsEndpoint: models.toString() }
|
||||
}
|
||||
@@ -29,10 +29,41 @@ V1 documentation and syntax may be consulted only when the user explicitly
|
||||
asks about V1 or when needed as migration input. Outputs and recommendations
|
||||
must still use V2 unless the user specifically requests a V1 result.
|
||||
|
||||
## [Configuration](https://opencode.ai/v2/docs/config)
|
||||
## [CLI](https://opencode.ai/v2/docs/cli)
|
||||
|
||||
OpenCode configuration uses JSON or JSONC. Include the published schema so the
|
||||
user's editor can validate fields and provide autocomplete:
|
||||
For questions about the terminal interface, command-line invocation, `run`,
|
||||
`mini`, terminal providers, or other CLI behavior, fetch the
|
||||
[CLI guide](https://opencode.ai/v2/docs/cli) and the relevant page linked from
|
||||
that section.
|
||||
|
||||
CLI and TUI preferences are separate from OpenCode's server and project
|
||||
configuration. They live in the global `~/.config/opencode/cli.json`, or
|
||||
`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no
|
||||
project-local CLI configuration. Most preferences can also be changed from the
|
||||
TUI by pressing `Ctrl+P` and selecting **Open settings**.
|
||||
|
||||
Fetch the full [CLI configuration guide](https://opencode.ai/v2/docs/cli/config)
|
||||
before editing `cli.json`. It covers terminal-only settings such as themes,
|
||||
keybindings, terminal plugins, scrolling, attention alerts, diff presentation,
|
||||
and terminal integration. Do not put these settings in `opencode.json(c)`.
|
||||
|
||||
### [Keybinds](https://opencode.ai/v2/docs/cli/keybinds)
|
||||
|
||||
Configure keybindings under `keybinds` in `cli.json`. The leader key is the
|
||||
`keybinds.leader` entry; leader timing is configured separately under
|
||||
`leader.timeout`. Bindings can use a string, an array of strings, or an object
|
||||
when event behavior such as `preventDefault` is required. Disable a binding
|
||||
with `"none"` or `false`.
|
||||
|
||||
Never guess a command ID, default binding, or accepted key syntax. Fetch the
|
||||
full [keybind reference](https://opencode.ai/v2/docs/cli/keybinds), which lists
|
||||
the current IDs and defaults, before answering or editing a binding.
|
||||
|
||||
## [OpenCode configuration](https://opencode.ai/v2/docs/config)
|
||||
|
||||
OpenCode's server and project configuration uses JSON or JSONC. Include the
|
||||
published schema so the user's editor can validate fields and provide
|
||||
autocomplete:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -55,6 +86,10 @@ Common configuration fields include `model`, `default_agent`, `permissions`,
|
||||
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
|
||||
`references`, `formatter`, and `lsp`.
|
||||
|
||||
This configuration is distinct from `cli.json`. Use the
|
||||
[CLI configuration guide](https://opencode.ai/v2/docs/cli/config) for terminal
|
||||
preferences, especially themes and keybindings.
|
||||
|
||||
Do not guess field names or shapes. Fetch the V2 configuration guide and its
|
||||
linked topic guide as the source of truth, and preserve unrelated settings when
|
||||
editing an existing file. Keep the published `$schema` URL in configuration
|
||||
|
||||
@@ -50,6 +50,7 @@ interface Channel {
|
||||
interface State {
|
||||
readonly lock: Semaphore.Semaphore
|
||||
closed: boolean
|
||||
httpFallback: boolean
|
||||
channel?: Channel
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const state = (sessionID: SessionSchema.ID) => {
|
||||
const current = states.get(sessionID)
|
||||
if (current) return current
|
||||
const created = { lock: Semaphore.makeUnsafe(1), closed: false }
|
||||
const created = { lock: Semaphore.makeUnsafe(1), closed: false, httpFallback: false }
|
||||
states.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
@@ -241,7 +242,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
channel.active?.lifecycle.delivery === "terminal" ||
|
||||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
: error.reason._tag === "Transport" && error.reason.code === "1009"
|
||||
? "rejected"
|
||||
: "ambiguous",
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -274,6 +277,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
phase: "queue",
|
||||
delivery: "not-sent",
|
||||
})
|
||||
if (owner.httpFallback) return fallback(exchange)
|
||||
const key = affinity(exchange)
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const current = owner.channel
|
||||
@@ -420,6 +424,26 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
yield* poison(owner, channel, error)
|
||||
}),
|
||||
),
|
||||
Stream.catch((error) => {
|
||||
if (
|
||||
error.reason._tag !== "Transport" ||
|
||||
error.reason.code !== "1009" ||
|
||||
error.reason.delivery !== "rejected"
|
||||
)
|
||||
return Stream.fail(error)
|
||||
owner.httpFallback = true
|
||||
return Stream.unwrap(
|
||||
Effect.logWarning("session websocket request too large; using http", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "close",
|
||||
delivery: "rejected",
|
||||
code: error.reason.code,
|
||||
}).pipe(
|
||||
Effect.andThen(metric("fallback", { reason: "message_too_large" })),
|
||||
Effect.as(exchange.fallback()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const complete = Effect.sync(() => {
|
||||
if (owner.channel !== channel || channel.pending?.token !== token) return
|
||||
|
||||
@@ -324,7 +324,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
Effect.flatMap((code) => finish("exited", code)),
|
||||
Effect.catch(() => Effect.void),
|
||||
Effect.catch(() => finish("exited")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
|
||||
import previousV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke"
|
||||
import workspaceMigration from "@opencode-ai/core/database/migration/20260808023530_workspace_domain"
|
||||
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
|
||||
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
const run = <A, E>(
|
||||
@@ -128,6 +132,142 @@ describe("DatabaseMigration", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves previous V2 state through the current migration lineage", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`PRAGMA foreign_keys = ON`)
|
||||
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO migration (id, time_completed)
|
||||
VALUES ('20260730195856_optional_session_title', 1)
|
||||
`)
|
||||
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE project_directory (
|
||||
project_id text NOT NULL,
|
||||
directory text NOT NULL,
|
||||
type text,
|
||||
strategy text,
|
||||
time_created integer NOT NULL,
|
||||
PRIMARY KEY (project_id, directory)
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE workspace (
|
||||
id text PRIMARY KEY,
|
||||
type text NOT NULL,
|
||||
name text NOT NULL,
|
||||
project_id text NOT NULL,
|
||||
time_used integer NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE session (
|
||||
id text PRIMARY KEY,
|
||||
project_id text NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
||||
workspace_id text,
|
||||
parent_id text,
|
||||
time_suspended integer
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`CREATE INDEX session_project_idx ON session (project_id)`)
|
||||
yield* db.run(sql`CREATE INDEX session_workspace_idx ON session (workspace_id)`)
|
||||
yield* db.run(sql`CREATE INDEX session_parent_idx ON session (parent_id)`)
|
||||
yield* db.run(
|
||||
sql`CREATE INDEX session_time_suspended_idx ON session (time_suspended) WHERE "session"."time_suspended" IS NOT NULL`,
|
||||
)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE session_message (
|
||||
id text PRIMARY KEY,
|
||||
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE,
|
||||
data text NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE session_pending (
|
||||
id text PRIMARY KEY,
|
||||
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE event (
|
||||
id text PRIMARY KEY,
|
||||
aggregate_id text NOT NULL REFERENCES event_sequence(aggregate_id) ON DELETE CASCADE,
|
||||
seq integer NOT NULL,
|
||||
created integer NOT NULL,
|
||||
type text NOT NULL,
|
||||
data text NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`CREATE TABLE data_migration (name text PRIMARY KEY)`)
|
||||
yield* db.run(sql`INSERT INTO project VALUES ('project')`)
|
||||
yield* db.run(sql`INSERT INTO project_directory VALUES ('project', '/repo', 'main', NULL, 1)`)
|
||||
yield* db.run(sql`INSERT INTO session VALUES ('session', 'project', NULL, NULL, NULL)`)
|
||||
yield* db.run(sql`INSERT INTO session_message VALUES ('message', 'session', '{"text":"preserved"}')`)
|
||||
yield* db.run(sql`INSERT INTO session_pending VALUES ('pending', 'session')`)
|
||||
yield* db.run(sql`INSERT INTO event_sequence VALUES ('session', 41)`)
|
||||
yield* db.run(sql`INSERT INTO event VALUES ('event', 'session', 41, 1, 'session.text.ended.1', '{}')`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [
|
||||
previousV2Migration,
|
||||
workspaceMigration,
|
||||
executionClaimsMigration,
|
||||
sessionInboxMigration,
|
||||
worktreeMigration,
|
||||
])
|
||||
|
||||
expect(yield* db.get(sql`SELECT id, resume_attempts FROM session_v2`)).toEqual({
|
||||
id: "session",
|
||||
resume_attempts: 0,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT id, data FROM session_message`)).toEqual({
|
||||
id: "message",
|
||||
data: '{"text":"preserved"}',
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT id FROM session_pending`)).toEqual({ id: "pending" })
|
||||
expect(yield* db.get(sql`SELECT seq FROM event_sequence`)).toEqual({ seq: 41 })
|
||||
expect(yield* db.get(sql`SELECT id, seq FROM event`)).toEqual({ id: "event", seq: 41 })
|
||||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`),
|
||||
).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT directory FROM worktree`)).toEqual({ directory: "/repo" })
|
||||
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_message)`)).toContainEqual(
|
||||
expect.objectContaining({ table: "session_v2" }),
|
||||
)
|
||||
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_pending)`)).toContainEqual(
|
||||
expect.objectContaining({ table: "session_v2" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects previous V2 databases with V1-only session history", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO migration (id, time_completed)
|
||||
VALUES ('20260730195856_optional_session_title', 1)
|
||||
`)
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL)`)
|
||||
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
|
||||
yield* db.run(sql`INSERT INTO session VALUES ('session')`)
|
||||
yield* db.run(sql`INSERT INTO message VALUES ('message', 'session')`)
|
||||
|
||||
expect((yield* Effect.exit(DatabaseMigration.applyOnly(db, [previousV2Migration])))._tag).toBe("Failure")
|
||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
||||
name: "session",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${previousV2Migration.id}`)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("copies project directories into worktrees without removing the old table", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("InstructionBuiltIns", () => {
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
||||
` Prefer ${temporary} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { LMStudioPlugin, make } from "@opencode-ai/core/plugin/provider/lmstudio"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer()))
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* make(origin, interval).effect(host)
|
||||
})
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 3000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
describe("LMStudioPlugin", () => {
|
||||
it.effect("is registered as a built-in provider plugin", () =>
|
||||
Effect.sync(() => {
|
||||
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers local language models with their capabilities and effective context", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
Response.json({
|
||||
models: [
|
||||
{
|
||||
type: "llm",
|
||||
key: "google/gemma-4-26b-a4b",
|
||||
display_name: "Gemma 4 26B A4B",
|
||||
architecture: "gemma4",
|
||||
loaded_instances: [{ config: { context_length: 32_768 } }, { config: { context_length: 16_384 } }],
|
||||
max_context_length: 262_144,
|
||||
capabilities: { vision: true, trained_for_tool_use: true },
|
||||
},
|
||||
{
|
||||
type: "llm",
|
||||
key: "deepseek-r1",
|
||||
display_name: "DeepSeek R1",
|
||||
architecture: "deepseek",
|
||||
loaded_instances: [],
|
||||
max_context_length: 131_072,
|
||||
capabilities: { vision: false, trained_for_tool_use: false },
|
||||
},
|
||||
{
|
||||
type: "embedding",
|
||||
key: "nomic-embed",
|
||||
display_name: "Nomic Embed",
|
||||
loaded_instances: [],
|
||||
max_context_length: 2048,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(server.url.origin)
|
||||
const providerID = Provider.ID.make("lmstudio")
|
||||
const gemma = yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("google/gemma-4-26b-a4b")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
expect(yield* catalog.provider.get(providerID)).toEqual({
|
||||
id: providerID,
|
||||
name: "LM Studio",
|
||||
activation: "enabled",
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: { baseURL: `${server.url.origin}/v1`, provider: "lmstudio", apiKey: "" },
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
expect(gemma).toMatchObject({
|
||||
family: "gemma4",
|
||||
name: "Gemma 4 26B A4B",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
limit: { context: 16_384, output: 0 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("deepseek-r1"))).toMatchObject({
|
||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||
limit: { context: 131_072, output: 0 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("refreshes the catalog when LM Studio models change", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const models: Array<Record<string, unknown>> = []
|
||||
return {
|
||||
models,
|
||||
server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }),
|
||||
}
|
||||
}),
|
||||
({ models, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("lmstudio")
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
|
||||
|
||||
models.push({
|
||||
type: "llm",
|
||||
key: "qwen/qwen3-coder",
|
||||
display_name: "Qwen 3 Coder",
|
||||
architecture: "qwen3",
|
||||
loaded_instances: [],
|
||||
max_context_length: 65_536,
|
||||
capabilities: { vision: false, trained_for_tool_use: true },
|
||||
})
|
||||
expect(
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("qwen/qwen3-coder")),
|
||||
(model) => model !== undefined,
|
||||
),
|
||||
).toMatchObject({ name: "Qwen 3 Coder" })
|
||||
|
||||
models.splice(0)
|
||||
yield* eventually(catalog.provider.get(providerID), (provider) => provider === undefined)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"discovers from configured endpoints with bearer authentication",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: Array<{ authorization: string | null; path: string }> = []
|
||||
const model = (key: string) => ({
|
||||
type: "llm",
|
||||
key,
|
||||
display_name: key,
|
||||
loaded_instances: [],
|
||||
max_context_length: 32_768,
|
||||
})
|
||||
return {
|
||||
requests,
|
||||
initial: Bun.serve({ port: 0, fetch: () => Response.json({ models: [model("initial-model")] }) }),
|
||||
configured: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
requests.push({
|
||||
authorization: request.headers.get("authorization"),
|
||||
path: new URL(request.url).pathname,
|
||||
})
|
||||
return Response.json({ models: [model("configured-model")] })
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ requests, initial, configured }) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Test
|
||||
const providerID = Provider.ID.make("lmstudio")
|
||||
yield* addPlugin(initial.url.origin)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("initial-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
const baseURL = `${configured.url.origin}/proxy/v1`
|
||||
yield* config.setEntries([configuration(baseURL, "secret")])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("configured-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/api/v1/models" })
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
|
||||
baseURL,
|
||||
provider: "lmstudio",
|
||||
apiKey: "secret",
|
||||
})
|
||||
|
||||
requests.splice(0)
|
||||
yield* config.setEntries([configuration(baseURL, "secret"), configuration(baseURL, null)])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(catalog.provider.get(providerID), (provider) => provider?.settings?.apiKey === "")
|
||||
expect(requests).toContainEqual({ authorization: null, path: "/proxy/api/v1/models" })
|
||||
}),
|
||||
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
|
||||
it.live("shares discovery requests across plugin instances", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests = { count: 0 }
|
||||
return {
|
||||
requests,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => {
|
||||
requests.count++
|
||||
return Response.json({
|
||||
models: [
|
||||
{
|
||||
type: "llm",
|
||||
key: "shared-model",
|
||||
display_name: "Shared Model",
|
||||
loaded_instances: [],
|
||||
max_context_length: 32_768,
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ requests, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin(server.url.origin)
|
||||
yield* addPlugin(server.url.origin)
|
||||
yield* eventually(
|
||||
catalog.model.get(Provider.ID.make("lmstudio"), Model.ID.make("shared-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(requests.count).toBe(1)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("replaces the credential-gated Models.dev catalog when discovery succeeds", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const models = [
|
||||
{
|
||||
type: "llm",
|
||||
key: "discovered-model",
|
||||
display_name: "Discovered Model",
|
||||
loaded_instances: [],
|
||||
max_context_length: 32_768,
|
||||
},
|
||||
]
|
||||
return { models, server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }) }
|
||||
}),
|
||||
({ models, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const providerID = Provider.ID.make("lmstudio")
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(Integration.ID.make("lmstudio"), (integration) => {
|
||||
integration.name = "LMStudio"
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("lmstudio"),
|
||||
method: { type: "env", names: ["LMSTUDIO_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.name = "LMStudio"
|
||||
provider.package = "aisdk:@ai-sdk/openai-compatible"
|
||||
provider.integrationID = Integration.ID.make("lmstudio")
|
||||
})
|
||||
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).not.toContain(providerID)
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("discovered-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBeUndefined()
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(Integration.ID.make("lmstudio"), (integration) => {
|
||||
integration.name = "Configured LM Studio"
|
||||
})
|
||||
draft.method.update({ integrationID: Integration.ID.make("lmstudio"), method: { type: "key" } })
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
|
||||
models.splice(0)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("static-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeDefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("lmstudio"))
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function configuration(baseURL: string, apiKey: string | null) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
info: decode({ providers: { lmstudio: { settings: { baseURL, apiKey } } } }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { OllamaPlugin, make } from "@opencode-ai/core/plugin/provider/ollama"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer()))
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const decodeShowRequest = Schema.decodeUnknownSync(Schema.Struct({ model: Schema.String }))
|
||||
|
||||
const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* make(origin, interval).effect(host)
|
||||
})
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 3000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
describe("OllamaPlugin", () => {
|
||||
it.live("discovers local completion models and native metadata", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: Array<{ method: string; path: string; model?: string }> = []
|
||||
return {
|
||||
requests,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (request.method === "GET") {
|
||||
requests.push({ method: request.method, path })
|
||||
return Response.json({
|
||||
models: [
|
||||
summary("gemma3:4b", "gemma-digest", "gemma3"),
|
||||
summary("nomic-embed", "embed-digest"),
|
||||
summary("removed-model", "removed-digest"),
|
||||
],
|
||||
})
|
||||
}
|
||||
const body = decodeShowRequest(await request.json())
|
||||
requests.push({ method: request.method, path, model: body.model })
|
||||
if (body.model === "removed-model") return new Response("Not found", { status: 404 })
|
||||
return Response.json(
|
||||
body.model === "gemma3:4b"
|
||||
? {
|
||||
capabilities: ["completion", "tools", "vision"],
|
||||
model_info: { "gemma3.context_length": 131_072 },
|
||||
}
|
||||
: show({ family: "nomic-bert", capabilities: ["embedding"], context: 8192 }),
|
||||
)
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ requests, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("ollama")
|
||||
expect(OllamaPlugin.id).toBe("opencode.provider.ollama")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.ollama")
|
||||
yield* addPlugin(server.url.origin)
|
||||
const model = yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("gemma3:4b")),
|
||||
(item) => item !== undefined,
|
||||
)
|
||||
|
||||
expect(yield* catalog.provider.get(providerID)).toEqual({
|
||||
id: providerID,
|
||||
name: "Ollama",
|
||||
activation: "enabled",
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: { baseURL: `${server.url.origin}/v1`, provider: "ollama", apiKey: "" },
|
||||
})
|
||||
expect(model).toMatchObject({
|
||||
modelID: "gemma3:4b",
|
||||
name: "gemma3:4b",
|
||||
family: "gemma3",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
limit: { context: 131_072, output: 0 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
|
||||
expect(requests).toContainEqual({ method: "GET", path: "/api/tags" })
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "gemma3:4b" })
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "nomic-embed" })
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "removed-model" })
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("refreshes changed digests and retains inventory through transient failures", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { digest: "digest-1", context: 32_768, fail: false }
|
||||
const requests = { tags: 0, show: 0 }
|
||||
return {
|
||||
state,
|
||||
requests,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
if (request.method === "GET") {
|
||||
requests.tags++
|
||||
if (state.fail) return new Response("unavailable", { status: 503 })
|
||||
return Response.json({ models: [summary("qwen3:8b", state.digest, "qwen3")] })
|
||||
}
|
||||
decodeShowRequest(await request.json())
|
||||
requests.show++
|
||||
return Response.json(
|
||||
show({ family: "qwen3", capabilities: ["completion", "tools"], context: state.context }),
|
||||
)
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ state, requests, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("ollama")
|
||||
const modelID = Model.ID.make("qwen3:8b")
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.limit.context === 32_768)
|
||||
yield* eventually(
|
||||
Effect.sync(() => requests.tags),
|
||||
(count) => count >= 2,
|
||||
)
|
||||
expect(requests.show).toBe(1)
|
||||
|
||||
state.digest = "digest-2"
|
||||
state.context = 65_536
|
||||
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.limit.context === 65_536)
|
||||
expect(requests.show).toBe(2)
|
||||
|
||||
state.fail = true
|
||||
yield* Effect.promise(() => Bun.sleep(30))
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.limit.context).toBe(65_536)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("replaces and restores the same-ID Models.dev provider", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const models = [summary("discovered-model", "digest")]
|
||||
return {
|
||||
models,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
if (request.method === "GET") return Response.json({ models })
|
||||
decodeShowRequest(await request.json())
|
||||
return Response.json(show({ capabilities: ["completion"], context: 32_768 }))
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ models, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const providerID = Provider.ID.make("ollama")
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(Integration.ID.make("ollama"), (integration) => {
|
||||
integration.name = "Ollama"
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("ollama"),
|
||||
method: { type: "env", names: ["OLLAMA_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.name = "Ollama"
|
||||
provider.package = "aisdk:@ai-sdk/openai-compatible"
|
||||
provider.integrationID = Integration.ID.make("ollama")
|
||||
})
|
||||
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
|
||||
})
|
||||
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("discovered-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* integrations.get(Integration.ID.make("ollama"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.activation).toBe("enabled")
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
|
||||
|
||||
models.splice(0)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("static-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("ollama"))).toBeDefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.activation).toBe("auto")
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("ollama"))
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reloads layered endpoint and bearer authentication settings",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: Array<{ authorization: string | null; method: string; path: string }> = []
|
||||
return {
|
||||
requests,
|
||||
initial: Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
if (request.method === "GET")
|
||||
return Response.json({ models: [summary("initial-model", "initial-digest")] })
|
||||
decodeShowRequest(await request.json())
|
||||
return Response.json(show({ capabilities: ["completion"], context: 4096 }))
|
||||
},
|
||||
}),
|
||||
configured: Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
requests.push({
|
||||
authorization: request.headers.get("authorization"),
|
||||
method: request.method,
|
||||
path: new URL(request.url).pathname,
|
||||
})
|
||||
if (request.method === "GET")
|
||||
return Response.json({ models: [summary("configured-model", "configured-digest")] })
|
||||
decodeShowRequest(await request.json())
|
||||
return Response.json(show({ capabilities: ["completion", "vision"], context: 65_536 }))
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ requests, initial, configured }) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Test
|
||||
const providerID = Provider.ID.make("ollama")
|
||||
yield* addPlugin(initial.url.origin)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("initial-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
const baseURL = `${configured.url.origin}/proxy/v1`
|
||||
yield* config.setEntries([configuration({ baseURL, apiKey: "old" }), configuration({ apiKey: "secret" })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("configured-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(requests).toContainEqual({ authorization: "Bearer secret", method: "GET", path: "/proxy/api/tags" })
|
||||
expect(requests).toContainEqual({ authorization: "Bearer secret", method: "POST", path: "/proxy/api/show" })
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
|
||||
baseURL,
|
||||
provider: "ollama",
|
||||
apiKey: "secret",
|
||||
})
|
||||
|
||||
requests.splice(0)
|
||||
yield* config.setEntries([configuration({ baseURL, apiKey: "secret" }), configuration({ apiKey: null })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(catalog.provider.get(providerID), (provider) => provider?.settings?.apiKey === "")
|
||||
expect(requests).toContainEqual({ authorization: null, method: "GET", path: "/proxy/api/tags" })
|
||||
expect(requests).toContainEqual({ authorization: null, method: "POST", path: "/proxy/api/show" })
|
||||
}),
|
||||
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
})
|
||||
|
||||
function summary(model: string, digest: string, family = "llama") {
|
||||
return {
|
||||
name: model,
|
||||
model,
|
||||
modified_at: "2026-01-01T00:00:00Z",
|
||||
size: 1_000_000,
|
||||
digest,
|
||||
details: {
|
||||
format: "gguf",
|
||||
family,
|
||||
families: [family],
|
||||
parameter_size: "8B",
|
||||
quantization_level: "Q4_K_M",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function show(input: { family?: string; capabilities: string[]; context: number }) {
|
||||
const family = input.family ?? "llama"
|
||||
return {
|
||||
parameters: "temperature 0.7",
|
||||
details: {
|
||||
parent_model: "",
|
||||
format: "gguf",
|
||||
family,
|
||||
families: [family],
|
||||
parameter_size: "8B",
|
||||
quantization_level: "Q4_K_M",
|
||||
},
|
||||
capabilities: input.capabilities,
|
||||
model_info: {
|
||||
"general.architecture": family,
|
||||
[`${family}.context_length`]: input.context,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function configuration(settings: Record<string, string | null>) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
info: decode({ providers: { ollama: { settings } } }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { make, VLLMPlugin } from "@opencode-ai/core/plugin/provider/vllm"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer()))
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* make(origin, interval).effect(host)
|
||||
})
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 3000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
const remoteModel = (id: string, max_model_len = 32_768, owned_by = "vllm") => ({
|
||||
id,
|
||||
object: "model",
|
||||
created: 1,
|
||||
owned_by,
|
||||
root: id,
|
||||
parent: null,
|
||||
max_model_len,
|
||||
permission: [],
|
||||
})
|
||||
|
||||
describe("VLLMPlugin", () => {
|
||||
it.live("waits for readiness and discovers official vLLM model metadata", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { healthy: false, models: 0 }
|
||||
return {
|
||||
state,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/health") return new Response(null, { status: state.healthy ? 200 : 503 })
|
||||
state.models++
|
||||
return Response.json({
|
||||
object: "list",
|
||||
data: [remoteModel("Qwen/Qwen3-Coder", 65_536), remoteModel("foreign-model", 4096, "other")],
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ state, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("vllm")
|
||||
expect(VLLMPlugin.id).toBe("opencode.provider.vllm")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.vllm")
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* Effect.promise(() => Bun.sleep(20))
|
||||
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
|
||||
expect(state.models).toBe(0)
|
||||
|
||||
state.healthy = true
|
||||
const model = yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("Qwen/Qwen3-Coder")),
|
||||
(item) => item !== undefined,
|
||||
)
|
||||
expect(yield* catalog.provider.get(providerID)).toEqual({
|
||||
id: providerID,
|
||||
name: "vLLM",
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: { baseURL: `${server.url.origin}/v1`, provider: "vllm", apiKey: "" },
|
||||
activation: "enabled",
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
expect(model).toMatchObject({
|
||||
modelID: "Qwen/Qwen3-Coder",
|
||||
name: "Qwen/Qwen3-Coder",
|
||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||
limit: { context: 65_536, output: 0 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("foreign-model"))).toBeUndefined()
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("refreshes inventory while retaining the last success through transient failures", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { failing: false, models: [remoteModel("first-model")] }
|
||||
return {
|
||||
state,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
if (state.failing) return new Response(null, { status: 503 })
|
||||
if (new URL(request.url).pathname === "/health") return new Response()
|
||||
return Response.json({ object: "list", data: state.models })
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ state, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("vllm")
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* eventually(catalog.model.get(providerID, Model.ID.make("first-model")), (model) => model !== undefined)
|
||||
|
||||
state.failing = true
|
||||
state.models = [remoteModel("second-model")]
|
||||
yield* Effect.promise(() => Bun.sleep(30))
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("first-model"))).toBeDefined()
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("second-model"))).toBeUndefined()
|
||||
|
||||
state.failing = false
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("second-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("first-model"))).toBeUndefined()
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("replaces and restores same-ID Models.dev entries after an empty success", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const models = [remoteModel("discovered-model")]
|
||||
return {
|
||||
models,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
new URL(request.url).pathname === "/health"
|
||||
? new Response()
|
||||
: Response.json({ object: "list", data: models }),
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ models, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const providerID = Provider.ID.make("vllm")
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(Integration.ID.make("vllm"), (integration) => {
|
||||
integration.name = "vLLM"
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("vllm"),
|
||||
method: { type: "env", names: ["VLLM_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.name = "vLLM"
|
||||
provider.package = "aisdk:@ai-sdk/openai-compatible"
|
||||
provider.integrationID = Integration.ID.make("vllm")
|
||||
provider.activation = "auto"
|
||||
})
|
||||
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
|
||||
})
|
||||
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("discovered-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* integrations.get(Integration.ID.make("vllm"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.activation).toBe("enabled")
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
|
||||
|
||||
models.splice(0)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("static-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("vllm"))).toBeDefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("vllm"))
|
||||
expect((yield* catalog.provider.get(providerID))?.activation).toBe("auto")
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reloads layered custom endpoint and bearer authentication settings",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: Array<{ authorization: string | null; path: string }> = []
|
||||
return {
|
||||
requests,
|
||||
initial: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
new URL(request.url).pathname === "/health"
|
||||
? new Response()
|
||||
: Response.json({ object: "list", data: [remoteModel("initial-model")] }),
|
||||
}),
|
||||
configured: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
requests.push({
|
||||
authorization: request.headers.get("authorization"),
|
||||
path: new URL(request.url).pathname,
|
||||
})
|
||||
if (new URL(request.url).pathname === "/proxy/health") return new Response()
|
||||
return Response.json({ object: "list", data: [remoteModel("configured-model")] })
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ requests, initial, configured }) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Test
|
||||
const providerID = Provider.ID.make("vllm")
|
||||
yield* addPlugin(initial.url.origin)
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("initial-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
const baseURL = `${configured.url.origin}/proxy/v1`
|
||||
yield* config.setEntries([configuration({ baseURL }), configuration({ apiKey: "secret" })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("configured-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
|
||||
expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/health" })
|
||||
expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/v1/models" })
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
|
||||
baseURL,
|
||||
provider: "vllm",
|
||||
apiKey: "secret",
|
||||
})
|
||||
|
||||
requests.splice(0)
|
||||
yield* config.setEntries([configuration({ baseURL }), configuration({ apiKey: "next-secret" })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(
|
||||
catalog.provider.get(providerID),
|
||||
(provider) => provider?.settings?.apiKey === "next-secret",
|
||||
)
|
||||
expect(requests).toContainEqual({ authorization: "Bearer next-secret", path: "/proxy/health" })
|
||||
expect(requests).toContainEqual({ authorization: "Bearer next-secret", path: "/proxy/v1/models" })
|
||||
}),
|
||||
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
})
|
||||
|
||||
function configuration(settings: { baseURL?: string; apiKey?: string }) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
info: decode({ providers: { vllm: { settings } } }),
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from "@opencode-ai/ai/route"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Deferred, Effect, Fiber, Metric, Queue, Stream } from "effect"
|
||||
import { Cause, Deferred, Effect, Fiber, Metric, Queue, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
|
||||
@@ -545,6 +545,63 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back to HTTP after close code 1009 and keeps the Session on HTTP", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let opened = 0
|
||||
let fallbacks = 0
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.sync(() => {
|
||||
opened++
|
||||
return {
|
||||
sendText: () =>
|
||||
Effect.sync(() => {
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "websocket",
|
||||
reason: new TransportReason({
|
||||
message: "message too big",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
code: "1009",
|
||||
phase: "close",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}
|
||||
}),
|
||||
}
|
||||
const item = (id: string) =>
|
||||
exchange(id, {
|
||||
fallback: () => {
|
||||
fallbacks++
|
||||
return Stream.make(`http:${id}`)
|
||||
},
|
||||
})
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
|
||||
expect(yield* collect(executor, item("first"))).toEqual(["http:first"])
|
||||
expect(yield* collect(executor, item("second"))).toEqual(["http:second"])
|
||||
expect(opened).toBe(1)
|
||||
expect(fallbacks).toBe(2)
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("does not fall back after an ambiguous send failure", async () => {
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let fallbacks = 0
|
||||
|
||||
@@ -782,6 +782,40 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
if (!isWindows) {
|
||||
it.live("settles a shell terminated by an external signal", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const shell = yield* Shell.Service
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call({ command: idleCommand, background: true }, "call-external-signal"),
|
||||
)
|
||||
const shellID = settled.metadata?.shellID
|
||||
expect(typeof shellID).toBe("string")
|
||||
if (typeof shellID !== "string") return
|
||||
const id = ShellSchema.ID.make(shellID)
|
||||
const info = yield* shell.get(id)
|
||||
expect(typeof info.pid).toBe("number")
|
||||
if (info.pid === undefined) return
|
||||
|
||||
process.kill(-info.pid, "SIGTERM")
|
||||
const result = yield* shell.wait(id).pipe(Effect.timeoutOption(Duration.seconds(1)))
|
||||
expect(result._tag).toBe("Some")
|
||||
if (result._tag === "Some") expect(result.value.status).toBe("exited")
|
||||
expect((yield* shell.list()).map((item) => item.id)).not.toContain(id)
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("backgrounds a foreground command when the session is signaled", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -13,13 +13,7 @@ type Experiment = {
|
||||
// In-flight features anyone can opt into. Each entry is temporary: an
|
||||
// experiment either graduates (delete the entry, make the behavior
|
||||
// unconditional) or dies (delete the entry and the branch it gated).
|
||||
export const experiments: Experiment[] = [
|
||||
{
|
||||
id: "tab_scroll",
|
||||
title: "Remember tab scroll",
|
||||
description: "Keep each open tab's reading position and show a shortcut back to the bottom.",
|
||||
},
|
||||
]
|
||||
export const experiments: Experiment[] = []
|
||||
|
||||
export function DialogExperiments() {
|
||||
const config = useConfig()
|
||||
|
||||
@@ -452,7 +452,6 @@ export function Prompt(props: PromptProps) {
|
||||
title: "Queue prompt",
|
||||
name: "prompt.queue",
|
||||
category: "Prompt",
|
||||
palette: undefined,
|
||||
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabShortcutLabel,
|
||||
sessionTabNumberLabel,
|
||||
seedSessionTabMotion,
|
||||
sessionTabOverflowWidth,
|
||||
type SessionTab,
|
||||
@@ -426,7 +426,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
})
|
||||
const numberWidth = () => 2
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
|
||||
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
|
||||
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
|
||||
@@ -657,14 +657,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
backgroundColor={pulseBackground()}
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
|
||||
<text
|
||||
width={numberWidth()}
|
||||
width={numberWidth() + 1}
|
||||
fg={numberColor()}
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{sessionTabShortcutLabel(index())}
|
||||
{sessionTabNumberLabel(index()).padStart(numberWidth())}
|
||||
</text>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
@@ -1040,8 +1040,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
|
||||
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
|
||||
const numberWidth = () => 2
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
const restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth())
|
||||
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 2)
|
||||
@@ -1141,11 +1140,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row">
|
||||
<text width={1} selectable={false}>
|
||||
{" "}
|
||||
</text>
|
||||
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
|
||||
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())}
|
||||
</text>
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
|
||||
@@ -178,7 +178,7 @@ export const Definitions = {
|
||||
"session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
"prompt.submit": keybind("none", "Submit prompt"),
|
||||
"prompt.queue": keybind("alt+return", "Queue prompt"),
|
||||
"prompt.queue": keybind("<leader>return", "Queue prompt"),
|
||||
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
|
||||
"prompt.images.view": keybind("<leader>i", "View image attachments"),
|
||||
"prompt.skills": keybind("none", "Open skill selector"),
|
||||
|
||||
@@ -163,7 +163,7 @@ export const Definitions = {
|
||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
prompt_submit: keybind("none", "Submit prompt"),
|
||||
prompt_queue: keybind("alt+return", "Queue prompt"),
|
||||
prompt_queue: keybind("<leader>return", "Queue prompt"),
|
||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||
prompt_images_view: keybind("<leader>i", "View image attachments"),
|
||||
prompt_skills: keybind("none", "Open skill selector"),
|
||||
|
||||
@@ -1,177 +1,39 @@
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { createClientConnection } from "@opencode-ai/client/solid"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useLog } from "./log"
|
||||
|
||||
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||
export type ClientConnectionEvent = {
|
||||
readonly type: "client.connection"
|
||||
readonly created: number
|
||||
readonly data: {
|
||||
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
|
||||
readonly attempt: number
|
||||
readonly error?: string
|
||||
}
|
||||
}
|
||||
|
||||
type ManagedService = {
|
||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
|
||||
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
const connectTimeout = 2_000
|
||||
const connectionHistoryLimit = 50
|
||||
const eventFlushInterval = 10
|
||||
|
||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||
name: "Client",
|
||||
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
|
||||
const log = useLog({ component: "client" })
|
||||
const abort = new AbortController()
|
||||
const history: ClientConnectionEvent[] = []
|
||||
let api = props.api
|
||||
const service = props.service
|
||||
const events = createGlobalEmitter<ClientEventMap>()
|
||||
let pending: OpenCodeEvent[] = []
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: ClientConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
})
|
||||
let stream: AbortController | undefined
|
||||
let api = props.api
|
||||
|
||||
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
|
||||
if (history.length > connectionHistoryLimit) history.shift()
|
||||
}
|
||||
|
||||
function flushEvents() {
|
||||
flushTimer = undefined
|
||||
const queued = pending
|
||||
pending = []
|
||||
batch(() => queued.forEach((event) => events.emit(event.type, event)))
|
||||
}
|
||||
|
||||
function emit(event: OpenCodeEvent) {
|
||||
pending.push(event)
|
||||
if (flushTimer) return
|
||||
flushTimer = setTimeout(flushEvents, eventFlushInterval)
|
||||
}
|
||||
|
||||
async function connect(signal: AbortSignal, attempt: number) {
|
||||
let connectedAt: number | undefined
|
||||
|
||||
// Bound the initial handshake and tie this request to the stream lifetime.
|
||||
const request = new AbortController()
|
||||
const cancel = () => request.abort(signal.reason)
|
||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
|
||||
try {
|
||||
// Open the event stream and validate its initial handshake.
|
||||
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
|
||||
log.info("event stream connecting", { attempt })
|
||||
|
||||
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (first.done) {
|
||||
const error =
|
||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
||||
return { error, connectedAt }
|
||||
}
|
||||
if (first.value.type !== "server.connected")
|
||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||
|
||||
// Publish the connected state before forwarding live events.
|
||||
clearTimeout(timeout)
|
||||
record("connected", attempt)
|
||||
connectedAt = Date.now()
|
||||
log.info("event stream connected")
|
||||
emit(first.value)
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
|
||||
// Forward events until the stream closes or this connection is cancelled.
|
||||
while (!signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||
|
||||
if ("durable" in event.value)
|
||||
log.debug("event", {
|
||||
type: event.value.type,
|
||||
aggregateID: event.value.durable.aggregateID,
|
||||
seq: event.value.durable.seq,
|
||||
})
|
||||
|
||||
emit(event.value)
|
||||
}
|
||||
|
||||
return { error: undefined, connectedAt }
|
||||
} catch (error) {
|
||||
return { error, connectedAt }
|
||||
} finally {
|
||||
request.abort()
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", cancel)
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
stream?.abort()
|
||||
const controller = new AbortController()
|
||||
stream = controller
|
||||
void (async () => {
|
||||
let attempt = 0
|
||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||
const result = await connect(controller.signal, attempt)
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) attempt = 0
|
||||
attempt += 1
|
||||
const message = errorMessage(result.error)
|
||||
record("disconnected", attempt, message)
|
||||
log.info("event stream disconnected", {
|
||||
attempt,
|
||||
error: message,
|
||||
})
|
||||
setConnection({ status: "reconnecting", attempt, error: message })
|
||||
// Re-resolve the transport before retrying: the server may have
|
||||
// moved (service restarted on a new port) or need starting. Static
|
||||
// transports (--server, standalone) resolve to the same address.
|
||||
if (props.service) {
|
||||
const next = await props.service.reconnect(controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted)
|
||||
log.info("server resolution failed", {
|
||||
attempt,
|
||||
error: errorMessage(error),
|
||||
})
|
||||
})
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (next) {
|
||||
api = next.api
|
||||
if (attempt === 1) continue
|
||||
}
|
||||
const connection = createClientConnection(api, {
|
||||
reconnect: service
|
||||
? async (signal) => {
|
||||
api = (await service.reconnect(signal)).api
|
||||
return api
|
||||
}
|
||||
await wait(1_000, controller.signal)
|
||||
}
|
||||
})()
|
||||
}
|
||||
: undefined,
|
||||
onEvent(event) {
|
||||
events.emit(event.type, event)
|
||||
},
|
||||
log,
|
||||
})
|
||||
|
||||
onMount(start)
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
stream?.abort()
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
pending = []
|
||||
events.clear()
|
||||
})
|
||||
|
||||
@@ -183,35 +45,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
||||
on: events.on,
|
||||
listen: events.listen,
|
||||
},
|
||||
connection: {
|
||||
status() {
|
||||
return connection.status
|
||||
},
|
||||
attempt() {
|
||||
return connection.attempt
|
||||
},
|
||||
error() {
|
||||
return connection.error
|
||||
},
|
||||
internal: {
|
||||
history() {
|
||||
return history.slice()
|
||||
},
|
||||
},
|
||||
},
|
||||
restart: props.service?.restart,
|
||||
connection,
|
||||
restart: service?.restart,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,8 @@ export type SessionTabUnread = "activity" | "error"
|
||||
|
||||
export const NEW_SESSION_TAB_TITLE = "New session"
|
||||
|
||||
export function sessionTabShortcutLabel(index: number) {
|
||||
if (index >= 0 && index < 9) return String(index + 1)
|
||||
if (index === 9) return "0"
|
||||
return "·"
|
||||
export function sessionTabNumberLabel(index: number) {
|
||||
return String(index + 1)
|
||||
}
|
||||
|
||||
export function sessionTabDetail(
|
||||
|
||||
@@ -87,11 +87,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
renderer.off("blur", onBlur)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (config.experimental?.tab_scroll === true) return
|
||||
scrollAnchors.clear()
|
||||
})
|
||||
|
||||
function state() {
|
||||
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
|
||||
return store.global
|
||||
|
||||
@@ -1050,6 +1050,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
id: "prompt.queue",
|
||||
title: "Queue prompt",
|
||||
group: "Prompt",
|
||||
palette: true,
|
||||
run() {
|
||||
syncDraft()
|
||||
submitPrompt(promptCopy(draft), "queue")
|
||||
|
||||
@@ -595,7 +595,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
{
|
||||
id: "session.queued_prompts",
|
||||
title: "View queued prompts",
|
||||
group: "Session",
|
||||
group: "Prompt",
|
||||
run: openQueuedMenu,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -429,7 +429,6 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
|
||||
}
|
||||
function updateAwayFromBottom() {
|
||||
if (config.experimental?.tab_scroll !== true) return
|
||||
if (awayTimer) clearTimeout(awayTimer)
|
||||
awayTimer = setTimeout(() => {
|
||||
awayTimer = undefined
|
||||
@@ -440,7 +439,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
})
|
||||
}
|
||||
function saveScrollAnchor() {
|
||||
if (config.experimental?.tab_scroll !== true || !isAwayFromBottom()) {
|
||||
if (!isAwayFromBottom()) {
|
||||
sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||
return
|
||||
}
|
||||
@@ -457,7 +456,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
else sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||
}
|
||||
function restoreScrollPosition() {
|
||||
const anchor = config.experimental?.tab_scroll === true ? sessionTabs.scrollAnchor(sessionID) : undefined
|
||||
const anchor = sessionTabs.scrollAnchor(sessionID)
|
||||
const index = anchor ? boundaries().indexOf(anchor.messageID) : -1
|
||||
if (!anchor || index === -1) {
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
@@ -1063,7 +1062,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
{
|
||||
title: "View queued prompts",
|
||||
id: "session.queued_prompts",
|
||||
group: "Session",
|
||||
group: "Prompt",
|
||||
enabled: queuedPrompts().length > 0,
|
||||
run: openQueuedPrompts,
|
||||
},
|
||||
@@ -1195,15 +1194,21 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
|
||||
<Show when={config.experimental?.tab_scroll === true && awayFromBottom()}>
|
||||
<text
|
||||
fg={latestHovered() ? theme.text.default : theme.text.subdued}
|
||||
<Show when={awayFromBottom()}>
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
latestHovered() ? theme.background.action.primary.focused : theme.background.action.primary.default
|
||||
}
|
||||
onMouseOver={() => setLatestHovered(true)}
|
||||
onMouseOut={() => setLatestHovered(false)}
|
||||
onMouseUp={toBottom}
|
||||
>
|
||||
Latest ↓
|
||||
</text>
|
||||
<text fg={latestHovered() ? theme.text.action.primary.focused : theme.text.action.primary.default}>
|
||||
Jump to latest ↓
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<box flexShrink={0}>
|
||||
|
||||
@@ -107,6 +107,7 @@ test("preserves migrated v1 keybind defaults", () => {
|
||||
const pairs = [
|
||||
["app.exit", "app_exit"],
|
||||
["prompt.paste", "input_paste"],
|
||||
["prompt.queue", "prompt_queue"],
|
||||
["session.delete", "session_delete"],
|
||||
["session.list", "session_list"],
|
||||
["agent.list", "agent_list"],
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabOverflowWidth,
|
||||
sessionTabShortcutLabel,
|
||||
sessionTabNumberLabel,
|
||||
} from "../../src/context/session-tabs-model"
|
||||
|
||||
describe("session tabs", () => {
|
||||
@@ -25,8 +25,8 @@ describe("session tabs", () => {
|
||||
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
|
||||
})
|
||||
|
||||
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
|
||||
test("labels tabs by ordinal", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabNumberLabel(index))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
@@ -36,9 +36,9 @@ describe("session tabs", () => {
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"0",
|
||||
"·",
|
||||
"·",
|
||||
"10",
|
||||
"11",
|
||||
"12",
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -239,6 +239,23 @@ test("stores session tabs for the current working directory by default", async (
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps scroll anchors for open session tabs", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
try {
|
||||
await wait(() => setup.tabs.current() === "first")
|
||||
setup.tabs.setScrollAnchor("first", { messageID: "msg_1", screenY: -3 })
|
||||
|
||||
expect(setup.tabs.scrollAnchor("first")).toEqual({ messageID: "msg_1", screenY: -3 })
|
||||
|
||||
setup.tabs.close("first")
|
||||
await wait(() => setup.tabs.tabs().every((tab) => tab.sessionID !== "first"))
|
||||
expect(setup.tabs.scrollAnchor("first")).toBeUndefined()
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("only the foreground TUI mutates unread state", async () => {
|
||||
await using temporary = await tmpdir()
|
||||
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
|
||||
@@ -981,7 +981,8 @@ test("direct footer steers the oldest queued prompt from an empty composer", asy
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter({ meta: true })
|
||||
app.mockInput.pressKey("x", { ctrl: true })
|
||||
app.mockInput.pressEnter()
|
||||
await Bun.sleep(0)
|
||||
expect(steered).toEqual([])
|
||||
app.mockInput.pressEnter()
|
||||
@@ -1034,7 +1035,8 @@ test("direct footer rejects local commands submitted with the queue shortcut", a
|
||||
try {
|
||||
await app.renderOnce()
|
||||
await app.mockInput.typeText("/settings ")
|
||||
app.mockInput.pressEnter({ meta: true })
|
||||
app.mockInput.pressKey("x", { ctrl: true })
|
||||
app.mockInput.pressEnter()
|
||||
await Bun.sleep(0)
|
||||
expect(submitted).toEqual([])
|
||||
expect(statuses).toContain("this prompt cannot be queued")
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("run runtime boot", () => {
|
||||
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
|
||||
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
|
||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
|
||||
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
|
||||
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("<leader>return")
|
||||
})
|
||||
|
||||
test("preserves shared config while resolving independent Mini defaults", async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
[data-slot="animated-number-digit"] {
|
||||
display: inline-block;
|
||||
display: inline-grid;
|
||||
width: 1ch;
|
||||
height: 1em;
|
||||
line-height: 1em;
|
||||
@@ -41,19 +41,12 @@
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
[data-slot="animated-number-static"],
|
||||
[data-slot="animated-number-strip"] {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
|
||||
transition-property: transform;
|
||||
transition-duration: var(--animated-number-duration, 560ms);
|
||||
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="animated-number-strip"][data-animating="false"] {
|
||||
transition-duration: 0ms;
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
[data-slot="animated-number-static"],
|
||||
[data-slot="animated-number-cell"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -62,6 +55,24 @@
|
||||
height: 1em;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
[data-slot="animated-number-digit"][data-animating="true"] [data-slot="animated-number-static"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
[data-slot="animated-number-strip"] {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
margin-top: calc(var(--animated-number-offset, 10) * -1em);
|
||||
transition-property: margin-top;
|
||||
transition-duration: var(--animated-number-duration, 560ms);
|
||||
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="animated-number-digit"][data-animating="false"] [data-slot="animated-number-strip"] {
|
||||
transition-duration: 0ms;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -71,5 +82,12 @@
|
||||
|
||||
[data-component="animated-number"] [data-slot="animated-number-strip"] {
|
||||
transition-duration: 0ms;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
[data-component="animated-number"]
|
||||
[data-slot="animated-number-digit"][data-animating]
|
||||
[data-slot="animated-number-static"] {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,10 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<span data-slot="animated-number-digit">
|
||||
<span data-slot="animated-number-digit" data-animating={animating() ? "true" : "false"}>
|
||||
<span data-slot="animated-number-static">{props.value}</span>
|
||||
<span
|
||||
data-slot="animated-number-strip"
|
||||
data-animating={animating() ? "true" : "false"}
|
||||
onTransitionEnd={() => {
|
||||
setState("animating", false)
|
||||
setState("step", (value) => normalize(value) + 10)
|
||||
|
||||
@@ -152,6 +152,112 @@ provider and model configuration. An unknown variant fails model resolution inst
|
||||
|
||||
### Local models
|
||||
|
||||
#### Ollama
|
||||
|
||||
OpenCode automatically discovers language models from an Ollama server listening on its default address,
|
||||
`http://127.0.0.1:11434`. Discovered models use the `ollama` provider ID and Ollama's model name:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "ollama/gemma3:4b",
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from Ollama.
|
||||
Embedding-only models are excluded because they cannot drive a session. Disable discovery with
|
||||
`"plugins": ["-opencode.provider.ollama"]`.
|
||||
|
||||
For a different host or port, configure Ollama's OpenAI-compatible base URL. Models are still discovered through the
|
||||
native Ollama API at the same path prefix:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"settings": {
|
||||
"baseURL": "http://127.0.0.1:5678/v1",
|
||||
"apiKey": "{env:OLLAMA_API_KEY}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Omit `apiKey` when the Ollama endpoint does not require bearer authentication.
|
||||
|
||||
#### LM Studio
|
||||
|
||||
OpenCode automatically discovers language models from an unauthenticated LM Studio server listening on its default
|
||||
address, `http://127.0.0.1:1234`. Discovered models use the `lmstudio` provider ID and LM Studio's model key:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "lmstudio/google/gemma-4-26b-a4b",
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from LM
|
||||
Studio. Embedding models are excluded because they cannot drive a session. Disable discovery with
|
||||
`"plugins": ["-opencode.provider.lmstudio"]`.
|
||||
|
||||
For a different host or port, configure the OpenAI-compatible base URL. Models are still discovered automatically:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"providers": {
|
||||
"lmstudio": {
|
||||
"settings": {
|
||||
"baseURL": "http://127.0.0.1:5678/v1",
|
||||
"apiKey": "{env:LMSTUDIO_API_KEY}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Omit `apiKey` when LM Studio authentication is disabled.
|
||||
|
||||
#### vLLM
|
||||
|
||||
OpenCode automatically discovers models from a vLLM server listening on its default address, `http://127.0.0.1:8000`.
|
||||
Discovered models use the `vllm` provider ID and the model ID reported by vLLM:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "vllm/Qwen/Qwen3-Coder-30B-A3B-Instruct",
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode checks vLLM's `/health` endpoint and refreshes `/v1/models` in the background. It uses the reported
|
||||
`max_model_len` as the context limit and only includes model cards owned by `vllm`. Discovered vLLM models advertise
|
||||
text input and output, but not vision or tools. Tool calling is conservative because vLLM enables it with server-level
|
||||
flags such as `--enable-auto-tool-choice` and `--tool-call-parser`, which model discovery does not report. Disable
|
||||
discovery with `"plugins": ["-opencode.provider.vllm"]`.
|
||||
|
||||
For a different endpoint or an authenticated server, configure its OpenAI-compatible base URL:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"providers": {
|
||||
"vllm": {
|
||||
"settings": {
|
||||
"baseURL": "http://127.0.0.1:9000/v1",
|
||||
"apiKey": "{env:VLLM_API_KEY}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Omit `apiKey` when authentication is disabled. Path-prefixed proxy URLs are supported; for example,
|
||||
`https://example.com/vllm/v1` checks `/vllm/health` and discovers `/vllm/v1/models`.
|
||||
|
||||
For an OpenAI-compatible server, define a provider package, endpoint, and at least one model:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
@@ -161,7 +267,7 @@ For an OpenAI-compatible server, define a provider package, endpoint, and at lea
|
||||
"providers": {
|
||||
"local": {
|
||||
"name": "Local server",
|
||||
"package": "aisdk:@ai-sdk/openai-compatible",
|
||||
"package": "@opencode-ai/ai/providers/openai-compatible",
|
||||
"settings": {
|
||||
"baseURL": "http://127.0.0.1:1234/v1",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user