Compare commits

...
Author SHA1 Message Date
Shoubhit Dash 9bf98793d9 refactor(cli): simplify auth command flow 2026-08-18 02:49:07 +05:30
Shoubhit Dash a5cb47f4ce fix(cli): restore auth help descriptions 2026-08-18 02:38:37 +05:30
Shoubhit Dash ef8685ab77 feat(cli): add interactive auth commands 2026-08-18 02:24:52 +05:30
opencode-agent[bot] f161c90057 chore: generate 2026-08-17 20:21:54 +00:00
opencode-agent[bot] 728ae7c949 fix(stats): prefer Go catalog pricing (#43120) 2026-08-17 15:20:06 -05:00
Dax 42fc297d30 feat(tui): inherit terminal environment per session (#42957) 2026-08-17 20:18:28 +00:00
Aiden Cline 43dd33842e fix(ai): default Gemini function args (#43111) 2026-08-17 13:43:52 -05:00
opencode-agent[bot] 3d13b6c5b6 chore: generate 2026-08-17 18:14:05 +00:00
Aiden Cline 996b05432f fix(ai): preserve Gemini prompt safety blocks (#43070) 2026-08-17 13:12:51 -05:00
Aiden Cline c53f4cfb09 fix(core): clarify temporary directory guidance (#43095) 2026-08-17 11:50:51 -05:00
Kit Langton 45a49ae32a fix(core): preserve previous V2 database lineage (#43092) 2026-08-17 12:39:02 -04:00
Kit Langton 238ce304a9 fix(tui): expose queued prompt shortcut (#43083) 2026-08-17 12:33:18 -04:00
opencode-agent[bot]andShoubhit Dash cf606660fb fix(core): settle externally signaled shells (#43086)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-08-17 21:57:35 +05:30
Kit Langton 759695d87c fix(tui): show full tab numbers (#43081) 2026-08-17 11:30:49 -04:00
Dax Raad f14724dfb1 docs(core): reference CLI configuration 2026-08-17 09:57:50 -04:00
Shoubhit Dash cc53db4406 feat(core): discover vLLM models (#43022) 2026-08-17 17:56:03 +05:30
Shoubhit Dash fa055143ea feat(core): discover Ollama models (#43021) 2026-08-17 15:46:47 +05:30
opencode-agent[bot]andShoubhit Dash 875b28658f fix(tui): preserve scroll position across tabs (#43013)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-08-17 13:56:30 +05:30
Brendan Allan 0e022036fb refactor(app): use shared client connection (#43016) 2026-08-17 07:53:03 +00:00
Shoubhit Dash f0ae3b9569 fix(core): activate discovered LM Studio provider (#43015) 2026-08-17 13:10:57 +05:30
opencode-agent[bot] 3b5837d354 chore: generate 2026-08-17 07:35:07 +00:00
Brendan Allan e1ff217e44 refactor(client): share Solid server data (#42999) 2026-08-17 15:33:57 +08:00
opencode-agent[bot] ecda3779fa chore: generate 2026-08-17 07:33:17 +00:00
Shoubhit DashKit Langtonopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>Aiden Cline
a3e69a967b feat(core): discover LM Studio models (#42607)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-17 13:02:08 +05:30
opencode-agent[bot]andLuke Parker 6359623e24 fix(ui): align settled animated numbers (#42998)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-17 16:53:43 +10:00
78 changed files with 5466 additions and 2137 deletions
+4
View File
@@ -121,6 +121,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -188,12 +189,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": {
+41 -24
View File
@@ -92,7 +92,7 @@ const GeminiFunctionCallPart = Schema.Struct({
functionCall: Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.String,
args: Schema.Unknown,
args: Schema.optional(Schema.Unknown),
}),
thoughtSignature: Schema.optional(Schema.String),
})
@@ -190,8 +190,19 @@ const GeminiCandidate = Schema.Struct({
finishReason: Schema.optional(Schema.String),
})
const GeminiPromptFeedback = Schema.StructWithRest(
Schema.Struct({
blockReason: Schema.optional(Schema.String),
blockReasonMessage: Schema.optional(Schema.String),
safetyRatings: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
const GeminiEvent = Schema.Struct({
candidates: optionalArray(GeminiCandidate),
promptFeedback: Schema.optional(GeminiPromptFeedback),
usageMetadata: Schema.optional(GeminiUsage),
})
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
@@ -200,6 +211,7 @@ interface ParserState {
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly nextToolCallId: number
readonly promptFeedback?: GeminiPromptFeedback
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
readonly reasoningSignature?: string
@@ -503,32 +515,37 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
return "unknown"
}
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
state.finishReason || state.usage
? (() => {
const events: LLMEvent[] = []
const lifecycle = state.reasoningSignature
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
: state.lifecycle
Lifecycle.finish(lifecycle, events, {
reason: {
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
raw: state.finishReason,
},
usage: state.usage,
})
return events
})()
: []
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
const finishReason = state.finishReason ?? promptBlockReason
if (finishReason === undefined && state.usage === undefined) return []
const events: LLMEvent[] = []
const lifecycle = state.reasoningSignature
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
"reasoning-0",
googleMetadata({ thoughtSignature: state.reasoningSignature }),
)
: state.lifecycle
Lifecycle.finish(lifecycle, events, {
reason: {
normalized:
promptBlockReason === undefined ? mapFinishReason(finishReason, state.hasToolCalls) : "content-filter",
raw: finishReason,
},
usage: state.usage,
providerMetadata:
state.promptFeedback === undefined ? undefined : googleMetadata({ promptFeedback: state.promptFeedback }),
})
return events
}
const step = (state: ParserState, event: GeminiEvent) => {
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
}
const candidate = event.candidates?.[0]
@@ -569,7 +586,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
}
if ("functionCall" in part) {
const input = part.functionCall.args
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
const id = `tool_${nextToolCallId++}`
const metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
+70
View File
@@ -767,6 +767,31 @@ describe("Gemini route", () => {
}),
)
it.effect("defaults omitted function call args to an empty object", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "ping", description: "Ping", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: { role: "model", parts: [{ functionCall: { name: "ping" } }] },
finishReason: "STOP",
},
],
}),
),
),
)
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
}),
)
it.effect("maps tool calls without a finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -862,6 +887,51 @@ describe("Gemini route", () => {
}),
)
it.effect("preserves candidate-less prompt safety blocks as content-filter outcomes", () =>
Effect.gen(function* () {
const blocked = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
promptFeedback: {
blockReason: "FUTURE_SAFETY_REASON",
blockReasonMessage: "Prompt blocked",
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
},
}),
),
),
)
const blockedWithUsage = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ promptFeedback: { blockReason: "SAFETY" } },
{ usageMetadata: { promptTokenCount: 7, totalTokenCount: 7 } },
),
),
),
)
expect(blocked.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
expect(blocked.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "content-filter", raw: "FUTURE_SAFETY_REASON" },
providerMetadata: {
google: {
promptFeedback: {
blockReason: "FUTURE_SAFETY_REASON",
blockReasonMessage: "Prompt blocked",
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
},
},
},
})
expect(blockedWithUsage.finishReason).toEqual({ normalized: "content-filter", raw: "SAFETY" })
expect(blockedWithUsage.usage).toMatchObject({ inputTokens: 7, totalTokens: 7 })
}),
)
it.effect("maps current blocking and invalid-output finish reasons", () =>
Effect.gen(function* () {
const reasons = [
+1 -58
View File
@@ -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"])
})
})
+21 -274
View File
@@ -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),
+1
View File
@@ -23,6 +23,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
+29 -4
View File
@@ -86,12 +86,37 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
],
}),
Spec.make("auth", {
description: "Manage authentication",
description: "manage AI providers and credentials",
commands: [
Spec.make("login", {
description: "Log in to a well-known authentication provider",
Spec.make("list", {
description: "list providers and credentials",
params: {
url: Argument.string("url").pipe(Argument.withDescription("Well-known provider URL")),
...ServerParams,
format: Flag.choice("format", ["default", "json"]).pipe(
Flag.withDescription("Output format"),
Flag.withDefault("default"),
),
},
}),
Spec.make("login", {
description: "log in to a provider",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID, name, or well-known provider URL"),
Argument.optional,
),
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
},
}),
Spec.make("logout", {
description: "log out from a configured provider",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID or name"),
Argument.optional,
),
},
}),
],
@@ -0,0 +1,155 @@
import { confirm, log, multiselect, password, select, text, type Option } from "@clack/prompts"
import { Effect } from "effect"
import type { FormAnswer, FormField, FormFields } from "@opencode-ai/client"
import { openUrl, prompt, requireInteractive } from "../../../ui/prompt"
const skip = Symbol("skip")
const custom = Symbol("custom")
export const answerForm = Effect.fn("cli.auth.form")(function* (fields: FormFields | undefined) {
if (!fields) return undefined
yield* requireInteractive("Authentication form input requires an interactive terminal")
const answer: FormAnswer = {}
for (const field of fields) {
if (!active(field, answer)) continue
const value = yield* answerField(field)
if (value !== undefined) answer[field.key] = value
}
return answer
})
export const secret = Effect.fn("cli.auth.secret")(function* (message: string) {
yield* requireInteractive("API key input requires an interactive terminal")
return yield* prompt<string>(() => password({ message, validate: (value) => (!value ? "Required" : undefined) }))
})
const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField) {
const message = field.title ?? field.key
if (field.description) log.info(field.description)
if (field.type === "external") {
log.info(field.url)
yield* openUrl(field.url)
const acknowledged = yield* prompt<boolean>(() =>
confirm({ message: message || "Continue after completing this step?", initialValue: true }),
)
if (!acknowledged) return yield* Effect.fail(new Error(`${message || "External step"} is required`))
return true
}
if (field.type === "boolean") {
if (field.required) return yield* prompt<boolean>(() => confirm({ message, initialValue: field.default ?? true }))
const options: Array<Option<boolean | typeof skip>> = [
{ value: true, label: "Yes" },
{ value: false, label: "No" },
{ value: skip, label: "Skip" },
]
const value = yield* prompt<boolean | typeof skip>(() =>
select<boolean | typeof skip>({
message,
options,
initialValue: field.default ?? skip,
}),
)
if (value === skip) return undefined
return value
}
if (field.type === "multiselect") {
const options: Array<Option<string | typeof custom>> = field.options.map((option) => ({
value: option.value,
label: option.label,
hint: option.description,
}))
if (field.custom) options.push({ value: custom, label: "Type another value" })
const values = yield* prompt<Array<string | typeof custom>>(() =>
multiselect<string | typeof custom>({
message,
options,
initialValues: field.default,
required: field.required || (field.minItems ?? 0) > 0,
}),
)
const selected = values.filter((value): value is string => value !== custom)
if (values.includes(custom)) {
selected.push(yield* prompt<string>(() => text({ message: "Enter value", validate: required })))
}
const invalid = validateMultiselect(field, selected)
if (invalid) return yield* Effect.fail(new Error(invalid))
return selected
}
if (field.type === "string" && field.options) {
const options: Array<Option<string | typeof custom | typeof skip>> = field.options.map((option) => ({
value: option.value,
label: option.label,
hint: option.description,
}))
if (field.custom) options.push({ value: custom, label: "Type your own answer" })
if (!field.required) options.push({ value: skip, label: "Skip" })
const value = yield* prompt<string | typeof custom | typeof skip>(() =>
select<string | typeof custom | typeof skip>({ message, options, initialValue: field.default }),
)
if (value === skip) return undefined
if (value !== custom) return value
}
const value = yield* prompt<string>(() =>
text({
message,
placeholder: field.type === "string" ? field.placeholder : undefined,
initialValue: field.default === undefined ? undefined : String(field.default),
validate: (input) => validateText(field, input),
}),
)
if (!value && !field.required) return undefined
if (field.type === "string") return value
return Number(value)
})
function active(field: FormField, answer: FormAnswer) {
if (field.type === "external" || !field.when) return true
return field.when.every((condition) => {
const value = answer[condition.key]
if (value === undefined) return false
const matches = Array.isArray(value) ? value.includes(String(condition.value)) : value === condition.value
return condition.op === "eq" ? matches : !matches
})
}
function required(value: string | undefined) {
return value ? undefined : "Required"
}
function validateText(field: Exclude<FormField, { type: "boolean" | "external" | "multiselect" }>, value?: string) {
if (!value) return field.required ? "Required" : undefined
if (field.type === "number" || field.type === "integer") {
const number = Number(value)
if (!Number.isFinite(number)) return "Expected a number"
if (field.type === "integer" && !Number.isInteger(number)) return "Expected an integer"
if (typeof field.minimum === "number" && number < field.minimum) return `Must be at least ${field.minimum}`
if (typeof field.maximum === "number" && number > field.maximum) return `Must be at most ${field.maximum}`
return undefined
}
if (field.minLength !== undefined && value.length < field.minLength)
return `Must be at least ${field.minLength} characters`
if (field.maxLength !== undefined && value.length > field.maxLength)
return `Must be at most ${field.maxLength} characters`
if (field.pattern) {
try {
if (!new RegExp(field.pattern).test(value)) return "Invalid format"
} catch {
return "Invalid format"
}
}
if (field.format === "uri" && !URL.canParse(value)) return "Expected a URL"
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
if (field.format === "date") {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "Expected a date"
const date = new Date(`${value}T00:00:00.000Z`)
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return "Expected a date"
}
if (field.format === "date-time" && Number.isNaN(Date.parse(value))) return "Expected a date and time"
return undefined
}
function validateMultiselect(field: Extract<FormField, { type: "multiselect" }>, value: string[]) {
if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}`
if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}`
return undefined
}
@@ -0,0 +1,42 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { createClient, loadIntegrations } from "./shared"
import { handleCommandErrors } from "../../../ui/prompt"
export default Runtime.handler(Commands.commands.auth.commands.list, (input) => list(input).pipe(handleCommandErrors))
const list = Effect.fn("cli.auth.list")(function* (input) {
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
const integrations = (yield* loadIntegrations(client)).filter((integration) => integration.connections.length > 0)
if (input.format === "json") {
process.stdout.write(
JSON.stringify(
integrations.map((integration) => ({
id: integration.id,
name: integration.name,
connections: integration.connections,
})),
null,
2,
) + EOL,
)
return
}
const rows = integrations.flatMap((integration) =>
integration.connections.map((connection) => ({
integration: integration.name,
source: connection.type === "credential" ? connection.label : connection.name,
type: connection.type === "credential" ? "stored" : "environment",
})),
)
if (rows.length === 0) {
process.stdout.write("No authenticated integrations" + EOL)
return
}
const width = Math.max(...rows.map((row) => row.integration.length)) + 2
process.stdout.write(
rows.map((row) => row.integration.padEnd(width) + row.source.padEnd(28) + row.type).join(EOL) + EOL,
)
})
+254 -37
View File
@@ -1,52 +1,269 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts"
import { Effect, Option } from "effect"
import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt"
import { answerForm, secret } from "./form"
import {
createClient,
connectMethods,
loadIntegrations,
location,
methodID,
methodLabel,
request,
resolveIntegration,
resolveMethod,
type ConnectMethod,
} from "./shared"
const location = { directory: process.cwd() }
const integrationPriority = new Map([
["opencode", 0],
["opencode-go", 1],
["openai", 2],
["github-copilot", 3],
["google", 4],
["anthropic", 5],
["openrouter", 6],
["vercel", 7],
])
export default Runtime.handler(
Commands.commands.auth.commands.login,
Effect.fn("cli.auth.login")(function* (input) {
process.stdout.write("Logging in..." + EOL + EOL)
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
yield* request(() => client.integration.wellknown.add({ url: input.url, location }))
const integrationID = input.url.replace(/\/+$/, "")
const started = yield* request(() =>
client.integration.command.connect({ integrationID, methodID: "login", location }),
)
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }),
).pipe(Effect.ignore),
)
const status = yield* wait(client, integrationID, started.data.attemptID)
if (status.status === "failed") return yield* Effect.fail(new Error(status.message))
if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired"))
process.stdout.write("Logged in" + EOL)
}),
Effect.fn("cli.auth.login")((input) =>
login({
target: Option.getOrUndefined(input.target),
method: Option.getOrUndefined(input.method),
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
),
)
const wait = (
const login = Effect.fn("cli.auth.login.run")(function* (input: {
target?: string
method?: string
server?: string
standalone: boolean
}) {
if (!input.target)
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
intro("Connect an integration")
const client = yield* createClient({ server: input.server, standalone: input.standalone })
const integration = yield* findIntegration(client, input.target)
const methods = connectMethods(integration)
if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`))
const method = yield* chooseMethod(methods, input.method)
const answer = method.type === "command" ? undefined : yield* answerForm(method.form)
yield* authenticate(client, integration, method, answer)
outro("Done")
})
const findIntegration = Effect.fn("cli.auth.login.integration")(function* (client: OpenCodeClient, target?: string) {
if (target && isURL(target)) {
const progress = spinner()
progress.start("Discovering authentication provider...")
yield* request((signal) => client.integration.wellknown.add({ url: target, location }, { signal })).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Authentication provider discovered"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Discovery failed", 1))),
)
}
const integrations = yield* loadIntegrations(client)
if (target) return yield* resolveIntegration(integrations, target)
const available = integrations
.filter((integration) => connectMethods(integration).length > 0)
.toSorted(
(a, b) =>
(integrationPriority.get(a.id) ?? integrationPriority.size) -
(integrationPriority.get(b.id) ?? integrationPriority.size) ||
a.name.localeCompare(b.name) ||
a.id.localeCompare(b.id),
)
if (available.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available"))
const id = yield* prompt<string>(() =>
autocomplete({
message: "Select integration",
maxItems: 8,
options: available.map((integration) => {
const option = { value: integration.id, label: integration.name, hint: integration.id }
if (integration.connections.length > 0) return { ...option, hint: "connected" }
if (integration.id === "opencode") return { ...option, hint: "recommended" }
return option
}),
}),
)
return yield* resolveIntegration(available, id)
})
const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) {
if (target) return yield* resolveMethod(methods, target)
if (methods.length === 1) return methods[0]
yield* requireInteractive("Pass --method when running without an interactive terminal")
const id = yield* prompt<string>(() =>
select({
message: "Select login method",
options: methods.map((method) => ({ value: methodID(method), label: methodLabel(method) })),
}),
)
return yield* resolveMethod(methods, id)
})
const authenticate = Effect.fn("cli.auth.login.authenticate")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: ConnectMethod,
answer?: FormAnswer,
) {
if (method.type === "key") return yield* keyLogin(client, integration, method, answer)
if (method.type === "command") return yield* commandLogin(client, integration, method)
return yield* oauthLogin(client, integration, method, answer)
})
const keyLogin = Effect.fn("cli.auth.login.key")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "key" }>,
answer?: FormAnswer,
) {
const key = yield* secret(method.label ?? `Enter your ${integration.name} API key`)
const progress = spinner()
progress.start("Saving credential...")
yield* request((signal) =>
client.integration.connect.key({ integrationID: integration.id, key, answer, location }, { signal }),
).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Connected to ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))),
)
})
const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "oauth" }>,
answer?: FormAnswer,
) {
const progress = spinner()
progress.start("Starting authorization...")
const started = yield* request((signal) =>
client.integration.oauth.connect(
{ integrationID: integration.id, methodID: method.id, answer, location },
{ signal },
),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
const attempt = started.data
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.oauth.cancel(
{ integrationID: integration.id, attemptID: attempt.attemptID, location },
{ signal: AbortSignal.timeout(5_000) },
),
).pipe(Effect.ignore),
)
progress.stop("Authorization started")
log.info(attempt.instructions)
log.info(attempt.url)
if (process.stdin.isTTY && process.stdout.isTTY) yield* openUrl(attempt.url)
if (attempt.mode === "code") {
yield* requireInteractive("This login requires an interactive terminal to enter the authorization code")
const code = yield* prompt<string>(() =>
text({ message: "Paste the authorization code", validate: (value) => (!value ? "Required" : undefined) }),
)
const completing = spinner()
completing.start("Completing authorization...")
yield* request((signal) =>
client.integration.oauth.complete(
{ integrationID: integration.id, attemptID: attempt.attemptID, code, location },
{ signal },
),
).pipe(
Effect.tap(() => Effect.sync(() => completing.stop(`Connected to ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => completing.stop("Authentication failed", 1))),
)
return
}
const waiting = spinner()
waiting.start("Waiting for authorization...")
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe(
Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))),
)
if (status.status === "complete") {
waiting.stop(`Connected to ${integration.name}`)
return
}
waiting.stop("Authentication failed", 1)
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
yield* Effect.fail(new Error("Authorization expired"))
})
const commandLogin = Effect.fn("cli.auth.login.command")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "command" }>,
) {
const progress = spinner()
progress.start("Starting authentication command...")
const started = yield* request((signal) =>
client.integration.command.connect({ integrationID: integration.id, methodID: method.id, location }, { signal }),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
yield* Effect.addFinalizer(() =>
request(() =>
client.integration.command.cancel(
{
integrationID: integration.id,
attemptID: started.data.attemptID,
location,
},
{ signal: AbortSignal.timeout(5_000) },
),
).pipe(Effect.ignore),
)
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) =>
progress.message(message.trim() || "Waiting for authentication command..."),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
if (status.status === "complete") {
progress.stop(`Connected to ${integration.name}`)
return
}
progress.stop("Authentication failed", 1)
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
yield* Effect.fail(new Error("Authentication expired"))
})
const waitForOAuth = Effect.fn("cli.auth.login.oauth.wait")(function* (
client: OpenCodeClient,
integrationID: string,
attemptID: string,
shown = false,
): Effect.Effect<Exclude<IntegrationCommandStatusOutput["data"], { status: "pending" }>, unknown> =>
Effect.gen(function* () {
const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location }))
) {
while (true) {
const response = yield* request((signal) =>
client.integration.oauth.status({ integrationID, attemptID, location }, { signal }),
)
if (response.data.status !== "pending") return response.data
const output = response.data.message?.trim()
if (!shown && output) process.stdout.write(output + EOL + EOL)
yield* Effect.sleep(500)
return yield* wait(client, integrationID, attemptID, shown || !!output)
})
}
})
function request<A>(task: () => Promise<A>) {
return Effect.tryPromise({ try: task, catch: (cause) => cause })
const waitForCommand = Effect.fn("cli.auth.login.command.wait")(function* (
client: OpenCodeClient,
integrationID: string,
attemptID: string,
update: (message: string) => void,
) {
while (true) {
const response = yield* request((signal) =>
client.integration.command.status({ integrationID, attemptID, location }, { signal }),
)
if (response.data.status !== "pending") return response.data
if (response.data.message) update(response.data.message)
yield* Effect.sleep(500)
}
})
function isURL(value: string) {
if (!URL.canParse(value)) return false
const protocol = new URL(value).protocol
return protocol === "http:" || protocol === "https:"
}
@@ -0,0 +1,83 @@
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import type { IntegrationInfo } from "@opencode-ai/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared"
export default Runtime.handler(
Commands.commands.auth.commands.logout,
Effect.fn("cli.auth.logout")((input) =>
logout({
target: Option.getOrUndefined(input.target),
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
),
)
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
target?: string
server?: string
standalone: boolean
}) {
if (!input.target)
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
intro("Remove credential")
const client = yield* createClient({ server: input.server, standalone: input.standalone })
const integrations = yield* loadIntegrations(client)
const integration = yield* chooseIntegration(integrations, input.target)
const credentials = integration.connections.filter((connection) => connection.type === "credential")
if (credentials.length === 0) {
const environment = integration.connections
.filter((connection) => connection.type === "env")
.map((connection) => connection.name)
if (environment.length) {
yield* Effect.fail(
new Error(
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
),
)
}
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
}
const progress = spinner()
progress.start("Removing credential...")
yield* Effect.forEach(
credentials,
(connection) =>
request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })),
{ concurrency: "unbounded", discard: true },
).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
)
outro("Done")
})
const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* (
integrations: IntegrationInfo[],
target?: string,
) {
if (target) return yield* resolveIntegration(integrations, target)
const configured = integrations.filter((integration) =>
integration.connections.some((connection) => connection.type === "credential"),
)
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
const id = yield* prompt<string>(() =>
autocomplete({
message: "Select integration",
maxItems: 8,
options: configured.map((integration) => ({
value: integration.id,
label: integration.name,
hint: integration.connections
.filter((connection) => connection.type === "credential")
.map((connection) => connection.label)
.join(", "),
})),
}),
)
return yield* resolveIntegration(configured, id)
})
@@ -0,0 +1,73 @@
import { Effect } from "effect"
import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { ServerConnection } from "../../../services/server-connection"
export const location = { directory: process.cwd() }
export const createClient = Effect.fn("cli.auth.client")(function* (input: ServerConnection.Args) {
const server = yield* ServerConnection.resolve(input)
return OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
})
export function request<A>(run: (signal: AbortSignal) => Promise<A>) {
return Effect.tryPromise({ try: run, catch: (cause) => cause })
}
export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (client: OpenCodeClient) {
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
yield* request((signal) => client.model.default({ location }, { signal }))
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
Effect.map((response) => response.data),
)
})
export const resolveIntegration = Effect.fn("cli.auth.resolve-integration")(function* (
integrations: IntegrationInfo[],
target: string,
) {
const normalized = target.replace(/\/+$/, "")
const byID = integrations.find((integration) => integration.id === normalized)
if (byID) return byID
const matches = integrations.filter((integration) => integration.name.toLowerCase() === normalized.toLowerCase())
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
return yield* Effect.fail(
new Error(
`Integration name "${target}" is ambiguous: ${matches.map((integration) => integration.id).join(", ")}`,
),
)
}
return yield* Effect.fail(new Error(`Integration not found: ${target}`))
})
export type ConnectMethod = Exclude<IntegrationMethod, { type: "env" }>
export function connectMethods(integration: IntegrationInfo) {
return integration.methods
.filter((method): method is ConnectMethod => method.type !== "env")
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
}
export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) {
const normalized = target.toLowerCase()
const matches = methods.filter((method) => {
if (method.type === "key") return normalized === "key" || method.label?.toLowerCase() === normalized
return method.id === target || method.label.toLowerCase() === normalized
})
if (matches.length === 1) return matches[0]
if (matches.length > 1) return yield* Effect.fail(new Error(`Authentication method "${target}" is ambiguous`))
const available = methods.map(methodID).join(", ")
return yield* Effect.fail(
new Error(`Authentication method not found: ${target}${available ? `. Available: ${available}` : ""}`),
)
})
export function methodID(method: ConnectMethod) {
return method.type === "key" ? "key" : method.id
}
export function methodLabel(method: ConnectMethod) {
if (method.type === "key") return method.label ?? "API key"
return method.label
}
@@ -10,15 +10,17 @@ import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/util/npm"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const requestedDirectory = Option.getOrUndefined(input.directory)
const requestedServer = Option.getOrUndefined(input.server)
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
server: requestedServer,
standalone: input.standalone,
mismatch: "replace",
onStart: (reason, previousVersion) => {
@@ -75,6 +77,7 @@ export default Runtime.handler(Commands, (input) =>
resolve: (spec) =>
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
log: (level, message, tags) => {
const effect =
+9
View File
@@ -12,4 +12,13 @@ export const password = Config.redacted("OPENCODE_PASSWORD").pipe(
Config.withDefault(undefined),
)
export function session() {
return Object.fromEntries(
Object.entries(process.env).filter(
(entry): entry is [string, string] =>
entry[1] !== undefined && entry[0] !== "OPENCODE_PASSWORD" && entry[0] !== "OPENCODE_SERVER_PASSWORD",
),
)
}
export * as Env from "./env"
+2
View File
@@ -19,7 +19,9 @@ const Handlers = Runtime.handlers(Commands, {
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
list: () => import("./commands/handlers/auth/list"),
login: () => import("./commands/handlers/auth/login"),
logout: () => import("./commands/handlers/auth/logout"),
},
debug: {
agents: () => import("./commands/handlers/debug/agents"),
+4
View File
@@ -5,6 +5,7 @@ import { setTimeout } from "node:timers/promises"
import { readStdin } from "./util/io"
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
import { Env } from "./env"
export type MiniCommandInput = {
server: {
@@ -38,6 +39,7 @@ export async function runMini(input: MiniCommandInput) {
const directory = localDirectory()
const connection = createMiniConnection(input.server)
const sdk = connection.sdk
const environment = input.server.reconnect ? Env.session() : undefined
const requested = parseModel(input.model)
const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined
const prepare = prepareTarget(input.agent)
@@ -55,6 +57,7 @@ export async function runMini(input: MiniCommandInput) {
fork: input.fork,
model: requested,
agent: input.agent,
environment,
prepare,
signal,
}).catch((error) => {
@@ -89,6 +92,7 @@ export async function runMini(input: MiniCommandInput) {
client,
location: { directory: next.location.directory, workspace: next.location.workspaceID },
agent: next.agent,
environment,
model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
: undefined,
+2
View File
@@ -9,6 +9,7 @@ import { parseSessionTargetModel, resolveSessionTarget } from "../session-target
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
import { runNonInteractivePrompt } from "./noninteractive"
import { UI } from "./ui"
import { Env } from "../env"
export type RunCommandInput = {
server: ServerConnection.Resolved
@@ -91,6 +92,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
? { providerID: explicit.model.providerID, id: explicit.model.modelID, variant: explicit.variant }
: undefined,
agent: input.agent,
environment: input.server.service ? Env.session() : undefined,
prepare: async (next) => {
const selected =
next.model ??
+7
View File
@@ -36,6 +36,7 @@ export async function resolveSessionTarget(input: {
fork?: boolean
model?: ModelRef
agent?: string
environment?: Readonly<Record<string, string>>
prepare: SessionTargetPreparation
signal?: AbortSignal
}): Promise<SessionTarget> {
@@ -70,6 +71,12 @@ export async function resolveSessionTarget(input: {
.catch((error) => {
throw new SessionTargetMutationError(error)
}))
if (input.environment !== undefined && location.workspaceID === undefined)
await input.client.session
.environment({ sessionID: session.id, variables: input.environment }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
return {
session,
location,
+69
View File
@@ -0,0 +1,69 @@
import { cancel, isCancel, log, outro } from "@clack/prompts"
import { Effect } from "effect"
import { EOL } from "node:os"
export class CancelledError extends Error {
constructor() {
super("Cancelled")
}
}
export function prompt<A>(run: () => Promise<A | symbol>) {
return Effect.tryPromise({
try: async () => {
const value = await run()
if (isCancel(value)) throw new CancelledError()
return value as A
},
catch: (cause) => cause,
})
}
export function requireInteractive(message: string) {
if (process.stdin.isTTY && process.stdout.isTTY) return Effect.void
return Effect.fail(new Error(message))
}
export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) {
const { default: open } = yield* Effect.promise(() => import("open"))
yield* Effect.promise(() => open(url)).pipe(Effect.ignore)
})
export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
return effect.pipe(
Effect.catchIf(
(error) => error instanceof CancelledError,
() =>
Effect.sync(() => {
cancel("Cancelled")
process.exitCode = 130
}),
),
Effect.catch((error) =>
Effect.sync(() => {
log.error(message(error))
outro("Failed")
process.exitCode = 1
}),
),
)
}
export function handleCommandErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
return effect.pipe(
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(message(error) + EOL)
process.exitCode = 1
}),
),
)
}
function message(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
return error.message
}
return String(error)
}
+252 -5
View File
@@ -1,20 +1,267 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
describe("auth command", () => {
test("registers login", async () => {
const [auth, login] = await Promise.all([cli(["auth", "--help"]), cli(["auth", "login", "--help"])])
test("registers authentication commands", async () => {
const [auth, list, login, logout] = await Promise.all([
cli(["auth", "--help"]),
cli(["auth", "list", "--help"]),
cli(["auth", "login", "--help"]),
cli(["auth", "logout", "--help"]),
])
expect(auth.exitCode).toBe(0)
expect(auth.stdout).toContain("list")
expect(auth.stdout).toContain("login")
expect(auth.stdout).toContain("Log in to a well-known authentication provider")
expect(auth.stdout).toContain("logout")
expect(auth.stdout).toContain("manage AI providers and credentials")
expect(auth.stdout).toContain("list providers and credentials")
expect(auth.stdout).toContain("log in to a provider")
expect(auth.stdout).toContain("log out from a configured provider")
expect(auth.stdout).not.toContain("connect")
expect(list.exitCode).toBe(0)
expect(list.stdout).toContain("opencode auth list [flags]")
expect(list.stdout).toContain("--format")
expect(login.exitCode).toBe(0)
expect(login.stdout).toContain("opencode auth login [flags] <url>")
expect(login.stdout).toContain("Well-known provider URL")
expect(login.stdout).toContain("opencode auth login [flags] [<target>]")
expect(login.stdout).toContain("Integration ID, name, or well-known provider URL")
expect(login.stdout).toContain("--method")
expect(logout.exitCode).toBe(0)
expect(logout.stdout).toContain("opencode auth logout [flags] [<target>]")
})
test("lists stored and environment connections", async () => {
const requests: string[] = []
using server = authServer((request, url) => {
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "anthropic",
name: "Anthropic",
methods: [],
connections: [
{ type: "credential", id: "cred_test", label: "default" },
{ type: "env", name: "ANTHROPIC_API_KEY" },
],
},
{ id: "openai", name: "OpenAI", methods: [], connections: [] },
]),
)
}
return new Response("Not found", { status: 404 })
}, requests)
const result = await cli(["auth", "list", "--format", "json", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(JSON.parse(result.stdout)).toEqual([
{
id: "anthropic",
name: "Anthropic",
connections: [
{ type: "credential", id: "cred_test", label: "default" },
{ type: "env", name: "ANTHROPIC_API_KEY" },
],
},
])
expect(requests.indexOf("/api/model/default")).toBeLessThan(requests.indexOf("/api/integration"))
})
test("runs command authentication without interactive input", async () => {
const requests: Array<{ method: string; path: string }> = []
using server = authServer((request, url) => {
requests.push({ method: request.method, path: url.pathname })
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "company",
name: "Company",
methods: [{ id: "login", type: "command", label: "Company login", command: ["company", "login"] }],
connections: [],
},
]),
)
}
if (url.pathname === "/api/integration/company/connect/command" && request.method === "POST") {
return Response.json(located({ attemptID: "con_test", time: { created: 1, expires: 2 } }))
}
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "GET") {
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
}
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "DELETE") {
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "login", "company", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(result.stdout).toContain("Connected to Company")
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/company/connect/command" })
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/company/connect/command/con_test" })
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/company/connect/command/con_test" })
})
test("completes automatic OAuth authentication", async () => {
const requests: Array<{ method: string; path: string }> = []
using server = authServer((request, url) => {
requests.push({ method: request.method, path: url.pathname })
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "openai",
name: "OpenAI",
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
connections: [],
},
]),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
return Response.json(
located({
attemptID: "con_oauth",
url: "https://example.com/authorize",
instructions: "Authorize OpenAI",
mode: "auto",
time: { created: 1, expires: 2 },
}),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(result.stdout).toContain("https://example.com/authorize")
expect(result.stdout).toContain("Connected to OpenAI")
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/openai/connect/oauth" })
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/openai/connect/oauth/con_oauth" })
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/openai/connect/oauth/con_oauth" })
})
test("settles the OAuth spinner when status polling fails", async () => {
using server = authServer((request, url) => {
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "openai",
name: "OpenAI",
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
connections: [],
},
]),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
return Response.json(
located({
attemptID: "con_oauth",
url: "https://example.com/authorize",
instructions: "Authorize OpenAI",
mode: "auto",
time: { created: 1, expires: 2 },
}),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
return new Response("Unavailable", { status: 500 })
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Authentication failed")
expect(result.stdout).toContain("Failed")
expect(result.stdout).not.toContain("\n at ")
})
test("removes stored credentials", async () => {
const removed: string[] = []
using server = authServer((request, url) => {
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "anthropic",
name: "Anthropic",
methods: [{ type: "key" }],
connections: [{ type: "credential", id: "cred_test", label: "default" }],
},
]),
)
}
if (url.pathname === "/api/credential/cred_test" && request.method === "DELETE") {
removed.push("cred_test")
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "logout", "anthropic", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(result.stdout).toContain("Disconnected from Anthropic")
expect(removed).toEqual(["cred_test"])
})
test("requires a target outside an interactive terminal", async () => {
const result = await cli(["auth", "login"])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Pass an integration ID or name")
expect(result.stdout).not.toContain("Background service failed to start")
})
test("reports list connection failures without a stack trace", async () => {
using server = Bun.serve({ port: 0, fetch: () => new Response("Unavailable", { status: 503 }) })
const result = await cli(["auth", "list", "--server", server.url.toString()])
expect(result.exitCode).toBe(1)
expect(result.stdout).toBe("")
expect(result.stderr).toContain("did not provide a compatible V2 health response")
expect(result.stderr).not.toContain("\n at ")
})
})
function authServer(fetch: (request: Request, url: URL) => Response | Promise<Response>, requests?: string[]) {
return Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
requests?.push(url.pathname)
if (url.pathname === "/api/health") return health()
if (url.pathname === "/api/model/default") return Response.json(located(null))
return fetch(request, url)
},
})
}
function health() {
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
}
function located<T>(data: T) {
return {
location: {
directory: process.cwd(),
project: { id: "project", directory: process.cwd(), canonical: process.cwd() },
},
data,
}
}
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, ".."),
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test"
import { Env } from "../src/env"
test("session environment omits server credentials", () => {
const previousPassword = process.env.OPENCODE_PASSWORD
const previousLegacyPassword = process.env.OPENCODE_SERVER_PASSWORD
const previousValue = process.env.OPENCODE_SESSION_ENV_TEST
process.env.OPENCODE_PASSWORD = "password"
process.env.OPENCODE_SERVER_PASSWORD = "legacy"
process.env.OPENCODE_SESSION_ENV_TEST = "included"
const environment = Env.session()
if (previousPassword === undefined) delete process.env.OPENCODE_PASSWORD
else process.env.OPENCODE_PASSWORD = previousPassword
if (previousLegacyPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD
else process.env.OPENCODE_SERVER_PASSWORD = previousLegacyPassword
if (previousValue === undefined) delete process.env.OPENCODE_SESSION_ENV_TEST
else process.env.OPENCODE_SESSION_ENV_TEST = previousValue
expect(environment.OPENCODE_PASSWORD).toBeUndefined()
expect(environment.OPENCODE_SERVER_PASSWORD).toBeUndefined()
expect(environment.OPENCODE_SESSION_ENV_TEST).toBe("included")
})
+20
View File
@@ -55,6 +55,26 @@ describe("session target resolver", () => {
expect(target.session.id).toBe("ses_implicit")
})
test("attaches the terminal environment to the resolved local Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const selected = session("ses_resume", "/session")
spyOn(client.session, "get").mockResolvedValue(selected)
spyOn(client.location, "get").mockResolvedValue(location("/session"))
const environment = spyOn(client.session, "environment").mockResolvedValue()
await resolveSessionTarget({
client,
session: selected.id,
environment: { PATH: "/terminal/bin" },
prepare,
})
expect(environment).toHaveBeenCalledWith({
sessionID: selected.id,
variables: { PATH: "/terminal/bin" },
})
})
test("prepares a fresh Session at the server Location before creation", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const order: string[] = []
+8 -2
View File
@@ -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:"
}
}
+5
View File
@@ -914,6 +914,10 @@ export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messa
export type Endpoint5_34Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } }
export type Endpoint5_35Output = void
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E>
@@ -958,6 +962,7 @@ export interface SessionApi<E = never> {
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: SessionMessageOperation<E>
readonly environment: SessionEnvironmentOperation<E>
}
export type Endpoint6_0Input = {
@@ -86,6 +86,8 @@ import type {
Endpoint5_33Output,
Endpoint5_34Input,
Endpoint5_34Output,
Endpoint5_35Input,
Endpoint5_35Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -610,6 +612,14 @@ const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34I
),
)
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
raw["session.environment"]({
params: { sessionID: input["sessionID"] },
payload: { variables: input["variables"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw),
create: Endpoint5_1(raw),
@@ -639,6 +649,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
interrupt: Endpoint5_32(raw),
background: Endpoint5_33(raw),
message: Endpoint5_34(raw),
environment: Endpoint5_35(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -80,6 +80,8 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
MessageListInput,
MessageListOutput,
ModelListInput,
@@ -896,6 +898,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) =>
request<SessionEnvironmentOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/environment`,
body: { variables: input["variables"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
},
message: {
list: (input: MessageListInput, requestOptions?: RequestOptions) =>
@@ -3936,6 +3936,13 @@ export type SessionMessageInput = {
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
export type SessionEnvironmentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly variables: { readonly variables: { readonly [x: string]: string } }["variables"]
}
export type SessionEnvironmentOutput = void
export type MessageListInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: {
+218
View File
@@ -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
+2
View File
@@ -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,
+1 -1
View File
@@ -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"),
),
+6
View File
@@ -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() }
}
+233
View File
@@ -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() }
}
+162
View File
@@ -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() }
}
+38 -3
View File
@@ -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
+19 -1
View File
@@ -51,6 +51,7 @@ import { Global } from "@opencode-ai/util/global"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { fileURLToPath } from "url"
import { SessionEnvironment } from "./session/environment.js"
// get project -> project.locations
//
@@ -165,6 +166,10 @@ export interface Interface {
input: ForkInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly environment: (input: {
readonly sessionID: SessionSchema.ID
readonly variables?: SessionEnvironment.Variables
}) => Effect.Effect<SessionEnvironment.Variables | undefined, NotFoundError>
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly messages: (input: {
sessionID: SessionSchema.ID
@@ -304,6 +309,7 @@ const layer = Layer.effect(
const locations = yield* LocationServiceMap.Service
const fs = yield* FSUtil.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const scope = yield* Scope.Scope
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
@@ -447,6 +453,11 @@ const layer = Layer.effect(
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
environment: Effect.fn("Session.environment")(function* (input) {
yield* result.get(input.sessionID)
if (input.variables !== undefined) yield* environments.set(input.sessionID, input.variables)
return yield* environments.get(input.sessionID)
}),
remove: Effect.fn("Session.remove")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* execution.interrupt(sessionID)
@@ -454,6 +465,7 @@ const layer = Layer.effect(
yield* closeTransport(session)
const children = yield* result.list({ parentID: sessionID })
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
yield* environments.clear(sessionID)
yield* bus.publish(SessionEvent.Deleted, { sessionID })
yield* bus.remove(sessionID)
}),
@@ -652,7 +664,12 @@ const layer = Layer.effect(
const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
return yield* shell
.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie)
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
@@ -1102,6 +1119,7 @@ export const node = makeGlobalNode({
layer: layer.pipe(Layer.orDie),
deps: [
Job.node,
SessionEnvironment.node,
Database.node,
Bus.node,
Project.node,
+33
View File
@@ -0,0 +1,33 @@
export * as SessionEnvironment from "./environment.js"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionSchema } from "./schema.js"
export type Variables = Readonly<Record<string, string>>
export interface Interface {
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<Variables | undefined>
readonly set: (sessionID: SessionSchema.ID, variables: Variables) => Effect.Effect<void>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEnvironment") {}
const layer = Layer.sync(Service, () => {
const environments = new Map<SessionSchema.ID, Variables>()
return Service.of({
get: (sessionID) => Effect.sync(() => environments.get(sessionID)),
set: (sessionID, variables) =>
Effect.sync(() => {
environments.set(sessionID, { ...variables })
}),
clear: (sessionID) =>
Effect.sync(() => {
environments.delete(sessionID)
}),
})
})
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+19 -3
View File
@@ -15,6 +15,8 @@ import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select.js"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks.js"
import { SessionEnvironment } from "./session/environment.js"
import { SessionSchema } from "./session/schema.js"
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
id: Shell.ID,
@@ -76,6 +78,7 @@ export const layer = (options?: ShellSelect.Options) =>
const global = yield* Global.Service
const environment = yield* Environment.Service
const hooks = yield* PluginHooks.Service
const environments = yield* SessionEnvironment.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
@@ -184,13 +187,18 @@ export const layer = (options?: ShellSelect.Options) =>
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const sessionID = input.metadata?.sessionID
const sessionEnvironment =
location.workspaceID === undefined && Schema.is(SessionSchema.ID)(sessionID)
? yield* environments.get(sessionID)
: undefined
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
...(sessionEnvironment ?? process.env),
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
@@ -324,7 +332,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")),
),
)
@@ -349,7 +357,15 @@ export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, Environment.node, PluginHooks.node],
deps: [
Bus.node,
Location.node,
Config.node,
Global.node,
Environment.node,
PluginHooks.node,
SessionEnvironment.node,
],
})
}
@@ -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 -3
View File
@@ -679,13 +679,18 @@ describe("Session.create", () => {
const created = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
yield* session.environment({ sessionID: created.id, variables: { OPENCODE_SESSION_ENV_TEST: "attached" } })
yield* session.shell({ sessionID: created.id, command: "echo hello" })
const command =
process.platform === "win32"
? "[Console]::Out.Write($env:OPENCODE_SESSION_ENV_TEST)"
: 'printf %s "$OPENCODE_SESSION_ENV_TEST"'
yield* session.shell({ sessionID: created.id, command })
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", command: "echo hello", status: "exited", exit: 0 })
expect(shell?.output?.output).toContain("hello")
expect(shell).toMatchObject({ type: "shell", command, status: "exited", exit: 0 })
expect(shell?.output?.output).toContain("attached")
expect(shell?.output?.truncated).toBe(false)
expect(shell?.time.completed).toBeDefined()
}),
@@ -0,0 +1,30 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Session } from "@opencode-ai/core/session"
import { SessionEnvironment } from "@opencode-ai/core/session/environment"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(SessionEnvironment.node))
describe("SessionEnvironment", () => {
it.effect("stores replacement snapshots by session", () =>
Effect.gen(function* () {
const environments = yield* SessionEnvironment.Service
const first = Session.ID.make("ses_environment_first")
const second = Session.ID.make("ses_environment_second")
yield* environments.set(first, { TOOLCHAIN: "first", PATH: "/first/bin" })
yield* environments.set(second, { TOOLCHAIN: "second" })
yield* environments.set(first, { TOOLCHAIN: "updated" })
expect(yield* environments.get(first)).toEqual({ TOOLCHAIN: "updated" })
expect(yield* environments.get(second)).toEqual({ TOOLCHAIN: "second" })
yield* environments.clear(first)
expect(yield* environments.get(first)).toBeUndefined()
expect(yield* environments.get(second)).toEqual({ TOOLCHAIN: "second" })
}),
)
})
@@ -12,6 +12,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionEnvironment } from "@opencode-ai/core/session/environment"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
@@ -32,6 +33,7 @@ const it = testEffect(
Bus.node,
SessionProjector.node,
SessionStore.node,
SessionEnvironment.node,
Session.node,
LocationServiceMap.node,
]),
@@ -50,6 +52,8 @@ describe("Session.remove", () => {
const session = yield* Session.Service
const parent = yield* session.create({ location })
const child = yield* session.create({ parentID: parent.id })
yield* session.environment({ sessionID: parent.id, variables: { SESSION_ENV: "parent" } })
yield* session.environment({ sessionID: child.id, variables: { SESSION_ENV: "child" } })
yield* (yield* LocationServiceMap.Service).contextEffect(location)
closed.length = 0
@@ -57,6 +61,9 @@ describe("Session.remove", () => {
expect((yield* session.list()).data).toEqual([])
expect(closed).toEqual([parent.id, child.id])
const environments = yield* SessionEnvironment.Service
expect(yield* environments.get(parent.id)).toBeUndefined()
expect(yield* environments.get(child.id)).toBeUndefined()
expect(yield* Effect.result(session.get(parent.id))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.result(session.get(child.id))).toMatchObject({ _tag: "Failure" })
}),
+64
View File
@@ -260,6 +260,36 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)
productionIt.live(
"uses the session environment instead of the server environment",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const sessions = yield* Session.Service
yield* sessions.environment({
sessionID,
variables: { OPENCODE_SESSION_ENV_TEST: "from-session" },
})
const command = isWindows
? "[Console]::Out.Write($env:OPENCODE_SESSION_ENV_TEST)"
: 'printf %s "$OPENCODE_SESSION_ENV_TEST"'
const settled = yield* executeTool(registry, call({ command }))
expect(settled.status).toBe("completed")
expect(settled.content?.[0]).toEqual({ type: "text", text: "from-session" })
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live("resolves a relative workdir from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -782,6 +812,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()),
+79
View File
@@ -4127,6 +4127,85 @@
"summary": "Get session message"
}
},
"/api/session/{sessionID}/environment": {
"put": {
"tags": ["session"],
"operationId": "v2.session.environment",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundError"
}
}
}
}
},
"description": "Replace the process environment used by local shell commands for this session.",
"summary": "Set session environment",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"variables": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["variables"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/message": {
"get": {
"tags": ["session"],
+14
View File
@@ -693,6 +693,20 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.put("session.environment", "/api/session/:sessionID/environment", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ variables: Schema.Record(Schema.String, Schema.String) }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.environment",
summary: "Set session environment",
description: "Replace the process environment used by local shell commands for this session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
+16
View File
@@ -196,6 +196,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.environment",
Effect.fn(function* (ctx) {
yield* session.environment({ sessionID: ctx.params.sessionID, variables: ctx.payload.variables }).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.fork",
Effect.fn(function* (ctx) {
@@ -0,0 +1,47 @@
import { describe, expect, mock, test } from "bun:test"
mock.module("@solidjs/router", () => ({ query: (load: () => unknown) => load }))
const { buildModelCatalog, findModelCatalogEntry } = await import("./model-catalog")
describe("model catalog pricing", () => {
test("prefers OpenCode Go pricing over a zero-cost coding plan with the same model id", () => {
const catalog = buildModelCatalog(
{
models: {
"zhipuai/glm-5.3": {
id: "zhipuai/glm-5.3",
name: "GLM-5.3",
},
},
},
{
"opencode-go": {
id: "opencode-go",
models: {
"glm-5.3": {
id: "glm-5.3",
cost: { input: 1.4, output: 4.4, cache_read: 0.26 },
},
},
},
"zhipuai-coding-plan": {
id: "zhipuai-coding-plan",
models: {
"glm-5.3": {
id: "glm-5.3",
cost: { input: 0, output: 0, cache_read: 0 },
},
},
},
},
)
expect(findModelCatalogEntry(catalog, "glm-5.3")?.cost).toEqual({
input: 1.4,
output: 4.4,
cacheRead: 0.26,
cacheWrite: undefined,
})
})
})
@@ -119,7 +119,7 @@ export function catalogSlug(value: string) {
.replace(/-{2,}/g, "-")
}
function buildModelCatalog(payload: unknown, pricingPayload?: unknown, labPayload?: unknown): ModelCatalog {
export function buildModelCatalog(payload: unknown, pricingPayload?: unknown, labPayload?: unknown): ModelCatalog {
const costs = readCatalogCosts(pricingPayload)
const labDescriptions = readCatalogLabDescriptions(payload, pricingPayload, labPayload)
const models = readCatalogModels(payload)
@@ -129,6 +129,7 @@ function buildModelCatalog(payload: unknown, pricingPayload?: unknown, labPayloa
cost:
costs.get(catalogIdKey(model.id)) ??
costs.get(`${model.lab}/${model.slug}`) ??
costs.get(`opencode-go/${model.slug}`) ??
costs.get(model.slug) ??
model.cost,
}))
+14
View File
@@ -41,6 +41,7 @@ import {
useTuiApp,
useTuiPaths,
useTuiStartup,
useTuiTerminalEnvironment,
type TuiApp,
} from "./context/runtime"
import { DialogProvider, useDialog } from "./ui/dialog"
@@ -185,6 +186,7 @@ export type TuiInput = {
args: Args
config: Config.Interface
packages: PackageResolver
environment?: Readonly<Record<string, string>>
terminalHandoff?: () => Promise<
| {
readonly renderer: CliRenderer
@@ -332,6 +334,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
: process.env.DISPLAY
? "x11"
: undefined,
variables: input.environment,
}}
>
<TuiStartupProvider
@@ -480,6 +483,17 @@ function App(props: { pair?: DialogPairCredentials }) {
const promptRef = usePromptRef()
const plugins = usePlugin()
const clipboard = useClipboard()
const terminalEnvironment = useTuiTerminalEnvironment()
createEffect(() => {
if (client.connection.status() !== "connected") return
if (route.data.type !== "session") return
const session = data.session.get(route.data.sessionID)
if (!session) return
if (session.location.workspaceID !== undefined || terminalEnvironment.variables === undefined) return
void client.api.session
.environment({ sessionID: session.id, variables: terminalEnvironment.variables })
.catch(toast.error)
})
const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", {
initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH },
})
@@ -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()
+13 -1
View File
@@ -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()
@@ -1206,6 +1205,19 @@ export function Prompt(props: PromptProps) {
sessionID = created.id
session = created
if (created.location.workspaceID === undefined && terminalEnvironment.variables !== undefined) {
const error = await client.api.session
.environment({ sessionID, variables: terminalEnvironment.variables })
.then(
() => undefined,
(error) => error,
)
if (error) {
if (finishMoveProgress) move.finishSubmit()
toast.show({ title: "Failed to set session environment", message: errorMessage(error), variant: "error" })
return true
}
}
}
// Capture mode before it gets reset
+8 -12
View File
@@ -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()}
+1 -1
View File
@@ -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"),
+1 -1
View File
@@ -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"),
+17 -182
View File
@@ -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
+1
View File
@@ -17,6 +17,7 @@ export type TuiTerminalEnvironment = Readonly<{
platform: string
multiplexer?: "tmux" | "screen"
displayServer?: "wayland" | "x11"
variables?: Readonly<Record<string, string>>
}>
export type TuiStartup = Readonly<{
@@ -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
+1
View File
@@ -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")
+1 -1
View File
@@ -595,7 +595,7 @@ export function RunFooterView(props: RunFooterViewProps) {
{
id: "session.queued_prompts",
title: "View queued prompts",
group: "Session",
group: "Prompt",
run: openQueuedMenu,
},
],
+14 -9
View File
@@ -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}>
+1
View File
@@ -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
+4 -2
View File
@@ -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")
+1 -1
View File
@@ -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 () => {
+29 -11
View File
@@ -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",
},
+79
View File
@@ -4127,6 +4127,85 @@
"summary": "Get session message"
}
},
"/api/session/{sessionID}/environment": {
"put": {
"tags": ["session"],
"operationId": "v2.session.environment",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundError"
}
}
}
}
},
"description": "Replace the process environment used by local shell commands for this session.",
"summary": "Set session environment",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"variables": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["variables"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/message": {
"get": {
"tags": ["session"],
+79
View File
@@ -4127,6 +4127,85 @@
"summary": "Get session message"
}
},
"/api/session/{sessionID}/environment": {
"put": {
"tags": ["session"],
"operationId": "v2.session.environment",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundError"
}
}
}
}
},
"description": "Replace the process environment used by local shell commands for this session.",
"summary": "Set session environment",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"variables": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["variables"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/message": {
"get": {
"tags": ["session"],