Compare commits

...

29 Commits

Author SHA1 Message Date
opencode 744a17379b release: v1.14.50 2026-05-14 03:03:12 +00:00
Kit Langton cda8cc7285 test(httpapi): simplify event stream regression coverage (#27427) 2026-05-14 02:18:40 +00:00
Kit Langton b928a1fff9 fix(httpapi): preserve event stream context (#27425)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
2026-05-13 22:02:30 -04:00
Kit Langton 04286d0415 docs(effect): plan Instance deletion path (#27424) 2026-05-13 21:58:54 -04:00
opencode-agent[bot] 33bb33ba90 chore: generate 2026-05-14 01:55:00 +00:00
Sebastian b0ade40265 flip back to markdown renderable (#27421) 2026-05-14 03:53:48 +02:00
Kit Langton 681594b551 refactor(storage): remove not found wire serializer (#27416) 2026-05-13 21:39:50 -04:00
Kit Langton edf7649400 fix(session): type busy errors (#27410) 2026-05-14 01:28:04 +00:00
Kit Langton 3fc7486d15 test(session): fix shell-cancel race when trap hasn't installed yet (#27408) 2026-05-14 01:10:40 +00:00
Kit Langton 8e353584c7 test(format): remove formatter check sleeps (#27407) 2026-05-13 21:02:34 -04:00
Sebastian 5c35ea2181 notification docs (#27406) 2026-05-14 03:01:25 +02:00
opencode-agent[bot] faf8713053 chore: generate 2026-05-14 00:59:33 +00:00
Dax 16c457e712 refactor(core): move models.dev into core (#27347) 2026-05-13 20:58:24 -04:00
Kit Langton 9818c9e8d0 fix(provider): make small model fallback optional (#27405) 2026-05-14 00:44:34 +00:00
Kit Langton 5e41dbbcbf test(effect): use Effect sleep in instance state tests (#27404) 2026-05-13 20:43:46 -04:00
Kit Langton ba5c8d3822 fix(llm): preserve tool error defects (#27403) 2026-05-13 20:43:32 -04:00
opencode-agent[bot] 10c90eb445 chore: generate 2026-05-14 00:33:32 +00:00
Kit Langton aa8a41d1b8 effect(patch,tool): migrate patch/index and tool/read to AppFileSystem (#27155) 2026-05-13 20:32:19 -04:00
Kit Langton 3f33be1928 effect(server): typed errors in session/sync handlers, fix concurrency (#27146) 2026-05-13 20:31:27 -04:00
Kit Langton 42e6b7d541 effect(core): track stderr truncation; polish AppProcess callers (#27353) 2026-05-13 20:31:03 -04:00
Kit Langton ccb207f946 effect(util): migrate filesystem callers to AppFileSystem.Service (#27152) 2026-05-13 20:25:37 -04:00
Kit Langton de1e0b5d6d test(workspace): effectify sync state cases (#27400) 2026-05-13 19:49:03 -04:00
Aiden Cline df3895d74f cleanup: make smallOptions rely on variants (#27390) 2026-05-13 18:30:36 -05:00
Kit Langton 55e0af1405 fix(provider): type model not found errors (#27334) 2026-05-13 22:23:09 +00:00
Kit Langton 5182a3698d test(workspace): use Effect for local session warp cases (#27393) 2026-05-13 22:22:51 +00:00
opencode 73e1de4513 sync release versions for v1.14.49 2026-05-13 22:18:40 +00:00
Frank 44b432c3fd sync 2026-05-13 18:05:06 -04:00
Kit Langton 0d8c9f3437 docs: add LayerMap example (#27388) 2026-05-13 17:46:47 -04:00
opencode-agent[bot] 0d074492df chore: update nix node_modules hashes 2026-05-13 21:20:10 +00:00
161 changed files with 73711 additions and 1038 deletions
+17 -17
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.48",
"version": "1.14.50",
"bin": {
"opencode": "./bin/opencode",
},
@@ -253,7 +253,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -307,7 +307,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -337,7 +337,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -353,7 +353,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -366,7 +366,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -384,7 +384,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.48",
"version": "1.14.50",
"bin": {
"opencode": "./bin/opencode",
},
@@ -520,7 +520,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -558,7 +558,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -573,7 +573,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -608,7 +608,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -657,7 +657,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-cRhvzZoW6gBbE0sQm1+e+6/WgajuA6MSIL5iroFsfqs=",
"aarch64-linux": "sha256-0knZfxBULqkt5u6sXFx+a/vqw2rc6IC1+LeAd4TNFhM=",
"aarch64-darwin": "sha256-jL4tO+EHSmUF+gQGEaLzAbTxxjkL8OyhTk13vsbomgM=",
"x86_64-darwin": "sha256-bsa7IpS3GaxagcigTa0yqZTkf4e/nbcTQ9aZeb+5eHQ="
"x86_64-linux": "sha256-QIj9PhOXR/GngV/dPjCF7n5rKro2fcTNGzJ47a41Z2Q=",
"aarch64-linux": "sha256-fQl7BjjTYtRKT3HRVhubaIVww/puUFSTzVV5bTy8II8=",
"aarch64-darwin": "sha256-81IAmdjiYZz8IgMJt0+VxzdOS80gTHc5SendwEW/vD4=",
"x86_64-darwin": "sha256-5OMX4VVBMfEmkYvzd09oksAt5hKkxDs84miO804LBI8="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.48",
"version": "1.14.50",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.48",
"version": "1.14.50",
"type": "module",
"license": "MIT",
"scripts": {
@@ -216,7 +216,7 @@ export async function handler(
// ie. 400 error is usually provider error like malformed request
res.status !== 400 &&
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 &&
!(modelInfo.id.startsWith("gpt-") && res.status === 404) &&
// ie. cannot change codex model providers mid-session
modelInfo.stickyProvider !== "strict" &&
modelInfo.fallbackProvider &&
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.48",
"version": "1.14.50",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.48",
"version": "1.14.50",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.48",
"version": "1.14.50",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48",
"version": "1.14.50",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+2
View File
@@ -5,6 +5,7 @@ import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Instance } from "./instance"
type ProviderRecord = {
provider: ProviderV2.Info
@@ -56,6 +57,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
yield* Instance.Service
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service
-6
View File
@@ -5,11 +5,6 @@ function truthy(key: string) {
return value === "true" || value === "1"
}
function falsy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "false" || value === "0"
}
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
@@ -72,7 +67,6 @@ export const Flag = {
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"),
OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
+12
View File
@@ -0,0 +1,12 @@
import { Layer, LayerMap } from "effect"
import { Instance } from "./instance"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
export class InstanceServiceMap extends LayerMap.Service<InstanceServiceMap>()("@opencode/example/InstanceServiceMap", {
lookup: (ref: Instance.Ref) => {
const instance = Layer.succeed(Instance.Service, Instance.Service.of(ref))
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(instance))
},
idleTimeToLive: "5 minutes",
}) {}
+10
View File
@@ -0,0 +1,10 @@
import { Context } from "effect"
export * as Instance from "./instance"
export type Ref = {
readonly directory: string
readonly workspaceID?: string
}
export class Service extends Context.Service<Service, Ref>()("@opencode/Instance") {}
+2
View File
@@ -0,0 +1,2 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,17 @@
import { Global } from "@opencode-ai/core/global"
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Installation } from "../installation"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Flock } from "@opencode-ai/core/util/flock"
import { Hash } from "@opencode-ai/core/util/hash"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { CatalogModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Global } from "./global"
import { Flag } from "./flag/flag"
import { Flock } from "./util/flock"
import { Hash } from "./util/hash"
import { AppFileSystem } from "./filesystem"
import { InstallationChannel, InstallationVersion } from "./installation/version"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
const CostTier = Schema.Struct({
input: Schema.Finite,
@@ -110,14 +112,21 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
type Requirements = AppFileSystem.Service | HttpClient.HttpClient | RuntimeFlags.Service
type Requirements = AppFileSystem.Service | HttpClient.HttpClient
export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const flags = yield* RuntimeFlags.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
retryOn: "errors-and-responses",
times: 2,
schedule: Schedule.exponential(200).pipe(Schedule.jittered),
}),
),
)
const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
const filepath = path.join(
@@ -136,7 +145,7 @@ export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
HttpClientRequest.setHeader("User-Agent", Installation.userAgent(flags.client)),
HttpClientRequest.setHeader("User-Agent", USER_AGENT),
http.execute,
Effect.flatMap((res) => res.text),
Effect.timeout("10 seconds"),
@@ -212,7 +221,6 @@ export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
export * as ModelsDev from "./models"
+71
View File
@@ -0,0 +1,71 @@
export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect"
import { AuthV2 } from "../auth"
import { Catalog } from "../catalog"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { AuthPlugin } from "./auth"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
type Plugin = {
id: PluginV2.ID
effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service>
}
export interface Interface {
readonly wait: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Service | AuthV2.Service | Npm.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
const npm = yield* Npm.Service
const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
yield* plugin.add({
id: input.id,
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(AuthV2.Service, auth),
Effect.provideService(Npm.Service, npm),
),
})
})
const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AuthPlugin)
for (const item of ProviderPlugins) {
yield* add(item)
}
yield* add(ModelsDevPlugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(
Effect.exit,
Effect.flatMap((exit) => Deferred.done(done, exit)),
Effect.forkScoped,
)
return Service.of({
wait: () => Deferred.await(done),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Catalog.defaultLayer),
Layer.provide(PluginV2.defaultLayer),
Layer.provide(Layer.orDie(AuthV2.defaultLayer)),
Layer.provide(Npm.defaultLayer),
)
@@ -0,0 +1,94 @@
export * as LayerMapExample from "./layer-map.example"
import { Context, Effect, Layer, LayerMap } from "effect"
import { Npm } from "../npm"
/**
* Tutorial: split global services from context-specific services.
*
* Use this pattern when part of the app should be constructed once at the app edge,
* while another part should be cached per request/project/workspace key.
*
* In this example:
* - Npm.Service is the global service. It is not keyed by request context and should
* be provided once by the application runtime.
* - ConfigService is context-specific. It is built from a RequestContext key and is
* cached by LayerMap for that key.
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
* request can provide ConfigServiceMap.get(context) to select the right instance.
*
* Lifetime model:
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
* ConfigService for exactly that context key.
* - The cached ConfigService entry stays alive while something is using it. Once idle,
* it remains cached for idleTimeToLive, then its scope is finalized.
* - invalidate(context) removes the cache entry for future lookups. Active users keep
* running on the old instance; the next lookup can create a fresh instance.
*
* Key model:
* - Keys can be strings, structs, classes, arrays, etc.
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
* lookup, so mutating an object after it has been used as a key is a bug.
*/
export type RequestContext = {
readonly directory: string
readonly workspace: string
}
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
"@opencode/example/RequestContextRef",
) {}
export interface ConfigServiceShape {
readonly directory: string
readonly workspace: string
readonly nextUse: () => Effect.Effect<number>
readonly which: Npm.Interface["which"]
}
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
"@opencode/example/ConfigService",
) {}
const configServiceLayer = Layer.effect(
ConfigService,
Effect.gen(function* () {
const context = yield* RequestContextRef
const npm = yield* Npm.Service
let useCount = 0
return ConfigService.of({
directory: context.directory,
workspace: context.workspace,
nextUse: () => Effect.succeed(++useCount),
which: npm.which,
})
}),
)
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
lookup: (context: RequestContext) =>
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
idleTimeToLive: "5 minutes",
}) {}
export const appLayer = ConfigServiceMap.layer
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
const config = yield* ConfigService
return {
directory: config.directory,
workspace: config.workspace,
useCount: yield* config.nextUse(),
}
})
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
})
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
@@ -1,9 +1,9 @@
import { DateTime, Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelsDev } from "@/provider/models"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Catalog } from "../catalog"
import { ModelV2 } from "../model"
import { ModelsDev } from "../models"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
function released(date: string) {
const time = Date.parse(date)
+4 -2
View File
@@ -31,7 +31,8 @@ export interface RunResult {
readonly exitCode: number
readonly stdout: Buffer
readonly stderr: Buffer
readonly truncated: boolean
readonly stdoutTruncated: boolean
readonly stderrTruncated: boolean
}
export type Interface = ChildProcessSpawner["Service"] & {
@@ -147,7 +148,8 @@ export const layer = Layer.effect(
exitCode,
stdout: stdout.buffer,
stderr: stderr.buffer,
truncated: stdout.truncated,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated,
} satisfies RunResult
}),
)
@@ -1,12 +1,14 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { testEffect } from "./lib/effect"
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
describe("CatalogV2", () => {
it.effect("normalizes provider baseURL into endpoint url", () =>
@@ -4,11 +4,10 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "../../src/provider/models"
import { it } from "../lib/effect"
import { ModelsDev } from "@opencode-ai/core/models"
import { it } from "./lib/effect"
import { rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
import { RuntimeFlags } from "@/effect/runtime-flags"
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
// resolve providers without network. These tests need to drive the on-disk
@@ -93,7 +92,6 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
Layer.fresh(ModelsDev.layer).pipe(
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(RuntimeFlags.layer({ client: "test-client" })),
)
const writeCache = (data: object, mtimeMs?: number) =>
@@ -138,14 +136,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns {} when disk empty and fetch disabled", () =>
it.live("get() returns bundled snapshot when disk empty and fetch disabled", () =>
Effect.gen(function* () {
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual({})
expect(Object.keys(result).length).toBeGreaterThan(0)
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
@@ -207,7 +205,7 @@ describe("ModelsDev Service", () => {
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
expect(final.calls[0].userAgent).toContain("/test-client")
expect(final.calls[0].userAgent).toContain("/cli")
}),
)
@@ -257,7 +255,7 @@ describe("ModelsDev Service", () => {
}),
)
expect(result).toEqual(fixture)
// withTransientReadRetry retries 5xx, so calls may be > 1.
// retryTransient retries 5xx, so calls may be > 1.
const final = yield* Ref.get(state)
expect(final.calls.length).toBeGreaterThanOrEqual(1)
}),
@@ -4,7 +4,7 @@ import { AuthV2 } from "@opencode-ai/core/auth"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
@@ -5,7 +5,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
@@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { it, model } from "./provider-helper"
const itAISDK = testEffect(Layer.provideMerge(AISDK.layer, PluginV2.defaultLayer))
@@ -9,7 +9,7 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { fixtureProvider, it, model, npmLayer } from "./provider-helper"
const fixtureProviderPath = fileURLToPath(fixtureProvider)
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { fakeSelectorSdk, it, model } from "../v2/plugin/provider-helper"
import { fakeSelectorSdk, it, model } from "./provider-helper"
describe("GithubCopilotPlugin", () => {
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
@@ -4,7 +4,7 @@ import { AuthV2 } from "@opencode-ai/core/auth"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { it, model, npmLayer, provider, withEnv } from "./provider-helper"
const gitlabSDKOptions: Record<string, unknown>[] = []
@@ -4,7 +4,7 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { it, model } from "./provider-helper"
const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
@@ -6,7 +6,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
import { it, model } from "./provider-helper"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
const aisdkIt = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
@@ -5,7 +5,7 @@ import { Effect, Layer, Option } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Option } from "effect"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
@@ -8,6 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider, withEnv } from "./provider-helper"
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
describe("OpencodePlugin", () => {
it.effect("uses a public key and cancels paid models without credentials", () =>
@@ -190,6 +192,6 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe(Effect.provide(Catalog.defaultLayer)),
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(instanceLayer)))),
)
})
@@ -4,7 +4,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk } from "./provider-helper"
const it = testEffect(PluginV2.defaultLayer)
+18 -3
View File
@@ -20,7 +20,8 @@ describe("AppProcess", () => {
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
expect(result.exitCode).toBe(0)
expect(result.stdout.toString("utf8")).toBe("hi\n")
expect(result.truncated).toBe(false)
expect(result.stdoutTruncated).toBe(false)
expect(result.stderrTruncated).toBe(false)
}),
)
@@ -84,17 +85,31 @@ describe("AppProcess", () => {
)
it.effect(
"truncates output when maxOutputBytes is set",
"truncates stdout when maxOutputBytes is set",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
expect(result.exitCode).toBe(0)
expect(result.truncated).toBe(true)
expect(result.stdoutTruncated).toBe(true)
expect(result.stderrTruncated).toBe(false)
expect(result.stdout.length).toBe(5)
expect(result.stdout.toString("utf8")).toBe("01234")
}),
)
it.effect(
"truncates stderr when maxErrorBytes is set",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const result = yield* svc.run(cmd("-e", "process.stderr.write('0123456789')"), { maxErrorBytes: 5 })
expect(result.exitCode).toBe(0)
expect(result.stdoutTruncated).toBe(false)
expect(result.stderrTruncated).toBe(true)
expect(result.stderr.length).toBe(5)
expect(result.stderr.toString("utf8")).toBe("01234")
}),
)
it.effect(
"result includes command description",
Effect.gen(function* () {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.48",
"version": "1.14.50",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.48",
"version": "1.14.50",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.48"
version = "1.14.50"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.50/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.50/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.50/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.50/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.50/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.48",
"version": "1.14.50",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48",
"version": "1.14.50",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48",
"version": "1.14.50",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1
View File
@@ -198,5 +198,6 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
*/
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
message: Schema.String,
error: Schema.optional(Schema.Defect),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
+1
View File
@@ -171,6 +171,7 @@ export const ToolError = Schema.Struct({
id: ToolCallID,
name: Schema.String,
message: Schema.String,
error: Schema.optional(Schema.Defect),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolError" })
export type ToolError = Schema.Schema.Type<typeof ToolError>
+26 -10
View File
@@ -112,17 +112,29 @@ export const stream = <T extends Tools>(options: StreamOptions<T>): Stream.Strea
const dispatched = yield* Effect.forEach(
state.toolCalls,
(call) => dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)),
(call) =>
dispatch(tools, call).pipe(Effect.map((result) => [call, result.result, result.error] as const)),
{ concurrency },
)
const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result)))
const resultStream = Stream.fromIterable(
dispatched.flatMap(([call, result, error]) => emitEvents(call, result, error)),
)
if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream))
if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream))
return resultStream.pipe(
Stream.concat(
loop(followUpRequest(request, state, dispatched), step + 1, totalUsage, totalProviderMetadata),
loop(
followUpRequest(
request,
state,
dispatched.map(([call, result]) => [call, result] as const),
),
step + 1,
totalUsage,
totalProviderMetadata,
),
),
)
}),
@@ -215,7 +227,7 @@ const addUsage = (left: Usage | undefined, right: Usage | undefined) => {
| "reasoningTokens"
| "totalTokens"
const sum = (key: UsageKey) =>
left[key] === undefined && right[key] === undefined ? undefined : Number(left[key] ?? 0) + Number(right[key] ?? 0)
left[key] === undefined && right[key] === undefined ? undefined : (left[key] ?? 0) + (right[key] ?? 0)
return new Usage({
inputTokens: sum("inputTokens"),
@@ -264,16 +276,20 @@ const appendStreamingText = (
state.assistantContent.push({ type, text, providerMetadata })
}
const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<ToolResultValue> => {
const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<{ result: ToolResultValue; error?: unknown }> => {
const tool = tools[call.name]
if (!tool) return Effect.succeed({ type: "error" as const, value: `Unknown tool: ${call.name}` })
if (!tool) return Effect.succeed({ result: { type: "error" as const, value: `Unknown tool: ${call.name}` } })
if (!tool.execute)
return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` })
return Effect.succeed({ result: { type: "error" as const, value: `Tool has no execute handler: ${call.name}` } })
return decodeAndExecute(tool, call).pipe(
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue),
Effect.succeed({
result: { type: "error" as const, value: failure.message } satisfies ToolResultValue,
error: failure.error,
}),
),
Effect.map((result) => ("result" in result ? result : { result })),
)
}
@@ -294,10 +310,10 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })),
)
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
const emitEvents = (call: ToolCallPart, result: ToolResultValue, error: unknown): ReadonlyArray<LLMEvent> =>
result.type === "error"
? [
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value), error }),
LLMEvent.toolResult({ id: call.id, name: call.name, result }),
]
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
+20 -13
View File
@@ -25,6 +25,7 @@ const baseRequest = LLM.request({
model,
prompt: "Use the tool.",
})
const weatherFailureCause = new Error("weather lookup denied")
const get_weather = tool({
description: "Get current weather for a city.",
@@ -32,7 +33,8 @@ const get_weather = tool({
success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
execute: ({ city }) =>
Effect.gen(function* () {
if (city === "FAIL") return yield* new ToolFailure({ message: `Weather lookup failed for ${city}` })
if (city === "FAIL")
return yield* new ToolFailure({ message: `Weather lookup failed for ${city}`, error: weatherFailureCause })
return { temperature: 22, condition: "sunny" }
}),
})
@@ -85,23 +87,27 @@ describe("LLMClient tools", () => {
tools: { get_weather },
}).pipe(Stream.runCollect, Effect.provide(layer))
const second = bodies[1] as {
readonly messages?: ReadonlyArray<Record<string, unknown>>
readonly tools?: ReadonlyArray<unknown>
readonly tool_choice?: unknown
readonly max_tokens?: unknown
}
const second = bodies[1]
if (!second || typeof second !== "object") throw new Error("Expected second request body")
const messages = Reflect.get(second, "messages")
const tools = Reflect.get(second, "tools")
expect(second.max_tokens).toBe(50)
expect(second.tool_choice).toBe("auto")
expect(second.tools).toHaveLength(1)
expect(second.messages?.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(second.messages?.[1]).toMatchObject({
expect(Reflect.get(second, "max_tokens")).toBe(50)
expect(Reflect.get(second, "tool_choice")).toBe("auto")
expect(tools).toHaveLength(1)
expect(
Array.isArray(messages)
? messages.map((message) =>
message && typeof message === "object" ? Reflect.get(message, "role") : undefined,
)
: undefined,
).toEqual(["user", "assistant", "tool"])
expect(Array.isArray(messages) ? messages[1] : undefined).toMatchObject({
role: "assistant",
content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }],
})
expect(second.messages?.[2]).toMatchObject({
expect(Array.isArray(messages) ? messages[2] : undefined).toMatchObject({
role: "tool",
tool_call_id: "call_1",
content: '{"temperature":22,"condition":"sunny"}',
@@ -327,6 +333,7 @@ describe("LLMClient tools", () => {
const toolError = events.find(LLMEvent.is.toolError)
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
expect(toolError?.message).toBe("Weather lookup failed for FAIL")
expect(toolError?.error).toBe(weatherFailureCause)
}),
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48",
"version": "1.14.50",
"name": "opencode",
"type": "module",
"license": "MIT",
+2 -2
View File
@@ -13,11 +13,11 @@ const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.js"),
path.join(dir, "../core/src/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
await Bun.write(
path.join(dir, "src/provider/models-snapshot.d.ts"),
path.join(dir, "../core/src/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")
@@ -0,0 +1,235 @@
# Error Boundaries Plan
Plan for removing `NamedError` as connective tissue while keeping public
wire contracts stable.
## Desired Shape
```text
Domain/service error
Schema.TaggedErrorClass
- catchable with catchTag / catchTags
- appears in service method error type
- no HTTP status
- no toObject()
HTTP public error
Schema.ErrorClass / TaggedErrorClass with httpApiStatus
- endpoint-declared public contract
- owns legacy { name, data } only when that is the SDK wire shape
CLI/user rendering
FormatError and small format helpers
- converts domain errors to text
- preserves useful structured fields
Session/model-visible error
first-class session/message error schema or helper
- owns { name, data } event/message shape
- not a service error class
```
The important rule: a service error should not also be the HTTP body, CLI
formatter, and session event body. Each seam adapts the error into the
shape it owns.
## Concrete Example: Provider Model Not Found
Before:
```ts
export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
})
```
Problems:
- Throwing it inside `Effect.fn` made it behave like a defect unless a
compatibility bridge caught it.
- HTTP middleware knew that this one domain error should be a `400`.
- Callers read `.data.*`, which couples them to the legacy `{ name, data }`
wire shape.
After:
```ts
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
}
```
Boundary adapters:
```text
CLI
└─ FormatError sees _tag ProviderModelNotFoundError -> nice text
Session prompt
└─ catch ModelNotFoundError -> publish Session.Event.Error as message/session wire shape
HTTP route
└─ catch ModelNotFoundError -> declared BadRequest public API error when the endpoint needs it
HTTP middleware
└─ no Provider.ModelNotFoundError knowledge
```
## Refining Known Promise Failures
Use `EffectPromise.refineRejection(...)` when a Promise boundary can reject
with many unknown values, but only one or two rejection classes are expected
domain failures. Unknown rejections stay defects; the helper maps only known
rejection shapes to typed errors.
```ts
const language =
yield *
EffectPromise.refineRejection(
async () => loadFromProvider(),
(cause) => (cause instanceof NoSuchModelError ? new ModelNotFoundError({ providerID, modelID, cause }) : undefined),
)
```
Use this when the Promise can genuinely reject and most rejection values are
still defects for the current module. Use `Effect.tryPromise({ try, catch })`
when every rejection should become the same expected error type. Use
`Effect.promise(...)` only when rejection means a defect and you do not need
to refine known rejection classes.
## Helper Modules We Probably Want
Add helpers only when repeated call sites prove the seam is real.
### HTTP API Errors
Likely location: `src/server/routes/instance/httpapi/errors.ts`.
Purpose:
- construct public HTTP error bodies
- preserve legacy `{ name, data }` where needed
- attach `httpApiStatus`
Good helpers:
```ts
notFound(message)
badRequest(message)
unknown()
```
Avoid:
```ts
mapAnyDomainError(error)
```
That recreates the giant middleware mapper problem.
### Session / Message Error Wire Helpers
Likely location: near `src/session/message-error.ts` or a new narrow
module such as `src/session/event-error.ts`.
Purpose:
- construct the `{ name, data }` shape used by `Session.Event.Error` and
assistant message errors
- replace `new NamedError.Unknown(...).toObject()` call sites
- keep model-visible error bodies separate from service/domain errors
Good helpers:
```ts
unknown(message)
agentNotFound(agent, available)
commandNotFound(command, available)
modelNotFound(error: Provider.ModelNotFoundError)
```
### CLI Formatters
Likely location: `src/cli/error.ts` until repetition demands domain-local
format helpers.
Purpose:
- produce human-readable terminal messages from typed errors
- support old `{ name, data }` shapes only while compatibility is needed
## Migration Queue
### Remove Domain Knowledge From HTTP Middleware
- [x] Storage not found no longer maps through defect fallback.
- [x] Worktree expected errors moved to typed errors.
- [x] Provider auth expected errors moved to typed errors.
- [x] Provider model not found no longer needs an HTTP middleware status
special case.
- [ ] Convert `Session.BusyError` and map it at route boundaries.
- [ ] Delete the broad `NamedError` middleware branch once no route relies
on defect-wrapped legacy domain errors.
- [ ] Keep one final unknown-defect fallback that logs `Cause.pretty(cause)`
and returns a safe `500` body.
### Remaining `NamedError.create(...)` Service Errors
These should become `Schema.TaggedErrorClass` when touched:
- [ ] `src/provider/provider.ts``ProviderInitError`.
- [ ] `src/storage/db.ts` — database `NotFoundError`.
- [ ] `src/mcp/index.ts``MCPFailed`.
- [ ] `src/skill/index.ts``SkillInvalidError`,
`SkillNameMismatchError`.
- [ ] `src/lsp/client.ts``LSPInitializeError`.
- [ ] `src/ide/index.ts` — install errors.
- [ ] `src/config/error.ts`, `src/config/config.ts`,
`src/config/markdown.ts` — config errors. These already render well
in the CLI, so migrate carefully and preserve diagnostics.
### Session / Message Wire Errors
These are not ordinary service errors. They mostly build `{ name, data }`
objects for model-visible/session-visible output.
- [ ] Add a first-class session/message error wire helper.
- [ ] Replace `new NamedError.Unknown(...).toObject()` in
`src/session/prompt.ts`.
- [ ] Replace `new NamedError.Unknown(...).toObject()` in config/skill/plugin
session event publishing.
- [ ] Move `src/session/message-error.ts` and `src/session/message-v2.ts`
away from `NamedError.create(...)` once the wire helper exists.
- [ ] Update retry/message tests to assert the wire schema/helper output,
not `NamedError` instances.
### CLI Rendering
- [x] Tagged config errors render with useful diagnostics.
- [x] Provider model not found renders from both old `{ name, data }` and
new `_tag` shapes.
- [ ] Add typed render cases as more `NamedError.create(...)` domains move
to `Schema.TaggedErrorClass`.
- [ ] Eventually remove old-shape compatibility branches when no callers can
produce them.
## PR Checklist
For each migrated error:
- [ ] Domain error is `Schema.TaggedErrorClass`.
- [ ] Service method exposes the typed error in its error channel.
- [ ] No service error has `toObject()` just for compatibility.
- [ ] CLI, HTTP, and session/message adapters each own their output shape.
- [ ] HTTP middleware gets smaller or stays unchanged.
- [ ] Focused tests cover the domain error and any public rendering/wire
shape touched by the PR.
+3
View File
@@ -4,6 +4,9 @@ This note expands the `ERR`, `RENDER`, and `HTTP` tracks from
[`todo.md`](./todo.md). It is the current reference for expected failures,
typed service errors, and HTTP error boundaries.
For the migration architecture and queue, see
[`error-boundaries-plan.md`](./error-boundaries-plan.md).
## Goal
- Expected service failures live on the Effect error channel.
+60 -2
View File
@@ -2,8 +2,9 @@
Short roadmap for Effect cleanup in `packages/opencode`.
Current patterns and examples live in [`guide.md`](./guide.md). Test
migration rules live in
Current patterns and examples live in [`guide.md`](./guide.md). Error
boundary migration details live in
[`error-boundaries-plan.md`](./error-boundaries-plan.md). Test migration rules live in
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
Older deep-dive notes in this directory may still be useful, but treat
this roadmap and the guide as the current entry points.
@@ -223,6 +224,63 @@ Goal:
explicit context where practical.
- Delete `project/instance.ts` only after ambient Instance coupling is gone.
Important distinction:
- `InstanceState.context`, `InstanceState.directory`, and
`InstanceState.workspaceID` are acceptable inside normal Effect service
code when `InstanceRef` / `WorkspaceRef` are provided by the runtime.
- The deletion blockers are the fallback and callback paths that rely on
ambient ALS: direct `Instance.*` reads, `InstanceState.bind(...)`,
`AppRuntime.runPromise(...)` re-entry from plain JS, and bridge restore
code that installs legacy ALS before invoking callbacks.
Current bottom-up inventory from `dev`:
- Direct `Instance.*` value readers:
[`tool/repo_overview.ts`](../../src/tool/repo_overview.ts),
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
[`cli/bootstrap.ts`](../../src/cli/bootstrap.ts).
- `InstanceState.bind(...)` callback boundaries:
[`file/watcher.ts`](../../src/file/watcher.ts) native watcher callback,
[`storage/db.ts`](../../src/storage/db.ts) transaction/effect callbacks,
[`session/llm.ts`](../../src/session/llm.ts) workflow approval callback.
- `AppRuntime.runPromise(...)` / re-entry from plain JS:
[`project/with-instance.ts`](../../src/project/with-instance.ts),
[`project/instance-runtime.ts`](../../src/project/instance-runtime.ts),
[`control-plane/adapters/worktree.ts`](../../src/control-plane/adapters/worktree.ts),
[`cli/effect-cmd.ts`](../../src/cli/effect-cmd.ts), plus global/non-instance
callsites such as CLI upgrade and ACP agent defaults.
- Intentional bridge users to classify, not delete blindly:
workspace adapters in [`control-plane/workspace.ts`](../../src/control-plane/workspace.ts),
MCP, command execution, plugins, pty lifecycle, bus scope cleanup, task
cancellation, and HTTP lifecycle reload/dispose paths.
- Core fallback layer to shrink last:
[`effect/run-service.ts`](../../src/effect/run-service.ts),
[`effect/bridge.ts`](../../src/effect/bridge.ts), and
[`effect/instance-state.ts`](../../src/effect/instance-state.ts).
Recommended PR order:
- [ ] `INST-1` Remove direct `Instance.*` value readers. Start with
`repo_overview`, `worktree` adapter, and `cli/bootstrap`; pass context
explicitly or obtain it from an Effect boundary.
- [ ] `INST-2` Move type-only `InstanceContext` imports from
[`project/instance.ts`](../../src/project/instance.ts) to
[`project/instance-context.ts`](../../src/project/instance-context.ts).
- [ ] `INST-3` Audit each `InstanceState.bind(...)` callback from the inside
out: list what the callback calls (`Bus.publish`, database effects,
permission/session services), then replace ambient capture with explicit
`InstanceRef` / `WorkspaceRef` provision or an `EffectBridge` call.
- [ ] `INST-4` Classify `AppRuntime.runPromise(...)` callsites as global,
instance-scoped with explicit refs, or bridge-required. Eliminate the
instance-scoped callsites that rely on `run-service.attach()` falling
back to `Instance.current`.
- [ ] `INST-5` After consumers are explicit, remove `Instance.current` fallback
from `InstanceState.context` and `run-service.attach()`.
- [ ] `INST-6` Move any remaining `restore` / `bind` compatibility helpers to
the boundary that still needs them, then delete
[`project/instance.ts`](../../src/project/instance.ts).
## Lower Priority Tracks
- `PROC` / `FS` — continue AppProcess and AppFileSystem migrations as
+2 -4
View File
@@ -67,11 +67,11 @@ Most exported tools are already on the intended Effect-native shape. The remaini
Current spot cleanups worth tracking:
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
- [x] `read.ts` — streams through `AppFileSystem.Service.stream` with `Stream.splitLines`; the legacy Node stream / `readline` helper is gone
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
- [x] `patch/index.ts` — apply path now returns `Effect` over `AppFileSystem.Service`; the parser and chunk replacer stay pure
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
@@ -85,6 +85,4 @@ Notable items that are already effectively on the target path and do not need se
Current raw fs users that still appear relevant here:
- `tool/read.ts``fs.createReadStream`, `readline`
- `file/ripgrep.ts``fs/promises`
- `patch/index.ts``fs`, `fs/promises`
+8 -5
View File
@@ -62,11 +62,14 @@ export interface Interface {
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
}) => Effect.Effect<
{
identifier: string
whenToUse: string
systemPrompt: string
},
Provider.ModelNotFoundError
>
}
type State = Omit<Interface, "generate">
+16 -10
View File
@@ -1,22 +1,23 @@
import { EOL } from "os"
import { Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { InstanceServiceMap } from "@opencode-ai/core/instance-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { effectCmd } from "../../effect-cmd"
import { PluginBoot } from "@/v2/plugin-boot"
const layer = Catalog.defaultLayer.pipe(Layer.provide(PluginBoot.defaultLayer))
const Runtime = Layer.mergeAll(InstanceServiceMap.layer)
export const V2Command = effectCmd({
command: "v2",
describe: "debug v2 catalog and built-in plugins",
instance: false,
handler: Effect.fn("Cli.debug.v2")(function* () {
const result = yield* Effect.gen(function* () {
handler: Effect.fn("Cli.debug.v2")(
function* () {
yield* PluginBoot.Service.use((service) => service.wait())
const catalog = yield* Catalog.Service
const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id))
const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id))
return {
const result = {
providers,
default: catalog.model
.default()
@@ -33,8 +34,13 @@ export const V2Command = effectCmd({
),
),
}
}).pipe(Effect.provide(layer), Effect.orDie)
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
}),
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
},
Effect.provide(
InstanceServiceMap.get({
directory: process.cwd(),
}),
),
Effect.provide(Runtime),
),
})
+1 -1
View File
@@ -19,7 +19,7 @@ import type {
import { UI } from "../ui"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { InstanceRef } from "@/effect/instance-ref"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
+5 -4
View File
@@ -7,7 +7,7 @@ import { SessionTable, MessageTable, PartTable } from "../../session/session.sql
import { InstanceRef } from "@/effect/instance-ref"
import { ShareNext } from "@/share/share-next"
import { EOL } from "os"
import { Filesystem } from "@/util/filesystem"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Schema } from "effect"
const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info)
@@ -95,6 +95,7 @@ export const ImportCommand = effectCmd({
const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) {
const share = yield* ShareNext.Service
const fs = yield* AppFileSystem.Service
let exportData: ExportData | undefined
@@ -149,9 +150,9 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI
exportData = transformed
} else {
exportData = yield* Effect.promise(() =>
Filesystem.readJson<NonNullable<typeof exportData>>(file).catch(() => undefined),
)
exportData = (yield* fs.readJson(file).pipe(Effect.orElseSucceed(() => undefined))) as
| NonNullable<typeof exportData>
| undefined
if (!exportData) {
process.stdout.write(`File not found: ${file}`)
process.stdout.write(EOL)
+1 -1
View File
@@ -2,7 +2,7 @@ import { EOL } from "os"
import { Effect } from "effect"
import { Provider } from "@/provider/provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
+1 -1
View File
@@ -3,7 +3,7 @@ import { cmd } from "./cmd"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import * as Prompt from "../effect/prompt"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
@@ -1528,14 +1528,14 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
return (
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
<markdown
syntaxStyle={syntax()}
streaming={true}
internalBlockMode="top-level"
content={props.part.text.trim()}
conceal={ctx.conceal()}
fg={theme.text}
fg={theme.markdownText}
bg={theme.background}
/>
</box>
</Show>
+7 -8
View File
@@ -1,5 +1,6 @@
import { NamedError } from "@opencode-ai/core/util/error"
import { errorFormat } from "@/util/error"
import { isRecord } from "@/util/record"
interface ErrorLike {
name?: string
@@ -10,10 +11,6 @@ interface ErrorLike {
type ConfigIssue = { message: string; path: string[] }
function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null
}
function isTaggedError(error: unknown, tag: string): boolean {
return isRecord(error) && error._tag === tag
}
@@ -61,11 +58,13 @@ export function FormatError(input: unknown) {
}
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
const data = (input as ErrorLike).data
const suggestions = Array.isArray(data?.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : []
const providerModelNotFound = configData(input, "ProviderModelNotFoundError")
if (providerModelNotFound) {
const suggestions = Array.isArray(providerModelNotFound.suggestions)
? providerModelNotFound.suggestions.filter((x) => typeof x === "string")
: []
return [
`Model not found: ${data?.providerID}/${data?.modelID}`,
`Model not found: ${providerModelNotFound.providerID}/${providerModelNotFound.modelID}`,
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`,
@@ -10,9 +10,9 @@ import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "./workspace.sql"
@@ -176,6 +176,7 @@ export const layer = Layer.effect(
const sync = yield* SyncEvent.Service
const vcs = yield* Vcs.Service
const flags = yield* RuntimeFlags.Service
const fs = yield* AppFileSystem.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
@@ -501,7 +502,7 @@ export const layer = Layer.effect(
if (!target) return
if (target.type === "local") {
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
setStatus(space.id, (yield* fs.existsSafe(target.directory)) ? "connected" : "error")
return
}
@@ -1040,6 +1041,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(RuntimeFlags.defaultLayer),
)
+1 -1
View File
@@ -14,7 +14,7 @@ import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
+17
View File
@@ -0,0 +1,17 @@
import { Cause, Effect } from "effect"
export function refineRejection<A, E>(
evaluate: (signal: AbortSignal) => PromiseLike<A>,
refine: (cause: unknown) => E | undefined,
) {
return Effect.tryPromise(evaluate).pipe(
Effect.catch((error) => {
const cause = Cause.isUnknownError(error) ? error.cause : error
const refined = refine(cause)
if (refined !== undefined) return Effect.fail(refined)
return Effect.die(cause)
}),
)
}
export * as EffectPromise from "./promise"
+7 -12
View File
@@ -4,11 +4,12 @@ export interface Runner<A, E = never> {
readonly state: State<A, E>
readonly busy: boolean
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
readonly startShell: (work: Effect.Effect<A, E>, ready?: Latch.Latch) => Effect.Effect<A, E>
readonly startShell: (work: Effect.Effect<A, E>, ready?: Latch.Latch) => Effect.Effect<A, E | Busy>
readonly cancel: Effect.Effect<void>
}
export class Cancelled extends Schema.TaggedErrorClass<Cancelled>()("RunnerCancelled", {}) {}
export class Busy extends Schema.TaggedErrorClass<Busy>()("RunnerBusy", {}) {}
interface RunHandle<A, E> {
id: number
@@ -41,12 +42,11 @@ export const make = <A, E = never>(
onIdle?: Effect.Effect<void>
onBusy?: Effect.Effect<void>
onInterrupt?: Effect.Effect<A, E>
busy?: () => never
},
): Runner<A, E> => {
const ref = SynchronizedRef.makeUnsafe<State<A, E>>({ _tag: "Idle" })
const idle = opts?.onIdle ?? Effect.void
const busy = opts?.onBusy ?? Effect.void
const onBusy = opts?.onBusy ?? Effect.void
const onInterrupt = opts?.onInterrupt
let ids = 0
@@ -137,20 +137,15 @@ export const make = <A, E = never>(
}),
).pipe(Effect.flatten)
const startShell = (work: Effect.Effect<A, E>, ready?: Latch.Latch) =>
const startShell = (work: Effect.Effect<A, E>, ready?: Latch.Latch): Effect.Effect<A, E | Busy> =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (st) {
if (st._tag !== "Idle") {
return [
Effect.sync(() => {
if (opts?.busy) opts.busy()
throw new Error("Runner is busy")
}),
st,
] as const
const reject: Effect.Effect<A, E | Busy> = Effect.fail(new Busy())
return [reject, st] as const
}
yield* busy
yield* onBusy
const id = next()
const cancelled = yield* Deferred.make<void>()
const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
+2 -1
View File
@@ -5,6 +5,7 @@ import { InstanceState } from "@/effect/instance-state"
import path from "path"
import { mergeDeep } from "remeda"
import { Config } from "@/config/config"
import { errorMessage } from "@/util/error"
import * as Log from "@opencode-ai/core/util/log"
import * as Formatter from "./formatter"
@@ -100,7 +101,7 @@ export const layer = Layer.effect(
command: cmd,
...item.environment,
file: filepath,
cause: error.message,
cause: errorMessage(error.cause ?? error),
})
return undefined
}),
+1 -1
View File
@@ -124,7 +124,7 @@ export const layer = Layer.effect(
text: () => result.stdout.toString("utf8"),
stdout: result.stdout,
stderr: result.stderr,
truncated: result.truncated,
truncated: result.stdoutTruncated || result.stderrTruncated,
} satisfies Result
},
Effect.catch((err) => Effect.succeed(fail(err))),
+2 -1
View File
@@ -1,6 +1,7 @@
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { errorMessage } from "@/util/error"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import path from "path"
@@ -124,7 +125,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
stderr: result.stderr.toString("utf8"),
}
},
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
Effect.catch((err) => Effect.succeed({ code: 1, stdout: "", stderr: errorMessage(err) })),
)
const getBrewFormula = Effect.fnUntraced(function* () {
+77 -71
View File
@@ -1,7 +1,6 @@
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import * as path from "path"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import * as Bom from "../util/bom"
@@ -308,14 +307,12 @@ interface ApplyPatchFileUpdate {
bom: boolean
}
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
// Read original file content
let originalContent: ReturnType<typeof Bom.split>
try {
originalContent = Bom.split(readFileSync(filePath, "utf-8"))
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
}
export function deriveNewContentsFromChunks(
filePath: string,
chunks: UpdateFileChunk[],
originalText: string,
): ApplyPatchFileUpdate {
const originalContent = Bom.split(originalText)
let originalLines = originalContent.text.split("\n")
@@ -423,11 +420,11 @@ function applyReplacements(lines: string[], replacements: Array<[number, number,
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
function normalizeUnicode(str: string): string {
return str
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
.replace(/\u2026/g, "...") // ellipsis
.replace(/\u00A0/g, " ") // non-breaking space
.replace(/[‘’‚‛]/g, "'") // single quotes
.replace(/[“”„‟]/g, '"') // double quotes
.replace(/[‐‑‒–—―]/g, "-") // dashes
.replace(//g, "...") // ellipsis
.replace(/ /g, " ") // non-breaking space
}
type Comparator = (a: string, b: string) => boolean
@@ -517,77 +514,71 @@ function generateUnifiedDiff(oldContent: string, newContent: string): string {
}
// Apply hunks to filesystem
export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
export const applyHunksToFiles = Effect.fn("Patch.applyHunksToFiles")(function* (hunks: Hunk[]) {
if (hunks.length === 0) {
throw new Error("No files were modified.")
return yield* Effect.fail(new Error("No files were modified."))
}
const fs = yield* AppFileSystem.Service
const added: string[] = []
const modified: string[] = []
const deleted: string[] = []
for (const hunk of hunks) {
switch (hunk.type) {
case "add":
// Create parent directories
const addDir = path.dirname(hunk.path)
if (addDir !== "." && addDir !== "/") {
await fs.mkdir(addDir, { recursive: true })
}
await fs.writeFile(hunk.path, hunk.contents, "utf-8")
case "add": {
yield* fs.writeWithDirs(hunk.path, hunk.contents)
added.push(hunk.path)
log.info(`Added file: ${hunk.path}`)
break
}
case "delete":
await fs.unlink(hunk.path)
case "delete": {
yield* fs.remove(hunk.path)
deleted.push(hunk.path)
log.info(`Deleted file: ${hunk.path}`)
break
}
case "update":
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
case "update": {
const originalText = yield* fs.readFileString(hunk.path)
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks, originalText)
if (hunk.move_path) {
// Handle file move
const moveDir = path.dirname(hunk.move_path)
if (moveDir !== "." && moveDir !== "/") {
await fs.mkdir(moveDir, { recursive: true })
}
await fs.writeFile(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8")
await fs.unlink(hunk.path)
yield* fs.writeWithDirs(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom))
yield* fs.remove(hunk.path)
modified.push(hunk.move_path)
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
} else {
// Regular update
await fs.writeFile(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8")
yield* fs.writeWithDirs(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom))
modified.push(hunk.path)
log.info(`Updated file: ${hunk.path}`)
}
break
}
}
}
return { added, modified, deleted }
}
return { added, modified, deleted } satisfies AffectedPaths
})
// Main patch application function
export async function applyPatch(patchText: string): Promise<AffectedPaths> {
export const applyPatch = Effect.fn("Patch.applyPatch")(function* (patchText: string) {
const { hunks } = parsePatch(patchText)
return applyHunksToFiles(hunks)
}
return yield* applyHunksToFiles(hunks)
})
// Async version of maybeParseApplyPatchVerified
export async function maybeParseApplyPatchVerified(
argv: string[],
cwd: string,
): Promise<
type MaybeApplyPatchVerifiedResult =
| { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction }
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
| { type: MaybeApplyPatchVerified.NotApplyPatch }
> {
// Effectful verified-parse: needs AppFileSystem.Service to read existing files
export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatchVerified")(function* (
argv: string[],
cwd: string,
) {
// Detect implicit patch invocation (raw patch without apply_patch command)
if (argv.length === 1) {
try {
@@ -595,7 +586,7 @@ export async function maybeParseApplyPatchVerified(
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(ApplyPatchError.ImplicitInvocation),
}
} satisfies MaybeApplyPatchVerifiedResult
} catch {
// Not a patch, continue
}
@@ -604,8 +595,9 @@ export async function maybeParseApplyPatchVerified(
const result = maybeParseApplyPatch(argv)
switch (result.type) {
case MaybeApplyPatch.Body:
const { args } = result
case MaybeApplyPatch.Body: {
const fs = yield* AppFileSystem.Service
const args = result.args
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
const changes = new Map<string, ApplyPatchFileChange>()
@@ -623,27 +615,39 @@ export async function maybeParseApplyPatchVerified(
})
break
case "delete":
// For delete, we need to read the current content
case "delete": {
const deletePath = path.resolve(effectiveCwd, hunk.path)
try {
const content = await fs.readFile(deletePath, "utf-8")
changes.set(resolvedPath, {
type: "delete",
content,
})
} catch {
const content = yield* fs.readFileString(deletePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (content === undefined) {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(`Failed to read file for deletion: ${deletePath}`),
}
} satisfies MaybeApplyPatchVerifiedResult
}
changes.set(resolvedPath, {
type: "delete",
content,
})
break
}
case "update":
case "update": {
const updatePath = path.resolve(effectiveCwd, hunk.path)
const originalText = yield* fs
.readFileString(updatePath)
.pipe(
Effect.catch((cause) =>
Effect.succeed(new Error(`Failed to read file ${updatePath}: ${cause}`, { cause })),
),
)
if (originalText instanceof Error) {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: originalText,
} satisfies MaybeApplyPatchVerifiedResult
}
try {
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks, originalText)
changes.set(resolvedPath, {
type: "update",
unified_diff: fileUpdate.unified_diff,
@@ -654,9 +658,10 @@ export async function maybeParseApplyPatchVerified(
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: error as Error,
}
} satisfies MaybeApplyPatchVerifiedResult
}
break
}
}
}
@@ -667,17 +672,18 @@ export async function maybeParseApplyPatchVerified(
patch: args.patch,
cwd: effectiveCwd,
},
}
} satisfies MaybeApplyPatchVerifiedResult
}
case MaybeApplyPatch.PatchParseError:
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: result.error,
}
} satisfies MaybeApplyPatchVerifiedResult
case MaybeApplyPatch.NotApplyPatch:
return { type: MaybeApplyPatchVerified.NotApplyPatch }
return { type: MaybeApplyPatchVerified.NotApplyPatch } satisfies MaybeApplyPatchVerifiedResult
}
}
})
export * as Patch from "."
@@ -1,7 +1,6 @@
import { Schema } from "effect"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
export { CatalogModelStatus } from "@opencode-ai/core/models"
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
export type ModelStatus = typeof ModelStatus.Type
+36 -33
View File
@@ -8,7 +8,7 @@ import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "./models"
import * as ModelsDev from "@opencode-ai/core/models"
import { Auth } from "../auth"
import { Env } from "../env"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -21,6 +21,7 @@ import { pathToFileURL } from "url"
import { Effect, Layer, Context, Schema, Types } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { EffectPromise } from "@/effect/promise"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { isRecord } from "@/util/record"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
@@ -963,11 +964,24 @@ export function defaultModelIDs<T extends { models: Record<string, { id: string
return mapValues(providers, (item) => sort(Object.values(item.models))[0].id)
}
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {
static isInstance(input: unknown): input is ModelNotFoundError {
return input instanceof ModelNotFoundError
}
}
export type Error = ModelNotFoundError
export interface Interface {
readonly list: () => Effect.Effect<Record<ProviderID, Info>>
readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model>
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3>
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3, ModelNotFoundError>
readonly closest: (
providerID: ProviderID,
query: string[],
@@ -1638,14 +1652,14 @@ const layer = Layer.effect(
: fuzzysort
.go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 })
.map((m) => m.target)
throw new ModelNotFoundError({ providerID, modelID, suggestions })
return yield* new ModelNotFoundError({ providerID, modelID, suggestions })
}
const info = provider.models[modelID]
if (!info) {
const current = modelSuggestions(provider, modelID)
const suggestions = current.length ? current : modelSuggestions(s.catalog[providerID], modelID)
throw new ModelNotFoundError({ providerID, modelID, suggestions })
return yield* new ModelNotFoundError({ providerID, modelID, suggestions })
}
return info
})
@@ -1656,11 +1670,10 @@ const layer = Layer.effect(
const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)!
return yield* Effect.promise(async () => {
const provider = s.providers[model.providerID]
const sdk = await resolveSDK(model, s, envs)
try {
const provider = s.providers[model.providerID]
return yield* EffectPromise.refineRejection(
async () => {
const sdk = await resolveSDK(model, s, envs)
const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, {
...provider.options,
@@ -1669,18 +1682,12 @@ const layer = Layer.effect(
: sdk.languageModel(model.api.id)
s.models.set(key, language)
return language
} catch (e) {
if (e instanceof NoSuchModelError)
throw new ModelNotFoundError(
{
modelID: model.id,
providerID: model.providerID,
},
{ cause: e },
)
throw e
}
})
},
(cause) =>
cause instanceof NoSuchModelError
? new ModelNotFoundError({ modelID: model.id, providerID: model.providerID, cause })
: undefined,
)
})
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
@@ -1700,7 +1707,9 @@ const layer = Layer.effect(
if (cfg.small_model) {
const parsed = parseModel(cfg.small_model)
return yield* getModel(parsed.providerID, parsed.modelID)
return yield* getModel(parsed.providerID, parsed.modelID).pipe(
Effect.catchTag("ProviderModelNotFoundError", () => Effect.succeed(undefined)),
)
}
const s = yield* InstanceState.get(state)
@@ -1728,22 +1737,22 @@ const layer = Layer.effect(
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
const globalMatch = candidates.find((m) => m.startsWith("global."))
if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch))
if (globalMatch) return provider.models[globalMatch]
const region = provider.options?.region
if (region) {
const regionPrefix = region.split("-")[0]
if (regionPrefix === "us" || regionPrefix === "eu") {
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch))
if (regionalMatch) return provider.models[regionalMatch]
}
}
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed))
if (unprefixed) return provider.models[unprefixed]
} else {
for (const model of Object.keys(provider.models)) {
if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model))
if (model.includes(item)) return provider.models[model]
}
}
}
@@ -1818,12 +1827,6 @@ export function parseModel(model: string) {
}
}
export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
})
export const InitError = NamedError.create("ProviderInitError", {
providerID: ProviderID,
})
+7 -28
View File
@@ -2,7 +2,7 @@ import type { ModelMessage, ToolResultPart } from "ai"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type * as Provider from "./provider"
import type * as ModelsDev from "./models"
import type * as ModelsDev from "@opencode-ai/core/models"
import { iife } from "@/util/iife"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -617,14 +617,6 @@ function googleThinkingBudgetMax(apiId: string) {
return 24_576
}
function googleSmallThinkingConfig(apiId: string) {
const levels = googleThinkingLevelEfforts(apiId)
if (apiId.toLowerCase().includes("gemini-3")) {
return { thinkingLevel: levels.includes("minimal") ? "minimal" : levels.includes("low") ? "low" : "high" }
}
return { thinkingBudget: googleThinkingBudgetMax(apiId) === 32_768 ? 128 : 0 }
}
export function variants(model: Provider.Model): Record<string, Record<string, any>> {
if (!model.capabilities.reasoning) return {}
@@ -1184,40 +1176,27 @@ export function options(input: {
}
export function smallOptions(model: Provider.Model) {
const small = Object.values(model.variants ?? {})[0] ?? {}
if (
model.providerID === "openai" ||
model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/github-copilot"
) {
if (model.api.id.includes("gpt-5")) {
if (model.api.id.includes("-chat")) {
if (gpt5Version(model.api.id) === undefined) return { store: false }
return { store: false, reasoningEffort: "medium" }
}
if (model.api.id.includes("search-api")) return { store: false }
if (model.api.id.includes("5.") || model.api.id.includes("5-mini")) {
return { store: false, reasoningEffort: "low" }
}
return { store: false, reasoningEffort: "minimal" }
}
return { store: false }
}
if (model.providerID === "google") {
// gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget
return { thinkingConfig: googleSmallThinkingConfig(model.api.id) }
const base = { store: false }
return mergeDeep(base, small)
}
if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
if (model.api.id.includes("google")) {
if (Object.keys(small).length === 0 && model.api.id.includes("google")) {
return { reasoning: { enabled: false } }
}
return { reasoningEffort: "minimal" }
}
if (model.providerID === "venice") {
if (Object.keys(small).length > 0) return small
return { veniceParameters: { disableThinking: true } }
}
return {}
return small
}
// Maps model ID prefix to provider slug used in providerOptions.
@@ -40,30 +40,37 @@ function eventData(data: unknown): Sse.Event {
}
function eventResponse(bus: Bus.Interface) {
const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type))
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })),
)
return Effect.gen(function* () {
const context = yield* Effect.context()
log.info("event connected")
return HttpServerResponse.stream(
Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
const events = bus.subscribeAll().pipe(
Stream.provideContext(context),
Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type),
)
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })),
)
log.info("event connected")
return HttpServerResponse.stream(
Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
},
)
)
})
}
export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) =>
@@ -72,7 +79,7 @@ export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers)
return handlers.handleRaw(
"subscribe",
Effect.fn("EventHttpApi.subscribe")(function* () {
return eventResponse(bus)
return yield* eventResponse(bus)
}),
)
}),
@@ -0,0 +1,59 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { InstanceServiceMap } from "@opencode-ai/core/instance-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
export const InstanceQuery = Schema.Struct({
instance: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
}),
),
}).annotate({ identifier: "V2InstanceQuery" })
export const instanceQueryOpenApi = OpenApi.annotations({
transform: (operation) => {
const parameters = operation.parameters
if (!Array.isArray(parameters)) return operation
return {
...operation,
parameters: parameters.map((parameter) =>
parameter?.name === "instance" && parameter?.in === "query"
? { ...parameter, style: "deepObject", explode: true }
: parameter,
),
}
},
})
export class V2InstanceMiddleware extends HttpApiMiddleware.Service<
V2InstanceMiddleware,
{
provides: Catalog.Service | PluginBoot.Service
}
>()("@opencode/ExperimentalHttpApiV2Instance") {}
function ref(request: HttpServerRequest.HttpServerRequest): Instance.Ref {
const query = new URL(request.url, "http://localhost").searchParams
return {
directory: query.get("instance[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
workspaceID: query.get("instance[workspace]") || request.headers["x-opencode-workspace"],
}
}
export const layer = Layer.effect(
V2InstanceMiddleware,
Effect.gen(function* () {
const instances = yield* InstanceServiceMap
return V2InstanceMiddleware.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* effect.pipe(Effect.provide(instances.get(ref(request))))
}),
)
}),
).pipe(Layer.provide(InstanceServiceMap.layer))
@@ -2,18 +2,22 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../../middleware/authorization"
import { InstanceQuery, instanceQueryOpenApi, V2InstanceMiddleware } from "./instance"
export const ModelGroup = HttpApiGroup.make("v2.model")
.add(
HttpApiEndpoint.get("models", "/api/model", {
query: InstanceQuery,
success: Schema.Array(ModelV2.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.model.list",
summary: "List v2 models",
description: "Retrieve available v2 models ordered by release date.",
}),
),
})
.annotateMerge(instanceQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.model.list",
summary: "List v2 models",
description: "Retrieve available v2 models ordered by release date.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
@@ -21,4 +25,5 @@ export const ModelGroup = HttpApiGroup.make("v2.model")
description: "Experimental v2 model routes.",
}),
)
.middleware(V2InstanceMiddleware)
.middleware(Authorization)
@@ -3,31 +3,39 @@ import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ApiNotFoundError } from "../../errors"
import { Authorization } from "../../middleware/authorization"
import { InstanceQuery, instanceQueryOpenApi, V2InstanceMiddleware } from "./instance"
export const ProviderGroup = HttpApiGroup.make("v2.provider")
.add(
HttpApiEndpoint.get("providers", "/api/provider", {
query: InstanceQuery,
success: Schema.Array(ProviderV2.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.list",
summary: "List v2 providers",
description: "Retrieve active v2 AI providers so clients can show provider availability and configuration.",
}),
),
})
.annotateMerge(instanceQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.list",
summary: "List v2 providers",
description: "Retrieve active v2 AI providers so clients can show provider availability and configuration.",
}),
),
)
.add(
HttpApiEndpoint.get("provider", "/api/provider/:providerID", {
params: { providerID: ProviderV2.ID },
query: InstanceQuery,
success: ProviderV2.Info,
error: ApiNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.get",
summary: "Get v2 provider",
description: "Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.",
}),
),
})
.annotateMerge(instanceQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.get",
summary: "Get v2 provider",
description:
"Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
@@ -35,4 +43,5 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
description: "Experimental v2 provider routes.",
}),
)
.middleware(V2InstanceMiddleware)
.middleware(Authorization)
@@ -38,7 +38,9 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance"
})
const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () {
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
concurrency: "unbounded",
})
return { branch, default_branch }
})
@@ -1,6 +1,6 @@
import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
@@ -36,6 +36,12 @@ import {
} from "../groups/session"
import * as SessionError from "./session-errors"
const tryParseJson = (text: string) =>
Effect.try({
try: () => JSON.parse(text) as unknown,
catch: () => new HttpApiError.BadRequest({}),
})
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -52,13 +58,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const bus = yield* Bus.Service
const scope = yield* Scope.Scope
const mapBusy = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | HttpApiError.BadRequest, R> =>
effect.pipe(
Effect.catchCause((cause): Effect.Effect<never, E | HttpApiError.BadRequest> => {
if (Cause.squash(cause) instanceof Session.BusyError) return Effect.fail(new HttpApiError.BadRequest({}))
return Effect.failCause(cause)
}),
)
const mapBusy = <A, R>(
effect: Effect.Effect<A, Session.BusyError, R>,
): Effect.Effect<A, HttpApiError.BadRequest, R> =>
effect.pipe(Effect.catchTag("SessionBusyError", () => Effect.fail(new HttpApiError.BadRequest({}))))
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
return yield* session.list({
@@ -160,10 +163,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const body = yield* Effect.orDie(ctx.request.text)
if (body.trim().length === 0) return yield* create({})
const json = yield* Effect.try({
try: () => JSON.parse(body) as unknown,
catch: () => new HttpApiError.BadRequest({}),
})
const json = yield* tryParseJson(body)
const payload = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
@@ -211,10 +211,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const body = yield* Effect.orDie(ctx.request.text)
if (body.trim().length === 0) return yield* fork({ params: ctx.params })
const json = yield* Effect.try({
try: () => JSON.parse(body) as unknown,
catch: () => new HttpApiError.BadRequest({}),
})
const json = yield* tryParseJson(body)
const payload = yield* Schema.decodeUnknownEffect(ForkPayload)(json).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)

Some files were not shown because too many files have changed in this diff Show More