feat(core): add command registry (#30624)

This commit is contained in:
Dax 2026-06-04 02:57:43 -04:00 committed by GitHub
commit 1ff19103a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
150 changed files with 4642 additions and 2546 deletions

View file

@ -87,6 +87,7 @@
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.8.1",

View file

@ -3,6 +3,7 @@ import { Command } from "@/command"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
import type * as ACPError from "./error"
@ -10,7 +11,7 @@ import type * as ACPError from "./error"
export type ModelOption = {
readonly providerID: ProviderV2.ID
readonly providerName: string
readonly modelID: ProviderV2.ModelID
readonly modelID: ModelV2.ID
readonly modelName: string
}
@ -24,7 +25,7 @@ export type ModelVariants = NonNullable<Provider.Model["variants"]>
export type DefaultModel = {
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
readonly modelID: ModelV2.ID
}
export type Snapshot = {

View file

@ -42,6 +42,7 @@ import { ACPSession } from "./session"
import { UsageService } from "./usage"
import { ACPProfile } from "./profile"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import type { Command } from "@/command"
@ -650,7 +651,7 @@ function makeUsageService(sdk: OpencodeClient) {
const size = yield* contextLimit({
directory: params.directory,
providerID: ProviderV2.ID.make(message.providerID),
modelID: ProviderV2.ModelID.make(message.modelID),
modelID: ModelV2.ID.make(message.modelID),
})
if (!size) return
@ -812,7 +813,7 @@ function selectDefaultModel(snapshot: Directory.Snapshot) {
if (snapshot.defaultModel) return snapshot.defaultModel
const model = snapshot.modelOptions[0]
if (model) return { providerID: model.providerID, modelID: model.modelID }
return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ProviderV2.ModelID }
return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ModelV2.ID }
}
function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
@ -872,7 +873,7 @@ function configOptions(snapshot: Directory.Snapshot, session: ConfigState) {
function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) {
const selected = parseModelSelection(modelId, Object.values(snapshot.providers))
const provider = snapshot.providers[ProviderV2.ID.make(selected.model.providerID)]
const model = provider?.models[ProviderV2.ModelID.make(selected.model.modelID)]
const model = provider?.models[ModelV2.ID.make(selected.model.modelID)]
if (!model) {
return Effect.fail(
new ACPError.InvalidModelError({
@ -1000,7 +1001,7 @@ function restoreFromMessages(messages: readonly MessageInfo[]) {
)
if (user?.model?.providerID && user.model.modelID) {
return {
model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ProviderV2.ModelID },
model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ModelV2.ID },
variant: user.model.variant,
modeId: user.agent,
}
@ -1009,7 +1010,7 @@ function restoreFromMessages(messages: readonly MessageInfo[]) {
const assistant = messages.findLast((message) => message.providerID && message.modelID)
if (assistant?.providerID && assistant.modelID) {
return {
model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ProviderV2.ModelID },
model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ModelV2.ID },
variant: assistant.variant,
modeId: assistant.mode ?? assistant.agent,
}

View file

@ -1,12 +1,13 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Context, Effect, Layer, Ref } from "effect"
import * as ACPError from "./error"
export type SelectedModel = {
providerID: ProviderV2.ID
modelID: ProviderV2.ModelID
modelID: ModelV2.ID
}
export type KnownMessagePartMetadata = {

View file

@ -4,6 +4,7 @@ import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@ope
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
@ -50,7 +51,7 @@ export interface Interface {
readonly contextLimit: (input: {
readonly directory: string
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
readonly modelID: ModelV2.ID
}) => Effect.Effect<number | undefined>
readonly sendUpdate: (input: {
readonly connection: UsageConnection
@ -112,7 +113,7 @@ export function totalSessionCost(messages: readonly SessionMessage[]): number {
export function findContextLimit(
providers: Record<ProviderV2.ID, Provider.Info>,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
): number | undefined {
return providers[providerID]?.models[modelID]?.limit.context
}
@ -144,7 +145,7 @@ export const layer = Layer.effect(
const cachedLimit = Effect.fnUntraced(function* (input: {
readonly directory: string
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
readonly modelID: ModelV2.ID
}) {
return yield* SynchronizedRef.modifyEffect(
limits,
@ -171,7 +172,7 @@ export const layer = Layer.effect(
const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: {
readonly directory: string
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
readonly modelID: ModelV2.ID
}) {
return yield* yield* cachedLimit(input)
})
@ -198,7 +199,7 @@ export const layer = Layer.effect(
const size = yield* contextLimit({
directory: input.directory,
providerID: ProviderV2.ID.make(message.providerID),
modelID: ProviderV2.ModelID.make(message.modelID),
modelID: ModelV2.ID.make(message.modelID),
})
if (!size) return

View file

@ -25,6 +25,7 @@ import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { type DeepMutable } from "@opencode-ai/core/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
export const Info = Schema.Struct({
name: Schema.String,
@ -38,7 +39,7 @@ export const Info = Schema.Struct({
permission: PermissionV1.Ruleset,
model: Schema.optional(
Schema.Struct({
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
providerID: ProviderV2.ID,
}),
),
@ -62,7 +63,7 @@ export interface Interface {
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
}) => Effect.Effect<
{
identifier: string
@ -350,7 +351,7 @@ export const layer = Layer.effect(
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
}) {
const cfg = yield* config.get()
const model = input.model ?? (yield* provider.defaultModel())

View file

@ -348,7 +348,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
message: {
async sync(sessionID: string) {
const response = await sdk.client.v2.session.messages({ sessionID })
setStore("messages", sessionID, reconcile(response.data?.items ?? []))
setStore("messages", sessionID, reconcile(response.data?.data ?? []))
},
fromSession(sessionID: string) {
const messages = store.messages[sessionID]

View file

@ -3,6 +3,8 @@
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { GlobalBus } from "@/bus/global"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import "@opencode-ai/core/account"
import "@opencode-ai/core/catalog"
@ -24,10 +26,11 @@ export const layer = Layer.effect(
const workspaceID = yield* WorkspaceRef
return yield* events.publish(definition, data, {
...options,
location: {
location: new Location.Info({
directory: AbsolutePath.make(ctx.directory),
...(workspaceID ? { workspaceID } : {}),
},
project: { id: Project.ID.make(ctx.project.id), directory: AbsolutePath.make(ctx.worktree) },
}),
})
})
@ -41,6 +44,25 @@ export const layer = Layer.effect(
workspace: workspaceID,
payload: { id: event.id, type: event.type, properties: event.data },
})
const sync = EventV2.registry.get(event.type)?.sync
if (sync === undefined || event.seq === undefined || event.version === undefined) return
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") return
GlobalBus.emit("event", {
directory: event.location?.directory ?? ctx?.directory,
project: ctx?.project.id,
workspace: workspaceID,
payload: {
type: "sync",
syncEvent: {
id: event.id,
type: EventV2.versionedType(event.type, event.version),
seq: event.seq,
aggregateID,
data: event.data,
},
},
})
}),
)
yield* Effect.addFinalizer(() => unsubscribe)

View file

@ -27,6 +27,7 @@ import { isRecord } from "@/util/record"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import { ProviderTransform } from "./transform"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderError } from "./error"
@ -664,7 +665,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
for (const m of result.models) {
if (!input.models[m.id]) {
models[m.id] = {
id: ProviderV2.ModelID.make(m.id),
id: ModelV2.ID.make(m.id),
providerID: ProviderV2.ID.make("gitlab"),
name: `Agent Platform (${m.name})`,
family: "",
@ -920,7 +921,7 @@ const ProviderLimit = Schema.Struct({
})
export const Model = Schema.Struct({
id: ProviderV2.ModelID,
id: ModelV2.ID,
providerID: ProviderV2.ID,
api: ProviderApiInfo,
name: Schema.String,
@ -978,7 +979,7 @@ export function defaultModelIDs<T extends { models: Record<string, { id: string
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {
@ -1018,7 +1019,7 @@ export interface Interface {
readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect<Info>
readonly getModel: (
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
) => Effect.Effect<Model, ModelNotFoundError>
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3, ModelNotFoundError>
readonly closest: (
@ -1027,7 +1028,7 @@ export interface Interface {
) => Effect.Effect<{ providerID: ProviderV2.ID; modelID: string } | undefined>
readonly getSmallModel: (providerID: ProviderV2.ID) => Effect.Effect<Model | undefined>
readonly defaultModel: () => Effect.Effect<
{ providerID: ProviderV2.ID; modelID: ProviderV2.ModelID },
{ providerID: ProviderV2.ID; modelID: ModelV2.ID },
DefaultModelError
>
}
@ -1080,7 +1081,7 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
const base: Model = {
id: ProviderV2.ModelID.make(model.id),
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(provider.id),
name: model.name,
family: model.family,
@ -1138,7 +1139,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
const base = fromModelsDevModel(provider, model)
models[id] = {
...base,
id: ProviderV2.ModelID.make(id),
id: ModelV2.ID.make(id),
name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost,
options: opts.provider?.body
@ -1163,7 +1164,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
}
}
function modelSuggestions(provider: Info | undefined, modelID: ProviderV2.ModelID, enableExperimentalModels: boolean) {
function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) {
const available = provider
? Object.keys(provider.models).filter((id) => {
const model = provider.models[id]
@ -1279,7 +1280,7 @@ export const layer = Layer.effect(
id,
{
...model,
id: ProviderV2.ModelID.make(id),
id: ModelV2.ID.make(id),
providerID,
},
]),
@ -1314,7 +1315,7 @@ export const layer = Layer.effect(
return existingModel?.name ?? modelID
})
const parsedModel: Model = {
id: ProviderV2.ModelID.make(modelID),
id: ModelV2.ID.make(modelID),
api: {
id: apiID,
npm: apiNpm,
@ -1703,7 +1704,7 @@ export const layer = Layer.effect(
InstanceState.use(state, (s) => s.providers[providerID]),
)
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID) {
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ModelV2.ID) {
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) {
@ -1792,7 +1793,7 @@ export const layer = Layer.effect(
if (experimental.model) {
return {
...experimental.model,
id: ProviderV2.ModelID.make(experimental.model.id),
id: ModelV2.ID.make(experimental.model.id),
providerID: ProviderV2.ID.make(experimental.model.providerID),
}
}
@ -1846,16 +1847,16 @@ export const layer = Layer.effect(
const s = yield* InstanceState.get(state)
const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
Effect.map((x): { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[] => {
Effect.map((x): { providerID: ProviderV2.ID; modelID: ModelV2.ID }[] => {
if (!isRecord(x) || !Array.isArray(x.recent)) return []
return x.recent.flatMap((item) => {
if (!isRecord(item)) return []
if (typeof item.providerID !== "string") return []
if (typeof item.modelID !== "string") return []
return [{ providerID: ProviderV2.ID.make(item.providerID), modelID: ProviderV2.ModelID.make(item.modelID) }]
return [{ providerID: ProviderV2.ID.make(item.providerID), modelID: ModelV2.ID.make(item.modelID) }]
})
}),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[])),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderV2.ID; modelID: ModelV2.ID }[])),
)
for (const entry of recent) {
const provider = s.providers[entry.providerID]
@ -1904,7 +1905,7 @@ export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: ProviderV2.ID.make(providerID),
modelID: ProviderV2.ModelID.make(rest.join("/")),
modelID: ModelV2.ID.make(rest.join("/")),
}
}

View file

@ -20,7 +20,7 @@ import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace"
import { V2Api } from "./groups/v2"
import { V2Api } from "@opencode-ai/server/api"
// GlobalEventSchema snapshots the registry after event-producing groups register their variants.
import { GlobalApi } from "./groups/global"
import { Authorization } from "./middleware/authorization"
@ -60,7 +60,6 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(ProviderApi)
.addHttpApi(SessionApi)
.addHttpApi(SyncApi)
.addHttpApi(V2Api)
.addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi)
.middleware(SchemaErrorMiddleware)
@ -69,6 +68,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi)
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(V2Api)
.addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected])

View file

@ -16,6 +16,7 @@ import {
import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
@ -51,7 +52,7 @@ const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
export const ToolListQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
provider: ProviderV2.ID,
model: ProviderV2.ModelID,
model: ModelV2.ID,
})
const WorktreeList = Schema.Array(Schema.String)

View file

@ -20,11 +20,14 @@ const SyncEventSchemas = EventV2.registry
return [
Schema.Struct({
type: Schema.Literal("sync"),
name: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)),
id: Schema.String,
seq: Schema.Finite,
aggregateID: Schema.Literal(definition.sync.aggregate),
data: definition.data,
syncEvent: Schema.Struct({
type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)),
id: Schema.String,
seq: Schema.Finite,
aggregateID: Schema.String,
data: definition.data,
}),
}).annotate({ identifier: `SyncEvent.${definition.type}` }),
]
})

View file

@ -24,6 +24,7 @@ import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../
import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const root = "/session"
export const ListQuery = Schema.Struct({
@ -57,13 +58,13 @@ export const UpdatePayload = Schema.Struct({
})
export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"]))
export const InitPayload = Schema.Struct({
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
providerID: ProviderV2.ID,
messageID: MessageID,
})
export const SummarizePayload = Schema.Struct({
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
auto: Schema.optional(Schema.Boolean),
})
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))

View file

@ -1,27 +0,0 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { MessageGroup } from "./v2/message"
import { ModelGroup } from "./v2/model"
import { ProviderGroup } from "./v2/provider"
import { SessionGroup } from "./v2/session"
import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission"
import { FileSystemGroup } from "./v2/fs"
import { QuestionGroup, SessionQuestionGroup } from "./v2/question"
export const V2Api = HttpApi.make("v2")
.add(SessionGroup)
.add(MessageGroup)
.add(ModelGroup)
.add(ProviderGroup)
.add(PermissionGroup)
.add(SessionPermissionGroup)
.add(PermissionSavedGroup)
.add(FileSystemGroup)
.add(QuestionGroup)
.add(SessionQuestionGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)

View file

@ -1,56 +0,0 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { RelativePath } from "@opencode-ai/core/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
const ReadQuery = Schema.Struct({
...LocationQuery.fields,
path: RelativePath,
reference: Schema.String.pipe(Schema.optional),
})
const ListQuery = Schema.Struct({
...LocationQuery.fields,
path: RelativePath.pipe(Schema.optional),
reference: Schema.String.pipe(Schema.optional),
})
export const FileSystemGroup = HttpApiGroup.make("v2.fs")
.add(
HttpApiEndpoint.get("read", "/api/fs/read", {
query: ReadQuery,
success: FileSystem.Content,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.read",
summary: "Read file",
description: "Read one file relative to the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("list", "/api/fs/list", {
query: ListQuery,
success: Schema.Array(FileSystem.Entry),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.list",
summary: "List directory",
description: "List direct children of one directory relative to the requested location.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "v2 filesystem",
description: "Experimental v2 location-scoped filesystem routes.",
}),
)
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)

View file

@ -1,74 +0,0 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { QuestionV2 } from "@opencode-ai/core/question"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
export const LocationQuery = Schema.Struct({
location: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
}),
),
}).annotate({ identifier: "V2LocationQuery" })
export const locationQueryOpenApi = OpenApi.annotations({
transform: (operation) => {
const parameters = operation.parameters
if (!Array.isArray(parameters)) return operation
return {
...operation,
parameters: parameters.map((parameter) =>
parameter?.name === "location" && parameter?.in === "query"
? { ...parameter, style: "deepObject", explode: true }
: parameter,
),
}
},
})
export class V2LocationMiddleware extends HttpApiMiddleware.Service<
V2LocationMiddleware,
{
provides:
| Catalog.Service
| PluginBoot.Service
| PermissionV2.Service
| ProjectReference.Service
| FileSystem.Service
| QuestionV2.Service
}
>()("@opencode/ExperimentalHttpApiV2Location") {}
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
return {
directory: AbsolutePath.make(
query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
),
workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined,
}
}
export const layer = Layer.effect(
V2LocationMiddleware,
Effect.gen(function* () {
const locations = yield* LocationServiceMap
return V2LocationMiddleware.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* effect.pipe(Effect.provide(locations.get(ref(request))))
}),
)
}),
)

View file

@ -1,55 +0,0 @@
import { SessionID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
export const MessagesQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
limit: Schema.optional(
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
).annotate({
description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
}),
cursor: Schema.optional(
Schema.String.annotate({
description:
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
}),
),
}).annotate({ identifier: "V2SessionMessagesQuery" })
export const MessageGroup = HttpApiGroup.make("v2.message")
.add(
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
params: { sessionID: SessionID },
query: MessagesQuery,
success: Schema.Struct({
items: Schema.Array(SessionMessage.Message),
cursor: Schema.Struct({
previous: Schema.String.pipe(Schema.optional),
next: Schema.String.pipe(Schema.optional),
}),
}).annotate({ identifier: "V2SessionMessagesResponse" }),
error: [InvalidCursorError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.messages",
summary: "Get v2 session messages",
description:
"Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "v2 messages",
description: "Experimental v2 message routes.",
}),
)
.middleware(V2Authorization)

View file

@ -1,31 +0,0 @@
import { ModelV2 } from "@opencode-ai/core/model"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ServiceUnavailableError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
export const ModelGroup = HttpApiGroup.make("v2.model")
.add(
HttpApiEndpoint.get("models", "/api/model", {
query: LocationQuery,
success: Schema.Array(ModelV2.PublicInfo),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.model.list",
summary: "List v2 models",
description: "Retrieve available v2 models ordered by release date.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "v2 models",
description: "Experimental v2 model routes.",
}),
)
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)

View file

@ -1,94 +0,0 @@
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
export const PermissionGroup = HttpApiGroup.make("v2.permission")
.add(
HttpApiEndpoint.get("permissionRequests", "/api/permission/request", {
query: LocationQuery,
success: Schema.Array(PermissionV2.Request),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.request.list",
summary: "List pending permission requests",
description: "Retrieve pending permission requests for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "v2 permissions", description: "Experimental v2 permission routes." }))
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
export const SessionPermissionGroup = HttpApiGroup.make("v2.session.permission")
.add(
HttpApiEndpoint.get("sessionPermissionRequests", "/api/session/:sessionID/permission/request", {
params: { sessionID: SessionV2.ID },
success: Schema.Array(PermissionV2.Request),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("permissionRequestReply", "/api/session/:sessionID/permission/request/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID },
payload: Schema.Struct({
reply: PermissionV2.Reply,
message: Schema.String.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, PermissionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.reply",
summary: "Reply to pending permission request",
description: "Respond to a pending permission request owned by a session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "v2 session permissions", description: "Experimental v2 session permission routes." }),
)
.middleware(V2Authorization)
export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved")
.add(
HttpApiEndpoint.get("savedPermissions", "/api/permission/saved", {
query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }),
success: Schema.Array(PermissionSaved.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.list",
summary: "List saved permissions",
description: "Retrieve saved permissions, optionally filtered by project.",
}),
),
)
.add(
HttpApiEndpoint.delete("removeSavedPermission", "/api/permission/saved/:id", {
params: { id: PermissionSaved.ID },
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.remove",
summary: "Remove saved permission",
description: "Remove a saved permission by ID.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "v2 saved permissions", description: "Experimental v2 saved permission routes." }),
)
.middleware(V2Authorization)

View file

@ -1,48 +0,0 @@
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
export const ProviderGroup = HttpApiGroup.make("v2.provider")
.add(
HttpApiEndpoint.get("providers", "/api/provider", {
query: LocationQuery,
success: Schema.Array(ProviderV2.PublicInfo),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.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: LocationQuery,
success: ProviderV2.PublicInfo,
error: [ProviderNotFoundError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)
.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({
title: "v2 providers",
description: "Experimental v2 provider routes.",
}),
)
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)

View file

@ -1,59 +0,0 @@
import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { QuestionNotFoundError, SessionNotFoundError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
export const QuestionGroup = HttpApiGroup.make("v2.question")
.add(
HttpApiEndpoint.get("questionRequests", "/api/question/request", {
query: LocationQuery,
success: Schema.Array(QuestionV2.Request),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.question.request.list",
summary: "List pending question requests",
description: "Retrieve pending question requests for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "v2 questions", description: "Experimental v2 question routes." }))
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
export const SessionQuestionGroup = HttpApiGroup.make("v2.session.question")
.add(
HttpApiEndpoint.post("questionRequestReply", "/api/session/:sessionID/question/request/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
payload: QuestionV2.Reply,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reply",
summary: "Reply to pending question request",
description: "Answer a pending question request owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("questionRequestReject", "/api/session/:sessionID/question/request/:requestID/reject", {
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reject",
summary: "Reject pending question request",
description: "Reject a pending question request owned by a session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "v2 session questions", description: "Experimental v2 session question routes." }),
)
.middleware(V2Authorization)

View file

@ -1,178 +0,0 @@
import { SessionID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionInput } from "@opencode-ai/core/session/input"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionV2 } from "@opencode-ai/core/session"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ConflictError,
InvalidCursorError,
InvalidRequestError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
} from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing"
const SessionsQueryFields = {
workspace: WorkspaceV2.ID.pipe(Schema.optional),
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
}),
search: Schema.optional(Schema.String),
}
const SessionsDirectoryQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath,
})
const SessionsProjectQuery = Schema.Struct({
...SessionsQueryFields,
project: ProjectV2.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
schema.mapFields((fields) => ({
...Struct.omit(fields, ["limit"]),
anchor: SessionV2.ListAnchor,
}))
const SessionsCursorInput = Schema.Union([
withCursor(SessionsDirectoryQuery),
withCursor(SessionsProjectQuery),
withCursor(SessionsAllQuery),
])
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
export const SessionsCursor = Schema.String.pipe(
Schema.brand("V2SessionsCursor"),
withStatics((schema) => {
const make = schema.make
return {
make: (input: typeof SessionsCursorInput.Type) =>
make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")),
parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")),
}
}),
)
export type SessionsCursor = typeof SessionsCursor.Type
const SessionsCursorQuery = Schema.Struct({
cursor: SessionsCursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
}),
limit: SessionsQueryFields.limit,
})
export const SessionsQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath.pipe(Schema.optional),
project: ProjectV2.ID.pipe(Schema.optional),
subpath: RelativePath.pipe(Schema.optional),
cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional),
}).annotate({ identifier: "V2SessionsQuery" })
export const SessionGroup = HttpApiGroup.make("v2.session")
.add(
HttpApiEndpoint.get("sessions", "/api/session", {
query: SessionsQuery,
success: Schema.Struct({
items: Schema.Array(SessionV2.Info),
cursor: Schema.Struct({
previous: SessionsCursor.pipe(Schema.optional),
next: SessionsCursor.pipe(Schema.optional),
}),
}).annotate({ identifier: "V2SessionsResponse" }),
error: [InvalidCursorError, InvalidRequestError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.list",
summary: "List v2 sessions",
description:
"Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
}),
),
)
.add(
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
prompt: Prompt,
delivery: SessionInput.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: SessionMessage.User,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.prompt",
summary: "Send v2 message",
description: "Durably admit one v2 session input and schedule agent-loop execution unless resume is false.",
}),
),
)
.add(
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.compact",
summary: "Compact v2 session",
description: "Compact a v2 session conversation.",
}),
),
)
.add(
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.wait",
summary: "Wait for v2 session",
description: "Wait for a v2 session agent loop to become idle.",
}),
),
)
.add(
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: Schema.Array(SessionMessage.Message),
error: [SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context",
summary: "Get v2 session context",
description: "Retrieve the active context messages for a v2 session (all messages after the last compaction).",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "v2",
description: "Experimental v2 routes.",
}),
)
.middleware(V2Authorization)

View file

@ -1,47 +0,0 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { ProjectV2 } from "@opencode-ai/core/project"
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Layer } from "effect"
import { layer as v2LocationLayer } from "../groups/v2/location"
import { messageHandlers } from "./v2/message"
import { modelHandlers } from "./v2/model"
import { providerHandlers } from "./v2/provider"
import { sessionHandlers } from "./v2/session"
import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission"
import { fileSystemHandlers } from "./v2/fs"
import { questionHandlers, sessionQuestionHandlers } from "./v2/question"
const routedSessions = SessionV2.layer.pipe(
Layer.provide(SessionProjector.layer),
Layer.provide(SessionExecutionLocal.layer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(SessionStore.layer),
Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
)
export const v2Handlers = Layer.mergeAll(
sessionHandlers,
messageHandlers,
modelHandlers,
providerHandlers,
permissionHandlers,
sessionPermissionHandlers,
savedPermissionHandlers,
fileSystemHandlers,
questionHandlers,
sessionQuestionHandlers,
).pipe(
Layer.provide(v2LocationLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(PermissionSaved.layer),
Layer.provide(routedSessions),
)

View file

@ -1,12 +0,0 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) =>
Effect.gen(function* () {
return handlers
.handle("read", (ctx) => FileSystem.Service.use((fs) => fs.read(ctx.query)))
.handle("list", (ctx) => FileSystem.Service.use((fs) => fs.list(ctx.query)))
}),
)

View file

@ -1,84 +0,0 @@
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@opencode-ai/core/session"
import { Effect, Schema } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
const DefaultMessagesLimit = 50
const Cursor = Schema.Struct({
id: SessionMessage.ID,
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
})
const decodeCursor = Schema.decodeUnknownSync(Cursor)
const cursor = {
encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") {
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
},
decode(input: string) {
return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
},
}
export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message", (handlers) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
return handlers.handle(
"messages",
Effect.fn(function* (ctx) {
if (ctx.query.cursor && ctx.query.order !== undefined)
return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" })
const decoded = yield* Effect.try({
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
})
const order = decoded?.order ?? ctx.query.order ?? "desc"
const messages = yield* session
.messages({
sessionID: ctx.params.sessionID,
limit: ctx.query.limit ?? DefaultMessagesLimit,
order,
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode v2 session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
const first = messages[0]
const last = messages.at(-1)
return {
items: messages,
cursor: {
previous: first ? cursor.encode(first, order, "previous") : undefined,
next: last ? cursor.encode(last, order, "next") : undefined,
},
}
}),
)
}),
)

View file

@ -1,26 +0,0 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { ModelV2 } from "@opencode-ai/core/model"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { ServiceUnavailableError } from "../../errors"
const catalogUnavailable = new ServiceUnavailableError({
message: "Model catalog is unavailable",
service: "catalog",
})
export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"models",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
return (yield* catalog.model.available()).map(ModelV2.toPublic)
}),
)
}),
)

View file

@ -1,105 +0,0 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
function missingRequest(id: PermissionV2.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
}
export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"permissionRequests",
Effect.fn(function* () {
return yield* (yield* PermissionV2.Service).list()
}),
)
}),
)
export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.permission", (handlers) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap
const withSessionPermission = Effect.fnUntraced(function* <A, E>(
sessionID: Parameters<PermissionV2.Interface["forSession"]>[0],
use: (permission: PermissionV2.Interface) => Effect.Effect<A, E>,
) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* Effect.gen(function* () {
return yield* use(yield* PermissionV2.Service)
}).pipe(
Effect.scoped,
Effect.provide(
locations.get({ directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }),
),
)
})
return handlers
.handle(
"sessionPermissionRequests",
Effect.fn(function* (ctx) {
return yield* withSessionPermission(ctx.params.sessionID, (permission) =>
permission.forSession(ctx.params.sessionID),
)
}),
)
.handle(
"permissionRequestReply",
Effect.fn(function* (ctx) {
yield* withSessionPermission(ctx.params.sessionID, (permission) =>
Effect.gen(function* () {
const request = yield* permission.get(ctx.params.requestID)
if (!request || request.sessionID !== ctx.params.sessionID)
return yield* missingRequest(ctx.params.requestID)
yield* permission
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message })
.pipe(Effect.catchTag("PermissionV2.NotFoundError", () => missingRequest(ctx.params.requestID)))
}),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)
export const savedPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission.saved", (handlers) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
return handlers
.handle(
"savedPermissions",
Effect.fn(function* (ctx) {
return yield* saved.list({ projectID: ctx.query.projectID })
}),
)
.handle(
"removeSavedPermission",
Effect.fn(function* (ctx) {
yield* saved.remove(ctx.params.id)
return HttpApiSchema.NoContent.make()
}),
)
}),
)

View file

@ -1,46 +0,0 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
const catalogUnavailable = new ServiceUnavailableError({
message: "Provider catalog is unavailable",
service: "catalog",
})
export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provider", (handlers) =>
Effect.gen(function* () {
return handlers
.handle(
"providers",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
return (yield* catalog.provider.available()).map(ProviderV2.toPublic)
}),
)
.handle(
"provider",
Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
return yield* catalog.provider.get(ctx.params.providerID).pipe(
Effect.map(ProviderV2.toPublic),
Effect.catchTag("CatalogV2.ProviderNotFound", (error) =>
Effect.fail(
new ProviderNotFoundError({
providerID: error.providerID,
message: `Provider not found: ${error.providerID}`,
}),
),
),
)
}),
)
}),
)

View file

@ -1,96 +0,0 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { QuestionV2 } from "@opencode-ai/core/question"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { QuestionNotFoundError, SessionNotFoundError } from "../../errors"
function missingRequest(id: QuestionV2.ID) {
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
}
export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.question", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"questionRequests",
Effect.fn(function* () {
return yield* (yield* QuestionV2.Service).list()
}),
)
}),
)
export const sessionQuestionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.question", (handlers) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap
const withSessionQuestion = Effect.fnUntraced(function* <A, E>(
sessionID: QuestionV2.Request["sessionID"],
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* Effect.gen(function* () {
return yield* use(yield* QuestionV2.Service)
}).pipe(
Effect.scoped,
Effect.provide(
locations.get({ directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }),
),
)
})
const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
sessionID: QuestionV2.Request["sessionID"],
requestID: QuestionV2.ID,
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
) {
return yield* withSessionQuestion(sessionID, (question) =>
Effect.gen(function* () {
const request = (yield* question.list()).find((request) => request.id === requestID)
if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
return yield* use(question)
}),
)
})
return handlers
.handle(
"questionRequestReply",
Effect.fn(function* (ctx) {
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
question
.reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"questionRequestReject",
Effect.fn(function* (ctx) {
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
question
.reject(ctx.params.requestID)
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)

View file

@ -1,173 +0,0 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { DateTime, Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { SessionsCursor } from "../../groups/v2/session"
import {
ConflictError,
InvalidCursorError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
} from "../../errors"
const DefaultSessionsLimit = 50
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
return handlers
.handle(
"sessions",
Effect.fn(function* (ctx) {
const query =
ctx.query.cursor !== undefined
? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
)
: ctx.query
const sessions = yield* session.list({
...query,
workspaceID: query.workspace,
limit: ctx.query.limit ?? DefaultSessionsLimit,
})
const first = sessions[0]
const last = sessions.at(-1)
return {
items: sessions,
cursor: {
previous: first
? SessionsCursor.make({
...query,
anchor: {
id: first.id,
time: DateTime.toEpochMillis(first.time.created),
direction: "previous",
},
})
: undefined,
next: last
? SessionsCursor.make({
...query,
anchor: {
id: last.id,
time: DateTime.toEpochMillis(last.time.created),
direction: "next",
},
})
: undefined,
},
}
}),
)
.handle(
"prompt",
Effect.fn(function* (ctx) {
return yield* session
.prompt({
sessionID: ctx.params.sessionID,
id: ctx.payload.id,
prompt: ctx.payload.prompt,
delivery: ctx.payload.delivery,
resume: ctx.payload.resume,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.PromptConflictError", (error) =>
Effect.fail(
new ConflictError({
message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`,
resource: error.messageID,
}),
),
),
)
}),
)
.handle(
"compact",
Effect.fn(function* (ctx) {
yield* session.compact({ sessionID: ctx.params.sessionID }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
message: `V2 session ${error.operation} is not available yet`,
service: `v2.session.${error.operation}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"wait",
Effect.fn(function* (ctx) {
yield* session.wait(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
message: `V2 session ${error.operation} is not available yet`,
service: `v2.session.${error.operation}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"context",
Effect.fn(function* (ctx) {
return yield* session.context(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode v2 session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
}),
)
}),
)

View file

@ -4,7 +4,7 @@ import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "e
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
import { isPublicUIPath } from "@/server/shared/public-ui"
import { UnauthorizedError } from "../errors"
export { V2Authorization, v2AuthorizationLayer } from "@opencode-ai/server/middleware/authorization"
const AUTH_TOKEN_QUERY = "auth_token"
const UNAUTHORIZED = 401
@ -20,13 +20,6 @@ export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
},
) {}
export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>()(
"@opencode/ExperimentalHttpApiV2Authorization",
{
error: UnauthorizedError,
},
) {}
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
{
@ -152,27 +145,3 @@ export const ptyConnectAuthorizationLayer = Layer.effect(
)
}),
)
export const v2AuthorizationLayer = Layer.effect(
V2Authorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return V2Authorization.of((effect) => effect)
return V2Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* credentialFromRequest(request).pipe(
Effect.flatMap((credential) =>
Effect.gen(function* () {
if (ServerAuth.authorized(credential, config)) return yield* effect
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
)
return yield* new UnauthorizedError({ message: "Authentication required" })
}),
),
)
}),
)
}),
)

View file

@ -44,6 +44,7 @@ import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Database } from "@opencode-ai/core/database/database"
import { Skill } from "@/skill"
import { Snapshot } from "@/snapshot"
@ -56,6 +57,7 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { V2Api } from "@opencode-ai/server/api"
import { PublicApi } from "./public"
import {
authorizationLayer,
@ -82,7 +84,8 @@ import { questionHandlers } from "./handlers/question"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { v2Handlers } from "./handlers/v2"
import { v2Handlers } from "@opencode-ai/server/handlers"
import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer } from "./middleware/instance-context"
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
@ -144,14 +147,17 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
providerHandlers,
sessionHandlers,
syncHandlers,
v2Handlers,
tuiHandlers,
workspaceHandlers,
]),
)
const instanceRoutes = instanceApiRoutes.pipe(
Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
)
const v2Routes = HttpApiBuilder.layer(V2Api).pipe(
Layer.provide(v2Handlers),
Layer.provide([v2HttpApiAuthLayer, v2SchemaErrorLayer]),
)
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
@ -186,7 +192,7 @@ type RouteRequirements =
export function createRoutes(
corsOptions?: CorsOptions,
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, v2Routes, docRoute, uiRoute).pipe(
Layer.provide([
errorLayer,
compressionLayer,
@ -226,6 +232,7 @@ export function createRoutes(
ShareNext.defaultLayer,
Snapshot.defaultLayer,
EventV2Bridge.defaultLayer,
EventV2.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
ToolRegistry.defaultLayer,

View file

@ -21,6 +21,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "session.compaction" })
@ -201,7 +202,7 @@ export interface Interface {
readonly create: (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
auto: boolean
overflow?: boolean
}) => Effect.Effect<void>
@ -585,7 +586,7 @@ export const layer = Layer.effect(
const create = Effect.fn("SessionCompaction.create")(function* (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
auto: boolean
overflow?: boolean
}) {

View file

@ -5,6 +5,7 @@ import { NonNegativeInt } from "@opencode-ai/core/schema"
import { MessageError } from "./message-error"
import { AuthError, OutputLengthError } from "./message-error"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
export { AuthError, OutputLengthError } from "./message-error"
export const ToolCall = Schema.Struct({
@ -120,7 +121,7 @@ export const Info = Schema.Struct({
assistant: Schema.optional(
Schema.Struct({
system: Schema.Array(Schema.String),
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
providerID: ProviderV2.ID,
path: Schema.Struct({
cwd: Schema.String,

View file

@ -241,7 +241,7 @@ export const layer = Layer.effect(
session: Session.Info
history: SessionV1.WithParts[]
providerID: ProviderV2.ID
modelID: ProviderV2.ModelID
modelID: ModelV2.ID
}) {
if (input.session.parentID) return
if (!Session.isDefaultTitle(input.session.title)) return
@ -653,7 +653,7 @@ export const layer = Layer.effect(
const getModel = Effect.fn("SessionPrompt.getModel")(function* (
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
sessionID: SessionID,
) {
const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit)
@ -681,7 +681,7 @@ export const layer = Layer.effect(
if (current?.model) {
return {
providerID: ProviderV2.ID.make(current.model.providerID),
modelID: ProviderV2.ModelID.make(current.model.id),
modelID: ModelV2.ID.make(current.model.id),
...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}),
}
}
@ -1679,7 +1679,7 @@ export const defaultLayer = Layer.suspend(() =>
)
const ModelRef = Schema.Struct({
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
modelID: ModelV2.ID,
})
export const PromptInput = Schema.Struct({

View file

@ -40,9 +40,10 @@ import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission"
import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const log = Log.create({ service: "session" })
const runtime = makeRuntime(Database.Service, Database.defaultLayer)
@ -82,7 +83,7 @@ export function fromRow(row: SessionRow): Info {
agent: row.agent ?? undefined,
model: row.model
? {
id: ProviderV2.ModelID.make(row.model.id),
id: ModelV2.ID.make(row.model.id),
providerID: ProviderV2.ID.make(row.model.providerID),
variant: row.model.variant,
}
@ -112,13 +113,6 @@ export function fromRow(row: SessionRow): Info {
}
}
function eventLocation(info: Pick<Info, "directory" | "workspaceID">) {
return {
directory: AbsolutePath.make(info.directory),
workspaceID: info.workspaceID,
}
}
export function toRow(info: Info) {
return {
id: info.id,
@ -209,7 +203,7 @@ const Revert = Schema.Struct({
})
const Model = Schema.Struct({
id: ProviderV2.ModelID,
id: ModelV2.ID,
providerID: ProviderV2.ID,
variant: optionalOmitUndefined(Schema.String),
})
@ -544,20 +538,6 @@ export const layer: Layer.Layer<
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const locationForSession = Effect.fnUntraced(function* (sessionID: SessionID) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return
return {
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ?? undefined,
}
})
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
title?: string
@ -597,7 +577,6 @@ export const layer: Layer.Layer<
yield* events.publish(
SessionV1.Event.Created,
{ sessionID: result.id, info: result },
{ location: eventLocation(result) },
)
return result
@ -688,7 +667,6 @@ export const layer: Layer.Layer<
yield* events.publish(
SessionV1.Event.Deleted,
{ sessionID, info: session },
{ location: eventLocation(session) },
)
yield* events.remove(sessionID)
} catch (e) {
@ -698,14 +676,12 @@ export const layer: Layer.Layer<
const updateMessage = <T extends SessionV1.Info>(msg: T): Effect.Effect<T> =>
Effect.gen(function* () {
const location = yield* locationForSession(msg.sessionID)
yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg }, { location })
yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg })
return msg
}).pipe(Effect.withSpan("Session.updateMessage"))
const updatePart = <T extends SessionV1.Part>(part: T): Effect.Effect<T> =>
Effect.gen(function* () {
const location = yield* locationForSession(part.sessionID)
yield* events.publish(
SessionV1.Event.PartUpdated,
{
@ -713,7 +689,6 @@ export const layer: Layer.Layer<
part: structuredClone(part),
time: Date.now(),
},
{ location },
)
return part
}).pipe(Effect.withSpan("Session.updatePart"))
@ -819,7 +794,7 @@ export const layer: Layer.Layer<
revert: info.revert === null ? undefined : (info.revert ?? current.revert),
permission: info.permission === null ? undefined : (info.permission ?? current.permission),
} as Info
yield* events.publish(SessionV1.Event.Updated, { sessionID, info: next }, { location: eventLocation(next) })
yield* events.publish(SessionV1.Event.Updated, { sessionID, info: next })
})
const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
@ -917,14 +892,12 @@ export const layer: Layer.Layer<
sessionID: SessionID
messageID: MessageID
}) {
const location = yield* locationForSession(input.sessionID)
yield* events.publish(
SessionV1.Event.MessageRemoved,
{
sessionID: input.sessionID,
messageID: input.messageID,
},
{ location },
)
return input.messageID
})
@ -934,7 +907,6 @@ export const layer: Layer.Layer<
messageID: MessageID
partID: PartID
}) {
const location = yield* locationForSession(input.sessionID)
yield* events.publish(
SessionV1.Event.PartRemoved,
{
@ -942,7 +914,6 @@ export const layer: Layer.Layer<
messageID: input.messageID,
partID: input.partID,
},
{ location },
)
return input.partID
})

View file

@ -20,6 +20,7 @@ import { PartID } from "./schema"
import { Log } from "@opencode-ai/core/util/log"
import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const log = Log.create({ service: "session.tools" })
@ -75,7 +76,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
for (const item of yield* registry.tools({
modelID: ProviderV2.ModelID.make(input.model.api.id),
modelID: ModelV2.ID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
})) {

View file

@ -16,6 +16,7 @@ import { Config } from "@/config/config"
import * as Log from "@opencode-ai/core/util/log"
import { SessionShareTable } from "@opencode-ai/core/share/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "share-next" })
@ -284,7 +285,7 @@ export const layer = Layer.effect(
.map((item) => [`${item.providerID}/${item.modelID}`, item] as const),
).values(),
),
(item) => provider.getModel(ProviderV2.ID.make(item.providerID), ProviderV2.ModelID.make(item.modelID)),
(item) => provider.getModel(ProviderV2.ID.make(item.providerID), ModelV2.ID.make(item.modelID)),
{ concurrency: 8 },
)

View file

@ -15,7 +15,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { Glob } from "@opencode-ai/core/util/glob"
import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
import { isRecord } from "@/util/record"
const log = Log.create({ service: "skill" })
@ -33,6 +32,9 @@ const SKILL_PATTERN = "**/SKILL.md"
const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode"
const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION =
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself."
const CUSTOMIZE_OPENCODE_SKILL_BODY = await Bun.file(
new URL("../../../core/src/plugin/skill/customize-opencode.md", import.meta.url),
).text()
export const Info = Schema.Struct({
name: Schema.String,

View file

@ -1,376 +0,0 @@
<!--
Built-in skill. Name and description are registered in code at
packages/opencode/src/skill/index.ts (see CUSTOMIZE_OPENCODE_SKILL_NAME
and CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION). The body below becomes the
skill's content.
-->
# Customizing opencode
opencode validates its own config strictly and refuses to start when a field
is wrong. The shapes below cover the common surface area, but they are a
**summary, not the source of truth**.
## Full schema reference
The authoritative list of every config option — with field types, enums,
defaults, and descriptions — lives in the published JSON Schema:
**<https://opencode.ai/config.json>**
If a field is not documented in this skill, or you need to confirm an exact
shape before writing config, **fetch that URL and read the schema directly**
rather than guessing. opencode hard-fails on invalid config, so the cost of a
wrong shape is a broken startup.
Independently, every `opencode.json` should declare
`"$schema": "https://opencode.ai/config.json"` so the user's editor catches
mistakes as they type.
## Applying changes
Config is loaded once when opencode starts and is not hot-reloaded. After
saving changes to `opencode.json`, an agent file, a skill, a plugin, or any
other config-time file, **tell the user to quit and restart opencode** for
the changes to take effect. The running session will keep using the
already-loaded config until then.
## Where files live
| Scope | Path |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) |
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
| Project skills | `.opencode/skill(s)/<name>/SKILL.md` |
| Global skills | `~/.config/opencode/skill(s)/<name>/SKILL.md` |
| External skills (auto-loaded) | `~/.claude/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` |
Configs from each scope are deep-merged. Project overrides global. Unknown
top-level keys in `opencode.json` are rejected with `ConfigInvalidError`.
## opencode.json
Every field is optional.
```json
{
"$schema": "https://opencode.ai/config.json",
"username": "string",
"model": "provider/model-id",
"small_model": "provider/model-id",
"default_agent": "agent-name",
"shell": "/bin/zsh",
"logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR",
"share": "manual" | "auto" | "disabled",
"autoupdate": true | false | "notify",
"snapshot": true,
"instructions": ["AGENTS.md", "docs/style.md"],
"skills": {
"paths": [".opencode/skills", "/abs/path/to/skills"],
"urls": ["https://example.com/.well-known/skills/"]
},
"agent": {
"my-agent": {
"model": "anthropic/claude-sonnet-4-6",
"mode": "subagent",
"description": "...",
"permission": { "edit": "deny" }
}
},
"command": {
"deploy": { "description": "...", "prompt": "..." }
},
"provider": {
"anthropic": { "options": { "apiKey": "..." } }
},
"disabled_providers": ["openai"],
"enabled_providers": ["anthropic"],
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp"],
"enabled": true,
"env": {}
},
"remote-thing": {
"type": "remote",
"url": "https://...",
"headers": { "Authorization": "Bearer ..." }
}
},
"plugin": [
"opencode-gemini-auth",
"opencode-foo@1.2.3",
"./local-plugin.ts",
["opencode-bar", { "option": "value" }]
],
"permission": {
"edit": "deny",
"bash": { "git *": "allow", "*": "ask" }
},
"formatter": false,
"lsp": false,
"experimental": {
"primary_tools": ["edit"],
"mcp_timeout": 30000
},
"tool_output": { "max_lines": 200, "max_bytes": 8192 },
"compaction": { "auto": true, "tail_turns": 15 }
}
```
Shape notes worth being explicit about:
- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`.
- `skills` is an object with `paths` and/or `urls`, not an array.
- `agent` is an object keyed by agent name, not an array.
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
- `permission` is either a string action or an object keyed by tool name.
## Skills
opencode's skill loader scans for `**/SKILL.md` inside skill directories. The
file is named `SKILL.md` exactly, and lives in its own folder named after the
skill:
```
.opencode/skills/my-skill/SKILL.md
```
Frontmatter:
```markdown
---
name: my-skill
description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say.
---
# My Skill
(skill body in markdown: instructions, examples, references)
```
- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name.
- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics.
- Optional: `license`, `compatibility`, `metadata` (string-string map).
Register skills from non-default locations via `skills.paths` (scanned
recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of
skills).
## Agents
Two ways to define an agent. Use the file form for anything non-trivial.
### Inline (in `opencode.json`)
```json
{
"agent": {
"my-reviewer": {
"description": "Reviews PRs for style violations.",
"mode": "subagent",
"model": "anthropic/claude-sonnet-4-6",
"permission": { "edit": "deny", "bash": "ask" },
"prompt": "You are a strict PR reviewer..."
}
}
}
```
### File
```
.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md
```
```markdown
---
description: Reviews PRs for style violations.
mode: subagent
model: anthropic/claude-sonnet-4-6
permission:
edit: deny
bash: ask
---
You are a strict PR reviewer. Focus on...
```
The file body becomes the agent's `prompt`. Do not also put `prompt:` in the
frontmatter.
`mode` is one of `"primary"`, `"subagent"`, `"all"`.
Allowed top-level frontmatter fields: `name, model, variant, description, mode,
hidden, color, steps, options, permission, disable, temperature, top_p`. Any
unknown field is silently routed into `options`.
To disable a built-in agent: `agent: { build: { disable: true } }`, or in a
file, `disable: true` in frontmatter.
`default_agent` must point to a non-hidden, primary-mode agent.
### Built-in agents
opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agents:
`compaction`, `title`, `summary`. To override a built-in's fields, define the
same key in `agent: { <name>: { ... } }`.
## Plugins
`plugin:` is an array. Each entry is one of:
```json
"plugin": [
"opencode-gemini-auth", // npm spec, latest
"opencode-foo@1.2.3", // npm spec, pinned
"./local-plugin.ts", // file path, relative to the declaring config
"file:///abs/path/plugin.js", // file URL
["opencode-bar", { "key": "val" }] // tuple form with options
]
```
Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in
`.opencode/plugin/` or `.opencode/plugins/`.
A plugin module exports `default` (or any named export) of type
`Plugin = (input: PluginInput, options?) => Promise<Hooks>`. The export is a
function, not a plain object literal, and the function returns an object
(return `{}` if there is nothing to register).
```ts
import type { Plugin } from "@opencode-ai/plugin"
export default (async ({ client, project, directory, $ }) => {
return {
config: (cfg) => {
// cfg is the live merged config; mutate fields here.
},
"tool.execute.before": async (input, output) => {
// mutate output.args before the tool runs
},
}
}) satisfies Plugin
```
Hook surface (mutate `output` in place; return `void`):
- `event(input)`: every bus event
- `config(cfg)`: once on init with the merged config
- `chat.message`, `chat.params`, `chat.headers`
- `tool.execute.before`, `tool.execute.after`
- `tool.definition`
- `command.execute.before`
- `shell.env`
- `permission.ask`
- `experimental.chat.messages.transform`, `experimental.chat.system.transform`,
`experimental.session.compacting`, `experimental.compaction.autocontinue`,
`experimental.text.complete`
Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
`auth: { ... }`, `provider: { ... }`.
## MCP servers
`mcp:` is an object keyed by server name. Each server is discriminated by
`type`:
```json
{
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp"],
"enabled": true,
"env": { "BROWSER": "chromium" }
},
"github": {
"type": "remote",
"url": "https://...",
"enabled": true,
"headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }
},
"old-server": { "enabled": false }
}
}
```
`command` is an array of strings. `type` is required. Use `enabled: false` to
disable a server inherited from a parent config.
## Permissions
```json
"permission": {
"edit": "deny",
"bash": { "git *": "allow", "rm *": "deny", "*": "ask" },
"external_directory": { "~/secrets/**": "deny", "*": "allow" }
}
```
Actions: `"allow"`, `"ask"`, `"deny"`.
Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an
object `{ pattern: action }`. Within an object, **insertion order matters**.
opencode evaluates the LAST matching rule, so put broad rules first and narrow
rules last.
`permission: "allow"` (a string at the top level) is shorthand for "allow
everything" and is rarely what the user wants.
Known permission keys: `read, edit, glob, grep, list, bash, task,
external_directory, todowrite, question, webfetch, websearch, lsp, doom_loop,
skill`. Some of these (`todowrite,
question, webfetch, websearch, doom_loop`) only accept a flat
action, not a per-pattern object.
`external_directory` patterns are filesystem paths (use `~/`, absolute paths,
or globs like `~/projects/**`).
Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on
the `plan` agent's permission ruleset (`edit: deny *`).
## Escape hatches
When a user's config is broken and opencode won't start, these env vars help:
- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
and start from globals only. Run from the project directory, opencode loads,
the user edits the broken file, then they restart without the flag.
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
inject inline JSON as a final local-scope merge.
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
- `OPENCODE_PURE=1`: skip external plugins entirely.
- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`,
`OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
`~/.claude/` and `~/.agents/`.
## When proposing edits
- Validate against the schema before writing. If you are unsure of a field's
exact shape, or the field is not covered in this skill, fetch
`https://opencode.ai/config.json` and read the schema rather than guessing.
- Preserve `$schema` and any existing fields the user did not ask to change.
- For agent, skill, and plugin definitions, prefer creating new files in the
correct location over inlining everything in `opencode.json`.
- If the user's existing config is malformed, point them at the env-var escape
hatches above so they can edit from inside opencode without breaking their
session.
- After saving any config change, remind the user to quit and restart opencode
— running sessions keep using the already-loaded config.

View file

@ -51,6 +51,7 @@ import { Reference } from "@/reference/reference"
import { BackgroundJob } from "@/background/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const log = Log.create({ service: "tool.registry" })
@ -74,7 +75,7 @@ export interface Interface {
readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }>
readonly tools: (model: {
providerID: ProviderV2.ID
modelID: ProviderV2.ModelID
modelID: ModelV2.ID
agent: Agent.Info
}) => Effect.Effect<Tool.Def[]>
}

View file

@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import { Directory } from "@/acp/directory"
import { Command } from "@/command"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
import { it } from "../lib/effect"
@ -14,7 +15,7 @@ const command = (name: string): Command.Info => ({
})
const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({
id: ProviderV2.ModelID.make(id),
id: ModelV2.ID.make(id),
providerID,
api: {
id,
@ -50,7 +51,7 @@ const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.Model
const snapshot = (directory: string) => {
const providerID = ProviderV2.ID.make(`provider-${directory}`)
const modelID = ProviderV2.ModelID.make(`model-${directory}`)
const modelID = ModelV2.ID.make(`model-${directory}`)
const providers = {
[providerID]: {
id: providerID,
@ -63,7 +64,7 @@ const snapshot = (directory: string) => {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
}),
[ProviderV2.ModelID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
[ModelV2.ID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
},
},
} satisfies Record<ProviderV2.ID, Provider.Info>
@ -148,7 +149,7 @@ describe("ACP directory snapshot", () => {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
})
expect(directory.variants(alpha, { ...model, modelID: ProviderV2.ModelID.make("missing") })).toBeUndefined()
expect(directory.variants(alpha, { ...model, modelID: ModelV2.ID.make("missing") })).toBeUndefined()
}).pipe(Effect.provide(fakeLayer([]))),
)

View file

@ -12,6 +12,7 @@ import type {
} from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Effect } from "effect"
import * as ACPService from "@/acp/service"
import * as ACPError from "@/acp/error"
@ -19,9 +20,9 @@ import { UsageService } from "@/acp/usage"
import type { Provider } from "@/provider/provider"
const providerID = ProviderV2.ID.make("test")
const modelID = ProviderV2.ModelID.make("test-model")
const configuredModelID = ProviderV2.ModelID.make("configured-model")
const secondModelID = ProviderV2.ModelID.make("second-model")
const modelID = ModelV2.ID.make("test-model")
const configuredModelID = ModelV2.ID.make("configured-model")
const secondModelID = ModelV2.ID.make("second-model")
const provider: Provider.Info = {
id: providerID,

View file

@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import type { McpServer } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import * as ACPError from "@/acp/error"
import * as ACPSession from "@/acp/session"
import { testEffect } from "../lib/effect"
@ -10,7 +11,7 @@ const sessionTest = testEffect(ACPSession.defaultLayer)
const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({
providerID: ProviderV2.ID.make(providerID),
modelID: ProviderV2.ModelID.make(modelID),
modelID: ModelV2.ID.make(modelID),
})
const mcpServer: McpServer = {

View file

@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { UsageService } from "@/acp/usage"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
@ -41,7 +42,7 @@ const assistantWithoutProvider = (): UsageService.SessionMessage => ({
},
})
const model = (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID, context: number): Provider.Model => ({
const model = (providerID: ProviderV2.ID, modelID: ModelV2.ID, context: number): Provider.Model => ({
id: modelID,
providerID,
api: {
@ -77,7 +78,7 @@ const model = (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID, context:
const providers = (context = 128_000): Record<ProviderV2.ID, Provider.Info> => {
const providerID = ProviderV2.ID.make("anthropic")
const modelID = ProviderV2.ModelID.make("claude-sonnet")
const modelID = ModelV2.ID.make("claude-sonnet")
return {
[providerID]: {
id: providerID,
@ -179,12 +180,12 @@ describe("acp usage", () => {
const first = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderV2.ID.make("anthropic"),
modelID: ProviderV2.ModelID.make("claude-sonnet"),
modelID: ModelV2.ID.make("claude-sonnet"),
})
const second = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderV2.ID.make("anthropic"),
modelID: ProviderV2.ModelID.make("claude-sonnet"),
modelID: ModelV2.ID.make("claude-sonnet"),
})
expect(first).toBe(200_000)

View file

@ -1,10 +1,11 @@
import { Effect, Layer } from "effect"
import { Provider } from "@/provider/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
export namespace ProviderTest {
export function model(override: Partial<Provider.Model> = {}): Provider.Model {
const id = override.id ?? ProviderV2.ModelID.make("gpt-5.2")
const id = override.id ?? ModelV2.ID.make("gpt-5.2")
const providerID = override.providerID ?? ProviderV2.ID.make("openai")
return {
id,

View file

@ -18,6 +18,7 @@ import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
@ -75,7 +76,7 @@ const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransfo
{
model: {
providerID: ProviderV2.ID.anthropic,
modelID: ProviderV2.ModelID.make("claude-sonnet-4-6"),
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
},
},
out,

View file

@ -10,6 +10,7 @@ import { Provider } from "@/provider/provider"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer))
@ -113,7 +114,7 @@ it.instance(
() =>
Effect.gen(function* () {
yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token")
const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ProviderV2.ModelID.make("openai.gpt-5.5"))
const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5"))
const language = yield* Provider.use.getLanguage(model)
expect((language as { provider: string }).provider).toBe("bedrock-mantle.responses")
expect((language as { modelId: string }).modelId).toBe("openai.gpt-5.5")
@ -143,7 +144,7 @@ it.instance(
yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token")
const model = yield* Provider.use.getModel(
ProviderV2.ID.amazonBedrock,
ProviderV2.ModelID.make("openai.gpt-oss-safeguard-120b"),
ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
)
const language = yield* Provider.use.getLanguage(model)
expect((language as { provider: string }).provider).toBe("bedrock-mantle.chat")

View file

@ -14,6 +14,7 @@ import { createUnified } from "ai-gateway-provider/providers/unified"
import { ProviderTransform } from "@/provider/transform"
import type * as Provider from "@/provider/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
type Captured = { url: string; outerBody: unknown }
type ProviderOptions = Record<string, Record<string, JSONValue>>
@ -56,7 +57,7 @@ afterEach(() => {
})
const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({
id: ProviderV2.ModelID.make(`cloudflare-ai-gateway/${apiId}`),
id: ModelV2.ID.make(`cloudflare-ai-gateway/${apiId}`),
providerID: ProviderV2.ID.make("cloudflare-ai-gateway"),
name: apiId,
api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: "ai-gateway-provider" },

View file

@ -4,6 +4,7 @@ import { streamText } from "ai"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { testProviderConfig } from "../lib/test-provider"
@ -31,7 +32,7 @@ it.live("headerTimeout does not abort delayed SSE body after headers arrive", ()
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
messages: [{ role: "user", content: "hello" }],
@ -55,7 +56,7 @@ it.live("chunkTimeout raises a response stream error when SSE body stalls", () =
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
onError() {},
@ -89,7 +90,7 @@ it.live("headerTimeout aborts when response headers do not arrive", () =>
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
onError() {},
@ -121,7 +122,7 @@ it.live("headerTimeout is opt-in for non-OpenAI providers", () =>
() =>
Effect.gen(function* () {
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model"))
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
const result = streamText({
model: yield* provider.getLanguage(model),
messages: [{ role: "user", content: "hello" }],

View file

@ -19,6 +19,7 @@ import { Filesystem } from "@/util/filesystem"
import { InstanceLayer } from "@/project/instance-layer"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const originalEnv = new Map<string, string | undefined>()
@ -293,7 +294,7 @@ it.instance("getModel returns model for valid provider/model", () =>
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514"))
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514"))
expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
@ -306,7 +307,7 @@ it.instance("getModel throws ModelNotFoundError for invalid model", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const exit = yield* Provider.use
.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("nonexistent-model"))
.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("nonexistent-model"))
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
@ -315,7 +316,7 @@ it.instance("getModel throws ModelNotFoundError for invalid model", () =>
it.instance("getModel throws ModelNotFoundError for invalid provider", () =>
Effect.gen(function* () {
const exit = yield* Provider.use
.getModel(ProviderV2.ID.make("nonexistent-provider"), ProviderV2.ModelID.make("some-model"))
.getModel(ProviderV2.ID.make("nonexistent-provider"), ModelV2.ID.make("some-model"))
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
@ -464,7 +465,7 @@ it.instance(
const providers = yield* list
expect(providers[ProviderV2.ID.anthropic].models["my-sonnet"]).toBeDefined()
const model = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("my-sonnet"))
const model = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("my-sonnet"))
expect(model).toBeDefined()
expect(String(model.id)).toBe("my-sonnet")
expect(model.name).toBe("My Sonnet Alias")
@ -981,11 +982,11 @@ it.instance("getModel returns consistent results", () =>
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const model1 = yield* Provider.use.getModel(
ProviderV2.ID.anthropic,
ProviderV2.ModelID.make("claude-sonnet-4-20250514"),
ModelV2.ID.make("claude-sonnet-4-20250514"),
)
const model2 = yield* Provider.use.getModel(
ProviderV2.ID.anthropic,
ProviderV2.ModelID.make("claude-sonnet-4-20250514"),
ModelV2.ID.make("claude-sonnet-4-20250514"),
)
expect(model1.providerID).toEqual(model2.providerID)
expect(model1.id).toEqual(model2.id)
@ -1017,7 +1018,7 @@ it.instance("ModelNotFoundError includes suggestions for typos", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const error = yield* Provider.use
.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonet-4"))
.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonet-4"))
.pipe(Effect.flip)
expect(error.suggestions).toBeDefined()
expect((error.suggestions ?? []).length).toBeGreaterThan(0)
@ -1028,7 +1029,7 @@ it.instance("ModelNotFoundError for provider includes suggestions", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const error = yield* Provider.use
.getModel(ProviderV2.ID.make("antropic"), ProviderV2.ModelID.make("claude-sonnet-4"))
.getModel(ProviderV2.ID.make("antropic"), ModelV2.ID.make("claude-sonnet-4"))
.pipe(Effect.flip)
expect(error.suggestions).toBeDefined()
expect(error.suggestions).toContain("anthropic")
@ -1039,7 +1040,7 @@ it.instance("ModelNotFoundError suggests catalog models for unloaded providers",
Effect.gen(function* () {
yield* remove("OPENCODE_API_KEY")
const error = yield* Provider.use
.getModel(ProviderV2.ID.opencode, ProviderV2.ModelID.make("claude-haiku-fake-model"))
.getModel(ProviderV2.ID.opencode, ModelV2.ID.make("claude-haiku-fake-model"))
.pipe(Effect.flip)
if (!Provider.ModelNotFoundError.isInstance(error)) throw error
expect(error.suggestions ?? []).toContain("claude-haiku-4-5")
@ -1577,7 +1578,7 @@ it.instance("Google Vertex: uses REP endpoint for Claude continental multi-regio
const provider = yield* Provider.Service
const model = yield* provider.getModel(
ProviderV2.ID.make("google-vertex"),
ProviderV2.ModelID.make("claude-sonnet-4-6@default"),
ModelV2.ID.make("claude-sonnet-4-6@default"),
)
const language = yield* provider.getLanguage(model)
expect(languageBaseURL(language)).toBe(
@ -1593,7 +1594,7 @@ it.instance("Google Vertex Anthropic: uses REP endpoint for continental multi-re
const provider = yield* Provider.Service
const model = yield* provider.getModel(
ProviderV2.ID.make("google-vertex-anthropic"),
ProviderV2.ModelID.make("claude-sonnet-4-6@default"),
ModelV2.ID.make("claude-sonnet-4-6@default"),
)
const language = yield* provider.getLanguage(model)
expect(languageBaseURL(language)).toBe(
@ -1609,7 +1610,7 @@ it.instance("Google Vertex: keeps regional Claude endpoints unchanged", () =>
const provider = yield* Provider.Service
const model = yield* provider.getModel(
ProviderV2.ID.make("google-vertex"),
ProviderV2.ModelID.make("claude-sonnet-4-6@default"),
ModelV2.ID.make("claude-sonnet-4-6@default"),
)
const language = yield* provider.getLanguage(model)
expect(languageBaseURL(language)).toBe(
@ -1700,13 +1701,13 @@ it.effect("plugin config providers persist after instance dispose", () =>
const first = yield* loadAndList
expect(first[ProviderV2.ID.make("demo")]).toBeDefined()
expect(first[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined()
expect(first[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined()
yield* Effect.promise(() => disposeAllInstances())
const second = yield* loadAndList
expect(second[ProviderV2.ID.make("demo")]).toBeDefined()
expect(second[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined()
expect(second[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined()
}).pipe(provideMultiInstance),
)

View file

@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ProviderTransform } from "@/provider/transform"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
describe("ProviderTransform.options - setCacheKey", () => {
const sessionID = "test-session-123"
@ -1123,7 +1124,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
const result = ProviderTransform.message(
msgs,
{
id: ProviderV2.ModelID.make("deepseek/deepseek-chat"),
id: ModelV2.ID.make("deepseek/deepseek-chat"),
providerID: ProviderV2.ID.make("deepseek"),
api: {
id: "deepseek-chat",
@ -1185,7 +1186,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
const result = ProviderTransform.message(
msgs,
{
id: ProviderV2.ModelID.make("openai/gpt-4"),
id: ModelV2.ID.make("openai/gpt-4"),
providerID: ProviderV2.ID.make("openai"),
api: {
id: "gpt-4",

View file

@ -43,6 +43,22 @@ function cursor(input: Record<string, unknown>) {
return Buffer.from(JSON.stringify(input)).toString("base64url")
}
function data(validate: (value: any) => void) {
return (body: any) => {
object(body)
validate(body.data)
}
}
function locationData(validate: (value: any) => void) {
return (body: any) => {
object(body)
object(body.location)
object(body.location.project)
validate(body.data)
}
}
const scenarios: Scenario[] = [
http.protected
.get("/global/health", "global.health")
@ -609,20 +625,48 @@ const scenarios: Scenario[] = [
check(auth.test === undefined, "auth remove should delete provider from isolated auth file")
}),
),
http.protected.get("/api/model", "v2.model.list").json(200, array),
http.protected.get("/api/provider", "v2.provider.list").json(200, array),
http.protected.get("/api/health", "v2.health.get").json(200, (body) => {
object(body)
check(body.healthy === true, "v2 server should report healthy")
}),
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)),
http.protected.get("/api/skill", "v2.skill.list").json(200, locationData(array)),
http.protected
.get("/api/event", "v2.event.subscribe")
.stream()
.status(
200,
(ctx, result) =>
Effect.sync(() => {
check(result.contentType.includes("text/event-stream"), "v2 event should be an SSE stream")
check(result.text.includes("server.connected"), "v2 event should emit initial connection event")
check(!!ctx.directory && result.text.includes(ctx.directory), "v2 event should include the resolved location")
}),
"status",
),
http.protected
.get("/api/fs/read", "v2.fs.read")
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
.json(200, object),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, array),
.json(200, locationData(object)),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
http.protected
.get("/api/provider/{providerID}", "v2.provider.get")
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
.json(404, object, "status"),
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array),
http.protected.get("/api/question/request", "v2.question.request.list").json(200, array),
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, (body) => {
object(body)
object(body.location)
array(body.data)
}),
http.protected.get("/api/question/request", "v2.question.request.list").json(200, (body) => {
object(body)
object(body.location)
array(body.data)
}),
http.protected
.get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
.seeded((ctx) => ctx.session({ title: "Permission list owner" }))
@ -630,7 +674,7 @@ const scenarios: Scenario[] = [
path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, array),
.json(200, data(array)),
http.protected
.post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply")
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
@ -666,7 +710,10 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.json(404, object, "status"),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, (body) => {
object(body)
array(body.data)
}),
http.protected
.delete("/api/permission/saved/{id}", "v2.permission.saved.remove")
.at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() }))
@ -678,7 +725,7 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
array(body.data)
object(body.cursor)
},
"none",
@ -701,7 +748,7 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
array(body.data)
object(body.cursor)
},
"none",
@ -723,7 +770,7 @@ const scenarios: Scenario[] = [
200,
(body) => {
object(body)
array(body.items)
array(body.data)
object(body.cursor)
},
"none",

View file

@ -12,6 +12,7 @@ import { original } from "./environment"
import { runtime } from "./runtime"
import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
export function runScenario(options: Options) {
return (scenario: Scenario) => {
@ -153,7 +154,7 @@ function withContext<A, E>(
agent: "build",
model: {
providerID: ProviderV2.ID.opencode,
modelID: ProviderV2.ModelID.make("test"),
modelID: ModelV2.ID.make("test"),
},
}
const part: SessionV1.TextPart = {

View file

@ -1,129 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Schema } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
type OpenApiSchema = {
readonly $ref?: string
readonly items?: OpenApiSchema
readonly properties?: Record<string, OpenApiSchema>
}
type OpenApiSpec = {
readonly components?: { readonly schemas?: Record<string, OpenApiSchema> }
readonly paths: Record<
string,
{
readonly get?: {
readonly responses?: Record<string, { readonly content?: Record<string, { schema?: OpenApiSchema }> }>
}
}
>
}
function responseSchema(spec: OpenApiSpec, path: string) {
return spec.paths[path]?.get?.responses?.["200"]?.content?.["application/json"]?.schema
}
function componentName(ref: string | undefined) {
return ref?.replace("#/components/schemas/", "")
}
describe("PublicApi v2 catalog redaction", () => {
test("routes use redacted provider and model DTO schemas", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const provider = responseSchema(spec, "/api/provider/{providerID}")
const providers = responseSchema(spec, "/api/provider")
const models = responseSchema(spec, "/api/model")
expect(componentName(provider?.$ref)).toBe("ProviderV2PublicInfo")
expect(componentName(providers?.items?.$ref)).toBe("ProviderV2PublicInfo")
expect(componentName(models?.items?.$ref)).toBe("ModelV2PublicInfo")
const providerProperties = spec.components?.schemas?.ProviderV2PublicInfo?.properties
const modelProperties = spec.components?.schemas?.ModelV2PublicInfo?.properties
expect(providerProperties).not.toHaveProperty("request")
expect(modelProperties).not.toHaveProperty("request")
expect(JSON.stringify(providerProperties)).not.toMatch(/settings|headers|body|data/)
expect(JSON.stringify(modelProperties)).not.toMatch(/settings|headers|body/)
})
test("DTOs sanitize provider and model API URLs", () => {
const providerID = ProviderV2.ID.make("test")
const providers = [
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
api: {
type: "native",
url: "https://provider-user:provider-password@example.com:8443/provider/v1?api_key=provider-secret#fragment",
settings: {},
},
}),
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
api: {
type: "aisdk",
package: "@ai-sdk/openai",
url: "https://provider-aisdk-user:provider-aisdk-password@example.com:8444/provider/aisdk?api_key=provider-aisdk-secret#fragment",
},
}),
].map((provider) => Schema.encodeSync(ProviderV2.PublicInfo)(ProviderV2.toPublic(provider)))
const models = [
new ModelV2.Info({
...ModelV2.Info.empty(providerID, ModelV2.ID.make("native")),
api: {
id: ModelV2.ID.make("native"),
type: "native",
url: "https://native-user:native-password@example.com:9443/native/v1?api_key=native-secret#fragment",
settings: {},
},
}),
new ModelV2.Info({
...ModelV2.Info.empty(providerID, ModelV2.ID.make("aisdk")),
api: {
id: ModelV2.ID.make("aisdk"),
type: "aisdk",
package: "@ai-sdk/openai",
url: "https://aisdk-user:aisdk-password@example.com:10443/aisdk/v1?api_key=aisdk-secret#fragment",
},
}),
].map((model) => Schema.encodeSync(ModelV2.PublicInfo)(ModelV2.toPublic(model)))
expect(providers.map((provider) => provider.api)).toEqual([
{ type: "native", url: "https://example.com:8443" },
{ type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:8444" },
])
expect(models.map((model) => model.api)).toEqual([
{ id: "native", type: "native", url: "https://example.com:9443" },
{ id: "aisdk", type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:10443" },
])
expect(JSON.stringify({ providers, models })).not.toMatch(/user|password|api_key|secret|fragment/)
})
test("DTOs omit malformed API URLs", () => {
const providerID = ProviderV2.ID.make("test")
const provider = Schema.encodeSync(ProviderV2.PublicInfo)(
ProviderV2.toPublic(
new ProviderV2.Info({
...ProviderV2.Info.empty(providerID),
api: { type: "native", url: "not a url?api_key=provider-secret", settings: {} },
}),
),
)
const modelID = ModelV2.ID.make("aisdk")
const model = Schema.encodeSync(ModelV2.PublicInfo)(
ModelV2.toPublic(
new ModelV2.Info({
...ModelV2.Info.empty(providerID, modelID),
api: { id: modelID, type: "aisdk", package: "@ai-sdk/openai", url: "model-secret" },
}),
),
)
expect(provider.api).toEqual({ type: "native" })
expect(model.api).toEqual({ id: "aisdk", type: "aisdk", package: "@ai-sdk/openai" })
expect(JSON.stringify({ provider, model })).not.toMatch(/secret|api_key/)
})
})

View file

@ -3,7 +3,14 @@ import { OpenApi } from "effect/unstable/httpapi"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
type Method = "get" | "post" | "put" | "delete" | "patch"
type OpenApiSchema = { readonly $ref?: string; readonly anyOf?: ReadonlyArray<OpenApiSchema> }
type OpenApiSchema = {
readonly $ref?: string
readonly anyOf?: ReadonlyArray<OpenApiSchema>
readonly type?: string
readonly enum?: readonly unknown[]
readonly properties?: Record<string, OpenApiSchema>
readonly required?: readonly string[]
}
type OpenApiResponse = {
readonly description?: string
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
@ -20,7 +27,10 @@ type OpenApiOperation = {
readonly security?: unknown
}
type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
type OpenApiSpec = {
readonly paths: Record<string, OpenApiPathItem>
readonly components: { readonly schemas: Record<string, OpenApiSchema> }
}
const methods = ["get", "post", "put", "delete", "patch"] as const
@ -56,6 +66,23 @@ function isBuiltInEndpointError(name: string) {
}
describe("PublicApi OpenAPI v2 errors", () => {
test("documents nested legacy global sync events", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const schema = spec.components.schemas.SyncEventSessionCreated
expect(schema?.required).toEqual(["type", "id", "syncEvent"])
expect(schema?.properties?.type?.enum).toEqual(["sync"])
expect(schema?.properties?.syncEvent).toMatchObject({
required: ["type", "id", "seq", "aggregateID", "data"],
properties: {
type: { enum: ["session.created.1"] },
id: { type: "string" },
seq: { type: "number" },
aggregateID: { type: "string" },
},
})
})
test("preserves /api auth responses", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec

View file

@ -24,7 +24,7 @@ import {
SessionPaths,
} from "../../src/server/routes/instance/httpapi/groups/session"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
import { MessagesQuery as V2MessagesQuery } from "@opencode-ai/server/groups/v2/message"
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"

View file

@ -13,6 +13,7 @@ import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
@ -32,7 +33,7 @@ const seedCorruptStepFinishPart = Effect.gen(function* () {
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()

View file

@ -25,6 +25,7 @@ import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixt
import { awaitWithTimeout, testEffect } from "../lib/effect"
import { testProviderConfig } from "../lib/test-provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Database } from "@opencode-ai/core/database/database"
import { httpApiLayer } from "./httpapi-layer"
@ -310,7 +311,7 @@ function seedMessage(directory: string, sessionID: string) {
role: "user",
time: { created: Date.now() },
agent: "test",
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
tools: {},
} satisfies SessionV1.User)
const part = yield* svc.updatePart({
@ -392,7 +393,7 @@ describe("HttpApi SDK", () => {
const url = new URL(request!.url)
expect(file.response.status).toBe(200)
expect(file.data).toMatchObject({ content: "hello" })
expect(file.data).toMatchObject({ data: { content: "hello" } })
expect(url.searchParams.get("directory")).toBe(directory)
expect(url.searchParams.get("workspace")).toBe(workspaceID)
expect(url.searchParams.get("location[directory]")).toBe(directory)

View file

@ -88,7 +88,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) {
role: "user",
sessionID,
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
time: { created: Date.now() },
})
const part = yield* svc.updatePart({
@ -391,8 +391,9 @@ describe("session HttpApi", () => {
yield* insertLegacyAssistantMessage(parent.id)
expect(
(yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers }))
.items,
(yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, {
headers,
})).data,
).toMatchObject([{ type: "assistant" }])
}),
{ git: true, config: { formatter: false, lsp: false } },
@ -456,7 +457,7 @@ describe("session HttpApi", () => {
})}`,
{ headers },
)
const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next
const sessionCursor = (yield* json<{ data: Session.Info[]; cursor: { next?: string } }>(sessionPage)).cursor.next
expect(sessionCursor).toBeTruthy()
expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({
order: "asc",
@ -483,10 +484,10 @@ describe("session HttpApi", () => {
})
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
const messageBody = yield* json<{ items: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
const messageCursor = messageBody.cursor.next
expect(messageCursor).toBeTruthy()
expect(messageBody.items.map((message) => message.id)).toEqual([secondMessage.id])
expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id])
expect(JSON.parse(Buffer.from(messageCursor!, "base64url").toString("utf8"))).toEqual({
id: secondMessage.id,
order: "desc",
@ -497,7 +498,7 @@ describe("session HttpApi", () => {
headers,
})
expect(
(yield* json<{ items: SessionMessage.Message[] }>(nextMessagePage)).items.map((message) => message.id),
(yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id),
).toEqual([firstMessage.id])
const legacyMessageCursor = Buffer.from(
@ -507,7 +508,7 @@ describe("session HttpApi", () => {
headers,
})
expect(
(yield* json<{ items: SessionMessage.Message[] }>(legacyMessagePage)).items.map((message) => message.id),
(yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id),
).toEqual([firstMessage.id])
const messageCursorWithOrder = yield* request(
@ -587,17 +588,17 @@ describe("session HttpApi", () => {
const first = yield* recordPrompt()
const retried = yield* recordPrompt()
type PromptBody = { id: string; type: string; text: string }
const firstBody = yield* json<PromptBody>(first)
const retriedBody = yield* json<PromptBody>(retried)
const firstBody = yield* json<{ data: PromptBody }>(first)
const retriedBody = yield* json<{ data: PromptBody }>(retried)
expect(first.status).toBe(200)
expect(retried.status).toBe(200)
expect(retriedBody).toEqual(firstBody)
expect(firstBody).toMatchObject({ type: "user", text: "hello" })
expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } })
const messages = yield* requestJson<{ items: PromptBody[] }>(`/api/session/${session.id}/message`, {
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
headers,
})
expect(messages.items).toHaveLength(0)
expect(messages.data).toHaveLength(0)
const admitted = yield* Database.Service.use(({ db }) =>
db
.select()

View file

@ -0,0 +1,82 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Context, Schema } from "effect"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const context = Context.empty() as Context.Context<unknown>
function request(route: string, directory: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return HttpApiApp.webHandler().handler(
new Request(`http://localhost${route}`, {
...init,
headers,
}),
context,
)
}
const Event = Schema.Struct({
id: Schema.String,
type: Schema.String,
location: Schema.Struct({
directory: Schema.String,
project: Schema.Struct({ id: Schema.String, directory: Schema.String }),
}),
data: Schema.Unknown,
})
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
const value = await reader.read()
if (value.done) throw new Error("event stream closed")
return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, "")))
}
async function readEventType(reader: ReadableStreamDefaultReader<Uint8Array>, type: string) {
for (let index = 0; index < 20; index++) {
const event = await readEvent(reader)
if (event.type === type) return event
}
throw new Error(`timed out waiting for ${type}`)
}
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("v2 location HttpApi", () => {
test("returns command and skill snapshots with resolved locations", async () => {
await using tmp = await tmpdir({ git: true })
for (const route of ["/api/command", "/api/skill"]) {
const response = await request(route, tmp.path)
expect(response.status).toBe(200)
const body = (await response.json()) as { location: { directory: string; project: { id: string } }; data: unknown }
expect(body.data).toBeArray()
expect(body.location.directory).toBe(tmp.path)
expect(body.location.project.id).toBeTruthy()
}
})
test("streams native EventV2 payloads with resolved locations", async () => {
await using tmp = await tmpdir({ git: true })
const response = await request("/api/event", tmp.path)
const reader = response.body!.getReader()
expect((await readEvent(reader)).type).toBe("server.connected")
const created = await request("/session", tmp.path, { method: "POST" })
expect(created.status).toBe(200)
expect(await readEventType(reader, "session.created")).toMatchObject({
type: "session.created",
location: { directory: tmp.path, project: { directory: tmp.path } },
data: { sessionID: expect.any(String) },
})
await reader.cancel()
})
})

View file

@ -18,6 +18,7 @@ import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
@ -31,7 +32,7 @@ function seedNegativeTokenSession() {
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()

View file

@ -18,6 +18,7 @@ import { Storage } from "@/storage/storage"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { MessageID } from "@/session/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@ -79,7 +80,7 @@ describe("session diff with missing patch (#26574)", () => {
role: "user",
time: { created: Date.now() },
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("model") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") },
summary: {
diffs: [{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }],
},

View file

@ -10,6 +10,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
void Log.init({ print: false })
@ -18,7 +19,7 @@ const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
const model = {
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test"),
modelID: ModelV2.ID.make("test"),
}
afterEach(async () => {

View file

@ -33,6 +33,7 @@ import { TestConfig } from "../fixture/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { LLMEvent, Usage } from "@opencode-ai/llm"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
void Log.init({ print: false })
@ -47,7 +48,7 @@ const summary = Layer.succeed(
const ref = {
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test-model"),
modelID: ModelV2.ID.make("test-model"),
}
const usage = (input: ConstructorParameters<typeof Usage>[0]) => new Usage(input)

View file

@ -16,6 +16,7 @@ import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdirS
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer))
@ -77,7 +78,7 @@ function loaded(filepath: string): SessionV1.WithParts[] {
agent: "build",
model: {
providerID: ProviderV2.ID.make("anthropic"),
modelID: ProviderV2.ModelID.make("claude-sonnet-4-20250514"),
modelID: ModelV2.ID.make("claude-sonnet-4-20250514"),
},
},
parts: [

View file

@ -25,6 +25,7 @@ import { MessageID, SessionID } from "../../src/session/schema"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings")
@ -368,7 +369,7 @@ const driveToolLoop = (scenario: RecordedScenario) =>
const stableID = scenario.stableID ?? scenario.providerID
const sessionID = SessionID.make(`session-recorded-${stableID}-loop`)
const modelID = ProviderV2.ModelID.make(model.id)
const modelID = ModelV2.ID.make(model.id)
const agent = {
name: "test",
mode: "primary",

View file

@ -10,9 +10,10 @@ import type { Provider } from "@/provider/provider"
import { OAUTH_DUMMY_KEY } from "@/auth"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const baseModel: Provider.Model = {
id: ProviderV2.ModelID.make("gpt-5-mini"),
id: ModelV2.ID.make("gpt-5-mini"),
providerID: ProviderV2.ID.make("openai"),
api: {
id: "gpt-5-mini",

View file

@ -26,6 +26,7 @@ import { Permission } from "@/permission"
import { LLMAISDK } from "@/session/llm/ai-sdk"
import { Session as SessionNs } from "@/session/session"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
type ConfigModel = NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
@ -768,7 +769,7 @@ describe("session.llm.stream", () => {
const resolved = yield* Provider.use.getModel(
ProviderV2.ID.make(vivgridFixture.providerID),
ProviderV2.ModelID.make(fixture.model.id),
ModelV2.ID.make(fixture.model.id),
)
const sessionID = SessionID.make("session-test-1")
const agent = {
@ -842,7 +843,7 @@ describe("session.llm.stream", () => {
const resolved = yield* Provider.use.getModel(
ProviderV2.ID.make(alibabaQwenFixture.providerID),
ProviderV2.ModelID.make(fixture.model.id),
ModelV2.ID.make(fixture.model.id),
)
const sessionID = SessionID.make("session-test-service-abort")
const agent = {
@ -910,7 +911,7 @@ describe("session.llm.stream", () => {
const resolved = yield* Provider.use.getModel(
ProviderV2.ID.make(alibabaQwenFixture.providerID),
ProviderV2.ModelID.make(fixture.model.id),
ModelV2.ID.make(fixture.model.id),
)
const sessionID = SessionID.make("session-test-tools")
const agent = {
@ -1013,7 +1014,7 @@ describe("session.llm.stream", () => {
]
const request = waitRequest("/responses", createEventResponse(responseChunks, true))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
const sessionID = SessionID.make("session-test-2")
const agent = {
name: "test",
@ -1118,7 +1119,7 @@ describe("session.llm.stream", () => {
}),
)
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
const sessionID = SessionID.make("session-test-native-flag-off")
const agent = {
name: "test",
@ -1188,7 +1189,7 @@ describe("session.llm.stream", () => {
]
const request = waitRequest("/responses", createEventResponse(chunks, true))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
const sessionID = SessionID.make("session-test-native")
const agent = {
name: "test",
@ -1272,7 +1273,7 @@ describe("session.llm.stream", () => {
}),
)
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
const sessionID = SessionID.make("session-test-native-injected-tool")
const agent = {
name: "test",
@ -1360,7 +1361,7 @@ describe("session.llm.stream", () => {
const request = waitRequest("/responses", createEventResponse(chunks, true))
let executed: unknown
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
const sessionID = SessionID.make("session-test-native-tool")
const agent = {
name: "test",
@ -1486,7 +1487,7 @@ describe("session.llm.stream", () => {
),
).toString("base64")}`
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
const sessionID = SessionID.make("session-test-data-url")
const agent = {
name: "test",
@ -1575,7 +1576,7 @@ describe("session.llm.stream", () => {
const resolved = yield* Provider.use.getModel(
ProviderV2.ID.make(minimaxFixture.providerID),
ProviderV2.ModelID.make(model.id),
ModelV2.ID.make(model.id),
)
const sessionID = SessionID.make("session-test-3")
const agent = {
@ -1593,7 +1594,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderV2.ID.make("minimax"), modelID: ProviderV2.ModelID.make("MiniMax-M2.5") },
model: { providerID: ProviderV2.ID.make("minimax"), modelID: ModelV2.ID.make("MiniMax-M2.5") },
} satisfies SessionV1.User
yield* drain({
@ -1672,7 +1673,7 @@ describe("session.llm.stream", () => {
const resolved = yield* Provider.use.getModel(
ProviderV2.ID.make("anthropic"),
ProviderV2.ModelID.make(model.id),
ModelV2.ID.make(model.id),
)
const sessionID = SessionID.make("session-test-anthropic-tools")
const agent = {
@ -1874,7 +1875,7 @@ describe("session.llm.stream", () => {
const resolved = yield* Provider.use.getModel(
ProviderV2.ID.make(geminiFixture.providerID),
ProviderV2.ModelID.make(model.id),
ModelV2.ID.make(model.id),
)
const sessionID = SessionID.make("session-test-4")
const agent = {

View file

@ -8,11 +8,12 @@ import type { Provider } from "@/provider/provider"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { Question } from "../../src/question"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const sessionID = SessionID.make("session")
const providerID = ProviderV2.ID.make("test")
const model: Provider.Model = {
id: ProviderV2.ModelID.make("test-model"),
id: ModelV2.ID.make("test-model"),
providerID,
api: {
id: "test-model",
@ -67,7 +68,7 @@ function userInfo(id: string): SessionV1.User {
role: "user",
time: { created: 0 },
agent: "user",
model: { providerID, modelID: ProviderV2.ModelID.make("test") },
model: { providerID, modelID: ModelV2.ID.make("test") },
tools: {},
mode: "",
} as unknown as SessionV1.User
@ -413,7 +414,7 @@ describe("session.message-v2.toModelMessage", () => {
test("preserves jpeg tool-result media for anthropic models", async () => {
const anthropicModel: Provider.Model = {
...model,
id: ProviderV2.ModelID.make("anthropic/claude-opus-4-7"),
id: ModelV2.ID.make("anthropic/claude-opus-4-7"),
providerID: ProviderV2.ID.make("anthropic"),
api: {
id: "claude-opus-4-7-20250805",
@ -496,7 +497,7 @@ describe("session.message-v2.toModelMessage", () => {
test("moves bedrock pdf tool-result media into a separate user message", async () => {
const bedrockModel: Provider.Model = {
...model,
id: ProviderV2.ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"),
id: ModelV2.ID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"),
providerID: ProviderV2.ID.make("amazon-bedrock"),
api: {
id: "anthropic.claude-sonnet-4-6",
@ -1044,7 +1045,7 @@ describe("session.message-v2.toModelMessage", () => {
const assistantID = "m-assistant"
const openrouterModel: Provider.Model = {
...model,
id: ProviderV2.ModelID.make("deepseek/deepseek-v4-pro"),
id: ModelV2.ID.make("deepseek/deepseek-v4-pro"),
providerID: ProviderV2.ID.make("openrouter"),
api: {
id: "deepseek/deepseek-v4-pro",

View file

@ -10,6 +10,7 @@ import { NotFoundError } from "@/storage/storage"
import * as Log from "@opencode-ai/core/util/log"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
void Log.init({ print: false })
@ -98,7 +99,7 @@ const addAssistant = Effect.fn("Test.addAssistant")(function* (
role: "assistant",
time: { created: Date.now() },
parentID,
modelID: ProviderV2.ModelID.make("test"),
modelID: ModelV2.ID.make("test"),
providerID: ProviderV2.ID.make("test"),
mode: "",
agent: "default",

View file

@ -30,6 +30,7 @@ import { testEffect } from "../lib/effect"
import { raw, reply, TestLLMServer } from "../lib/llm-server"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { LLMEvent } from "@opencode-ai/llm"
@ -46,7 +47,7 @@ const summary = Layer.succeed(
const ref = {
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test-model"),
modelID: ModelV2.ID.make("test-model"),
}
const cfg = {

View file

@ -57,6 +57,7 @@ import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
void Log.init({ print: false })
@ -71,7 +72,7 @@ const summary = Layer.succeed(
const ref = {
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test-model"),
modelID: ModelV2.ID.make("test-model"),
}
function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
@ -759,7 +760,7 @@ it.instance("failed subtask preserves metadata on error tool state", () =>
expect(tool.state.metadata?.sessionId).toBeDefined()
expect(tool.state.metadata?.model).toEqual({
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("missing-model"),
modelID: ModelV2.ID.make("missing-model"),
})
}),
)
@ -2213,7 +2214,7 @@ noLLMServer.instance(
const other = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: ProviderV2.ID.make("opencode"), modelID: ProviderV2.ModelID.make("kimi-k2.5-free") },
model: { providerID: ProviderV2.ID.make("opencode"), modelID: ModelV2.ID.make("kimi-k2.5-free") },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
@ -2229,7 +2230,7 @@ noLLMServer.instance(
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test-model"),
modelID: ModelV2.ID.make("test-model"),
variant: "xhigh",
})
expect(match.info.model.variant).toBe("xhigh")

View file

@ -14,6 +14,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
void Log.init({ print: false })
@ -33,7 +34,7 @@ const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "de
role: "user" as const,
sessionID,
agent,
model: { providerID: ProviderV2.ID.make("openai"), modelID: ProviderV2.ModelID.make("gpt-4") },
model: { providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-4") },
time: { created: Date.now() },
})
})
@ -49,7 +50,7 @@ const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, p
path: { cwd: dir, root: dir },
cost: 0,
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ProviderV2.ModelID.make("gpt-4"),
modelID: ModelV2.ID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID,
time: { created: Date.now() },
@ -117,7 +118,7 @@ describe("revert + compact workflow", () => {
agent: "default",
model: {
providerID: ProviderV2.ID.make("openai"),
modelID: ProviderV2.ModelID.make("gpt-4"),
modelID: ModelV2.ID.make("gpt-4"),
},
time: {
created: Date.now(),
@ -149,7 +150,7 @@ describe("revert + compact workflow", () => {
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ProviderV2.ModelID.make("gpt-4"),
modelID: ModelV2.ID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID: userMsg1.id,
time: {
@ -174,7 +175,7 @@ describe("revert + compact workflow", () => {
agent: "default",
model: {
providerID: ProviderV2.ID.make("openai"),
modelID: ProviderV2.ModelID.make("gpt-4"),
modelID: ModelV2.ID.make("gpt-4"),
},
time: {
created: Date.now(),
@ -206,7 +207,7 @@ describe("revert + compact workflow", () => {
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ProviderV2.ModelID.make("gpt-4"),
modelID: ModelV2.ID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID: userMsg2.id,
time: {
@ -279,7 +280,7 @@ describe("revert + compact workflow", () => {
agent: "default",
model: {
providerID: ProviderV2.ID.make("openai"),
modelID: ProviderV2.ModelID.make("gpt-4"),
modelID: ModelV2.ID.make("gpt-4"),
},
time: {
created: Date.now(),
@ -311,7 +312,7 @@ describe("revert + compact workflow", () => {
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ProviderV2.ModelID.make("gpt-4"),
modelID: ModelV2.ID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID: userMsg.id,
time: {

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Deferred, Effect, Exit, Layer } from "effect"
import { Session as SessionNs } from "@/session/session"
@ -14,6 +15,7 @@ import { Storage } from "@/storage/storage"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { GlobalBus } from "@/bus/global"
void Log.init({ print: false })
@ -101,6 +103,31 @@ describe("session.created event", () => {
yield* session.remove(info.id)
}),
)
it.instance("emits legacy global sync payload", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const received = yield* Deferred.make<{ syncEvent: EventV2.SerializedEvent }>()
const listener = (event: { payload: { type?: string; syncEvent?: EventV2.SerializedEvent } }) => {
if (event.payload.type === "sync" && event.payload.syncEvent)
Deferred.doneUnsafe(received, Effect.succeed({ syncEvent: event.payload.syncEvent }))
}
GlobalBus.on("event", listener)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener)))
const info = yield* session.create({})
const event = yield* awaitDeferred(received, "timed out waiting for legacy global sync event")
expect(event.syncEvent).toMatchObject({
type: EventV2.versionedType(SessionNs.Event.Created.type, 1),
seq: 0,
aggregateID: info.id,
data: { sessionID: info.id },
})
yield* session.remove(info.id)
}),
)
})
describe("step-finish token propagation via event", () => {

View file

@ -18,7 +18,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { provideTmpdirInstance } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
import { testEffect } from "../lib/effect"
import { pollWithTimeout, testEffect } from "../lib/effect"
const env = Layer.mergeAll(
Session.defaultLayer,
@ -301,7 +301,11 @@ describe("ShareNext", () => {
},
],
})
yield* Effect.sleep(1_250)
yield* pollWithTimeout(
Effect.sync(() => (seen.length === 1 ? true : undefined)),
"timed out waiting for share sync",
"5 seconds",
)
expect(seen).toHaveLength(1)
expect(seen[0].url).toBe("https://legacy-share.example.com/api/share/shr_abc/sync")

View file

@ -36,6 +36,7 @@ import { ToolJsonSchema } from "@/tool/json-schema"
import { MessageID, SessionID } from "@/session/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const node = CrossSpawnSpawner.defaultLayer
const configLayer = TestConfig.layer({
@ -124,7 +125,7 @@ describe("tool.registry", () => {
if (!build) throw new Error("build agent not found")
const task = (yield* registry.tools({
providerID: ProviderV2.ID.opencode,
modelID: ProviderV2.ModelID.make("test"),
modelID: ModelV2.ID.make("test"),
agent: build,
})).find((tool) => tool.id === "task")
@ -302,7 +303,7 @@ describe("tool.registry", () => {
const agents = yield* Agent.Service
const promptTools = yield* registry.tools({
providerID: ProviderV2.ID.opencode,
modelID: ProviderV2.ModelID.make("test"),
modelID: ModelV2.ID.make("test"),
agent: yield* agents.defaultInfo(),
})
const promptTool = promptTools.find((tool) => tool.id === "sql")

View file

@ -21,6 +21,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
afterEach(async () => {
await disposeAllInstances()
@ -28,7 +29,7 @@ afterEach(async () => {
const ref = {
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test-model"),
modelID: ModelV2.ID.make("test-model"),
}
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>