refactor(opencode): migrate session events to core

This commit is contained in:
Dax Raad
2026-05-25 18:20:07 -04:00
parent 087d356d41
commit b97d254dcc
70 changed files with 1042 additions and 982 deletions
+2 -1
View File
@@ -103,6 +103,7 @@ export function definitions() {
export interface PublishOptions {
readonly id?: ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
}
export interface Interface {
@@ -236,7 +237,7 @@ export const layer = Layer.effect(
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location = options?.location ?? Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const event = {
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
+6 -1
View File
@@ -12,6 +12,7 @@ import type { Prompt } from "./session/prompt"
import { EventV2 } from "./event"
import { ProviderV2 } from "./provider"
import { Database } from "./database/database"
import { SessionProjector } from "./session/projector"
import { SessionMessageTable, SessionTable } from "./session/sql"
import { SessionSchema } from "./session/schema"
import { AbsolutePath, RelativePath } from "./schema"
@@ -253,4 +254,8 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.orDie)
export const defaultLayer = layer.pipe(
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.orDie,
)
-1
View File
@@ -397,7 +397,6 @@ export const All = Schema.Union(
mode: "oneOf",
},
).pipe(Schema.toTaggedUnion("type"))
export type Event = typeof All.Type
export type Type = Event["type"]
+143 -30
View File
@@ -1,11 +1,16 @@
export * as SessionLegacy from "./legacy"
import { Effect, Schema, Types } from "effect"
import { withStatics } from "../schema"
import { EventV2 } from "../event"
import { PermissionV2 } from "../permission"
import { ProjectV2 } from "../project"
import { ProviderV2 } from "../provider"
import { optionalOmitUndefined, withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { NonNegativeInt } from "../schema"
import { NamedError } from "../util/error"
import { SessionSchema } from "./schema"
import { WorkspaceV2 } from "../workspace"
export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
Schema.brand("MessageID"),
@@ -19,27 +24,6 @@ export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
)
export type PartID = typeof PartID.Type
export const ProviderID = Schema.String.pipe(
Schema.brand("ProviderID"),
withStatics((schema) => ({
opencode: schema.make("opencode"),
anthropic: schema.make("anthropic"),
openai: schema.make("openai"),
google: schema.make("google"),
googleVertex: schema.make("google-vertex"),
githubCopilot: schema.make("github-copilot"),
amazonBedrock: schema.make("amazon-bedrock"),
azure: schema.make("azure"),
openrouter: schema.make("openrouter"),
mistral: schema.make("mistral"),
gitlab: schema.make("gitlab"),
})),
)
export type ProviderID = typeof ProviderID.Type
export const ModelID = Schema.String.pipe(Schema.brand("ModelID"))
export type ModelID = typeof ModelID.Type
export const OutputLengthError = NamedError.create("MessageOutputLengthError", {})
export const AuthError = NamedError.create("ProviderAuthError", {
@@ -213,8 +197,8 @@ export const SubtaskPart = Schema.Struct({
agent: Schema.String,
model: Schema.optional(
Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
}),
),
command: Schema.optional(Schema.String),
@@ -357,8 +341,8 @@ export const User = Schema.Struct({
),
agent: Schema.String,
model: Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
variant: Schema.optional(Schema.String),
}),
system: Schema.optional(Schema.String),
@@ -453,8 +437,8 @@ export const SubtaskPartInput = Schema.Struct({
agent: Schema.String,
model: Schema.optional(
Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
}),
),
command: Schema.optional(Schema.String),
@@ -470,8 +454,8 @@ export const Assistant = Schema.Struct({
}),
error: Schema.optional(AssistantErrorSchema),
parentID: MessageID,
modelID: ModelID,
providerID: ProviderID,
modelID: ProviderV2.ModelID,
providerID: ProviderV2.ID,
mode: Schema.String,
agent: Schema.String,
path: Schema.Struct({
@@ -509,3 +493,132 @@ export type WithParts = {
info: Info
parts: Part[]
}
const options = {
sync: {
aggregate: "sessionID",
version: 1,
},
} as const
const SessionSummary = Schema.Struct({
additions: Schema.Finite,
deletions: Schema.Finite,
files: Schema.Finite,
diffs: optionalOmitUndefined(Schema.Array(FileDiff)),
})
const SessionTokens = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
const SessionShare = Schema.Struct({
url: Schema.String,
})
const SessionRevert = Schema.Struct({
messageID: MessageID,
partID: optionalOmitUndefined(PartID),
snapshot: optionalOmitUndefined(Schema.String),
diff: optionalOmitUndefined(Schema.String),
})
const SessionModel = Schema.Struct({
id: ProviderV2.ModelID,
providerID: ProviderV2.ID,
variant: optionalOmitUndefined(Schema.String),
})
export const SessionInfo = Schema.Struct({
id: SessionSchema.ID,
slug: Schema.String,
projectID: ProjectV2.ID,
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
directory: Schema.String,
path: optionalOmitUndefined(Schema.String),
parentID: optionalOmitUndefined(SessionSchema.ID),
summary: optionalOmitUndefined(SessionSummary),
cost: optionalOmitUndefined(Schema.Finite),
tokens: optionalOmitUndefined(SessionTokens),
share: optionalOmitUndefined(SessionShare),
title: Schema.String,
agent: optionalOmitUndefined(Schema.String),
model: optionalOmitUndefined(SessionModel),
version: Schema.String,
time: Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
compacting: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(Schema.Finite),
}),
permission: optionalOmitUndefined(PermissionV2.Ruleset),
revert: optionalOmitUndefined(SessionRevert),
}).annotate({ identifier: "Session" })
export type SessionInfo = typeof SessionInfo.Type
export const Event = {
Created: EventV2.define({
type: "session.created",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
Updated: EventV2.define({
type: "session.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
Deleted: EventV2.define({
type: "session.deleted",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
MessageUpdated: EventV2.define({
type: "message.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
info: Info,
},
}),
MessageRemoved: EventV2.define({
type: "message.removed",
...options,
schema: {
sessionID: SessionSchema.ID,
messageID: MessageID,
},
}),
PartUpdated: EventV2.define({
type: "message.part.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
part: Part,
time: Schema.Finite,
},
}),
PartRemoved: EventV2.define({
type: "message.part.removed",
...options,
schema: {
sessionID: SessionSchema.ID,
messageID: MessageID,
partID: PartID,
},
}),
}
+1 -1
View File
@@ -279,7 +279,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: event.data.callID,
name: event.data.name,
time: { created: event.data.timestamp },
state: { status: "pending", input: "" },
state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }),
}) as DraftTool,
)
}),
+255 -74
View File
@@ -1,19 +1,105 @@
export * as SessionProjector from "./projector"
import { and, eq } from "drizzle-orm"
import { and, eq, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { SessionEvent } from "./event"
import { SessionLegacy } from "./legacy"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { SessionMessageTable, SessionTable } from "./sql"
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
type DatabaseService = Database.Interface["db"]
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
type Usage = {
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
}
function usage(part: typeof SessionLegacy.Event.PartUpdated.Type["data"]["part"] | unknown): Usage | undefined {
if (typeof part !== "object" || part === null) return undefined
const value = part as Record<string, unknown>
if (value.type !== "step-finish") return undefined
if (!("cost" in value) || !("tokens" in value)) return undefined
return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] }
}
function sessionRow(info: SessionLegacy.SessionInfo): typeof SessionTable.$inferInsert {
return {
id: info.id,
project_id: info.projectID,
workspace_id: info.workspaceID,
parent_id: info.parentID,
slug: info.slug,
directory: info.directory,
path: info.path,
title: info.title,
agent: info.agent,
model: info.model,
version: info.version,
share_url: info.share?.url,
summary_additions: info.summary?.additions,
summary_deletions: info.summary?.deletions,
summary_files: info.summary?.files,
summary_diffs: info.summary?.diffs ? [...info.summary.diffs] : undefined,
cost: info.cost ?? 0,
tokens_input: (info.tokens ?? { input: 0 }).input,
tokens_output: (info.tokens ?? { output: 0 }).output,
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
revert: info.revert ?? null,
permission: info.permission ? [...info.permission] : undefined,
time_created: info.time.created,
time_updated: info.time.updated,
time_compacting: info.time.compacting,
time_archived: info.time.archived,
}
}
function messageData(info: typeof SessionLegacy.Event.MessageUpdated.Type["data"]["info"]): typeof MessageTable.$inferInsert.data {
const { id: _, sessionID: __, ...rest } = info
return rest as DeepMutable<typeof rest>
}
function partData(part: typeof SessionLegacy.Event.PartUpdated.Type["data"]["part"]): typeof PartTable.$inferInsert.data {
const { id: _, messageID: __, sessionID: ___, ...rest } = part
return rest as DeepMutable<typeof rest>
}
function applyUsage(
db: DatabaseService,
sessionID: typeof SessionLegacy.Event.MessageUpdated.Type["data"]["sessionID"],
value: Usage,
sign = 1,
) {
return db
.update(SessionTable)
.set({
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
}
function run(db: DatabaseService, event: SessionEvent.Event) {
return Effect.gen(function* () {
const adapter: SessionMessageUpdater.Adapter = {
@@ -175,91 +261,186 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* events.project(SessionEvent.AgentSwitched, (event) =>
yield* events.project(SessionLegacy.Event.Created, (event) =>
Effect.gen(function* () {
const message = Schema.encodeSync(SessionMessage.AgentSwitched)(
new SessionMessage.AgentSwitched({
id: event.id,
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
time: { created: event.data.timestamp },
}),
)
const data = { metadata: message.metadata, agent: message.agent, time: message.time }
yield* db.insert(SessionTable).values(sessionRow(event.data.info)).run().pipe(Effect.orDie)
if (event.data.info.workspaceID) {
yield* db
.update(WorkspaceTable)
.set({ time_used: Date.now() })
.where(eq(WorkspaceTable.id, event.data.info.workspaceID))
.run()
.pipe(Effect.orDie)
}
}),
)
yield* events.project(SessionLegacy.Event.Updated, (event) =>
db
.update(SessionTable)
.set(sessionRow(event.data.info))
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionLegacy.Event.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* events.project(SessionLegacy.Event.MessageUpdated, (event) =>
Effect.gen(function* () {
const time_created = event.data.info.time.created
const id = event.data.info.id
const sessionID = event.data.info.sessionID
const data = messageData(event.data.info)
yield* db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(event.id),
session_id: event.data.sessionID,
type: "agent-switched",
time_created: DateTime.toEpochMillis(event.data.timestamp),
data,
},
])
.insert(MessageTable)
.values({ id, session_id: sessionID, time_created, data })
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
.run()
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionEvent.ModelSwitched, (event) =>
yield* events.project(SessionLegacy.Event.MessageRemoved, (event) =>
Effect.gen(function* () {
const message = Schema.encodeSync(SessionMessage.ModelSwitched)(
new SessionMessage.ModelSwitched({
id: event.id,
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
time: { created: event.data.timestamp },
}),
)
const data = { metadata: message.metadata, model: message.model, time: message.time }
yield* db
.update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
const rows = yield* db
.select()
.from(PartTable)
.where(and(eq(PartTable.message_id, event.data.messageID), eq(PartTable.session_id, event.data.sessionID)))
.all()
.pipe(Effect.orDie)
for (const row of rows) {
const previous = usage(row.data)
if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1)
}
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(event.id),
session_id: event.data.sessionID,
type: "model-switched",
time_created: DateTime.toEpochMillis(event.data.timestamp),
data,
},
])
.delete(MessageTable)
.where(and(eq(MessageTable.id, event.data.messageID), eq(MessageTable.session_id, event.data.sessionID)))
.run()
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionEvent.Prompted, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
yield* events.project(SessionLegacy.Event.PartRemoved, (event) =>
Effect.gen(function* () {
const row = yield* db
.select()
.from(PartTable)
.where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
.get()
.pipe(Effect.orDie)
const previous = row && usage(row.data)
if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1)
yield* db
.delete(PartTable)
.where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
.run()
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionLegacy.Event.PartUpdated, (event) =>
Effect.gen(function* () {
const id = event.data.part.id
const messageID = event.data.part.messageID
const sessionID = event.data.part.sessionID
const data = partData(event.data.part)
const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie)
yield* db
.insert(PartTable)
.values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data })
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
.run()
.pipe(Effect.orDie)
const previous = row && usage(row.data)
const next = usage(event.data.part)
if (previous) yield* applyUsage(db, row.session_id, previous, -1)
if (next) yield* applyUsage(db, sessionID, next)
}),
)
// session.next.* projectors are disabled while the v2 message projection is stabilized.
// The events still publish through EventV2 and fan out through the opencode bridge.
// yield* events.project(SessionEvent.AgentSwitched, (event) =>
// Effect.gen(function* () {
// const message = Schema.encodeSync(SessionMessage.AgentSwitched)(
// new SessionMessage.AgentSwitched({
// id: event.id,
// type: "agent-switched",
// metadata: event.metadata,
// agent: event.data.agent,
// time: { created: event.data.timestamp },
// }),
// )
// const data = { metadata: message.metadata, agent: message.agent, time: message.time }
// yield* db
// .update(SessionTable)
// .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
// .where(eq(SessionTable.id, event.data.sessionID))
// .run()
// .pipe(Effect.orDie)
// yield* db
// .insert(SessionMessageTable)
// .values([
// {
// id: SessionMessage.ID.make(event.id),
// session_id: event.data.sessionID,
// type: "agent-switched",
// time_created: DateTime.toEpochMillis(event.data.timestamp),
// data,
// },
// ])
// .run()
// .pipe(Effect.orDie)
// }),
// )
// yield* events.project(SessionEvent.ModelSwitched, (event) =>
// Effect.gen(function* () {
// const message = Schema.encodeSync(SessionMessage.ModelSwitched)(
// new SessionMessage.ModelSwitched({
// id: event.id,
// type: "model-switched",
// metadata: event.metadata,
// model: event.data.model,
// time: { created: event.data.timestamp },
// }),
// )
// const data = { metadata: message.metadata, model: message.model, time: message.time }
// yield* db
// .update(SessionTable)
// .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
// .where(eq(SessionTable.id, event.data.sessionID))
// .run()
// .pipe(Effect.orDie)
// yield* db
// .insert(SessionMessageTable)
// .values([
// {
// id: SessionMessage.ID.make(event.id),
// session_id: event.data.sessionID,
// type: "model-switched",
// time_created: DateTime.toEpochMillis(event.data.timestamp),
// data,
// },
// ])
// .run()
// .pipe(Effect.orDie)
// }),
// )
// yield* events.project(SessionEvent.Prompted, (event) => run(db, event))
// yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
// yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
// yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
// yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
// yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
}),
)
+25 -24
View File
@@ -41,7 +41,7 @@ import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { ACPRuntime } from "./runtime"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { MessageV2 } from "@/session/message-v2"
import { ConfigMCP } from "@/config/mcp"
import { Todo } from "@/session/todo"
@@ -51,6 +51,7 @@ import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, T
import { applyPatch } from "diff"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ShellID } from "@/tool/shell/id"
import { ProviderV2 } from "@opencode-ai/core/provider"
type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string }
@@ -62,8 +63,8 @@ const log = Log.create({ service: "acp-agent" })
async function getContextLimit(
sdk: OpencodeClient,
providerID: ProviderID,
modelID: ModelID,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
directory: string,
): Promise<number | null> {
const providers = await sdk.config
@@ -104,7 +105,7 @@ async function sendUsageUpdate(
const msg = lastAssistant.info
if (!msg.providerID || !msg.modelID) return
const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory)
const size = await getContextLimit(sdk, ProviderV2.ID.make(msg.providerID), ProviderV2.ModelID.make(msg.modelID), directory)
if (!size) {
// Cannot calculate usage without known context size
@@ -579,7 +580,7 @@ export class Agent implements ACPAgent {
}
} catch (e) {
const error = MessageV2.fromError(e, {
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
providerID: ProviderV2.ID.make(this.config.defaultModel?.providerID ?? "unknown"),
})
if (LoadAPIKeyError.isInstance(error)) {
throw RequestError.authRequired()
@@ -619,7 +620,7 @@ export class Agent implements ACPAgent {
return result
} catch (e) {
const error = MessageV2.fromError(e, {
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
providerID: ProviderV2.ID.make(this.config.defaultModel?.providerID ?? "unknown"),
})
if (LoadAPIKeyError.isInstance(error)) {
throw RequestError.authRequired()
@@ -664,7 +665,7 @@ export class Agent implements ACPAgent {
return response
} catch (e) {
const error = MessageV2.fromError(e, {
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
providerID: ProviderV2.ID.make(this.config.defaultModel?.providerID ?? "unknown"),
})
if (LoadAPIKeyError.isInstance(error)) {
throw RequestError.authRequired()
@@ -718,7 +719,7 @@ export class Agent implements ACPAgent {
return mode
} catch (e) {
const error = MessageV2.fromError(e, {
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
providerID: ProviderV2.ID.make(this.config.defaultModel?.providerID ?? "unknown"),
})
if (LoadAPIKeyError.isInstance(error)) {
throw RequestError.authRequired()
@@ -752,7 +753,7 @@ export class Agent implements ACPAgent {
return result
} catch (e) {
const error = MessageV2.fromError(e, {
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
providerID: ProviderV2.ID.make(this.config.defaultModel?.providerID ?? "unknown"),
})
if (LoadAPIKeyError.isInstance(error)) {
throw RequestError.authRequired()
@@ -1531,8 +1532,8 @@ export class Agent implements ACPAgent {
if (lastUser?.role !== "user") return
this.sessionManager.setModel(sessionId, {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
providerID: ProviderV2.ID.make(lastUser.model.providerID),
modelID: ProviderV2.ModelID.make(lastUser.model.modelID),
})
this.sessionManager.setVariant(sessionId, lastUser.model.variant)
if (lastUser.agent) {
@@ -1658,7 +1659,7 @@ function imageContents(attachments: Array<{ mime: string; url: string }>): ToolC
})
}
async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> {
async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }> {
const sdk = config.sdk
const configured = config.defaultModel
if (configured) return configured
@@ -1700,8 +1701,8 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
const [best] = Provider.sort(Object.values(opencodeProvider.models))
if (best) {
return {
providerID: ProviderID.make(best.providerID),
modelID: ModelID.make(best.id),
providerID: ProviderV2.ID.make(best.providerID),
modelID: ProviderV2.ModelID.make(best.id),
}
}
}
@@ -1710,8 +1711,8 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
const [best] = Provider.sort(models)
if (best) {
return {
providerID: ProviderID.make(best.providerID),
modelID: ModelID.make(best.id),
providerID: ProviderV2.ID.make(best.providerID),
modelID: ProviderV2.ModelID.make(best.id),
}
}
@@ -1723,7 +1724,7 @@ async function lastUsedModel(
sdk: OpencodeClient,
directory: string,
providers: Array<{ id: string; models: Record<string, unknown> }>,
): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> {
): Promise<{ providerID: ProviderV2.ID; modelID: ProviderV2.ModelID } | undefined> {
const session = await sdk.session
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
.then((x) => x.data?.[0])
@@ -1745,8 +1746,8 @@ async function lastUsedModel(
const provider = providers.find((entry) => entry.id === lastUser.model.providerID)
if (!provider?.models[lastUser.model.modelID]) return
return {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
providerID: ProviderV2.ID.make(lastUser.model.providerID),
modelID: ProviderV2.ModelID.make(lastUser.model.modelID),
}
}
@@ -1810,7 +1811,7 @@ function sortProvidersByName<T extends { name: string }>(providers: T[]): T[] {
function modelVariantsFromProviders(
providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
model: { providerID: ProviderID; modelID: ModelID },
model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID },
): string[] {
const provider = providers.find((entry) => entry.id === model.providerID)
if (!provider) return []
@@ -1844,7 +1845,7 @@ function buildAvailableModels(
}
function formatModelIdWithVariant(
model: { providerID: ProviderID; modelID: ModelID },
model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID },
variant: string | undefined,
availableVariants: string[],
includeVariant: boolean,
@@ -1861,7 +1862,7 @@ function formatModelIdWithVariant(
}
function buildVariantMeta(input: {
model: { providerID: ProviderID; modelID: ModelID }
model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
variant?: string
availableVariants: string[]
}) {
@@ -1877,7 +1878,7 @@ function buildVariantMeta(input: {
function parseModelSelection(
modelId: string,
providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } {
): { model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }; variant?: string } {
const parsed = Provider.parseModel(modelId)
const provider = providers.find((p) => p.id === parsed.providerID)
if (!provider) {
@@ -1897,7 +1898,7 @@ function parseModelSelection(
const baseModelInfo = provider.models[baseModelId]
if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) {
return {
model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) },
model: { providerID: parsed.providerID, modelID: ProviderV2.ModelID.make(baseModelId) },
variant: candidateVariant,
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import type { ProviderID, ModelID } from "../provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
export interface ACPSessionState {
id: string
@@ -8,8 +8,8 @@ export interface ACPSessionState {
mcpServers: McpServer[]
createdAt: Date
model?: {
providerID: ProviderID
modelID: ModelID
providerID: ProviderV2.ID
modelID: ProviderV2.ModelID
}
variant?: string
modeId?: string
@@ -18,7 +18,7 @@ export interface ACPSessionState {
export interface ACPConfig {
sdk: OpencodeClient
defaultModel?: {
providerID: ProviderID
modelID: ModelID
providerID: ProviderV2.ID
modelID: ProviderV2.ModelID
}
}
+6 -5
View File
@@ -1,7 +1,7 @@
import { Config } from "@/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { Truncate } from "@/tool/truncate"
import { Auth } from "../auth"
@@ -25,6 +25,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
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"
export const Info = Schema.Struct({
name: Schema.String,
@@ -38,8 +39,8 @@ export const Info = Schema.Struct({
permission: Permission.Ruleset,
model: Schema.optional(
Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
modelID: ProviderV2.ModelID,
providerID: ProviderV2.ID,
}),
),
variant: Schema.optional(Schema.String),
@@ -62,7 +63,7 @@ export interface Interface {
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
}) => Effect.Effect<
{
identifier: string
@@ -383,7 +384,7 @@ export const layer = Layer.effect(
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
}) {
const cfg = yield* config.get()
const model = input.model ?? (yield* provider.defaultModel())
+16 -9
View File
@@ -2,6 +2,7 @@ import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema } from "eff
import { EffectBridge } from "@/effect/bridge"
import * as Log from "@opencode-ai/core/util/log"
import { BusEvent } from "./bus-event"
import { EventV2 } from "@opencode-ai/core/event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
@@ -12,7 +13,13 @@ import { InstanceRef } from "@/effect/instance-ref"
const log = Log.create({ service: "bus" })
type BusProperties<D extends BusEvent.Definition<string, Schema.Top>> = Schema.Schema.Type<D["properties"]>
type BusDefinition = BusEvent.Definition<string, Schema.Top> | EventV2.Definition<string, Schema.Top>
type BusSchema<D extends BusDefinition> = D extends { data: infer S extends Schema.Top }
? S
: D extends { properties: infer S extends Schema.Top }
? S
: never
type BusProperties<D extends BusDefinition> = Schema.Schema.Type<BusSchema<D>>
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
@@ -21,7 +28,7 @@ export const InstanceDisposed = BusEvent.define(
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type Payload<D extends BusDefinition = BusDefinition> = {
id: string
type: D["type"]
properties: BusProperties<D>
@@ -33,7 +40,7 @@ type State = {
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
readonly publish: <D extends BusDefinition>(
def: D,
properties: BusProperties<D>,
options?: { id?: string },
@@ -44,11 +51,11 @@ export interface Interface {
// Stream-returning shape acquired the subscription lazily on first pull,
// opening a race window during which publishes were lost — see
// test/bus/bus-effect.test.ts RACE tests.
readonly subscribe: <D extends BusEvent.Definition>(
readonly subscribe: <D extends BusDefinition>(
def: D,
) => Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope>
readonly subscribeAll: () => Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope>
readonly subscribeCallback: <D extends BusEvent.Definition>(
readonly subscribeCallback: <D extends BusDefinition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
@@ -86,7 +93,7 @@ export const layer = Layer.effect(
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
function getOrCreate<D extends BusDefinition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
@@ -97,7 +104,7 @@ export const layer = Layer.effect(
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>, options?: { id?: string }) {
function publish<D extends BusDefinition>(def: D, properties: BusProperties<D>, options?: { id?: string }) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties }
@@ -120,7 +127,7 @@ export const layer = Layer.effect(
})
}
const subscribe = <D extends BusEvent.Definition>(
const subscribe = <D extends BusDefinition>(
def: D,
): Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope> =>
Effect.gen(function* () {
@@ -169,7 +176,7 @@ export const layer = Layer.effect(
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusDefinition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
+5 -4
View File
@@ -1,10 +1,11 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Provider } from "@/provider/provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import { ProviderV2 } from "@opencode-ai/core/provider"
export const ModelsCommand = effectCmd({
command: "models [provider]",
@@ -33,7 +34,7 @@ export const ModelsCommand = effectCmd({
const provider = yield* Provider.Service
const providers = yield* provider.list()
const print = (providerID: ProviderID, verbose?: boolean) => {
const print = (providerID: ProviderV2.ID, verbose?: boolean) => {
const p = providers[providerID]
const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sorted) {
@@ -47,7 +48,7 @@ export const ModelsCommand = effectCmd({
}
if (args.provider) {
const providerID = ProviderID.make(args.provider)
const providerID = ProviderV2.ID.make(args.provider)
if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`)
print(providerID, args.verbose)
return
@@ -61,6 +62,6 @@ export const ModelsCommand = effectCmd({
return a.localeCompare(b)
})
for (const providerID of ids) print(ProviderID.make(providerID), args.verbose)
for (const providerID of ids) print(ProviderV2.ID.make(providerID), args.verbose)
}),
})
@@ -669,12 +669,7 @@ export const layer = Layer.effect(
}
if (input.workspaceID === null) {
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: null,
},
})
yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined })
log.info("session warp complete", {
workspaceID: input.workspaceID,
@@ -695,12 +690,7 @@ export const layer = Layer.effect(
const target = yield* WorkspaceAdapterRuntime.target(space)
if (target.type === "local") {
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
})
yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID })
log.info("session warp complete", {
workspaceID: input.workspaceID,
-15
View File
@@ -5,25 +5,12 @@ import { Bus as ProjectBus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { SyncEvent } from "@/sync"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import "@opencode-ai/core/account"
import "@opencode-ai/core/catalog"
import "@opencode-ai/core/session/event"
import { Context, Effect, Layer, Option, Stream } from "effect"
export function toSyncDefinition<D extends EventV2.Definition>(definition: D) {
const result = {
type: definition.type,
version: definition.sync?.version,
aggregate: definition.sync?.aggregate,
schema: definition.data,
properties: definition.data,
}
return result as SyncEvent.Definition<D["type"], D["data"], D["data"]>
}
export class Service extends Context.Service<Service, EventV2.Interface>()("@opencode/EventV2Bridge") {}
export const layer = Layer.effect(
@@ -76,9 +63,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(
Layer.provideMerge(SessionProjector.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(ProjectBus.defaultLayer),
)
+11 -11
View File
@@ -4,7 +4,7 @@ import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
const When = Schema.Struct({
@@ -65,11 +65,11 @@ export const CallbackInput = Schema.Struct({
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
export class OauthMissing extends Schema.TaggedErrorClass<OauthMissing>()("ProviderAuthOauthMissing", {
providerID: ProviderID,
providerID: ProviderV2.ID,
}) {}
export class OauthCodeMissing extends Schema.TaggedErrorClass<OauthCodeMissing>()("ProviderAuthOauthCodeMissing", {
providerID: ProviderID,
providerID: ProviderV2.ID,
}) {}
export class OauthCallbackFailed extends Schema.TaggedErrorClass<OauthCallbackFailed>()(
@@ -90,15 +90,15 @@ export interface Interface {
readonly methods: () => Effect.Effect<Methods>
readonly authorize: (
input: {
providerID: ProviderID
providerID: ProviderV2.ID
} & AuthorizeInput,
) => Effect.Effect<Authorization | undefined, Error>
readonly callback: (input: { providerID: ProviderID } & CallbackInput) => Effect.Effect<void, Error>
readonly callback: (input: { providerID: ProviderV2.ID } & CallbackInput) => Effect.Effect<void, Error>
}
interface State {
hooks: Record<ProviderID, Hook>
pending: Map<ProviderID, AuthOAuthResult>
hooks: Record<ProviderV2.ID, Hook>
pending: Map<ProviderV2.ID, AuthOAuthResult>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProviderAuth") {}
@@ -117,11 +117,11 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
hooks: Record.fromEntries(
Arr.filterMap(plugins, (x) =>
x.auth?.provider !== undefined
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
? Result.succeed([ProviderV2.ID.make(x.auth.provider), x.auth] as const)
: Result.failVoid,
),
),
pending: new Map<ProviderID, AuthOAuthResult>(),
pending: new Map<ProviderV2.ID, AuthOAuthResult>(),
}
}),
)
@@ -160,7 +160,7 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
})
const authorize = Effect.fn("ProviderAuth.authorize")(function* (
input: { providerID: ProviderID } & AuthorizeInput,
input: { providerID: ProviderV2.ID } & AuthorizeInput,
) {
const { hooks, pending } = yield* InstanceState.get(state)
const method = hooks[input.providerID].methods[input.method]
@@ -184,7 +184,7 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
}
})
const callback = Effect.fn("ProviderAuth.callback")(function* (input: { providerID: ProviderID } & CallbackInput) {
const callback = Effect.fn("ProviderAuth.callback")(function* (input: { providerID: ProviderV2.ID } & CallbackInput) {
const pending = (yield* InstanceState.get(state)).pending
const match = pending.get(input.providerID)
if (!match) return yield* new OauthMissing({ providerID: input.providerID })
+3 -3
View File
@@ -1,7 +1,7 @@
import { APICallError } from "ai"
import { STATUS_CODES } from "http"
import { iife } from "@/util/iife"
import type { ProviderID } from "./schema"
import type { ProviderV2 } from "@opencode-ai/core/provider"
// Adapted from overflow detection patterns in:
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts
@@ -45,7 +45,7 @@ function isOverflow(message: string) {
return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
}
function message(providerID: ProviderID, e: APICallError) {
function message(providerID: ProviderV2.ID, e: APICallError) {
return iife(() => {
const msg = e.message
if (msg === "") {
@@ -178,7 +178,7 @@ export type ParsedAPICallError =
metadata?: Record<string, string>
}
export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError {
export function parseAPICallError(input: { providerID: ProviderV2.ID; error: APICallError }): ParsedAPICallError {
const m = message(input.providerID, input.error)
const body = json(input.error.responseBody)
if (isOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") {
+53 -53
View File
@@ -25,7 +25,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { isRecord } from "@/util/record"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -653,8 +653,8 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
for (const m of result.models) {
if (!input.models[m.id]) {
models[m.id] = {
id: ModelID.make(m.id),
providerID: ProviderID.make("gitlab"),
id: ProviderV2.ModelID.make(m.id),
providerID: ProviderV2.ID.make("gitlab"),
name: `Agent Platform (${m.name})`,
family: "",
api: {
@@ -918,8 +918,8 @@ const ProviderLimit = Schema.Struct({
})
export const Model = Schema.Struct({
id: ModelID,
providerID: ProviderID,
id: ProviderV2.ModelID,
providerID: ProviderV2.ID,
api: ProviderApiInfo,
name: Schema.String,
family: optionalOmitUndefined(Schema.String),
@@ -935,7 +935,7 @@ export const Model = Schema.Struct({
export type Model = Types.DeepMutable<Schema.Schema.Type<typeof Model>>
export const Info = Schema.Struct({
id: ProviderID,
id: ProviderV2.ID,
name: Schema.String,
source: Schema.Literals(["env", "config", "custom", "api"]),
env: Schema.Array(Schema.String),
@@ -975,8 +975,8 @@ export function defaultModelIDs<T extends { models: Record<string, { id: string
}
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {
@@ -986,7 +986,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
}
export class InitError extends Schema.TaggedErrorClass<InitError>()("ProviderInitError", {
providerID: ProviderID,
providerID: ProviderV2.ID,
cause: Schema.optional(Schema.Defect),
}) {
static isInstance(input: unknown): input is InitError {
@@ -1001,7 +1001,7 @@ export class NoProvidersError extends Schema.TaggedErrorClass<NoProvidersError>(
}
export class NoModelsError extends Schema.TaggedErrorClass<NoModelsError>()("ProviderNoModelsError", {
providerID: ProviderID,
providerID: ProviderV2.ID,
}) {
static isInstance(input: unknown): input is NoModelsError {
return input instanceof NoModelsError
@@ -1012,22 +1012,22 @@ export type DefaultModelError = ModelNotFoundError | NoProvidersError | NoModels
export type Error = ModelNotFoundError | InitError | NoProvidersError | NoModelsError
export interface Interface {
readonly list: () => Effect.Effect<Record<ProviderID, Info>>
readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
readonly list: () => Effect.Effect<Record<ProviderV2.ID, Info>>
readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect<Info>
readonly getModel: (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID) => Effect.Effect<Model, ModelNotFoundError>
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3, ModelNotFoundError>
readonly closest: (
providerID: ProviderID,
providerID: ProviderV2.ID,
query: string[],
) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined>
readonly getSmallModel: (providerID: ProviderID) => Effect.Effect<Model | undefined>
readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }, DefaultModelError>
) => 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 }, DefaultModelError>
}
interface State {
models: Map<string, LanguageModelV3>
providers: Record<ProviderID, Info>
catalog: Record<ProviderID, Info>
providers: Record<ProviderV2.ID, Info>
catalog: Record<ProviderV2.ID, Info>
sdk: Map<string, BundledSDK>
modelLoaders: Record<string, CustomModelLoader>
varsLoaders: Record<string, CustomVarsLoader>
@@ -1072,8 +1072,8 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
const base: Model = {
id: ModelID.make(model.id),
providerID: ProviderID.make(provider.id),
id: ProviderV2.ModelID.make(model.id),
providerID: ProviderV2.ID.make(provider.id),
name: model.name,
family: model.family,
api: {
@@ -1130,7 +1130,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
const base = fromModelsDevModel(provider, model)
models[id] = {
...base,
id: ModelID.make(id),
id: ProviderV2.ModelID.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
@@ -1146,7 +1146,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
}
}
return {
id: ProviderID.make(provider.id),
id: ProviderV2.ID.make(provider.id),
source: "custom",
name: provider.name,
env: [...(provider.env ?? [])],
@@ -1165,7 +1165,7 @@ function suggestionModelIDs(provider: Info | undefined, enableExperimentalModels
})
}
function modelSuggestions(provider: Info | undefined, modelID: ModelID, enableExperimentalModels: boolean) {
function modelSuggestions(provider: Info | undefined, modelID: ProviderV2.ModelID, enableExperimentalModels: boolean) {
const available = suggestionModelIDs(provider, enableExperimentalModels)
const fuzzy = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }).map((m) => m.target)
if (fuzzy.length) return fuzzy
@@ -1207,7 +1207,7 @@ export const layer = Layer.effect(
const catalog = mapValues(modelsDev, fromModelsDevProvider)
const database = mapValues(catalog, toPublicInfo)
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
const providers: Record<ProviderV2.ID, Info> = {} as Record<ProviderV2.ID, Info>
const languages = new Map<string, LanguageModelV3>()
const modelLoaders: {
[providerID: string]: CustomModelLoader
@@ -1228,7 +1228,7 @@ export const layer = Layer.effect(
log.info("init")
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
function mergeProvider(providerID: ProviderV2.ID, provider: Partial<Info>) {
const existing = providers[providerID]
if (existing) {
// @ts-expect-error
@@ -1249,7 +1249,7 @@ export const layer = Layer.effect(
const disabled = new Set(cfg.disabled_providers ?? [])
const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null
function isProviderAllowed(providerID: ProviderID): boolean {
function isProviderAllowed(providerID: ProviderV2.ID): boolean {
if (enabled && !enabled.has(providerID)) return false
if (disabled.has(providerID)) return false
return true
@@ -1260,7 +1260,7 @@ export const layer = Layer.effect(
const models = p?.models
if (!p || !models) continue
const providerID = ProviderID.make(p.id)
const providerID = ProviderV2.ID.make(p.id)
if (disabled.has(providerID)) continue
const provider = database[providerID]
@@ -1274,7 +1274,7 @@ export const layer = Layer.effect(
id,
{
...model,
id: ModelID.make(id),
id: ProviderV2.ModelID.make(id),
providerID,
},
]),
@@ -1286,7 +1286,7 @@ export const layer = Layer.effect(
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: Info = {
id: ProviderID.make(providerID),
id: ProviderV2.ID.make(providerID),
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
@@ -1309,7 +1309,7 @@ export const layer = Layer.effect(
return existingModel?.name ?? modelID
})
const parsedModel: Model = {
id: ModelID.make(modelID),
id: ProviderV2.ModelID.make(modelID),
api: {
id: apiID,
npm: apiNpm,
@@ -1317,7 +1317,7 @@ export const layer = Layer.effect(
},
status: model.status ?? existingModel?.status ?? "active",
name,
providerID: ProviderID.make(providerID),
providerID: ProviderV2.ID.make(providerID),
capabilities: {
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
@@ -1379,7 +1379,7 @@ export const layer = Layer.effect(
// load env
const envs = yield* env.all()
for (const [id, provider] of Object.entries(database)) {
const providerID = ProviderID.make(id)
const providerID = ProviderV2.ID.make(id)
if (disabled.has(providerID)) continue
const apiKey = provider.env.map((item) => envs[item]).find(Boolean)
if (!apiKey) continue
@@ -1392,7 +1392,7 @@ export const layer = Layer.effect(
// load apikeys
const auths = yield* auth.all().pipe(Effect.orDie)
for (const [id, provider] of Object.entries(auths)) {
const providerID = ProviderID.make(id)
const providerID = ProviderV2.ID.make(id)
if (disabled.has(providerID)) continue
if (provider.type === "api") {
mergeProvider(providerID, {
@@ -1405,7 +1405,7 @@ export const layer = Layer.effect(
// plugin auth loader - database now has entries for config providers
for (const plugin of plugins) {
if (!plugin.auth) continue
const providerID = ProviderID.make(plugin.auth.provider)
const providerID = ProviderV2.ID.make(plugin.auth.provider)
if (disabled.has(providerID)) continue
const stored = yield* auth.get(providerID).pipe(Effect.orDie)
@@ -1424,7 +1424,7 @@ export const layer = Layer.effect(
}
for (const [id, fn] of Object.entries(custom(dep))) {
const providerID = ProviderID.make(id)
const providerID = ProviderV2.ID.make(id)
if (disabled.has(providerID)) continue
const data = database[providerID]
if (!data) {
@@ -1444,7 +1444,7 @@ export const layer = Layer.effect(
// load config - re-apply with updated data
for (const [id, provider] of configProviders) {
const providerID = ProviderID.make(id)
const providerID = ProviderV2.ID.make(id)
const partial: Partial<Info> = { source: "config" }
if (provider.env) partial.env = provider.env
if (provider.name) partial.name = provider.name
@@ -1452,7 +1452,7 @@ export const layer = Layer.effect(
mergeProvider(providerID, partial)
}
const gitlab = ProviderID.make("gitlab")
const gitlab = ProviderV2.ID.make("gitlab")
if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) {
yield* Effect.promise(async () => {
try {
@@ -1469,7 +1469,7 @@ export const layer = Layer.effect(
}
for (const [id, provider] of Object.entries(providers)) {
const providerID = ProviderID.make(id)
const providerID = ProviderV2.ID.make(id)
if (!isProviderAllowed(providerID)) {
delete providers[providerID]
continue
@@ -1483,10 +1483,10 @@ export const layer = Layer.effect(
// These chat aliases are invalid for the special handling in the
// built-in providers below, but custom providers may support them.
(modelID === "gpt-5-chat-latest" &&
(providerID === ProviderID.openai ||
providerID === ProviderID.githubCopilot ||
providerID === ProviderID.openrouter)) ||
(providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
(providerID === ProviderV2.ID.openai ||
providerID === ProviderV2.ID.githubCopilot ||
providerID === ProviderV2.ID.openrouter)) ||
(providerID === ProviderV2.ID.openrouter && modelID === "openai/gpt-5-chat")
)
delete provider.models[modelID]
if (model.status === "alpha" && !runtimeFlags.enableExperimentalModels) delete provider.models[modelID]
@@ -1687,11 +1687,11 @@ export const layer = Layer.effect(
}
}
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderV2.ID) =>
InstanceState.use(state, (s) => s.providers[providerID]),
)
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID) {
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) {
@@ -1741,7 +1741,7 @@ export const layer = Layer.effect(
)
})
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderV2.ID, query: string[]) {
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) return undefined
@@ -1753,7 +1753,7 @@ export const layer = Layer.effect(
return undefined
})
const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) {
const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderV2.ID) {
const cfg = yield* config.get()
if (cfg.small_model) {
@@ -1783,7 +1783,7 @@ export const layer = Layer.effect(
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
}
for (const item of priority) {
if (providerID === ProviderID.amazonBedrock) {
if (providerID === ProviderV2.ID.amazonBedrock) {
const crossRegionPrefixes = ["global.", "us.", "eu."]
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
@@ -1817,16 +1817,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: ProviderID; modelID: ModelID }[] => {
Effect.map((x): { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[] => {
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: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
return [{ providerID: ProviderV2.ID.make(item.providerID), modelID: ProviderV2.ModelID.make(item.modelID) }]
})
}),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[])),
)
for (const entry of recent) {
const provider = s.providers[entry.providerID]
@@ -1874,8 +1874,8 @@ export function sort<T extends { id: string }>(models: T[]) {
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: ProviderID.make(providerID),
modelID: ModelID.make(rest.join("/")),
providerID: ProviderV2.ID.make(providerID),
modelID: ProviderV2.ModelID.make(rest.join("/")),
}
}
-30
View File
@@ -1,30 +0,0 @@
import { Schema } from "effect"
import { withStatics } from "@opencode-ai/core/schema"
const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID"))
export type ProviderID = typeof providerIdSchema.Type
export const ProviderID = providerIdSchema.pipe(
withStatics((schema: typeof providerIdSchema) => ({
// Well-known providers
opencode: schema.make("opencode"),
anthropic: schema.make("anthropic"),
openai: schema.make("openai"),
google: schema.make("google"),
googleVertex: schema.make("google-vertex"),
githubCopilot: schema.make("github-copilot"),
amazonBedrock: schema.make("amazon-bedrock"),
azure: schema.make("azure"),
openrouter: schema.make("openrouter"),
mistral: schema.make("mistral"),
gitlab: schema.make("gitlab"),
})),
)
const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID"))
export type ModelID = typeof modelIdSchema.Type
export const ModelID = modelIdSchema
@@ -1,26 +1,2 @@
import sessionProjectors from "../session/projectors"
import { SyncEvent } from "@/sync"
import { Session } from "@/session/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
export function initProjectors() {
SyncEvent.init({
projectors: sessionProjectors,
convertEvent: (type, data) => {
if (type === "session.updated") {
const id = (data as SyncEvent.Event<typeof Session.Event.Updated>["data"]).sessionID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) return data
return {
sessionID: id,
info: Session.fromRow(row),
}
}
return data
},
})
}
@@ -1,11 +1,12 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
import { ProviderV2 } from "@opencode-ai/core/provider"
const AuthParams = Schema.Struct({
providerID: ProviderID,
providerID: ProviderV2.ID,
})
const LogQuery = Schema.Struct({
@@ -1,6 +1,6 @@
import { AccountID, OrgID } from "@/account/schema"
import { MCP } from "@/mcp"
import { ProviderID, ModelID } from "@/provider/schema"
import { Session } from "@/session/session"
import { Worktree } from "@/worktree"
import { NonNegativeInt } from "@opencode-ai/core/schema"
@@ -15,6 +15,7 @@ import {
} from "../middleware/workspace-routing"
import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
@@ -49,8 +50,8 @@ const ToolListItem = Schema.Struct({
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
export const ToolListQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
provider: ProviderID,
model: ModelID,
provider: ProviderV2.ID,
model: ProviderV2.ModelID,
})
const WorktreeList = Schema.Array(Schema.String)
@@ -1,12 +1,13 @@
import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
import { ProviderV2 } from "@opencode-ai/core/provider"
const root = "/provider"
@@ -21,7 +22,7 @@ export class ProviderAuthApiError extends Schema.ErrorClass<ProviderAuthApiError
{
name: ProviderAuthErrorName,
data: Schema.Struct({
providerID: Schema.optional(ProviderID),
providerID: Schema.optional(ProviderV2.ID),
field: Schema.optional(Schema.String),
message: Schema.optional(Schema.String),
kind: Schema.optional(Schema.String),
@@ -55,7 +56,7 @@ export const ProviderApi = HttpApi.make("provider")
}),
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
params: { providerID: ProviderV2.ID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.AuthorizeInput,
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
@@ -68,7 +69,7 @@ export const ProviderApi = HttpApi.make("provider")
}),
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
params: { providerID: ProviderV2.ID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.CallbackInput,
success: described(Schema.Boolean, "OAuth callback processed successfully"),
@@ -1,7 +1,7 @@
import { Permission } from "@/permission"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
import { SessionPrompt } from "@/session/prompt"
@@ -23,6 +23,7 @@ import {
import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors"
import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
const root = "/session"
export const ListQuery = Schema.Struct({
@@ -55,13 +56,13 @@ export const UpdatePayload = Schema.Struct({
})
export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"]))
export const InitPayload = Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
modelID: ProviderV2.ModelID,
providerID: ProviderV2.ID,
messageID: MessageID,
})
export const SummarizePayload = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
auto: Schema.optional(Schema.Boolean),
})
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
@@ -1,24 +1,25 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { RootHttpApi } from "../api"
import { LogInput } from "../groups/control"
import { ProviderV2 } from "@opencode-ai/core/provider"
export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) =>
Effect.gen(function* () {
const auth = yield* Auth.Service
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
payload: Auth.Info
}) {
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
return true
})
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderV2.ID } }) {
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
return true
})
@@ -2,13 +2,14 @@ import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ProviderAuthApiError } from "../groups/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
function mapProviderAuthError<A, R>(self: Effect.Effect<A, ProviderAuth.Error, R>) {
return self.pipe(
@@ -62,7 +63,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
})
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
payload: ProviderAuth.AuthorizeInput
}) {
return yield* mapProviderAuthError(
@@ -75,7 +76,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
})
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
@@ -90,7 +91,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
})
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
payload: ProviderAuth.CallbackInput
}) {
yield* mapProviderAuthError(
@@ -21,6 +21,7 @@ const log = Log.create({ service: "server.sync" })
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const session = yield* Session.Service
const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
@@ -61,12 +62,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
const workspaceID = yield* InstanceState.workspaceID
if (!workspaceID) return yield* new HttpApiError.BadRequest({})
yield* sync.run(Session.Event.Updated, {
sessionID: ctx.payload.sessionID,
info: {
workspaceID,
},
})
yield* session.setWorkspace({ sessionID: ctx.payload.sessionID, workspaceID })
log.info("sync session stolen", {
sessionID: ctx.payload.sessionID,
@@ -46,6 +46,7 @@ import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { Skill } from "@/skill"
import { Snapshot } from "@/snapshot"
import { SyncEvent } from "@/sync"
@@ -196,6 +197,7 @@ export function createRoutes(
Auth.defaultLayer,
Command.defaultLayer,
Config.defaultLayer,
Database.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Format.defaultLayer,
+4 -3
View File
@@ -12,7 +12,7 @@ import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/storage"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context, Schema } from "effect"
import * as DateTime from "effect/DateTime"
import { InstanceState } from "@/effect/instance-state"
@@ -21,6 +21,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
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"
const log = Log.create({ service: "session.compaction" })
@@ -200,7 +201,7 @@ export interface Interface {
readonly create: (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
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: ProviderID; modelID: ModelID }
model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
auto: boolean
overflow?: boolean
}) {
+7 -50
View File
@@ -1,5 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID, PartID } from "./schema"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import {
APIError,
AbortedError,
@@ -14,14 +16,11 @@ import {
SubtaskPart,
User,
WithParts,
type ModelID,
type ProviderID,
type ToolPart,
} from "@opencode-ai/core/session/legacy"
import { NamedError } from "@opencode-ai/core/util/error"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { SyncEvent } from "../sync"
import { Database } from "@opencode-ai/core/database/database"
import { NotFoundError } from "@/storage/storage"
import { and } from "drizzle-orm"
@@ -56,47 +55,10 @@ function truncateToolOutput(text: string, maxChars?: number) {
return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]`
}
const UpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
info: Info,
})
const RemovedEventSchema = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
})
const PartUpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
part: Part,
time: Schema.Number,
})
const PartRemovedEventSchema = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
partID: PartID,
})
export const Event = {
Updated: SyncEvent.define({
type: "message.updated",
version: 1,
aggregate: "sessionID",
schema: UpdatedEventSchema,
}),
Removed: SyncEvent.define({
type: "message.removed",
version: 1,
aggregate: "sessionID",
schema: RemovedEventSchema,
}),
PartUpdated: SyncEvent.define({
type: "message.part.updated",
version: 1,
aggregate: "sessionID",
schema: PartUpdatedEventSchema,
}),
Updated: BusEvent.define("message.updated", SessionLegacy.Event.MessageUpdated.data),
Removed: BusEvent.define("message.removed", SessionLegacy.Event.MessageRemoved.data),
PartUpdated: BusEvent.define("message.part.updated", SessionLegacy.Event.PartUpdated.data),
PartDelta: BusEvent.define(
"message.part.delta",
Schema.Struct({
@@ -107,12 +69,7 @@ export const Event = {
delta: Schema.String,
}),
),
PartRemoved: SyncEvent.define({
type: "message.part.removed",
version: 1,
aggregate: "sessionID",
schema: PartRemovedEventSchema,
}),
PartRemoved: BusEvent.define("message.part.removed", SessionLegacy.Event.PartRemoved.data),
}
const Cursor = Schema.Struct({
@@ -656,7 +613,7 @@ export function latest(msgs: WithParts[]) {
export function fromError(
e: unknown,
ctx: { providerID: ProviderID; aborted?: boolean },
ctx: { providerID: ProviderV2.ID; aborted?: boolean },
): NonNullable<Assistant["error"]> {
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
+4 -3
View File
@@ -1,9 +1,10 @@
import { Schema } from "effect"
import { SessionID } from "./schema"
import { ModelID, ProviderID } from "../provider/schema"
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"
export { AuthError, OutputLengthError } from "./message-error"
export const ToolCall = Schema.Struct({
@@ -119,8 +120,8 @@ export const Info = Schema.Struct({
assistant: Schema.optional(
Schema.Struct({
system: Schema.Array(Schema.String),
modelID: ModelID,
providerID: ProviderID,
modelID: ProviderV2.ModelID,
providerID: ProviderV2.ID,
path: Schema.Struct({
cwd: Schema.String,
root: Schema.String,
-199
View File
@@ -1,199 +0,0 @@
import { NotFoundError } from "@/storage/storage"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { eq } from "drizzle-orm"
import { and } from "drizzle-orm"
import { sql } from "drizzle-orm"
import type { TxOrDb } from "@/storage/db"
import { SyncEvent } from "@/sync"
import * as Session from "./session"
import { MessageV2 } from "./message-v2"
import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { Log } from "@opencode-ai/core/util/log"
const log = Log.create({ service: "session.projector" })
function foreign(err: unknown) {
if (typeof err !== "object" || err === null) return false
if ("code" in err && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") return true
return "message" in err && typeof err.message === "string" && err.message.includes("FOREIGN KEY constraint failed")
}
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
type Usage = Pick<SessionLegacy.StepFinishPart, "cost" | "tokens">
function usage(part: SessionLegacy.Part | unknown): Usage | undefined {
if (typeof part !== "object" || part === null) return undefined
const value = part as Record<string, unknown>
if (value.type !== "step-finish") return undefined
if (!("cost" in value) || !("tokens" in value)) return undefined
return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] }
}
function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sign = 1) {
db.update(SessionTable)
.set({
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.run()
}
function grab<T extends object, K1 extends keyof T, X>(
obj: T,
field1: K1,
cb?: (val: NonNullable<T[K1]>) => X,
): X | undefined {
if (obj == undefined || !(field1 in obj)) return undefined
const val = obj[field1]
if (val && typeof val === "object" && cb) {
return cb(val)
}
if (val === undefined) {
throw new Error(
"Session update failure: pass `null` to clear a field instead of `undefined`: " + JSON.stringify(obj),
)
}
return val as X | undefined
}
export function toPartialRow(info: DeepPartial<Session.Info>) {
const obj = {
id: grab(info, "id"),
project_id: grab(info, "projectID"),
workspace_id: grab(info, "workspaceID"),
parent_id: grab(info, "parentID"),
slug: grab(info, "slug"),
directory: grab(info, "directory"),
path: grab(info, "path"),
title: grab(info, "title"),
version: grab(info, "version"),
share_url: grab(info, "share", (v) => grab(v, "url")),
summary_additions: grab(info, "summary", (v) => grab(v, "additions")),
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
summary_files: grab(info, "summary", (v) => grab(v, "files")),
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
cost: grab(info, "cost"),
tokens_input: grab(info, "tokens", (v) => grab(v, "input")),
tokens_output: grab(info, "tokens", (v) => grab(v, "output")),
tokens_reasoning: grab(info, "tokens", (v) => grab(v, "reasoning")),
tokens_cache_read: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "read"))),
tokens_cache_write: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "write"))),
revert: grab(info, "revert"),
permission: grab(info, "permission"),
time_created: grab(info, "time", (v) => grab(v, "created")),
time_updated: grab(info, "time", (v) => grab(v, "updated")),
time_compacting: grab(info, "time", (v) => grab(v, "compacting")),
time_archived: grab(info, "time", (v) => grab(v, "archived")),
}
return Object.fromEntries(Object.entries(obj).filter(([_, val]) => val !== undefined))
}
export default [
SyncEvent.project(Session.Event.Created, (db, data) => {
db.insert(SessionTable)
.values(Session.toRow(data.info as Session.Info))
.run()
if (data.info.workspaceID) {
db.update(WorkspaceTable).set({ time_used: Date.now() }).where(eq(WorkspaceTable.id, data.info.workspaceID)).run()
}
}),
SyncEvent.project(Session.Event.Updated, (db, data) => {
const info = data.info
const row = db
.update(SessionTable)
.set({ time_updated: sql`${SessionTable.time_updated}`, ...toPartialRow(info as Session.Patch) })
.where(eq(SessionTable.id, data.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${data.sessionID}` })
}),
SyncEvent.project(Session.Event.Deleted, (db, data) => {
db.delete(SessionTable).where(eq(SessionTable.id, data.sessionID)).run()
}),
SyncEvent.project(MessageV2.Event.Updated, (db, data) => {
const time_created = data.info.time.created
const { id, sessionID, ...rest } = data.info
try {
db.insert(MessageTable)
.values({
id,
session_id: sessionID,
time_created,
data: rest,
})
.onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } })
.run()
} catch (err) {
if (!foreign(err)) throw err
log.warn("ignored late message update", { messageID: id, sessionID })
}
}),
SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
for (const row of db
.select()
.from(PartTable)
.where(and(eq(PartTable.message_id, data.messageID), eq(PartTable.session_id, data.sessionID)))
.all()) {
const previous = usage(row.data)
if (previous) applyUsage(db, data.sessionID, previous, -1)
}
db.delete(MessageTable)
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
.run()
}),
SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
const row = db
.select()
.from(PartTable)
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
.get()
const previous = row && usage(row.data)
if (previous) applyUsage(db, data.sessionID, previous, -1)
db.delete(PartTable)
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
.run()
}),
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
const { id, messageID, sessionID, ...rest } = data.part
const row = db.select().from(PartTable).where(eq(PartTable.id, id)).get()
try {
db.insert(PartTable)
.values({
id,
message_id: messageID,
session_id: sessionID,
time_created: data.time,
data: rest,
})
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
.run()
const previous = row && usage(row.data)
const next = usage(data.part)
if (previous) applyUsage(db, row.session_id, previous, -1)
if (next) applyUsage(db, sessionID, next)
} catch (err) {
if (!foreign(err)) throw err
log.warn("ignored late part update", { partID: id, messageID, sessionID })
}
}),
]
+9 -9
View File
@@ -8,7 +8,7 @@ import { SessionRevert } from "./revert"
import * as Session from "./session"
import { Agent } from "../agent/agent"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { type Tool as AITool, tool, jsonSchema } from "ai"
import type { JSONSchema7 } from "@ai-sdk/provider"
import { SessionCompaction } from "./compaction"
@@ -239,8 +239,8 @@ export const layer = Layer.effect(
const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
session: Session.Info
history: SessionLegacy.WithParts[]
providerID: ProviderID
modelID: ModelID
providerID: ProviderV2.ID
modelID: ProviderV2.ModelID
}) {
if (input.session.parentID) return
if (!Session.isDefaultTitle(input.session.title)) return
@@ -651,8 +651,8 @@ export const layer = Layer.effect(
})
const getModel = Effect.fn("SessionPrompt.getModel")(function* (
providerID: ProviderID,
modelID: ModelID,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
sessionID: SessionID,
) {
const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit)
@@ -679,8 +679,8 @@ export const layer = Layer.effect(
.pipe(Effect.orDie)
if (current?.model) {
return {
providerID: ProviderID.make(current.model.providerID),
modelID: ModelID.make(current.model.id),
providerID: ProviderV2.ID.make(current.model.providerID),
modelID: ProviderV2.ModelID.make(current.model.id),
...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}),
}
}
@@ -1666,8 +1666,8 @@ export const defaultLayer = Layer.suspend(() =>
),
)
const ModelRef = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
})
export const PromptInput = Schema.Struct({
+2 -12
View File
@@ -3,7 +3,6 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Bus } from "../bus"
import { Snapshot } from "../snapshot"
import { Storage } from "@/storage/storage"
import { SyncEvent } from "../sync"
import * as Log from "@opencode-ai/core/util/log"
import * as Session from "./session"
import { MessageV2 } from "./message-v2"
@@ -37,7 +36,6 @@ export const layer = Layer.effect(
const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service
const sync = yield* SyncEvent.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
yield* state.assertNotBusy(input.sessionID)
@@ -121,10 +119,7 @@ export const layer = Layer.effect(
remove.push(msg)
}
for (const msg of remove) {
yield* sync.run(MessageV2.Event.Removed, {
sessionID,
messageID: msg.info.id,
})
yield* sessions.removeMessage({ sessionID, messageID: msg.info.id })
}
if (session.revert.partID && target) {
const partID = session.revert.partID
@@ -133,11 +128,7 @@ export const layer = Layer.effect(
const removeParts = target.parts.slice(idx)
target.parts = target.parts.slice(0, idx)
for (const part of removeParts) {
yield* sync.run(MessageV2.Event.PartRemoved, {
sessionID,
messageID: target.info.id,
partID: part.id,
})
yield* sessions.removePart({ sessionID, messageID: target.info.id, partID: part.id })
}
}
}
@@ -156,7 +147,6 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
),
)
+108 -60
View File
@@ -10,6 +10,8 @@ import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Database } from "@opencode-ai/core/database/database"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionV2 } from "@opencode-ai/core/session"
import { NotFoundError } from "@/storage/storage"
import { eq } from "drizzle-orm"
@@ -21,7 +23,6 @@ import { like } from "drizzle-orm"
import { inArray } from "drizzle-orm"
import { lt } from "drizzle-orm"
import { or } from "drizzle-orm"
import { SyncEvent } from "../sync"
import type { SQL } from "drizzle-orm"
import { PartTable, SessionTable } from "@opencode-ai/core/session/sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -34,14 +35,14 @@ import { Snapshot } from "@/snapshot"
import { ProjectV2 } from "@opencode-ai/core/project"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { SessionID, MessageID, PartID } from "./schema"
import { ModelID, ProviderID } from "@/provider/schema"
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 { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
const log = Log.create({ service: "session" })
const runtime = makeRuntime(Database.Service, Database.defaultLayer)
@@ -85,8 +86,8 @@ export function fromRow(row: SessionRow): Info {
agent: row.agent ?? undefined,
model: row.model
? {
id: ModelID.make(row.model.id),
providerID: ProviderID.make(row.model.providerID),
id: ProviderV2.ModelID.make(row.model.id),
providerID: ProviderV2.ID.make(row.model.providerID),
variant: row.model.variant,
}
: undefined,
@@ -114,6 +115,13 @@ 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,
@@ -203,8 +211,8 @@ const Revert = Schema.Struct({
})
const Model = Schema.Struct({
id: ModelID,
providerID: ProviderID,
id: ProviderV2.ModelID,
providerID: ProviderV2.ID,
variant: optionalOmitUndefined(Schema.String),
})
@@ -334,25 +342,9 @@ const UpdatedEventSchema = Schema.Struct({
})
export const Event = {
Created: SyncEvent.define({
type: "session.created",
version: 1,
aggregate: "sessionID",
schema: CreatedEventSchema,
}),
Updated: SyncEvent.define({
type: "session.updated",
version: 1,
aggregate: "sessionID",
schema: UpdatedEventSchema,
busSchema: CreatedEventSchema,
}),
Deleted: SyncEvent.define({
type: "session.deleted",
version: 1,
aggregate: "sessionID",
schema: CreatedEventSchema,
}),
Created: BusEvent.define("session.created", SessionLegacy.Event.Created.data),
Updated: BusEvent.define("session.updated", SessionLegacy.Event.Updated.data),
Deleted: BusEvent.define("session.deleted", SessionLegacy.Event.Deleted.data),
Diff: BusEvent.define(
"session.diff",
Schema.Struct({
@@ -474,6 +466,8 @@ export interface Interface {
}) => Effect.Effect<void>
readonly clearRevert: (sessionID: SessionID) => Effect.Effect<void>
readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect<void>
readonly setShare: (input: { sessionID: SessionID; share: Info["share"] }) => Effect.Effect<void>
readonly setWorkspace: (input: { sessionID: SessionID; workspaceID: Info["workspaceID"] }) => Effect.Effect<void>
readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<SessionLegacy.WithParts[], NotFound>
readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
@@ -505,12 +499,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
export const use = serviceUse(Service)
export type Patch = Types.DeepMutable<SyncEvent.Event<typeof Event.Updated>["data"]["info"]>
export type Patch = Omit<Partial<Info>, "time" | "share" | "summary" | "revert" | "permission"> & {
time?: Partial<Info["time"]>
share?: Partial<NonNullable<Info["share"]>> | null
summary?: Info["summary"] | null
revert?: Info["revert"] | null
permission?: Info["permission"] | null
}
export const layer: Layer.Layer<
Service,
never,
BackgroundJob.Service | Bus.Service | Storage.Service | SyncEvent.Service | RuntimeFlags.Service | Database.Service
BackgroundJob.Service | Bus.Service | Storage.Service | RuntimeFlags.Service | Database.Service | EventV2.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -518,8 +518,8 @@ export const layer: Layer.Layer<
const database = yield* Database.Service
const background = yield* BackgroundJob.Service
const bus = yield* Bus.Service
const events = yield* EventV2.Service
const storage = yield* Storage.Service
const sync = yield* SyncEvent.Service
const flags = yield* RuntimeFlags.Service
const createNext = Effect.fn("Session.createNext")(function* (input: {
@@ -556,15 +556,12 @@ export const layer: Layer.Layer<
}
log.info("created", result)
yield* sync.run(Event.Created, { sessionID: result.id, info: result })
yield* events.publish(SessionLegacy.Event.Created, { sessionID: result.id, info: result }, { location: eventLocation(result) })
if (!flags.experimentalWorkspaces) {
// This only exist for backwards compatibility. We should not be
// manually publishing this event; it is a sync event now
yield* bus.publish(Event.Updated, {
sessionID: result.id,
info: result,
})
yield* bus.publish(Event.Updated, { sessionID: result.id, info: result })
}
return result
@@ -609,8 +606,8 @@ export const layer: Layer.Layer<
yield* remove(child.id)
}
yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
yield* sync.remove(sessionID)
yield* events.publish(SessionLegacy.Event.Deleted, { sessionID, info: session }, { location: eventLocation(session) })
yield* events.remove(sessionID)
} catch (e) {
log.error(e)
}
@@ -618,17 +615,27 @@ export const layer: Layer.Layer<
const updateMessage = <T extends SessionLegacy.Info>(msg: T): Effect.Effect<T> =>
Effect.gen(function* () {
yield* sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg })
const session = yield* get(msg.sessionID)
yield* events.publish(
SessionLegacy.Event.MessageUpdated,
{ sessionID: msg.sessionID, info: msg },
{ location: eventLocation(session) },
)
return msg
}).pipe(Effect.withSpan("Session.updateMessage"))
const updatePart = <T extends SessionLegacy.Part>(part: T): Effect.Effect<T> =>
Effect.gen(function* () {
yield* sync.run(MessageV2.Event.PartUpdated, {
sessionID: part.sessionID,
part: structuredClone(part),
time: Date.now(),
})
const session = yield* get(part.sessionID)
yield* events.publish(
SessionLegacy.Event.PartUpdated,
{
sessionID: part.sessionID,
part: structuredClone(part),
time: Date.now(),
},
{ location: eventLocation(session) },
)
return part
}).pipe(Effect.withSpan("Session.updatePart"))
@@ -718,25 +725,40 @@ export const layer: Layer.Layer<
return session
})
const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info })
const patch = (sessionID: SessionID, info: Patch) =>
Effect.gen(function* () {
const current = yield* get(sessionID)
const next = {
...current,
...info,
time: info.time ? { ...current.time, ...info.time } : current.time,
share: info.share === null ? undefined : info.share ? { ...current.share, ...info.share } : current.share,
summary: info.summary === null ? undefined : (info.summary ?? current.summary),
revert: info.revert === null ? undefined : (info.revert ?? current.revert),
permission: info.permission === null ? undefined : (info.permission ?? current.permission),
} as Info
yield* events.publish(SessionLegacy.Event.Updated, { sessionID, info: next }, { location: eventLocation(next) })
})
const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
yield* patch(sessionID, { time: { updated: Date.now() } })
yield* patch(sessionID, { time: { updated: Date.now() } }).pipe(Effect.orDie)
})
const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) {
yield* patch(input.sessionID, { title: input.title })
yield* patch(input.sessionID, { title: input.title }).pipe(Effect.orDie)
})
const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) {
yield* patch(input.sessionID, { time: { archived: input.time } })
yield* patch(input.sessionID, { time: { archived: input.time } }).pipe(Effect.orDie)
})
const setPermission = Effect.fn("Session.setPermission")(function* (input: {
sessionID: SessionID
permission: Permission.Ruleset
}) {
yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } })
yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }).pipe(
Effect.orDie,
)
})
const setRevert = Effect.fn("Session.setRevert")(function* (input: {
@@ -744,18 +766,31 @@ export const layer: Layer.Layer<
revert: Info["revert"]
summary: Info["summary"]
}) {
yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert })
yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert }).pipe(
Effect.orDie,
)
})
const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) {
yield* patch(sessionID, { time: { updated: Date.now() }, revert: null })
yield* patch(sessionID, { time: { updated: Date.now() }, revert: null }).pipe(Effect.orDie)
})
const setSummary = Effect.fn("Session.setSummary")(function* (input: {
sessionID: SessionID
summary: Info["summary"]
}) {
yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary })
yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary }).pipe(Effect.orDie)
})
const setShare = Effect.fn("Session.setShare")(function* (input: { sessionID: SessionID; share: Info["share"] }) {
yield* patch(input.sessionID, { share: input.share ?? null, time: { updated: Date.now() } }).pipe(Effect.orDie)
})
const setWorkspace = Effect.fn("Session.setWorkspace")(function* (input: {
sessionID: SessionID
workspaceID: Info["workspaceID"]
}) {
yield* patch(input.sessionID, { workspaceID: input.workspaceID, time: { updated: Date.now() } }).pipe(Effect.orDie)
})
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
@@ -793,10 +828,15 @@ export const layer: Layer.Layer<
sessionID: SessionID
messageID: MessageID
}) {
yield* sync.run(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
})
const session = yield* get(input.sessionID)
yield* events.publish(
SessionLegacy.Event.MessageRemoved,
{
sessionID: input.sessionID,
messageID: input.messageID,
},
{ location: eventLocation(session) },
)
return input.messageID
})
@@ -805,11 +845,16 @@ export const layer: Layer.Layer<
messageID: MessageID
partID: PartID
}) {
yield* sync.run(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
})
const session = yield* get(input.sessionID)
yield* events.publish(
SessionLegacy.Event.PartRemoved,
{
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
},
{ location: eventLocation(session) },
)
return input.partID
})
@@ -854,6 +899,8 @@ export const layer: Layer.Layer<
setRevert,
clearRevert,
setSummary,
setShare,
setWorkspace,
diff,
messages,
children,
@@ -873,8 +920,9 @@ export const defaultLayer = layer.pipe(
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(SessionV2.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
+3 -2
View File
@@ -8,7 +8,7 @@ import { Tool } from "@/tool/tool"
import { ToolJsonSchema } from "@/tool/json-schema"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { ModelID } from "@/provider/schema"
import { Plugin } from "@/plugin"
import type { TaskPromptOps } from "@/tool/task"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
@@ -19,6 +19,7 @@ import { SessionProcessor } from "./processor"
import { PartID } from "./schema"
import * as Log from "@opencode-ai/core/util/log"
import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
const log = Log.create({ service: "session.tools" })
@@ -74,7 +75,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
for (const item of yield* registry.tools({
modelID: ModelID.make(input.model.api.id),
modelID: ProviderV2.ModelID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
})) {
+2 -5
View File
@@ -1,6 +1,5 @@
import { Session } from "@/session/session"
import { SessionID } from "@/session/schema"
import { SyncEvent } from "@/sync"
import { Effect, Layer, Scope, Context } from "effect"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -21,20 +20,19 @@ export const layer = Layer.effect(
const session = yield* Session.Service
const shareNext = yield* ShareNext.Service
const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
const flags = yield* RuntimeFlags.Service
const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) {
const conf = yield* cfg.get()
if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration")
const result = yield* shareNext.create(sessionID)
yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } })
yield* session.setShare({ sessionID, share: { url: result.url } })
return result
})
const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) {
yield* shareNext.remove(sessionID)
yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })
yield* session.setShare({ sessionID, share: undefined })
})
const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) {
@@ -54,7 +52,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(ShareNext.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
+3 -2
View File
@@ -6,7 +6,7 @@ import { Account } from "@/account/account"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
import type { SessionID } from "@/session/schema"
@@ -15,6 +15,7 @@ import { eq } from "drizzle-orm"
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"
const log = Log.create({ service: "share-next" })
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
@@ -290,7 +291,7 @@ export const layer = Layer.effect(
.map((item) => [`${item.providerID}/${item.modelID}`, item] as const),
).values(),
),
(item) => provider.getModel(ProviderID.make(item.providerID), ModelID.make(item.modelID)),
(item) => provider.getModel(ProviderV2.ID.make(item.providerID), ProviderV2.ModelID.make(item.modelID)),
{ concurrency: 8 },
)
+5 -4
View File
@@ -22,7 +22,7 @@ import { Schema } from "effect"
import z from "zod"
import { Plugin } from "../plugin"
import { Provider } from "@/provider/provider"
import { ProviderID, type ModelID } from "../provider/schema"
import { WebSearchTool } from "./websearch"
import { RepoCloneTool } from "./repo_clone"
import { RepoOverviewTool } from "./repo_overview"
@@ -56,11 +56,12 @@ import { Reference } from "@/reference/reference"
import { BackgroundJob } from "@/background/job"
import { SessionStatus } from "@/session/status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
const log = Log.create({ service: "tool.registry" })
export function webSearchEnabled(providerID: ProviderID, flags = { exa: false, parallel: false }) {
return providerID === ProviderID.opencode || flags.exa || flags.parallel
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
}
type TaskDef = Tool.InferDef<typeof TaskTool>
@@ -77,7 +78,7 @@ export interface Interface {
readonly ids: () => Effect.Effect<string[]>
readonly all: () => Effect.Effect<Tool.Def[]>
readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }>
readonly tools: (model: { providerID: ProviderID; modelID: ModelID; agent: Agent.Info }) => Effect.Effect<Tool.Def[]>
readonly tools: (model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID; agent: Agent.Info }) => Effect.Effect<Tool.Def[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
@@ -341,10 +341,6 @@ function sessionSequenceOwner(sessionID: SessionID) {
)?.ownerID
}
function sessionUpdatedType() {
return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
}
describe("workspace schemas and exports", () => {
test("keeps the historical event type names", () => {
expect(Workspace.Event.Ready.type).toBe("workspace.ready")
@@ -984,7 +980,7 @@ describe("workspace CRUD", () => {
id: `evt_${unique("warp-source-history")}`,
aggregate_id: historySessionID!,
seq: historyNextSeq,
type: sessionUpdatedType(),
type: "session.updated.1",
data: { sessionID: historySessionID!, info: { title: "from source history" } },
},
])
@@ -1035,12 +1031,12 @@ describe("workspace CRUD", () => {
{
aggregateID: session.id,
seq: 0,
type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version),
type: "session.created.1",
},
{
aggregateID: session.id,
seq: historyNextSeq,
type: sessionUpdatedType(),
type: "session.updated.1",
},
],
})
@@ -1348,7 +1344,7 @@ describe("workspace sync state", () => {
id: `evt_${unique("history")}`,
aggregate_id: historySessionID!,
seq: historyNextSeq,
type: sessionUpdatedType(),
type: "session.updated.1",
data: { sessionID: historySessionID!, info: { title: "from history" } },
},
]),
@@ -1494,7 +1490,7 @@ describe("workspace sync state", () => {
id: `evt_${unique("sse")}`,
aggregateID: sseSessionID!,
seq: sseNextSeq,
type: sessionUpdatedType(),
type: "session.updated.1",
data: { sessionID: sseSessionID!, info: { title: "from sse" } },
},
},
+3 -3
View File
@@ -1,11 +1,11 @@
import { Effect, Layer } from "effect"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
export namespace ProviderTest {
export function model(override: Partial<Provider.Model> = {}): Provider.Model {
const id = override.id ?? ModelID.make("gpt-5.2")
const providerID = override.providerID ?? ProviderID.make("openai")
const id = override.id ?? ProviderV2.ModelID.make("gpt-5.2")
const providerID = override.providerID ?? ProviderV2.ID.make("openai")
return {
id,
providerID,
@@ -5,7 +5,7 @@ import { Effect, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { ProviderAuth } from "@/provider/auth"
import { ProviderID } from "../../src/provider/schema"
import { Plugin } from "@/plugin"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Auth } from "@/auth"
@@ -13,6 +13,7 @@ import { Bus } from "@/bus"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ProviderV2 } from "@opencode-ai/core/provider"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer))
@@ -77,11 +78,11 @@ describe("plugin.auth-override", () => {
.methods()
.pipe(Effect.provide(layer(plain, [])), provideInstance(plain))
const copilot = methods[ProviderID.make("github-copilot")]
const copilot = methods[ProviderV2.ID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
expect(plainMethods[ProviderV2.ID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}),
{ git: true },
30000,
@@ -11,12 +11,13 @@ import { Config } from "../../src/config/config"
import { Env } from "../../src/env"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin/index"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { ProviderV2 } from "@opencode-ai/core/provider"
const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
@@ -74,8 +75,8 @@ const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransfo
systemHook,
{
model: {
providerID: ProviderID.anthropic,
modelID: ModelID.make("claude-sonnet-4-6"),
providerID: ProviderV2.ID.anthropic,
modelID: ProviderV2.ModelID.make("claude-sonnet-4-6"),
},
},
out,
@@ -6,9 +6,10 @@ import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { Env } from "../../src/env"
import { Provider } from "@/provider/provider"
import { ProviderID } from "../../src/provider/schema"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer))
@@ -62,8 +63,8 @@ it.instance(
yield* set("AWS_REGION", "us-east-1")
yield* set("AWS_PROFILE", "default")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
}),
{ config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
)
@@ -73,8 +74,8 @@ it.instance("Bedrock: falls back to AWS_REGION env var when no config region", (
yield* set("AWS_REGION", "eu-west-1")
yield* set("AWS_PROFILE", "default")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
}),
)
@@ -87,8 +88,8 @@ it.instance(
yield* set("AWS_ACCESS_KEY_ID", "")
yield* set("AWS_BEARER_TOKEN_BEDROCK", "")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
}),
{ config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
)
@@ -100,8 +101,8 @@ it.instance(
yield* set("AWS_PROFILE", "default")
yield* set("AWS_ACCESS_KEY_ID", "test-key-id")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1")
}),
{
config: {
@@ -116,8 +117,8 @@ it.instance(
Effect.gen(function* () {
yield* set("AWS_PROFILE", "default")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe(
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock].options?.endpoint).toBe(
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
)
}),
@@ -141,8 +142,8 @@ it.instance(
yield* set("AWS_PROFILE", "")
yield* set("AWS_ACCESS_KEY_ID", "")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1")
}),
{ config: { provider: { "amazon-bedrock": { options: { region: "us-east-1" } } } } },
)
@@ -157,8 +158,8 @@ it.instance(
Effect.gen(function* () {
yield* set("AWS_PROFILE", "default")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
}),
{
config: {
@@ -178,8 +179,8 @@ it.instance(
Effect.gen(function* () {
yield* set("AWS_PROFILE", "default")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
}),
{
config: {
@@ -199,8 +200,8 @@ it.instance(
Effect.gen(function* () {
yield* set("AWS_PROFILE", "default")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
}),
{
config: {
@@ -220,8 +221,8 @@ it.instance(
Effect.gen(function* () {
yield* set("AWS_PROFILE", "default")
const providers = yield* list
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
expect(providers[ProviderV2.ID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
}),
{
config: {
@@ -13,7 +13,7 @@ import { createAiGateway } from "ai-gateway-provider"
import { createUnified } from "ai-gateway-provider/providers/unified"
import { ProviderTransform } from "@/provider/transform"
import type * as Provider from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
type Captured = { url: string; outerBody: unknown }
type ProviderOptions = Record<string, Record<string, JSONValue>>
@@ -56,8 +56,8 @@ afterEach(() => {
})
const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({
id: ModelID.make(`cloudflare-ai-gateway/${apiId}`),
providerID: ProviderID.make("cloudflare-ai-gateway"),
id: ProviderV2.ModelID.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" },
capabilities: {
@@ -1,10 +1,11 @@
import { expect } from "bun:test"
import { Provider } from "../../src/provider/provider"
import { ProviderID } from "../../src/provider/schema"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const DIGITALOCEAN = ProviderID.make("digitalocean")
const DIGITALOCEAN = ProviderV2.ID.make("digitalocean")
const it = testEffect(Provider.defaultLayer)
const withEnv = <A, E, R>(values: Record<string, string>, effect: Effect.Effect<A, E, R>) =>
@@ -6,7 +6,7 @@ export {}
// import { test, expect, describe } from "bun:test"
// import path from "path"
// import { ProviderID, ModelID } from "../../src/provider/schema"
// import { ProviderV2 } from "@opencode-ai/core/provider"
// import { tmpdir, withTestInstance } from "../fixture/fixture"
// import { Provider } from "@/provider/provider"
// import { Env } from "../../src/env"
+120 -119
View File
@@ -13,11 +13,12 @@ import { Config } from "@/config/config"
import { Env } from "../../src/env"
import { Plugin } from "../../src/plugin/index"
import { Provider } from "@/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Filesystem } from "@/util/filesystem"
import { InstanceLayer } from "@/project/instance-layer"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const originalEnv = new Map<string, string | undefined>()
@@ -68,7 +69,7 @@ const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
const list = Provider.use.list()
const paid = (providers: Record<string, { models: Record<string, { cost: { input: number } }> }>) => {
const item = providers[ProviderID.make("opencode")]
const item = providers[ProviderV2.ID.make("opencode")]
expect(item).toBeDefined()
return Object.values(item.models).filter((model) => model.cost.input > 0).length
}
@@ -104,11 +105,11 @@ it.instance("provider loaded from env variable", () =>
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
// Provider should retain its connection source even if custom loaders
// merge additional options.
expect(providers[ProviderID.anthropic].source).toBe("env")
expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
expect(providers[ProviderV2.ID.anthropic].source).toBe("env")
expect(providers[ProviderV2.ID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
}),
)
@@ -116,7 +117,7 @@ it.instance(
"provider loaded from config with apiKey option",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
}),
{ config: { provider: { anthropic: { options: { apiKey: "config-api-key" } } } } },
)
@@ -126,7 +127,7 @@ it.instance(
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeUndefined()
expect(providers[ProviderV2.ID.anthropic]).toBeUndefined()
}),
{ config: { disabled_providers: ["anthropic"] } },
)
@@ -137,8 +138,8 @@ it.instance(
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
yield* setProcessEnv("OPENAI_API_KEY", "test-openai-key")
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
expect(providers[ProviderV2.ID.openai]).toBeUndefined()
}),
{ config: { enabled_providers: ["anthropic"] } },
)
@@ -148,8 +149,8 @@ it.instance(
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514")
expect(models.length).toBe(1)
}),
@@ -161,8 +162,8 @@ it.instance(
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
expect(models).not.toContain("claude-sonnet-4-20250514")
}),
{ config: { provider: { anthropic: { blacklist: ["claude-sonnet-4-20250514"] } } } },
@@ -173,9 +174,9 @@ it.instance(
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
expect(providers[ProviderV2.ID.anthropic].models["my-alias"]).toBeDefined()
expect(providers[ProviderV2.ID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
}),
{
config: {
@@ -190,9 +191,9 @@ it.instance(
"custom provider with npm package",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
expect(providers[ProviderV2.ID.make("custom-provider")]).toBeDefined()
expect(providers[ProviderV2.ID.make("custom-provider")].name).toBe("Custom Provider")
expect(providers[ProviderV2.ID.make("custom-provider")].models["custom-model"]).toBeDefined()
}),
{
config: {
@@ -220,8 +221,8 @@ it.instance(
"filters alpha provider models by default",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("custom-provider")].models["active-model"]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].models["alpha-model"]).toBeUndefined()
expect(providers[ProviderV2.ID.make("custom-provider")].models["active-model"]).toBeDefined()
expect(providers[ProviderV2.ID.make("custom-provider")].models["alpha-model"]).toBeUndefined()
}),
{ config: alphaProviderConfig },
)
@@ -230,8 +231,8 @@ experimentalModels.instance(
"includes alpha provider models when experimental models are enabled",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("custom-provider")].models["active-model"]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].models["alpha-model"]).toBeDefined()
expect(providers[ProviderV2.ID.make("custom-provider")].models["active-model"]).toBeDefined()
expect(providers[ProviderV2.ID.make("custom-provider")].models["alpha-model"]).toBeDefined()
}),
{ config: alphaProviderConfig },
)
@@ -240,11 +241,11 @@ it.instance(
"custom DeepSeek openai-compatible model defaults interleaved reasoning field",
Effect.gen(function* () {
const providers = yield* list
const provider = providers[ProviderID.make("custom-provider")]
const provider = providers[ProviderV2.ID.make("custom-provider")]
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
expect(providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved).toBe(
expect(providers[ProviderV2.ID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved).toBe(
false,
)
}),
@@ -279,10 +280,10 @@ it.instance(
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "env-api-key")
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
// Config options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
expect(providers[ProviderV2.ID.anthropic].options.timeout).toBe(60000)
expect(providers[ProviderV2.ID.anthropic].options.chunkTimeout).toBe(15000)
}),
{ config: { provider: { anthropic: { options: { timeout: 60000, chunkTimeout: 15000 } } } } },
)
@@ -291,7 +292,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(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
const model = yield* provider.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514"))
expect(model).toBeDefined()
expect(String(model.providerID)).toBe("anthropic")
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
@@ -303,7 +304,7 @@ it.instance("getModel returns model for valid provider/model", () =>
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(ProviderID.anthropic, ModelID.make("nonexistent-model")).pipe(Effect.exit)
const exit = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("nonexistent-model")).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
@@ -311,7 +312,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(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))
.getModel(ProviderV2.ID.make("nonexistent-provider"), ProviderV2.ModelID.make("some-model"))
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
@@ -365,8 +366,8 @@ it.instance(
"provider with baseURL from config",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("custom-openai")]).toBeDefined()
expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1")
expect(providers[ProviderV2.ID.make("custom-openai")]).toBeDefined()
expect(providers[ProviderV2.ID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1")
}),
{
config: {
@@ -387,7 +388,7 @@ it.instance(
"model cost defaults to zero when not specified",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderID.make("test-provider")].models["test-model"]
const model = providers[ProviderV2.ID.make("test-provider")].models["test-model"]
expect(model.cost.input).toBe(0)
expect(model.cost.output).toBe(0)
expect(model.cost.cache.read).toBe(0)
@@ -412,7 +413,7 @@ it.instance(
"model options are merged from existing model",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.options.customOption).toBe("custom-value")
}),
{
@@ -431,7 +432,7 @@ it.instance(
"provider removed when all models filtered out",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeUndefined()
expect(providers[ProviderV2.ID.anthropic]).toBeUndefined()
}),
{ config: { provider: { anthropic: { options: { apiKey: "test-api-key" }, whitelist: ["nonexistent-model"] } } } },
)
@@ -439,7 +440,7 @@ it.instance(
it.instance("closest finds model by partial match", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const result = yield* Provider.use.closest(ProviderID.anthropic, ["sonnet-4"])
const result = yield* Provider.use.closest(ProviderV2.ID.anthropic, ["sonnet-4"])
expect(result).toBeDefined()
expect(String(result?.providerID)).toBe("anthropic")
expect(String(result?.modelID)).toContain("sonnet-4")
@@ -448,7 +449,7 @@ it.instance("closest finds model by partial match", () =>
it.instance("closest returns undefined for nonexistent provider", () =>
Effect.gen(function* () {
const result = yield* Provider.use.closest(ProviderID.make("nonexistent"), ["model"])
const result = yield* Provider.use.closest(ProviderV2.ID.make("nonexistent"), ["model"])
expect(result).toBeUndefined()
}),
)
@@ -458,9 +459,9 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
expect(providers[ProviderV2.ID.anthropic].models["my-sonnet"]).toBeDefined()
const model = yield* Provider.use.getModel(ProviderID.anthropic, ModelID.make("my-sonnet"))
const model = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("my-sonnet"))
expect(model).toBeDefined()
expect(String(model.id)).toBe("my-sonnet")
expect(model.name).toBe("My Sonnet Alias")
@@ -481,7 +482,7 @@ it.instance(
Effect.gen(function* () {
const providers = yield* list
// api field is stored on model.api.url, used by getSDK to set baseURL
expect(providers[ProviderID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1")
expect(providers[ProviderV2.ID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1")
}),
{
config: {
@@ -503,7 +504,7 @@ it.instance(
"explicit baseURL overrides api field",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1")
expect(providers[ProviderV2.ID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1")
}),
{
config: {
@@ -526,7 +527,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.name).toBe("Custom Name for Sonnet")
expect(model.capabilities.toolcall).toBe(true)
expect(model.capabilities.attachment).toBe(true)
@@ -544,7 +545,7 @@ it.instance(
Effect.gen(function* () {
yield* set("OPENAI_API_KEY", "test-openai-key")
const providers = yield* list
expect(providers[ProviderID.openai]).toBeUndefined()
expect(providers[ProviderV2.ID.openai]).toBeUndefined()
}),
{ config: { disabled_providers: ["openai"] } },
)
@@ -565,8 +566,8 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderV2.ID.anthropic].models)
expect(models).toContain("claude-sonnet-4-20250514")
expect(models).not.toContain("claude-opus-4-20250514")
expect(models.length).toBe(1)
@@ -587,7 +588,7 @@ it.instance(
"model modalities default correctly",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderID.make("test-provider")].models["test-model"]
const model = providers[ProviderV2.ID.make("test-provider")].models["test-model"]
expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.output.text).toBe(true)
}),
@@ -610,7 +611,7 @@ it.instance(
"model with custom cost values",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderID.make("test-provider")].models["test-model"]
const model = providers[ProviderV2.ID.make("test-provider")].models["test-model"]
expect(model.cost.input).toBe(5)
expect(model.cost.output).toBe(15)
expect(model.cost.cache.read).toBe(2.5)
@@ -641,7 +642,7 @@ it.instance(
it.instance("getSmallModel returns appropriate small model", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const model = yield* Provider.use.getSmallModel(ProviderID.anthropic)
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic)
expect(model).toBeDefined()
expect(model?.id).toContain("haiku")
}),
@@ -651,7 +652,7 @@ it.instance(
"getSmallModel respects config small_model override",
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const model = yield* Provider.use.getSmallModel(ProviderID.anthropic)
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic)
expect(model).toBeDefined()
expect(String(model?.providerID)).toBe("anthropic")
expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
@@ -663,7 +664,7 @@ it.instance(
"getSmallModel ignores invalid config small_model",
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const model = yield* Provider.use.getSmallModel(ProviderID.anthropic)
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.anthropic)
expect(model).toBeUndefined()
}),
{ config: { small_model: "anthropic/not-a-real-model" } },
@@ -690,10 +691,10 @@ it.instance(
yield* set("ANTHROPIC_API_KEY", "test-anthropic-key")
yield* set("OPENAI_API_KEY", "test-openai-key")
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeDefined()
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
expect(providers[ProviderID.openai].options.timeout).toBe(60000)
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
expect(providers[ProviderV2.ID.openai]).toBeDefined()
expect(providers[ProviderV2.ID.anthropic].options.timeout).toBe(30000)
expect(providers[ProviderV2.ID.openai].options.timeout).toBe(60000)
}),
{
config: {
@@ -709,9 +710,9 @@ it.instance(
"provider with custom npm package",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("local-llm")]).toBeDefined()
expect(providers[ProviderID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1")
expect(providers[ProviderV2.ID.make("local-llm")]).toBeDefined()
expect(providers[ProviderV2.ID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(providers[ProviderV2.ID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1")
}),
{
config: {
@@ -735,7 +736,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
expect(providers[ProviderV2.ID.anthropic].models["sonnet"].name).toBe("sonnet")
}),
{
config: {
@@ -753,9 +754,9 @@ it.instance(
Effect.gen(function* () {
yield* set("MULTI_ENV_KEY_1", "test-key")
const providers = yield* list
expect(providers[ProviderID.make("multi-env")]).toBeDefined()
expect(providers[ProviderV2.ID.make("multi-env")]).toBeDefined()
// When multiple env options exist, key should NOT be auto-set
expect(providers[ProviderID.make("multi-env")].key).toBeUndefined()
expect(providers[ProviderV2.ID.make("multi-env")].key).toBeUndefined()
}),
{
config: {
@@ -777,9 +778,9 @@ it.instance(
Effect.gen(function* () {
yield* set("SINGLE_ENV_KEY", "my-api-key")
const providers = yield* list
expect(providers[ProviderID.make("single-env")]).toBeDefined()
expect(providers[ProviderV2.ID.make("single-env")]).toBeDefined()
// Single env option should auto-set key
expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key")
expect(providers[ProviderV2.ID.make("single-env")].key).toBe("my-api-key")
}),
{
config: {
@@ -801,7 +802,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.cost.input).toBe(999)
expect(model.cost.output).toBe(888)
}),
@@ -820,9 +821,9 @@ it.instance(
"completely new provider not in database can be configured",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined()
expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New")
const model = providers[ProviderID.make("brand-new-provider")].models["new-model"]
expect(providers[ProviderV2.ID.make("brand-new-provider")]).toBeDefined()
expect(providers[ProviderV2.ID.make("brand-new-provider")].name).toBe("Brand New")
const model = providers[ProviderV2.ID.make("brand-new-provider")].models["new-model"]
expect(model.capabilities.reasoning).toBe(true)
expect(model.capabilities.attachment).toBe(true)
expect(model.capabilities.input.image).toBe(true)
@@ -861,11 +862,11 @@ it.instance(
yield* set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
const providers = yield* list
// anthropic: in enabled, not in disabled = allowed
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
// openai: in enabled, but also in disabled = NOT allowed
expect(providers[ProviderID.openai]).toBeUndefined()
expect(providers[ProviderV2.ID.openai]).toBeUndefined()
// google: not in enabled = NOT allowed (even though not disabled)
expect(providers[ProviderID.google]).toBeUndefined()
expect(providers[ProviderV2.ID.google]).toBeUndefined()
}),
{
// enabled_providers takes precedence — only these are considered
@@ -878,7 +879,7 @@ it.instance(
"model with tool_call false",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false)
expect(providers[ProviderV2.ID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false)
}),
{
config: {
@@ -899,7 +900,7 @@ it.instance(
"model defaults tool_call to true when not specified",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true)
expect(providers[ProviderV2.ID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true)
}),
{
config: {
@@ -920,7 +921,7 @@ it.instance(
"model headers are preserved",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderID.make("headers-provider")].models["model"]
const model = providers[ProviderV2.ID.make("headers-provider")].models["model"]
expect(model.headers).toEqual({
"X-Custom-Header": "custom-value",
Authorization: "Bearer special-token",
@@ -955,7 +956,7 @@ it.instance(
yield* set("FALLBACK_KEY", "fallback-api-key")
const providers = yield* list
// Provider should load because fallback env var is set
expect(providers[ProviderID.make("fallback-env")]).toBeDefined()
expect(providers[ProviderV2.ID.make("fallback-env")]).toBeDefined()
}),
{
config: {
@@ -975,8 +976,8 @@ it.instance(
it.instance("getModel returns consistent results", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const model1 = yield* Provider.use.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
const model2 = yield* Provider.use.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
const model1 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514"))
const model2 = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514"))
expect(model1.providerID).toEqual(model2.providerID)
expect(model1.id).toEqual(model2.id)
expect(model1).toEqual(model2)
@@ -987,7 +988,7 @@ it.instance(
"provider name defaults to id when not in database",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id")
expect(providers[ProviderV2.ID.make("my-custom-id")].name).toBe("my-custom-id")
}),
{
config: {
@@ -1006,7 +1007,7 @@ it.instance(
it.instance("ModelNotFoundError includes suggestions for typos", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const error = yield* Provider.use.getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")).pipe(Effect.flip)
const error = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonet-4")).pipe(Effect.flip)
expect(error.suggestions).toBeDefined()
expect((error.suggestions ?? []).length).toBeGreaterThan(0)
}),
@@ -1016,7 +1017,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(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4"))
.getModel(ProviderV2.ID.make("antropic"), ProviderV2.ModelID.make("claude-sonnet-4"))
.pipe(Effect.flip)
expect(error.suggestions).toBeDefined()
expect(error.suggestions).toContain("anthropic")
@@ -1027,7 +1028,7 @@ it.instance("ModelNotFoundError suggests catalog models for unloaded providers",
Effect.gen(function* () {
yield* remove("OPENCODE_API_KEY")
const error = yield* Provider.use
.getModel(ProviderID.opencode, ModelID.make("claude-haiku-fake-model"))
.getModel(ProviderV2.ID.opencode, ProviderV2.ModelID.make("claude-haiku-fake-model"))
.pipe(Effect.flip)
if (!Provider.ModelNotFoundError.isInstance(error)) throw error
expect(error.suggestions ?? []).toContain("claude-haiku-4-5")
@@ -1036,7 +1037,7 @@ it.instance("ModelNotFoundError suggests catalog models for unloaded providers",
it.instance("getProvider returns undefined for nonexistent provider", () =>
Effect.gen(function* () {
const provider = yield* Provider.Service.use((svc) => svc.getProvider(ProviderID.make("nonexistent")))
const provider = yield* Provider.Service.use((svc) => svc.getProvider(ProviderV2.ID.make("nonexistent")))
expect(provider).toBeUndefined()
}),
)
@@ -1044,7 +1045,7 @@ it.instance("getProvider returns undefined for nonexistent provider", () =>
it.instance("getProvider returns provider info", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const provider = yield* Provider.use.getProvider(ProviderID.anthropic)
const provider = yield* Provider.use.getProvider(ProviderV2.ID.anthropic)
expect(provider).toBeDefined()
expect(String(provider?.id)).toBe("anthropic")
}),
@@ -1053,7 +1054,7 @@ it.instance("getProvider returns provider info", () =>
it.instance("closest returns undefined when no partial match found", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const result = yield* Provider.use.closest(ProviderID.anthropic, ["nonexistent-xyz-model"])
const result = yield* Provider.use.closest(ProviderV2.ID.anthropic, ["nonexistent-xyz-model"])
expect(result).toBeUndefined()
}),
)
@@ -1062,7 +1063,7 @@ it.instance("closest checks multiple query terms in order", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
// First term won't match, second will
const result = yield* Provider.use.closest(ProviderID.anthropic, ["nonexistent", "haiku"])
const result = yield* Provider.use.closest(ProviderV2.ID.anthropic, ["nonexistent", "haiku"])
expect(result).toBeDefined()
expect(result?.modelID).toContain("haiku")
}),
@@ -1072,7 +1073,7 @@ it.instance(
"model limit defaults to zero when not specified",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderID.make("no-limit")].models["model"]
const model = providers[ProviderV2.ID.make("no-limit")].models["model"]
expect(model.limit.context).toBe(0)
expect(model.limit.output).toBe(0)
}),
@@ -1097,10 +1098,10 @@ it.instance(
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
// Custom options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value")
expect(providers[ProviderV2.ID.anthropic].options.timeout).toBe(30000)
expect(providers[ProviderV2.ID.anthropic].options.headers["X-Custom"]).toBe("custom-value")
// anthropic custom loader adds its own headers, they should coexist
expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
expect(providers[ProviderV2.ID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
}),
{
config: {
@@ -1113,7 +1114,7 @@ it.instance(
"hosted nvidia provider adds billing origin header",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({
expect(providers[ProviderV2.ID.make("nvidia")].options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
@@ -1126,7 +1127,7 @@ it.instance(
"custom nvidia baseURL adds billing origin header",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("nvidia")].options.headers).toEqual({
expect(providers[ProviderV2.ID.make("nvidia")].options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
@@ -1139,7 +1140,7 @@ it.instance(
"explicit nvidia billing origin header is preserved",
Effect.gen(function* () {
const providers = yield* list
expect(providers[ProviderID.make("nvidia")].options.headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin")
expect(providers[ProviderV2.ID.make("nvidia")].options.headers["X-BILLING-INVOKE-ORIGIN"]).toBe("CustomOrigin")
}),
{
config: {
@@ -1161,7 +1162,7 @@ it.instance(
Effect.gen(function* () {
yield* set("OPENAI_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderID.openai].models["my-custom-model"]
const model = providers[ProviderV2.ID.openai].models["my-custom-model"]
expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai")
}),
@@ -1187,15 +1188,15 @@ it.instance(
Effect.gen(function* () {
yield* set("OPENROUTER_API_KEY", "test-api-key")
const providers = yield* list
expect(providers[ProviderID.openrouter]).toBeDefined()
expect(providers[ProviderV2.ID.openrouter]).toBeDefined()
// New model not in database should inherit api.url from provider
const intellect = providers[ProviderID.openrouter].models["prime-intellect/intellect-3"]
const intellect = providers[ProviderV2.ID.openrouter].models["prime-intellect/intellect-3"]
expect(intellect).toBeDefined()
expect(intellect.api.url).toBe("https://openrouter.ai/api/v1")
// Another new model should also inherit api.url
const deepseek = providers[ProviderID.openrouter].models["deepseek/deepseek-r1-0528"]
const deepseek = providers[ProviderV2.ID.openrouter].models["deepseek/deepseek-r1-0528"]
expect(deepseek).toBeDefined()
expect(deepseek.api.url).toBe("https://openrouter.ai/api/v1")
expect(deepseek.name).toBe("DeepSeek R1")
@@ -1308,7 +1309,7 @@ it.instance("model variants are generated for reasoning models", () =>
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
// Claude sonnet 4 has reasoning capability
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.capabilities.reasoning).toBe(true)
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
@@ -1320,7 +1321,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
// max variant should still exist
@@ -1342,7 +1343,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
}),
@@ -1366,7 +1367,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["max"]).toBeDefined()
expect(model.variants!["max"].disabled).toBeUndefined()
expect(model.variants!["max"].customField).toBe("test")
@@ -1391,7 +1392,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBe(0)
}),
@@ -1415,7 +1416,7 @@ it.instance(
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
// Should have both the generated thinking config and the custom option
expect(model.variants!["high"].thinking).toBeDefined()
@@ -1439,7 +1440,7 @@ it.instance(
Effect.gen(function* () {
yield* set("OPENAI_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderID.openai].models["gpt-5"]
const model = providers[ProviderV2.ID.openai].models["gpt-5"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
// Other variants should still exist
@@ -1456,7 +1457,7 @@ it.instance(
"custom model with variants enabled and disabled",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"]
const model = providers[ProviderV2.ID.make("custom-reasoning")].models["reasoning-model"]
expect(model.variants).toBeDefined()
// Enabled variants should exist
expect(model.variants!["low"]).toBeDefined()
@@ -1506,8 +1507,8 @@ it.instance(
Effect.gen(function* () {
yield* set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
const providers = yield* list
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
expect(providers[ProviderV2.ID.make("vertex-proxy")]).toBeDefined()
expect(providers[ProviderV2.ID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
}),
{
config: {
@@ -1534,7 +1535,7 @@ it.instance(
Effect.gen(function* () {
yield* set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
const providers = yield* list
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
const model = providers[ProviderV2.ID.make("vertex-openai")].models["gpt-4"]
expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai-compatible")
}),
@@ -1563,7 +1564,7 @@ it.instance("Google Vertex: uses REP endpoint for Claude continental multi-regio
yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
yield* set("VERTEX_LOCATION", "eu")
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("google-vertex"), ModelID.make("claude-sonnet-4-6@default"))
const model = yield* provider.getModel(ProviderV2.ID.make("google-vertex"), ProviderV2.ModelID.make("claude-sonnet-4-6@default"))
const language = yield* provider.getLanguage(model)
expect(languageBaseURL(language)).toBe(
"https://aiplatform.eu.rep.googleapis.com/v1/projects/test-project/locations/eu/publishers/anthropic/models",
@@ -1577,8 +1578,8 @@ it.instance("Google Vertex Anthropic: uses REP endpoint for continental multi-re
yield* set("VERTEX_LOCATION", "us")
const provider = yield* Provider.Service
const model = yield* provider.getModel(
ProviderID.make("google-vertex-anthropic"),
ModelID.make("claude-sonnet-4-6@default"),
ProviderV2.ID.make("google-vertex-anthropic"),
ProviderV2.ModelID.make("claude-sonnet-4-6@default"),
)
const language = yield* provider.getLanguage(model)
expect(languageBaseURL(language)).toBe(
@@ -1592,7 +1593,7 @@ it.instance("Google Vertex: keeps regional Claude endpoints unchanged", () =>
yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
yield* set("VERTEX_LOCATION", "europe-west1")
const provider = yield* Provider.Service
const model = yield* provider.getModel(ProviderID.make("google-vertex"), ModelID.make("claude-sonnet-4-6@default"))
const model = yield* provider.getModel(ProviderV2.ID.make("google-vertex"), ProviderV2.ModelID.make("claude-sonnet-4-6@default"))
const language = yield* provider.getLanguage(model)
expect(languageBaseURL(language)).toBe(
"https://europe-west1-aiplatform.googleapis.com/v1/projects/test-project/locations/europe-west1/publishers/anthropic/models",
@@ -1606,7 +1607,7 @@ it.instance("cloudflare-ai-gateway loads with env variables", () =>
yield* set("CLOUDFLARE_GATEWAY_ID", "test-gateway")
yield* set("CLOUDFLARE_API_TOKEN", "test-token")
const providers = yield* list
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
expect(providers[ProviderV2.ID.make("cloudflare-ai-gateway")]).toBeDefined()
}),
)
@@ -1617,8 +1618,8 @@ it.instance(
yield* set("CLOUDFLARE_GATEWAY_ID", "test-gateway")
yield* set("CLOUDFLARE_API_TOKEN", "test-token")
const providers = yield* list
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
expect(providers[ProviderV2.ID.make("cloudflare-ai-gateway")]).toBeDefined()
expect(providers[ProviderV2.ID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
invoked_by: "test",
project: "opencode",
})
@@ -1681,14 +1682,14 @@ it.effect("plugin config providers persist after instance dispose", () =>
}).pipe(provideInstanceEffect(dir))
const first = yield* loadAndList
expect(first[ProviderID.make("demo")]).toBeDefined()
expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
expect(first[ProviderV2.ID.make("demo")]).toBeDefined()
expect(first[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined()
yield* Effect.promise(() => disposeAllInstances())
const second = yield* loadAndList
expect(second[ProviderID.make("demo")]).toBeDefined()
expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
expect(second[ProviderV2.ID.make("demo")]).toBeDefined()
expect(second[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined()
}).pipe(provideMultiInstance),
)
@@ -1721,8 +1722,8 @@ it.instance(
yield* set("ANTHROPIC_API_KEY", "test-anthropic-key")
yield* set("OPENAI_API_KEY", "test-openai-key")
const providers = yield* list
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
expect(providers[ProviderV2.ID.openai]).toBeUndefined()
}),
)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
describe("ProviderTransform.options - setCacheKey", () => {
const sessionID = "test-session-123"
@@ -1089,8 +1089,8 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
const result = ProviderTransform.message(
msgs,
{
id: ModelID.make("deepseek/deepseek-chat"),
providerID: ProviderID.make("deepseek"),
id: ProviderV2.ModelID.make("deepseek/deepseek-chat"),
providerID: ProviderV2.ID.make("deepseek"),
api: {
id: "deepseek-chat",
url: "https://api.deepseek.com",
@@ -1151,8 +1151,8 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
const result = ProviderTransform.message(
msgs,
{
id: ModelID.make("openai/gpt-4"),
providerID: ProviderID.make("openai"),
id: ProviderV2.ModelID.make("openai/gpt-4"),
providerID: ProviderV2.ID.make("openai"),
api: {
id: "gpt-4",
url: "https://api.openai.com",
@@ -60,7 +60,7 @@ const publishConnected = Bus.use.publish(ServerEvent.Connected, {})
const publishPartUpdated = (partID: ReturnType<typeof PartID.ascending>) => {
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
return SyncEvent.use.run(MessageV2.Event.PartUpdated, {
return Bus.use.publish(MessageV2.Event.PartUpdated, {
sessionID,
part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" },
time: Date.now(),
@@ -3,13 +3,14 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Cause, Duration, Effect } from "effect"
import { TestLLMServer } from "../../lib/llm-server"
import type { Config } from "../../../src/config/config"
import { ModelID, ProviderID } from "../../../src/provider/schema"
import type { MessageV2 } from "../../../src/session/message-v2"
import { MessageID, PartID } from "../../../src/session/schema"
import { call, callAuthProbe } from "./backend"
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"
export function runScenario(options: Options) {
return (scenario: Scenario) => {
@@ -148,8 +149,8 @@ function withContext<A, E>(
time: { created: Date.now() },
agent: "build",
model: {
providerID: ProviderID.opencode,
modelID: ModelID.make("test"),
providerID: ProviderV2.ID.opencode,
modelID: ProviderV2.ModelID.make("test"),
},
}
const part: SessionLegacy.TextPart = {
@@ -2,7 +2,7 @@ import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { eq } from "drizzle-orm"
import * as Database from "@/storage/db"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Server } from "../../src/server/server"
import { Session } from "@/session/session"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
@@ -12,6 +12,7 @@ import { PartTable } from "@opencode-ai/core/session/sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const it = testEffect(Session.defaultLayer)
@@ -28,7 +29,7 @@ const seedCorruptStepFinishPart = Effect.gen(function* () {
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
@@ -15,7 +15,7 @@ import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { ModelID, ProviderID } from "../../src/provider/schema"
import type { Config } from "@/config/config"
import { Session as SessionNs } from "@/session/session"
import { errorMessage } from "../../src/util/error"
@@ -25,6 +25,7 @@ import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { awaitWithTimeout, testEffect } from "../lib/effect"
import { testProviderConfig } from "../lib/test-provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const it = testEffect(
@@ -311,7 +312,7 @@ function seedMessage(directory: string, sessionID: string) {
role: "user",
time: { created: Date.now() },
agent: "test",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
tools: {},
} satisfies SessionLegacy.User)
const part = yield* svc.updatePart({
@@ -8,7 +8,7 @@ import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
@@ -65,7 +65,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) {
role: "user",
sessionID,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
time: { created: Date.now() },
})
const part = yield* svc.updatePart({
@@ -8,7 +8,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { eq } from "drizzle-orm"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
@@ -18,6 +18,7 @@ import { PartTable } from "@opencode-ai/core/session/sql"
import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const it = testEffect(Session.defaultLayer)
@@ -30,7 +31,7 @@ function seedNegativeTokenSession() {
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
@@ -1,6 +1,8 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Session as SessionNs } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
@@ -24,6 +26,8 @@ const it = testEffect(
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
Layer.provide(BackgroundJob.defaultLayer),
),
@@ -4,19 +4,20 @@ import { Effect } from "effect"
import { Server } from "../../src/server/server"
import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
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"
void Log.init({ print: false })
const it = testEffect(SessionNs.defaultLayer)
const model = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test"),
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test"),
}
afterEach(async () => {
@@ -21,7 +21,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { SessionV2 } from "@opencode-ai/core/session"
import { ModelID, ProviderID } from "../../src/provider/schema"
import type { Provider } from "@/provider/provider"
import * as SessionProcessorModule from "../../src/session/processor"
import { Snapshot } from "../../src/snapshot"
@@ -33,6 +33,7 @@ import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { LLMEvent, Usage } from "@opencode-ai/llm"
import { ProviderV2 } from "@opencode-ai/core/provider"
void Log.init({ print: false })
@@ -46,8 +47,8 @@ const summary = Layer.succeed(
)
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test-model"),
}
const usage = (input: ConstructorParameters<typeof Usage>[0]) => new Usage(input)
@@ -6,7 +6,7 @@ import { FetchHttpClient } from "effect/unstable/http"
import { NodeFileSystem } from "@effect/platform-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instruction } from "../../src/session/instruction"
import type { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -15,6 +15,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
import { ProviderV2 } from "@opencode-ai/core/provider"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
@@ -75,8 +76,8 @@ function loaded(filepath: string): SessionLegacy.WithParts[] {
time: { created: 0 },
agent: "build",
model: {
providerID: ProviderID.make("anthropic"),
modelID: ModelID.make("claude-sonnet-4-20250514"),
providerID: ProviderV2.ID.make("anthropic"),
modelID: ProviderV2.ModelID.make("claude-sonnet-4-20250514"),
},
},
parts: [
@@ -13,7 +13,7 @@ import { Auth } from "@/auth"
import { Config } from "@/config/config"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Filesystem } from "@/util/filesystem"
import { LLMEvent, LLMResponse } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
@@ -25,6 +25,7 @@ import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, SessionID } from "../../src/session/schema"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings")
@@ -41,7 +42,7 @@ const replayOpenAIOAuth = {
type RecordedScenario = {
readonly id: string
readonly name: string
readonly providerID: ProviderID
readonly providerID: ProviderV2.ID
readonly modelID: string
readonly cassette: string
readonly protocol: string
@@ -88,7 +89,7 @@ function decodeRecordOpenAIOAuth() {
}
const providerConfig = (input: {
readonly providerID: ProviderID
readonly providerID: ProviderV2.ID
readonly name: string
readonly env: string[]
readonly npm: string
@@ -113,7 +114,7 @@ const RECORDED_SCENARIOS = [
{
id: "openai-api-key",
name: "OpenAI API key",
providerID: ProviderID.openai,
providerID: ProviderV2.ID.openai,
modelID: "gpt-4.1-mini",
cassette: "session/native-openai-tool-loop",
protocol: "openai-responses",
@@ -121,7 +122,7 @@ const RECORDED_SCENARIOS = [
canRecord: () => Boolean(envValue("OPENCODE_RECORD_OPENAI_API_KEY", "OPENAI_API_KEY")),
config: (model) =>
providerConfig({
providerID: ProviderID.openai,
providerID: ProviderV2.ID.openai,
name: "OpenAI",
env: ["OPENAI_API_KEY"],
npm: "@ai-sdk/openai",
@@ -136,7 +137,7 @@ const RECORDED_SCENARIOS = [
{
id: "openai-oauth",
name: "OpenAI OAuth",
providerID: ProviderID.openai,
providerID: ProviderV2.ID.openai,
modelID: "gpt-5.5",
cassette: "session/native-openai-oauth-tool-loop",
protocol: "openai-responses",
@@ -147,7 +148,7 @@ const RECORDED_SCENARIOS = [
stableID: "openai-oauth",
config: (model) =>
providerConfig({
providerID: ProviderID.openai,
providerID: ProviderV2.ID.openai,
name: "OpenAI",
env: ["OPENAI_API_KEY"],
npm: "@ai-sdk/openai",
@@ -159,7 +160,7 @@ const RECORDED_SCENARIOS = [
{
id: "opencode-proxy",
name: "OpenCode proxy",
providerID: ProviderID.opencode,
providerID: ProviderV2.ID.opencode,
modelID: "gpt-5.2-codex",
cassette: "session/native-zen-tool-loop",
protocol: "openai-responses",
@@ -167,7 +168,7 @@ const RECORDED_SCENARIOS = [
canRecord: () => Boolean(process.env.OPENCODE_RECORD_CONSOLE_TOKEN && process.env.OPENCODE_RECORD_ZEN_ORG_ID),
config: (model) =>
providerConfig({
providerID: ProviderID.opencode,
providerID: ProviderV2.ID.opencode,
name: "OpenCode Zen",
env: ["OPENCODE_CONSOLE_TOKEN"],
npm: "@ai-sdk/openai-compatible",
@@ -182,7 +183,7 @@ const RECORDED_SCENARIOS = [
{
id: "anthropic-api-key",
name: "Anthropic API key",
providerID: ProviderID.anthropic,
providerID: ProviderV2.ID.anthropic,
modelID: "claude-haiku-4-5-20251001",
cassette: "session/native-anthropic-tool-loop",
protocol: "anthropic-messages",
@@ -190,7 +191,7 @@ const RECORDED_SCENARIOS = [
canRecord: () => Boolean(envValue("OPENCODE_RECORD_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY")),
config: (model) =>
providerConfig({
providerID: ProviderID.anthropic,
providerID: ProviderV2.ID.anthropic,
name: "Anthropic",
env: ["ANTHROPIC_API_KEY"],
npm: "@ai-sdk/anthropic",
@@ -372,7 +373,7 @@ const driveToolLoop = (scenario: RecordedScenario) =>
const stableID = scenario.stableID ?? scenario.providerID
const sessionID = SessionID.make(`session-recorded-${stableID}-loop`)
const modelID = ModelID.make(model.id)
const modelID = ProviderV2.ModelID.make(model.id)
const agent = {
name: "test",
mode: "primary",
@@ -6,13 +6,14 @@ import { Effect, Layer, Stream } from "effect"
import { LLMNative } from "@/session/llm/native-request"
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { OAUTH_DUMMY_KEY } from "@/auth"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const baseModel: Provider.Model = {
id: ModelID.make("gpt-5-mini"),
providerID: ProviderID.make("openai"),
id: ProviderV2.ModelID.make("gpt-5-mini"),
providerID: ProviderV2.ID.make("openai"),
api: {
id: "gpt-5-mini",
url: "https://api.openai.com/v1",
@@ -62,7 +63,7 @@ const baseModel: Provider.Model = {
}
const providerInfo: Provider.Info = {
id: ProviderID.make("openai"),
id: ProviderV2.ID.make("openai"),
name: "OpenAI",
source: "config",
env: ["OPENAI_API_KEY"],
@@ -354,7 +355,7 @@ describe("session.llm-native.request", () => {
const compatible = LLMNative.model({
model: {
...baseModel,
providerID: ProviderID.make("opencode"),
providerID: ProviderV2.ID.make("opencode"),
api: { ...baseModel.api, url: "https://ai.example.test/v1", npm: "@ai-sdk/openai-compatible" },
},
apiKey: "test-key",
@@ -388,8 +389,8 @@ describe("session.llm-native.request", () => {
})
expect(
LLMNativeRuntime.status({
model: { ...baseModel, providerID: ProviderID.make("opencode") },
provider: { ...providerInfo, id: ProviderID.make("opencode") },
model: { ...baseModel, providerID: ProviderV2.ID.make("opencode") },
provider: { ...providerInfo, id: ProviderV2.ID.make("opencode") },
auth: undefined,
}),
).toMatchObject({
@@ -400,10 +401,10 @@ describe("session.llm-native.request", () => {
LLMNativeRuntime.status({
model: {
...baseModel,
providerID: ProviderID.make("opencode"),
providerID: ProviderV2.ID.make("opencode"),
api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" },
},
provider: { ...providerInfo, id: ProviderID.make("opencode") },
provider: { ...providerInfo, id: ProviderV2.ID.make("opencode") },
auth: undefined,
}),
).toMatchObject({
@@ -412,8 +413,8 @@ describe("session.llm-native.request", () => {
})
expect(
LLMNativeRuntime.status({
model: { ...baseModel, providerID: ProviderID.make("google") },
provider: { ...providerInfo, id: ProviderID.make("google") },
model: { ...baseModel, providerID: ProviderV2.ID.make("google") },
provider: { ...providerInfo, id: ProviderV2.ID.make("google") },
auth: undefined,
}),
).toEqual({ type: "unsupported", reason: "provider is not openai, opencode, or anthropic" })
@@ -454,12 +455,12 @@ describe("session.llm-native.request", () => {
LLMNativeRuntime.status({
model: {
...baseModel,
providerID: ProviderID.make("anthropic"),
providerID: ProviderV2.ID.make("anthropic"),
api: { ...baseModel.api, npm: "@ai-sdk/anthropic", url: "https://api.anthropic.com/v1" },
},
provider: {
...providerInfo,
id: ProviderID.make("anthropic"),
id: ProviderV2.ID.make("anthropic"),
name: "Anthropic",
env: ["ANTHROPIC_API_KEY"],
options: { apiKey: "test-anthropic-key" },
@@ -472,10 +473,10 @@ describe("session.llm-native.request", () => {
test("prefers console provider api key over stored opencode auth", () => {
expect(
LLMNativeRuntime.status({
model: { ...baseModel, providerID: ProviderID.make("opencode") },
model: { ...baseModel, providerID: ProviderV2.ID.make("opencode") },
provider: {
...providerInfo,
id: ProviderID.make("opencode"),
id: ProviderV2.ID.make("opencode"),
options: { apiKey: "console-token" },
key: "zen-token",
},
+30 -29
View File
@@ -14,7 +14,7 @@ import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Plugin } from "@/plugin"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { testEffect } from "../lib/effect"
import type { Agent } from "../../src/agent/agent"
import { MessageV2 } from "../../src/session/message-v2"
@@ -23,6 +23,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
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"
type ConfigModel = NonNullable<NonNullable<Config.Info["provider"]>[string]["models"]>[string]
@@ -713,8 +714,8 @@ describe("session.llm.stream", () => {
)
const resolved = yield* Provider.use.getModel(
ProviderID.make(vivgridFixture.providerID),
ModelID.make(fixture.model.id),
ProviderV2.ID.make(vivgridFixture.providerID),
ProviderV2.ModelID.make(fixture.model.id),
)
const sessionID = SessionID.make("session-test-1")
const agent = {
@@ -732,7 +733,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make(vivgridFixture.providerID), modelID: resolved.id, variant: "high" },
model: { providerID: ProviderV2.ID.make(vivgridFixture.providerID), modelID: resolved.id, variant: "high" },
} satisfies SessionLegacy.User
yield* drain({
@@ -787,8 +788,8 @@ describe("session.llm.stream", () => {
const pending = waitStreamingRequest("/chat/completions")
const resolved = yield* Provider.use.getModel(
ProviderID.make(alibabaQwenFixture.providerID),
ModelID.make(fixture.model.id),
ProviderV2.ID.make(alibabaQwenFixture.providerID),
ProviderV2.ModelID.make(fixture.model.id),
)
const sessionID = SessionID.make("session-test-service-abort")
const agent = {
@@ -803,7 +804,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
model: { providerID: ProviderV2.ID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
} satisfies SessionLegacy.User
const fiber = yield* drain({
@@ -855,8 +856,8 @@ describe("session.llm.stream", () => {
)
const resolved = yield* Provider.use.getModel(
ProviderID.make(alibabaQwenFixture.providerID),
ModelID.make(fixture.model.id),
ProviderV2.ID.make(alibabaQwenFixture.providerID),
ProviderV2.ModelID.make(fixture.model.id),
)
const sessionID = SessionID.make("session-test-tools")
const agent = {
@@ -872,7 +873,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
model: { providerID: ProviderV2.ID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
tools: { question: true },
} satisfies SessionLegacy.User
@@ -959,7 +960,7 @@ describe("session.llm.stream", () => {
]
const request = waitRequest("/responses", createEventResponse(responseChunks, true))
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const sessionID = SessionID.make("session-test-2")
const agent = {
name: "test",
@@ -975,7 +976,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies SessionLegacy.User
yield* drain({
@@ -1064,7 +1065,7 @@ describe("session.llm.stream", () => {
}),
)
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const sessionID = SessionID.make("session-test-native-flag-off")
const agent = {
name: "test",
@@ -1089,7 +1090,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies SessionLegacy.User,
sessionID,
model: resolved,
@@ -1134,7 +1135,7 @@ describe("session.llm.stream", () => {
]
const request = waitRequest("/responses", createEventResponse(chunks, true))
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const sessionID = SessionID.make("session-test-native")
const agent = {
name: "test",
@@ -1151,7 +1152,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies SessionLegacy.User,
sessionID,
model: resolved,
@@ -1218,7 +1219,7 @@ describe("session.llm.stream", () => {
}),
)
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const sessionID = SessionID.make("session-test-native-injected-tool")
const agent = {
name: "test",
@@ -1234,7 +1235,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id },
} satisfies SessionLegacy.User,
sessionID,
model: resolved,
@@ -1306,7 +1307,7 @@ describe("session.llm.stream", () => {
const request = waitRequest("/responses", createEventResponse(chunks, true))
let executed: unknown
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const sessionID = SessionID.make("session-test-native-tool")
const agent = {
name: "test",
@@ -1322,7 +1323,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id },
} satisfies SessionLegacy.User,
sessionID,
model: resolved,
@@ -1432,7 +1433,7 @@ describe("session.llm.stream", () => {
),
).toString("base64")}`
const resolved = yield* Provider.use.getModel(ProviderID.openai, ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id))
const sessionID = SessionID.make("session-test-data-url")
const agent = {
name: "test",
@@ -1447,7 +1448,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id },
} satisfies SessionLegacy.User
yield* drain({
@@ -1520,8 +1521,8 @@ describe("session.llm.stream", () => {
const request = waitRequest("/messages", createEventResponse(chunks))
const resolved = yield* Provider.use.getModel(
ProviderID.make(minimaxFixture.providerID),
ModelID.make(model.id),
ProviderV2.ID.make(minimaxFixture.providerID),
ProviderV2.ModelID.make(model.id),
)
const sessionID = SessionID.make("session-test-3")
const agent = {
@@ -1539,7 +1540,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") },
model: { providerID: ProviderV2.ID.make("minimax"), modelID: ProviderV2.ModelID.make("MiniMax-M2.5") },
} satisfies SessionLegacy.User
yield* drain({
@@ -1616,7 +1617,7 @@ describe("session.llm.stream", () => {
]
const request = waitRequest("/messages", createEventResponse(chunks))
const resolved = yield* Provider.use.getModel(ProviderID.make("anthropic"), ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.make("anthropic"), ProviderV2.ModelID.make(model.id))
const sessionID = SessionID.make("session-test-anthropic-tools")
const agent = {
name: "test",
@@ -1630,7 +1631,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id, variant: "max" },
model: { providerID: ProviderV2.ID.make("anthropic"), modelID: resolved.id, variant: "max" },
} satisfies SessionLegacy.User
const input = [
@@ -1815,7 +1816,7 @@ describe("session.llm.stream", () => {
]
const request = waitRequest(pathSuffix, createEventResponse(chunks))
const resolved = yield* Provider.use.getModel(ProviderID.make(geminiFixture.providerID), ModelID.make(model.id))
const resolved = yield* Provider.use.getModel(ProviderV2.ID.make(geminiFixture.providerID), ProviderV2.ModelID.make(model.id))
const sessionID = SessionID.make("session-test-4")
const agent = {
name: "test",
@@ -1832,7 +1833,7 @@ describe("session.llm.stream", () => {
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make(geminiFixture.providerID), modelID: resolved.id },
model: { providerID: ProviderV2.ID.make(geminiFixture.providerID), modelID: resolved.id },
} satisfies SessionLegacy.User
yield* drain({
@@ -4,14 +4,15 @@ import { APICallError } from "ai"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderTransform } from "@/provider/transform"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { Question } from "../../src/question"
import { ProviderV2 } from "@opencode-ai/core/provider"
const sessionID = SessionID.make("session")
const providerID = ProviderID.make("test")
const providerID = ProviderV2.ID.make("test")
const model: Provider.Model = {
id: ModelID.make("test-model"),
id: ProviderV2.ModelID.make("test-model"),
providerID,
api: {
id: "test-model",
@@ -66,7 +67,7 @@ function userInfo(id: string): SessionLegacy.User {
role: "user",
time: { created: 0 },
agent: "user",
model: { providerID, modelID: ModelID.make("test") },
model: { providerID, modelID: ProviderV2.ModelID.make("test") },
tools: {},
mode: "",
} as unknown as SessionLegacy.User
@@ -412,8 +413,8 @@ describe("session.message-v2.toModelMessage", () => {
test("preserves jpeg tool-result media for anthropic models", async () => {
const anthropicModel: Provider.Model = {
...model,
id: ModelID.make("anthropic/claude-opus-4-7"),
providerID: ProviderID.make("anthropic"),
id: ProviderV2.ModelID.make("anthropic/claude-opus-4-7"),
providerID: ProviderV2.ID.make("anthropic"),
api: {
id: "claude-opus-4-7-20250805",
url: "https://api.anthropic.com",
@@ -495,8 +496,8 @@ describe("session.message-v2.toModelMessage", () => {
test("moves bedrock pdf tool-result media into a separate user message", async () => {
const bedrockModel: Provider.Model = {
...model,
id: ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"),
providerID: ProviderID.make("amazon-bedrock"),
id: ProviderV2.ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"),
providerID: ProviderV2.ID.make("amazon-bedrock"),
api: {
id: "anthropic.claude-sonnet-4-6",
url: "https://bedrock-runtime.us-east-1.amazonaws.com",
@@ -1041,8 +1042,8 @@ describe("session.message-v2.toModelMessage", () => {
const assistantID = "m-assistant"
const openrouterModel: Provider.Model = {
...model,
id: ModelID.make("deepseek/deepseek-v4-pro"),
providerID: ProviderID.make("openrouter"),
id: ProviderV2.ModelID.make("deepseek/deepseek-v4-pro"),
providerID: ProviderV2.ID.make("openrouter"),
api: {
id: "deepseek/deepseek-v4-pro",
url: "https://openrouter.ai/api/v1",
@@ -5,10 +5,11 @@ import { Effect, Layer, Option } from "effect"
import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
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"
void Log.init({ print: false })
@@ -97,8 +98,8 @@ const addAssistant = Effect.fn("Test.addAssistant")(function* (
role: "assistant",
time: { created: Date.now() },
parentID,
modelID: ModelID.make("test"),
providerID: ProviderID.make("test"),
modelID: ProviderV2.ModelID.make("test"),
providerID: ProviderV2.ID.make("test"),
mode: "",
agent: "default",
path: { cwd: "/", root: "/" },
@@ -14,7 +14,7 @@ import { Image } from "@/image/image"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Session } from "@/session/session"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
@@ -31,6 +31,7 @@ import { raw, reply, TestLLMServer } from "../lib/llm-server"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
void Log.init({ print: false })
@@ -44,8 +45,8 @@ const summary = Layer.succeed(
)
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test-model"),
}
const cfg = {
@@ -20,7 +20,7 @@ import { Provider as ProviderSvc } from "@/provider/provider"
import { Env } from "../../src/env"
import { Git } from "../../src/git"
import { Image } from "../../src/image/image"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question"
import { Todo } from "../../src/session/todo"
import { Session } from "@/session/session"
@@ -57,6 +57,7 @@ import { reply, TestLLMServer } from "../lib/llm-server"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
void Log.init({ print: false })
@@ -70,8 +71,8 @@ const summary = Layer.succeed(
)
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test-model"),
}
function withSh<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
@@ -727,8 +728,8 @@ it.instance("failed subtask preserves metadata on error tool state", () =>
expect(tool.state.metadata).toBeDefined()
expect(tool.state.metadata?.sessionId).toBeDefined()
expect(tool.state.metadata?.model).toEqual({
providerID: ProviderID.make("test"),
modelID: ModelID.make("missing-model"),
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("missing-model"),
})
}),
)
@@ -2172,7 +2173,7 @@ noLLMServer.instance(
const other = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
model: { providerID: ProviderV2.ID.make("opencode"), modelID: ProviderV2.ModelID.make("kimi-k2.5-free") },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
@@ -2187,8 +2188,8 @@ noLLMServer.instance(
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test-model"),
variant: "xhigh",
})
expect(match.info.model.variant).toBe("xhigh")
+5 -4
View File
@@ -7,13 +7,14 @@ import { Effect, Layer, Schedule, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderID } from "../../src/provider/schema"
import { SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const providerID = ProviderID.make("test")
const providerID = ProviderV2.ID.make("test")
const retryProvider = "test"
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
@@ -390,7 +391,7 @@ describe("session.message-v2.fromError", () => {
responseBody: '{"error":"boom"}',
isRetryable: false,
})
const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") })
const result = MessageV2.fromError(error, { providerID: ProviderV2.ID.make("openai") })
if (!SessionLegacy.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
})
@@ -409,7 +410,7 @@ describe("session.message-v2.fromError", () => {
},
}),
},
{ providerID: ProviderID.make("openai") },
{ providerID: ProviderV2.ID.make("openai") },
)
expect(SessionLegacy.APIError.isInstance(result)).toBe(true)
@@ -4,7 +4,7 @@ import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { Session } from "@/session/session"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionRevert } from "../../src/session/revert"
import { MessageV2 } from "../../src/session/message-v2"
import { Snapshot } from "../../src/snapshot"
@@ -13,6 +13,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
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"
void Log.init({ print: false })
@@ -32,7 +33,7 @@ const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "de
role: "user" as const,
sessionID,
agent,
model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") },
model: { providerID: ProviderV2.ID.make("openai"), modelID: ProviderV2.ModelID.make("gpt-4") },
time: { created: Date.now() },
})
})
@@ -48,8 +49,8 @@ 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: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"),
modelID: ProviderV2.ModelID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID,
time: { created: Date.now() },
finish: "end_turn",
@@ -115,8 +116,8 @@ describe("revert + compact workflow", () => {
sessionID,
agent: "default",
model: {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
modelID: ProviderV2.ModelID.make("gpt-4"),
},
time: {
created: Date.now(),
@@ -148,8 +149,8 @@ describe("revert + compact workflow", () => {
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"),
modelID: ProviderV2.ModelID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID: userMsg1.id,
time: {
created: Date.now(),
@@ -172,8 +173,8 @@ describe("revert + compact workflow", () => {
sessionID,
agent: "default",
model: {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
modelID: ProviderV2.ModelID.make("gpt-4"),
},
time: {
created: Date.now(),
@@ -205,8 +206,8 @@ describe("revert + compact workflow", () => {
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"),
modelID: ProviderV2.ModelID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID: userMsg2.id,
time: {
created: Date.now(),
@@ -277,8 +278,8 @@ describe("revert + compact workflow", () => {
sessionID,
agent: "default",
model: {
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
modelID: ProviderV2.ModelID.make("gpt-4"),
},
time: {
created: Date.now(),
@@ -310,8 +311,8 @@ describe("revert + compact workflow", () => {
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ModelID.make("gpt-4"),
providerID: ProviderID.make("openai"),
modelID: ProviderV2.ModelID.make("gpt-4"),
providerID: ProviderV2.ID.make("openai"),
parentID: userMsg.id,
time: {
created: Date.now(),
@@ -1,6 +1,8 @@
import { describe, expect } from "bun:test"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
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"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
@@ -25,6 +27,8 @@ const it = testEffect(
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
Layer.provide(BackgroundJob.defaultLayer),
),
+6 -5
View File
@@ -31,10 +31,11 @@ import * as Truncate from "@/tool/truncate"
import { InstanceState } from "@/effect/instance-state"
import { Reference } from "@/reference/reference"
import { RepositoryCache } from "@/reference/repository-cache"
import { ProviderID, ModelID } from "@/provider/schema"
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"
const node = CrossSpawnSpawner.defaultLayer
const configLayer = TestConfig.layer({
@@ -148,8 +149,8 @@ describe("tool.registry", () => {
const build = yield* agent.get("build")
if (!build) throw new Error("build agent not found")
const task = (yield* registry.tools({
providerID: ProviderID.opencode,
modelID: ModelID.make("test"),
providerID: ProviderV2.ID.opencode,
modelID: ProviderV2.ModelID.make("test"),
agent: build,
})).find((tool) => tool.id === "task")
@@ -335,8 +336,8 @@ describe("tool.registry", () => {
const agents = yield* Agent.Service
const promptTools = yield* registry.tools({
providerID: ProviderID.opencode,
modelID: ModelID.make("test"),
providerID: ProviderV2.ID.opencode,
modelID: ProviderV2.ModelID.make("test"),
agent: yield* agents.defaultInfo(),
})
const promptTool = promptTools.find((tool) => tool.id === "sql")
+4 -3
View File
@@ -13,21 +13,22 @@ import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
afterEach(async () => {
await disposeAllInstances()
})
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test-model"),
}
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
@@ -2,9 +2,10 @@ import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { parseResponse } from "../../src/tool/mcp-websearch"
import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch"
import { ProviderID } from "../../src/provider/schema"
import { webSearchEnabled } from "../../src/tool/registry"
import { it } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const SESSION_ID = "ses_0196aabbccddeeff001122334455"
@@ -37,10 +38,10 @@ describe("websearch provider", () => {
})
test("is only enabled for opencode or explicit websearch provider flags", () => {
expect(webSearchEnabled(ProviderID.opencode, { exa: false, parallel: false })).toBe(true)
expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: false })).toBe(false)
expect(webSearchEnabled(ProviderID.openai, { exa: true, parallel: false })).toBe(true)
expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: true })).toBe(true)
expect(webSearchEnabled(ProviderV2.ID.opencode, { exa: false, parallel: false })).toBe(true)
expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: false })).toBe(false)
expect(webSearchEnabled(ProviderV2.ID.openai, { exa: true, parallel: false })).toBe(true)
expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: true })).toBe(true)
})
test("uses branded labels", () => {