diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml new file mode 100644 index 000000000000..786a37744476 --- /dev/null +++ b/packages/core/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/preload.ts"] diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index d32f9366811a..ed982cb6d7f6 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -69,10 +69,10 @@ export const layer = Layer.effect( const policy = yield* Policy.Service const integrations = yield* Integration.Service - const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => { + const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => { if (provider.disabled) return false if (typeof provider.request.body.apiKey === "string") return true - if (connected) return true + if (integration?.connections.length) return true return !integration } @@ -183,13 +183,8 @@ export const layer = Layer.effect( available: Effect.fn("CatalogV2.provider.available")(function* () { const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) - const connections = yield* integrations.connection.list() return (yield* result.provider.all()).filter((provider) => - available( - provider, - active.get(Integration.ID.make(provider.id)), - connections.has(Integration.ID.make(provider.id)), - ), + available(provider, active.get(Integration.ID.make(provider.id))), ) }), }, diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 937ec4a51a7a..b01bb1d1fd8c 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -29,33 +29,33 @@ export class Key extends Schema.Class("Credential.Key")({ metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) {} -export const Info = Schema.Union([OAuth, Key]) +export const Value = Schema.Union([OAuth, Key]) .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Credential.Info" }) -export type Info = Schema.Schema.Type + .annotate({ identifier: "Credential.Value" }) +export type Value = Schema.Schema.Type -export class Stored extends Schema.Class("Credential.Stored")({ +export class Info extends Schema.Class("Credential.Info")({ id: ID, integrationID: IntegrationSchema.ID, label: Schema.String, - value: Info, + value: Value, }) {} export interface Interface { /** Returns every stored credential. */ - readonly all: () => Effect.Effect + readonly all: () => Effect.Effect /** Returns stored credentials belonging to one integration. */ - readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect + readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect /** Returns one stored credential by ID. */ - readonly get: (id: ID) => Effect.Effect + readonly get: (id: ID) => Effect.Effect /** Replaces any credential for an integration and returns the new record. */ readonly create: (input: { readonly integrationID: IntegrationSchema.ID - readonly value: Info + readonly value: Value readonly label?: string - }) => Effect.Effect + }) => Effect.Effect /** Updates the label or secret value of a stored credential. */ - readonly update: (id: ID, updates: Partial>) => Effect.Effect + readonly update: (id: ID, updates: Partial>) => Effect.Effect /** Removes a stored credential. */ readonly remove: (id: ID) => Effect.Effect } @@ -66,10 +66,10 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service - const decode = Schema.decodeUnknownSync(Info) + const decode = Schema.decodeUnknownSync(Value) const stored = (row: typeof CredentialTable.$inferSelect) => { if (!row.integration_id) return - return new Stored({ + return new Info({ id: row.id, integrationID: row.integration_id, label: row.label, @@ -106,7 +106,7 @@ export const layer = Layer.effect( return row ? stored(row) : undefined }), create: Effect.fn("Credential.create")(function* (input) { - const credential = new Stored({ + const credential = new Info({ id: ID.create(), integrationID: input.integrationID, label: input.label ?? "default", diff --git a/packages/core/src/credential/sql.ts b/packages/core/src/credential/sql.ts index a849092ea05a..3afd7284a58b 100644 --- a/packages/core/src/credential/sql.ts +++ b/packages/core/src/credential/sql.ts @@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", { id: text().$type().primaryKey(), integration_id: text().$type(), label: text().notNull(), - value: text({ mode: "json" }).$type().notNull(), + value: text({ mode: "json" }).$type().notNull(), connector_id: text(), method_id: text(), active: integer({ mode: "boolean" }), diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 3257fe88401a..7f6ae60ae0ae 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -123,6 +123,6 @@ const baseLayer = Layer.effect( }), ) -export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer)) +export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer)) export const locationLayer = layer diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 0f123f5b9c1f..c019b8034b14 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -232,6 +232,6 @@ export const fffLayer = Layer.effect( }), ) -export const defaultLayer = Layer.unwrap( +export const locationLayer = Layer.unwrap( Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)), ) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 4bd27ffd3c94..03192921b939 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -108,11 +108,11 @@ export type OAuthAuthorization = { } & ( | { readonly mode: "auto" - readonly callback: Effect.Effect + readonly callback: Effect.Effect } | { readonly mode: "code" - readonly callback: (code: string) => Effect.Effect + readonly callback: (code: string) => Effect.Effect } ) @@ -214,8 +214,6 @@ export interface Interface extends State.Transformable { /** Returns all integrations with their methods and current connections. */ readonly list: () => Effect.Effect readonly connection: { - /** Returns active connections for every registered or credential-backed integration. */ - readonly list: () => Effect.Effect> /** Returns the active connection for one integration. */ readonly forIntegration: (id: ID) => Effect.Effect /** Runs a key method and stores the resulting credential. */ @@ -241,7 +239,7 @@ export interface Interface extends State.Transformable { /** Updates a stored credential exposed as a connection. */ readonly update: ( credentialID: Credential.ID, - updates: Partial>, + updates: Partial>, ) => Effect.Effect /** Removes a stored credential connection. */ readonly remove: (credentialID: Credential.ID) => Effect.Effect @@ -353,39 +351,27 @@ export const locationLayer = Layer.effect( finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), }) - const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => { - const connected = saved.map((credential) => ({ - type: "credential" as const, - id: credential.id, - label: credential.label, - })) - const detected = entry.methods + const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => { + const credentials = saved + .map((credential) => ({ + type: "credential" as const, + id: credential.id, + label: credential.label, + })) + .toReversed() + const env = (entry?.methods ?? []) .filter((method) => method.type === "env") .flatMap((method) => method.names.filter((name) => process.env[name])) .map((name) => ({ type: "env" as const, name })) - return [...connected, ...detected] + return [...credentials, ...env] } - const activeConnection = ( - entry: Entry | undefined, - saved: readonly Credential.Stored[], - ): IntegrationConnection.Info | undefined => { - const credential = saved.at(-1) - if (credential) return { type: "credential", id: credential.id, label: credential.label } - if (!entry) return - const name = entry.methods - .filter((method) => method.type === "env") - .flatMap((method) => method.names) - .find((name) => process.env[name]) - if (name) return { type: "env", name } - } - - const project = (entry: Entry, saved: readonly Credential.Stored[]) => + const project = (entry: Entry, connections: IntegrationConnection.Info[]) => new Info({ id: entry.ref.id, name: entry.ref.name, methods: entry.methods, - connections: connections(entry, saved), + connections, }) const authorize = (effect: Effect.Effect) => @@ -399,7 +385,7 @@ export const locationLayer = Layer.effect( return error instanceof Error ? error.message : String(error) } - const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { + const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { const now = yield* Clock.currentTimeMillis const result = yield* SynchronizedRef.modify(attempts, (current) => { const attempt = current.get(attemptID) @@ -450,28 +436,18 @@ export const locationLayer = Layer.effect( get: Effect.fn("Integration.get")(function* (id) { const entry = state.get().integrations.get(id) if (!entry) return undefined - return project(entry, yield* credentials.list(id)) + return project(entry, resolveConnections(entry, yield* credentials.list(id))) }), list: Effect.fn("Integration.list")(function* () { - return (yield* Effect.forEach(state.get().integrations.values(), (entry) => - Effect.gen(function* () { - return project(entry, yield* credentials.list(entry.ref.id)) - }), - )).toSorted((a, b) => a.name.localeCompare(b.name)) + const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) + return Array.from(state.get().integrations.values(), (entry) => + project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])), + ).toSorted((a, b) => a.name.localeCompare(b.name)) }), connection: { - list: Effect.fn("Integration.connection.list")(function* () { - const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) - return new Map( - new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => { - const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? []) - return connection ? [[id, connection] as const] : [] - }), - ) - }), forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) { const entry = state.get().integrations.get(id) - return activeConnection(entry, yield* credentials.list(id)) + return resolveConnections(entry, yield* credentials.list(id))[0] }), key: Effect.fn("Integration.connection.key")(function* (input) { const method = state diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b5a3bf486181..7454e0fa7525 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -126,10 +126,7 @@ export interface Interface { sessionID: SessionSchema.ID after?: number }) => Stream.Stream - readonly switchAgent: (input: { - sessionID: SessionSchema.ID - agent: string - }) => Effect.Effect + readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID model: ModelV2.Ref @@ -376,8 +373,14 @@ export const layer = Layer.effect( skill: Effect.fn("V2Session.skill")(function* () { return yield* new OperationUnavailableError({ operation: "skill" }) }), - switchAgent: Effect.fn("V2Session.switchAgent")(function* () { - return yield* new OperationUnavailableError({ operation: "switchAgent" }) + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + agent: input.agent, + }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { yield* result.get(input.sessionID) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 233a4aa4d82b..5d84e985a620 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -305,14 +305,7 @@ export const layer = Layer.effect( const llmFailure = failure instanceof LLMError ? failure : undefined if (llmFailure && !publisher.hasProviderError()) { yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) - yield* withPublication( - events.publish(SessionEvent.Step.Failed, { - sessionID: session.id, - timestamp: yield* DateTime.now, - assistantMessageID: yield* publisher.startAssistant(), - error: { type: "unknown", message: llmFailure.reason.message }, - }), - ) + yield* withPublication(publisher.failAssistant(llmFailure.reason.message)) } if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) @@ -327,6 +320,8 @@ export const layer = Layer.effect( ) { yield* FiberSet.clear(toolFibers) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) + if (publisher.hasActiveAssistant()) + yield* withPublication(publisher.failAssistant("Provider turn interrupted")) } if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { const failure = Cause.squash(settled.cause) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index d4e617ebf421..787c62c19091 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -44,7 +44,7 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) -const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => { +const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => { if (credential?.value.type === "key") return Auth.value(credential.value.key) if (credential?.value.type === "oauth") return Auth.value(credential.value.access) const value = model.request.body.apiKey ?? model.api.settings?.apiKey @@ -85,7 +85,7 @@ const apiName = (model: ModelV2.Info) => export const fromCatalogModel = ( model: ModelV2.Info, connection?: IntegrationConnection.Info, - credential?: Credential.Stored, + credential?: Credential.Info, ): Effect.Effect => { const resolved = credential?.value.metadata === undefined diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 5390a26e3b8f..b412edba0dbb 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -65,11 +65,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) >() const timestamp = DateTime.now let assistantMessageID: SessionMessage.ID | undefined + let assistantActive = false + let assistantFailed = false let providerFailed = false const startAssistant = Effect.fnUntraced(function* () { if (assistantMessageID !== undefined) return assistantMessageID assistantMessageID = SessionMessage.ID.create() + assistantActive = true yield* events.publish(SessionEvent.Step.Started, { ...input, assistantMessageID, @@ -190,6 +193,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* flushFragments() }) + const failAssistant = Effect.fnUntraced(function* (message: string) { + if (assistantFailed) return + yield* flush() + const assistantMessageID = yield* startAssistant() + assistantActive = false + assistantFailed = true + yield* events.publish(SessionEvent.Step.Failed, { + sessionID: input.sessionID, + timestamp: yield* timestamp, + assistantMessageID, + error: { type: "unknown", message }, + }) + }) + const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* ( message: string, hostedOnly = false, @@ -375,6 +392,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } case "step-finish": yield* flush() + assistantActive = false yield* events.publish(SessionEvent.Step.Ended, { sessionID: input.sessionID, timestamp: yield* timestamp, @@ -388,13 +406,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return case "provider-error": providerFailed = true - yield* flush() - yield* events.publish(SessionEvent.Step.Failed, { - sessionID: input.sessionID, - timestamp: yield* timestamp, - assistantMessageID: yield* startAssistant(), - error: { type: "unknown", message: event.message }, - }) + yield* failAssistant(event.message) return } }) @@ -402,10 +414,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return { publish, flush, + failAssistant, failUnsettledTools, + hasActiveAssistant: () => assistantActive, hasAssistantStarted: () => assistantMessageID !== undefined, hasProviderError: () => providerFailed, - startAssistant, assistantMessageID: assistantMessageIDForTool, } } diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index ae36f205b177..f0ce7eef7f1b 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -82,12 +82,21 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined) return item.provider?.executed === true && result ? [call, result] : [call] }) + const meaningful = content.filter((part) => { + if (part.type === "text") return part.text !== "" + if (part.type !== "reasoning") return true + return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0) + }) const results = message.content .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) .map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)) .filter((message) => message !== undefined) .map(Message.tool) - return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results] + if (meaningful.length === 0) return results + return [ + Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }), + ...results, + ] } function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] { diff --git a/packages/core/src/tool/http-body.ts b/packages/core/src/tool/http-body.ts new file mode 100644 index 000000000000..7cb534a444fc --- /dev/null +++ b/packages/core/src/tool/http-body.ts @@ -0,0 +1,30 @@ +import { Effect, Stream } from "effect" +import { HttpClientResponse } from "effect/unstable/http" + +export const collectBoundedResponseBody = ( + response: HttpClientResponse.HttpClientResponse, + maximumBytes: number, + tooLarge: () => Error, +) => + Effect.gen(function* () { + const contentLength = response.headers["content-length"] + const parsedSize = contentLength ? Number.parseInt(contentLength, 10) : undefined + const declaredSize = + parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined + if (declaredSize !== undefined && declaredSize > maximumBytes) return yield* Effect.fail(tooLarge()) + let body = Buffer.allocUnsafe(Math.min(maximumBytes, declaredSize || 64 * 1024)) + let size = 0 + yield* Stream.runForEach(response.stream, (chunk) => { + if (chunk.byteLength === 0) return Effect.void + if (size + chunk.byteLength > maximumBytes) return Effect.fail(tooLarge()) + if (size + chunk.byteLength > body.byteLength) { + const grown = Buffer.allocUnsafe(Math.min(maximumBytes, Math.max(size + chunk.byteLength, body.byteLength * 2))) + body.copy(grown, 0, 0, size) + body = grown + } + body.set(chunk, size) + size += chunk.byteLength + return Effect.void + }) + return body.subarray(0, size) + }) diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts index c27bdae6dee8..ef0c4df179e2 100644 --- a/packages/core/src/tool/read-filesystem.ts +++ b/packages/core/src/tool/read-filesystem.ts @@ -13,23 +13,61 @@ export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024 const MAX_LINE_LENGTH = 2_000 const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` -export class BinaryFileError extends Error { - constructor(readonly resource: string) { - super(`Cannot read binary file: ${resource}`) - this.name = "BinaryFileError" +export class BinaryFileError extends Schema.TaggedErrorClass()("ReadTool.BinaryFileError", { + resource: Schema.String, +}) { + override get message() { + return `Cannot read binary file: ${this.resource}` } } -export class MediaIngestLimitError extends Error { - constructor( - readonly resource: string, - readonly maximumBytes: number, - ) { - super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`) - this.name = "MediaIngestLimitError" +export class MediaIngestLimitError extends Schema.TaggedErrorClass()( + "ReadTool.MediaIngestLimitError", + { + resource: Schema.String, + maximumBytes: Schema.Number, + }, +) { + override get message() { + return `Media exceeds ${this.maximumBytes} byte ingestion limit: ${this.resource}` + } +} + +export class MalformedUtf8Error extends Schema.TaggedErrorClass()("ReadTool.MalformedUtf8Error", { + resource: Schema.String, +}) { + override get message() { + return `File is not valid UTF-8: ${this.resource}` + } +} + +export class OffsetOutOfRangeError extends Schema.TaggedErrorClass()( + "ReadTool.OffsetOutOfRangeError", + { offset: Schema.Number }, +) { + override get message() { + return `Offset ${this.offset} is out of range` } } +export class PathKindError extends Schema.TaggedErrorClass()("ReadTool.PathKindError", { + resource: Schema.String, + expected: Schema.Literals(["a file", "a file or directory"]), +}) { + override get message() { + return `Path is not ${this.expected}: ${this.resource}` + } +} + +export type InspectError = FSUtil.Error | PathKindError +export type ReadError = + | FSUtil.Error + | BinaryFileError + | MediaIngestLimitError + | MalformedUtf8Error + | OffsetOutOfRangeError + | PathKindError + export const PageInput = Schema.Struct({ offset: PositiveInt.pipe(Schema.optional), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional), @@ -52,13 +90,13 @@ export class ListPage extends Schema.Class("ReadTool.ListPage")({ }) {} export interface Interface { - readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory"> + readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError> readonly read: ( path: AbsolutePath, resource: string, page?: PageInput, - ) => Effect.Effect - readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect + ) => Effect.Effect + readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/ReadToolFileSystem") {} @@ -111,11 +149,21 @@ const binary = (resource: string, bytes: Uint8Array) => { } return nonPrintable / bytes.length > 0.3 } +const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) => + Effect.try({ + try: () => decoder.decode(bytes, { stream: bytes !== undefined }), + catch: (error) => { + if (error instanceof TypeError) return new MalformedUtf8Error({ resource }) + throw error + }, + }) +const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) => + bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes) export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) { - const info = yield* fs.stat(input).pipe(Effect.orDie) + const info = yield* fs.stat(input) const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined - if (!type) return yield* Effect.die(new Error("Path is not a file or directory")) + if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" })) return type }) @@ -125,32 +173,30 @@ export const read = Effect.fn("ReadTool.read")(function* ( resource: string, page: PageInput = {}, ) { - const real = yield* fs.realPath(input).pipe(Effect.orDie) + const real = yield* fs.realPath(input) return yield* Effect.scoped( Effect.gen(function* () { - const file = yield* fs.open(real, { flag: "r" }).pipe(Effect.orDie) - const info = yield* file.stat.pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) + const file = yield* fs.open(real, { flag: "r" }) + const info = yield* file.stat + if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" })) const first = Option.getOrElse( - yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)).pipe(Effect.orDie), + yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)), () => new Uint8Array(), ) const mime = imageMime(first) if (mime) { if (info.size > MAX_MEDIA_INGEST_BYTES) - return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES)) + return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) const chunks = [first] let total = first.length while (total <= MAX_MEDIA_INGEST_BYTES) { - const chunk = yield* file - .readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) - .pipe(Effect.orDie) + const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) if (Option.isNone(chunk)) break chunks.push(chunk.value) total += chunk.value.length } if (total > MAX_MEDIA_INGEST_BYTES) - return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES)) + return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })) return { uri: pathToFileURL(real).href, name: path.basename(real), @@ -162,19 +208,19 @@ export const read = Effect.fn("ReadTool.read")(function* ( mime, } } - if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || binary(resource, first)) - return yield* Effect.die(new BinaryFileError(resource)) + if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || extensions.has(path.extname(resource).toLowerCase())) + return yield* Effect.fail(new BinaryFileError({ resource })) const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined if (!paged) { + if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource })) const decoder = new TextDecoder("utf-8", { fatal: true }) - const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))] + const text = [yield* decodeUtf8(resource, decoder, first)] while (true) { - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + const chunk = yield* file.readAlloc(64 * 1024) if (Option.isNone(chunk)) break - if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(resource)) - text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true }))) + text.push(yield* decodeChunk(resource, decoder, chunk.value)) } - text.push(yield* Effect.sync(() => decoder.decode())) + text.push(yield* decodeUtf8(resource, decoder)) return { uri: pathToFileURL(real).href, name: path.basename(real), @@ -191,34 +237,29 @@ export const read = Effect.fn("ReadTool.read")(function* ( let discard = false let line = 1 let bytes = 0 - let found = false - let truncated = false let next: number | undefined const append = (input: string) => { if (line < offset) { line++ - return + return true } if (lines.length >= limit || bytes >= MAX_READ_BYTES) { - truncated = true - next ??= line++ - return + next = line + return false } - found = true const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) if (bytes + size > MAX_READ_BYTES) { - truncated = true - next ??= line++ - return + next = line + return false } lines.push(text) bytes += size line++ + return true } - const consume = (chunk: Uint8Array) => { - if (chunk.includes(0)) throw new BinaryFileError(resource) - let text = decoder.decode(chunk, { stream: true }) + const consume = (input: string) => { + let text = input while (true) { const index = text.indexOf("\n") if (index === -1) { @@ -235,25 +276,44 @@ export const read = Effect.fn("ReadTool.read")(function* ( pending = "" discard = false text = text.slice(index + 1) - append(current.endsWith("\r") ? current.slice(0, -1) : current) + if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false } + return true } - yield* Effect.sync(() => consume(first)) - while (true) { - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { + let start = 0 + while (start < chunk.length) { + if (lines.length >= limit || bytes >= MAX_READ_BYTES) { + next = line + return false + } + const newline = chunk.indexOf(10, start) + const end = newline === -1 ? chunk.length : newline + 1 + const segment = chunk.subarray(start, end) + if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource })) + if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false + start = end + } + return true + }) + let done = !(yield* consumeChunk(first)) + while (!done) { + const chunk = yield* file.readAlloc(64 * 1024) if (Option.isNone(chunk)) break - yield* Effect.sync(() => consume(chunk.value)) + done = !(yield* consumeChunk(chunk.value)) + } + if (!done) { + const tail = yield* decodeUtf8(resource, decoder) + if (!discard) pending += tail + if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) } - const tail = yield* Effect.sync(() => decoder.decode()) - if (!discard) pending += tail - if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) - if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`)) + if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset })) return new TextPage({ type: "text-page", content: lines.join("\n"), mime: FSUtil.mimeType(real), offset, - truncated, + truncated: next !== undefined, ...(next === undefined ? {} : { next }), }) }), @@ -261,8 +321,8 @@ export const read = Effect.fn("ReadTool.read")(function* ( }) export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) { - const real = yield* fs.realPath(input).pipe(Effect.orDie) - const items = yield* fs.readDirectoryEntries(real).pipe(Effect.orDie) + const real = yield* fs.realPath(input) + const items = yield* fs.readDirectoryEntries(real) const offset = page.offset ?? 1 const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) const entries = yield* Effect.forEach( diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 64f02d813fe3..2635e4653ff4 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard( const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute)) return yield* Effect.die(new Error("Path escapes the allowed read root")) - const real = yield* fs.realPath(absolute).pipe(Effect.orDie) - const root = yield* fs.realPath(selected).pipe(Effect.orDie) + const real = yield* fs.realPath(absolute) + const root = yield* fs.realPath(selected) if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the allowed read root")) const resource = path.relative(root, real).replaceAll("\\", "/") || "." @@ -83,7 +83,7 @@ export const layer = Layer.effectDiscard( .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) } if ("encoding" in content && content.encoding === "base64") - return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError(resource)) + return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) return content }).pipe( Effect.mapError((error) => { diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 1e209e500861..2ce0868d991e 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -1,11 +1,12 @@ export * as WebFetchTool from "./webfetch" import { ToolFailure } from "@opencode-ai/llm" -import { Duration, Effect, Layer, Schema, Stream } from "effect" +import { Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Parser } from "htmlparser2" import TurndownService from "turndown" import { PermissionV2 } from "../permission" +import { collectBoundedResponseBody } from "./http-body" import { Tool } from "./tool" import { Tools } from "./tools" @@ -86,24 +87,11 @@ const execute = (http: HttpClient.HttpClient, url: string, format: Format, userA http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)) const collectBody = (response: HttpClientResponse.HttpClientResponse) => - Effect.gen(function* () { - const contentLength = response.headers["content-length"] - if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) { - return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) - } - const chunks: Uint8Array[] = [] - let size = 0 - yield* Stream.runForEach(response.stream, (chunk) => - Effect.gen(function* () { - size += chunk.byteLength - if (size > MAX_RESPONSE_BYTES) - return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) - chunks.push(chunk) - return undefined - }), - ) - return Buffer.concat(chunks, size) - }) + collectBoundedResponseBody( + response, + MAX_RESPONSE_BYTES, + () => new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`), + ) const mimeFrom = (contentType: string) => contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "" const isImageAttachment = (mime: string) => @@ -171,12 +159,16 @@ export const layer = Layer.effectDiscard( orElse: () => Effect.fail(new Error("Request timed out")), }), ) - const content = convert(new TextDecoder().decode(body), contentType, input.format) + const content = new TextDecoder().decode(body) + const output = yield* Effect.try({ + try: () => convert(content, contentType, input.format), + catch: (error) => error, + }) return { url: input.url, contentType, format: input.format, - output: content, + output, } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), }), diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index cea19c17e8f3..14c10377ee0c 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -9,6 +9,7 @@ import { PositiveInt } from "../schema" import { PermissionV2 } from "../permission" import { Tool } from "./tool" import { Tools } from "./tools" +import { collectBoundedResponseBody } from "./http-body" import { checksum } from "../util/encode" export const name = "websearch" @@ -164,10 +165,12 @@ const callMcp = ( ) return yield* Effect.gen(function* () { const response = yield* HttpClient.filterStatusOk(http).execute(request) - const body = yield* response.text - if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES) - return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`)) - return yield* parseResponse(body) + const body = yield* collectBoundedResponseBody( + response, + MAX_RESPONSE_BYTES, + () => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`), + ) + return yield* parseResponse(body.toString("utf8")) }).pipe( Effect.timeoutOrElse({ duration: Duration.seconds(25), diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index cc1051bc2c9b..bb4b256f8937 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -11,7 +11,11 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -import { required } from "./plugin/provider-helper" + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} const locationLayer = Layer.succeed( Location.Service, @@ -21,12 +25,7 @@ const it = testEffect( Catalog.locationLayer.pipe( Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.succeed([]), - list: () => Effect.succeed([]), - }), - ), + Layer.provideMerge(Credential.defaultLayer), ), ) @@ -48,38 +47,30 @@ describe("CatalogV2", () => { it.effect("derives availability from active credentials without changing provider state", () => { const integrationID = Integration.ID.make("test") - const first = { - id: Credential.ID.create(), - integrationID, - label: "First", - value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }), - } - const second = { - id: Credential.ID.create(), - integrationID, - label: "Second", - value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }), - } - let active = first const layer = Catalog.locationLayer.pipe( Layer.fresh, Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.sync(() => [active]), - list: () => Effect.sync(() => [active]), - }), - ), + Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)), ) return Effect.gen(function* () { const catalog = yield* Catalog.Service + const credentials = yield* Credential.Service yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + yield* credentials.create({ + integrationID, + label: "First", + value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }), + }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) - active = second + yield* credentials.create({ + integrationID, + label: "Second", + value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }), + }) expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) }).pipe(Effect.provide(layer)) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 054c6871d582..19311363edc2 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -1,14 +1,52 @@ import { describe, expect } from "bun:test" -import { Effect, Option, Schema } from "effect" +import { Effect, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it, required, withEnv } from "../plugin/provider-helper" -import { catalogHost, host, integrationHost } from "../plugin/host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* (config: Config.Interface) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ + ...ConfigProviderPlugin.Plugin, + effect: ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)), + }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} function request(headers: Record, variant?: string) { return { @@ -23,8 +61,6 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("partitions existing model variant bodies without changing config shape", () => Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") const config = Config.Service.of({ @@ -57,12 +93,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe(Effect.provideService(Config.Service, config)), - }) + yield* addPlugin(config) const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants).toMatchObject([ @@ -82,8 +113,6 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("uses the effective provider package across layered config", () => Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") const config = Config.Service.of({ @@ -116,12 +145,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe(Effect.provideService(Config.Service, config)), - }) + yield* addPlugin(config) const model = required(yield* catalog.model.get(providerID, modelID)) expect(model.variants[0]).toMatchObject({ @@ -137,7 +161,6 @@ describe("ConfigProviderPlugin.Plugin", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.make("custom") const modelID = ModelV2.ID.make("chat") const config = Config.Service.of({ @@ -217,12 +240,7 @@ describe("ConfigProviderPlugin.Plugin", () => { ]), }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ).pipe(Effect.provideService(Config.Service, config)), - }) + yield* addPlugin(config) const provider = required(yield* catalog.provider.get(providerID)) const model = required(yield* catalog.model.get(providerID, modelID)) diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index c038598543af..8c1901acd20c 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -1,49 +1,35 @@ -import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Credential } from "@opencode-ai/core/credential" -import { Database } from "@opencode-ai/core/database/database" import { Integration } from "@opencode-ai/core/integration" -import { tmpdir } from "./fixture/tmpdir" -import { it } from "./lib/effect" +import { testEffect } from "./lib/effect" -function layer(directory: string) { - return Credential.layer.pipe( - Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)), - ) -} +const it = testEffect(Credential.defaultLayer) describe("Credential", () => { - it.live("stores, updates, lists, and removes credentials", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - const credentials = yield* Credential.Service - const integrationID = Integration.ID.make("openai") - const created = yield* credentials.create({ - integrationID, - label: "Work", - value: new Credential.Key({ type: "key", key: "secret" }), - }) + it.effect("stores, updates, lists, and removes credentials", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const integrationID = Integration.ID.make("openai") + const created = yield* credentials.create({ + integrationID, + label: "Work", + value: new Credential.Key({ type: "key", key: "secret" }), + }) - expect(yield* credentials.list(integrationID)).toEqual([created]) - yield* credentials.update(created.id, { label: "Personal" }) - expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal") + expect(yield* credentials.list(integrationID)).toEqual([created]) + yield* credentials.update(created.id, { label: "Personal" }) + expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal") - const replacement = yield* credentials.create({ - integrationID, - label: "Replacement", - value: new Credential.Key({ type: "key", key: "replacement" }), - }) - expect(yield* credentials.list(integrationID)).toEqual([replacement]) + const replacement = yield* credentials.create({ + integrationID, + label: "Replacement", + value: new Credential.Key({ type: "key", key: "replacement" }), + }) + expect(yield* credentials.list(integrationID)).toEqual([replacement]) - yield* credentials.remove(replacement.id) - expect(yield* credentials.list(integrationID)).toEqual([]) - }).pipe(Effect.provide(layer(tmp.path))), - ), - ), + yield* credentials.remove(replacement.id) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), ) }) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index fb5e195e2a81..2001de4efecc 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -420,13 +420,12 @@ describe("EventV2", () => { const readStarted = yield* Deferred.make() const continueRead = yield* Deferred.make() let pause = true - const database = Database.layerFromPath(":memory:") const eventLayer = EventV2.layerWith({ beforeAggregateRead: () => pause ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) : Effect.void, - }).pipe(Layer.provide(database)) + }).pipe(Layer.provide(Database.defaultLayer)) yield* Effect.gen(function* () { const events = yield* EventV2.Service @@ -441,7 +440,7 @@ describe("EventV2", () => { expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ [0, { id: aggregateID, text: "during handoff" }], ]) - }).pipe(Effect.provide(Layer.mergeAll(database, eventLayer))) + }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer))) }), ) diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index 10f61d8a97f9..31371b0ac7eb 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -5,7 +5,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" import path from "path" -const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer)) +const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer) const { effect: it } = testEffect(live) describe("FSUtil", () => { diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index cdc8344de503..77d0a9e33cb5 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -22,7 +22,7 @@ describe("Ripgrep", () => { yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src"))) yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n")) const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 }) - expect(result.map((item) => item.path)).toEqual([RelativePath.make(path.join("src", "match.ts"))]) + expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")]) }), ), ) @@ -35,7 +35,7 @@ describe("Ripgrep", () => { yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n")) const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 }) expect(result).toHaveLength(1) - expect(result[0]?.entry.path).toBe(RelativePath.make(path.join("src", "match.ts"))) + expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts")) expect(result[0]?.submatches[0]?.text).toBe("needle") }), ), diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts index 00b3ffbd13f0..40d8ed9dc363 100644 --- a/packages/core/test/fixture/location.ts +++ b/packages/core/test/fixture/location.ts @@ -1,6 +1,8 @@ import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Effect, Layer } from "effect" +import { tmpdir } from "./tmpdir" export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) { return { @@ -10,3 +12,15 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute vcs: input.vcs, } satisfies Location.Interface } + +export const tempLocationLayer = Layer.unwrap( + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.map((tmp) => { + const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) + return Layer.succeed(Location.Service, Location.Service.of(location(ref))) + }), + ), +) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index ac9cd33e8d13..f4a1ccd83586 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -4,45 +4,12 @@ import * as TestClock from "effect/testing/TestClock" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" -import { it } from "./lib/effect" +import { testEffect } from "./lib/effect" -const layer = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: () => Effect.die("unexpected credential creation"), - list: () => Effect.succeed([]), - }), - ), +const it = testEffect( + Integration.locationLayer.pipe(Layer.provideMerge(Credential.defaultLayer), Layer.provideMerge(EventV2.defaultLayer)), ) -function connectionLayer( - created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }>, -) { - return Integration.locationLayer.pipe( - Layer.provideMerge(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: (input) => - Effect.sync(() => { - created.push(input) - return new Credential.Stored({ - id: Credential.ID.create(), - integrationID: input.integrationID, - label: input.label ?? "default", - value: input.value, - }) - }), - list: () => Effect.succeed([]), - }), - ), - ) -} - describe("Integration", () => { it.effect("registers integrations through the editor", () => Effect.gen(function* () { @@ -59,7 +26,7 @@ describe("Integration", () => { yield* Scope.close(scope, Exit.void) expect(yield* integrations.get(openai)).toBeUndefined() - }).pipe(Effect.provide(layer)), + }), ) it.effect("reveals the previous registration when an override closes", () => @@ -80,7 +47,7 @@ describe("Integration", () => { yield* Scope.close(second, Exit.void) expect((yield* integrations.get(id))?.name).toBe("OpenAI") expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id]) - }).pipe(Effect.provide(layer)), + }), ) it.effect("registers and overrides methods independently", () => @@ -128,17 +95,13 @@ describe("Integration", () => { yield* Scope.close(second, Exit.void) expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" }) expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })]) - }).pipe(Effect.provide(layer)), + }), ) - it.effect("connects with a key and stores the credential", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("connects with a key and stores the credential", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const events = yield* EventV2.Service const integrationID = Integration.ID.make("openai") yield* integrations.transform((editor) => @@ -158,25 +121,21 @@ describe("Integration", () => { label: "Work", }) - expect(created).toEqual([ - { + expect(yield* credentials.list(integrationID)).toEqual([ + expect.objectContaining({ integrationID, label: "Work", value: new Credential.Key({ type: "key", key: "secret" }), - }, + }), ]) expect((yield* Fiber.join(updated)).length).toBe(1) - }).pipe(Effect.provide(connectionLayer(created))) - }) + }), + ) - it.effect("completes code OAuth once and stores the credential", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("completes code OAuth once and stores the credential", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") yield* integrations.transform((editor) => @@ -212,29 +171,27 @@ describe("Integration", () => { expect(attempt.mode).toBe("code") yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }) - expect(created[0]).toEqual({ - integrationID, - label: "Personal", - value: new Credential.OAuth({ - type: "oauth", - methodID, - access: "access", - refresh: "refresh", - expires: 1, - metadata: { code: "1234" }, + expect((yield* credentials.list(integrationID))[0]).toEqual( + expect.objectContaining({ + integrationID, + label: "Personal", + value: new Credential.OAuth({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 1, + metadata: { code: "1234" }, + }), }), - }) - }).pipe(Effect.provide(connectionLayer(created))) - }) + ) + }), + ) - it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("chatgpt") let closed = false @@ -261,18 +218,14 @@ describe("Integration", () => { expect(closed).toBe(false) yield* integrations.attempt.cancel(attempt.attemptID) expect(closed).toBe(true) - expect(created).toEqual([]) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), + ) - it.effect("completes auto OAuth in the background", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("completes auto OAuth in the background", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") yield* integrations.transform((editor) => @@ -297,18 +250,14 @@ describe("Integration", () => { status: "complete", time: attempt.time, }) - expect(created).toHaveLength(1) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toHaveLength(1) + }), + ) - it.effect("expires abandoned OAuth attempts", () => { - const created: Array<{ - integrationID: Integration.ID - label?: string - value: Credential.Info - }> = [] - return Effect.gen(function* () { + it.effect("expires abandoned OAuth attempts", () => + Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service const integrationID = Integration.ID.make("openai") const methodID = Integration.MethodID.make("browser") let closed = false @@ -337,34 +286,12 @@ describe("Integration", () => { time: attempt.time, }) expect(closed).toBe(true) - expect(created).toEqual([]) - }).pipe(Effect.provide(connectionLayer(created))) - }) + expect(yield* credentials.list(integrationID)).toEqual([]) + }), + ) it.effect("projects credential and env connections", () => { const integrationID = Integration.ID.make("acme") - const rows = [ - { - id: Credential.ID.create(), - integrationID, - label: "Work", - value: new Credential.Key({ type: "key", key: "a" }), - }, - { - id: Credential.ID.create(), - integrationID, - label: "Personal", - value: new Credential.Key({ type: "key", key: "b" }), - }, - ] - const projectionLayer = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - list: () => Effect.succeed(rows.map((row) => new Credential.Stored(row))), - }), - ), - ) return Effect.acquireUseRelease( Effect.sync(() => { const previous = process.env.INTEGRATION_TEST_ACME_KEY @@ -375,6 +302,7 @@ describe("Integration", () => { () => Effect.gen(function* () { const integrations = yield* Integration.Service + const credentials = yield* Credential.Service yield* integrations.transform((editor) => editor.method.update({ integrationID, @@ -384,23 +312,33 @@ describe("Integration", () => { }, }), ) + const work = yield* credentials.create({ + integrationID, + label: "Work", + value: new Credential.Key({ type: "key", key: "a" }), + }) + const personal = yield* credentials.create({ + integrationID, + label: "Personal", + value: new Credential.Key({ type: "key", key: "b" }), + }) // Stored credentials and detected env vars appear as connections. expect((yield* integrations.get(integrationID))?.connections).toEqual([ - { type: "credential", id: rows[0]!.id, label: "Work" }, { type: "credential", - id: rows[1]!.id, + id: personal.id, label: "Personal", }, { type: "env", name: "INTEGRATION_TEST_ACME_KEY" }, ]) expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({ type: "credential", - id: rows[1]!.id, + id: personal.id, label: "Personal", }) - }).pipe(Effect.provide(projectionLayer)), + expect(work.id).not.toBe(personal.id) + }), (previous) => Effect.sync(() => { if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 9e75bbb641c1..21acc40ee71a 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -36,8 +36,7 @@ const it = testEffect( Layer.mergeAll( Project.defaultLayer, EventV2.defaultLayer, - Credential.defaultLayer, - Credential.layer.pipe(Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh))), + Credential.defaultLayer.pipe(Layer.fresh), Npm.defaultLayer, ModelsDev.defaultLayer, FSUtil.defaultLayer, diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 5f7fbb16d3af..84efa4cbadcd 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -21,34 +21,39 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) -const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events)) const project = Project.layer.pipe( - Layer.provide(database), + Layer.provide(Database.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), - Layer.provide(directories), + Layer.provide(ProjectDirectories.defaultLayer), ) -const store = SessionStore.layer.pipe(Layer.provide(database)) const sessions = SessionV2.layer.pipe( - Layer.provide(database), - Layer.provide(events), + Layer.provide(Database.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(project), - Layer.provide(store), + Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionExecution.noopLayer), ) const layer = MoveSession.layer.pipe( - Layer.provide(database), + Layer.provide(Database.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), - Layer.provide(events), + Layer.provide(EventV2.defaultLayer), Layer.provide(project), Layer.provide(sessions), ) const it = testEffect( - Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions), + Layer.mergeAll( + layer, + Database.defaultLayer, + EventV2.defaultLayer, + ProjectDirectories.defaultLayer, + project, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + SessionExecution.noopLayer, + sessions, + ), ) function abs(input: string) { diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 2120a9f51ade..0f07ed547643 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -18,29 +18,25 @@ import { eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") const current = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/project") })), ) -const events = EventV2.layer.pipe(Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(SessionExecution.noopLayer), ) -const saved = PermissionSaved.layer.pipe(Layer.provide(database)) const layer = PermissionV2.locationLayer.pipe( - Layer.provideMerge(database), - Layer.provideMerge(store), - Layer.provideMerge(events), + Layer.provideMerge(Database.defaultLayer), + Layer.provideMerge(SessionStore.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(current), Layer.provideMerge(sessions), Layer.provideMerge(SessionExecution.noopLayer), - Layer.provideMerge(saved), + Layer.provideMerge(PermissionSaved.defaultLayer), ) const it = testEffect(layer) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts new file mode 100644 index 000000000000..3faa65a6587c --- /dev/null +++ b/packages/core/test/plugin/fixture.ts @@ -0,0 +1,48 @@ +import { AgentV2 } from "@opencode-ai/core/agent" +import { Catalog } from "@opencode-ai/core/catalog" +import { CommandV2 } from "@opencode-ai/core/command" +import { Credential } from "@opencode-ai/core/credential" +import { EventV2 } from "@opencode-ai/core/event" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { Npm } from "@opencode-ai/core/npm" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { Reference } from "@opencode-ai/core/reference" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { SkillV2 } from "@opencode-ai/core/skill" +import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" +import { Effect, Layer } from "effect" +import { tempLocationLayer } from "../fixture/location" + +export const PluginTestLayer = Layer.mergeAll( + AgentV2.locationLayer, + CommandV2.locationLayer, + Catalog.locationLayer, + FileSystem.locationLayer, + PluginV2.locationLayer, + Reference.locationLayer, + SkillV2.locationLayer, +).pipe( + Layer.provideMerge( + Layer.mergeAll( + Credential.defaultLayer, + EventV2.defaultLayer, + FSUtil.defaultLayer, + Global.defaultLayer, + Layer.succeed( + Npm.Service, + Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }), + ), + RepositoryCache.defaultLayer, + SkillDiscovery.defaultLayer, + Ripgrep.defaultLayer, + tempLocationLayer, + ), + ), +) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 6b3e153c3cef..c872b6fe65eb 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -24,11 +24,7 @@ const locationLayer = Layer.succeed( ) const plugins = PluginV2.layer.pipe(Layer.provide(events)) const policy = Policy.layer.pipe(Layer.provide(locationLayer)) -const connections = Credential.layer.pipe( - Layer.fresh, - Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), - Layer.provide(events), -) +const connections = Credential.defaultLayer.pipe(Layer.fresh) const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) const catalog = Catalog.layer.pipe( Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts index 017f60fff30e..5fb8b16bf00c 100644 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ b/packages/core/test/plugin/provider-alibaba.test.ts @@ -3,17 +3,35 @@ import { createAlibaba } from "@ai-sdk/alibaba" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AlibabaPlugin.id, effect: AlibabaPlugin.effect(host) }) +}) describe("AlibabaPlugin", () => { it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/alibaba", + options: { name: "alibaba" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -23,10 +41,17 @@ describe("AlibabaPlugin", () => { it.effect("ignores non-Alibaba SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "alibaba" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -36,11 +61,14 @@ describe("AlibabaPlugin", () => { it.effect("matches the old bundled Alibaba SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-alibaba", "qwen"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/alibaba", options: { name: "custom-alibaba", apiKey: "test" }, }, @@ -56,8 +84,11 @@ describe("AlibabaPlugin", () => { it.effect("uses the old default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AlibabaPlugin) - const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } }) + yield* addPlugin() + const item = new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" }, + }) const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) const language = result.sdk?.languageModel(item.api.id) expect(language?.modelId).toBe("qwen-plus") diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index aadefcb5c03a..1a2512485b04 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -1,10 +1,61 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AmazonBedrockPlugin.id, effect: AmazonBedrockPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) @@ -28,11 +79,10 @@ function openAIUrl(language: unknown, path: string, modelId: string) { describe("AmazonBedrockPlugin", () => { it.effect("moves endpoint option to api URL", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) yield* catalog.transform((catalog) => { - const bedrock = provider("amazon-bedrock", { + const bedrock = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.amazonBedrock), api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" }, request: { headers: {}, @@ -44,6 +94,7 @@ describe("AmazonBedrockPlugin", () => { item.request = bedrock.request }) }) + yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)) expect(result.api).toEqual({ type: "aisdk", @@ -58,11 +109,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -83,11 +137,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -117,11 +174,18 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, }, @@ -137,11 +201,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", region: "eu-west-1" }, }, @@ -156,11 +223,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, }, @@ -175,11 +245,14 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock" }, }, @@ -195,11 +268,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -224,11 +300,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -252,12 +331,17 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "openai.gpt-5.5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, }), package: "@ai-sdk/amazon-bedrock/mantle", options: { @@ -281,12 +365,17 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "openai.gpt-5.5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, }), sdk: fakeSelectorSdk(calls), options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, @@ -296,8 +385,13 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "openai.gpt-oss-safeguard-120b", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), + api: { + id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, }), sdk: fakeSelectorSdk(calls), options: { region: "us-east-1" }, @@ -311,12 +405,17 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores other Bedrock provider subpaths", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5", { - api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/anthropic" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/anthropic", + }, }), package: "@ai-sdk/amazon-bedrock/anthropic", options: { name: "amazon-bedrock" }, @@ -340,11 +439,18 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const headers: Array = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/amazon-bedrock", options: { name: "amazon-bedrock", @@ -371,11 +477,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -384,7 +493,10 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, }, @@ -393,7 +505,14 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, }, @@ -402,7 +521,10 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "ap-northeast-1" }, }, @@ -411,7 +533,10 @@ describe("AmazonBedrockPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "ap-southeast-2" }, }, @@ -432,11 +557,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -517,12 +645,15 @@ describe("AmazonBedrockPlugin", () => { expected: "au.anthropic.claude-sonnet-4-5", }, ] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() for (const item of cases) { yield* plugin.trigger( "aisdk.language", { - model: model("amazon-bedrock", item.modelID), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), + api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: item.region }, }, @@ -537,11 +668,14 @@ describe("AmazonBedrockPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AmazonBedrockPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("openai", "anthropic.claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: { region: "eu-west-1" }, }, diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index d31574d0f694..ba3a33915b08 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -1,19 +1,34 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AnthropicPlugin.id, effect: AnthropicPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} describe("AnthropicPlugin", () => { it.effect("applies legacy beta headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AnthropicPlugin) yield* catalog.transform((catalog) => { - const item = provider("anthropic", { + const item = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.anthropic), api: { type: "aisdk", package: "@ai-sdk/anthropic" }, request: { headers: { Existing: "1" }, body: {} }, }) @@ -22,6 +37,7 @@ describe("AnthropicPlugin", () => { draft.request = item.request }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe( "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", ) @@ -31,10 +47,9 @@ describe("AnthropicPlugin", () => { it.effect("ignores non-Anthropic providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AnthropicPlugin) - yield* catalog.transform((catalog) => catalog.provider.update(provider("openai").id, () => {})) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {})) + yield* addPlugin() expect( required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"], ).toBeUndefined() @@ -44,54 +59,40 @@ describe("AnthropicPlugin", () => { it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, AnthropicPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("anthropic-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + }), package: "@ai-sdk/anthropic", options: { name: "custom-anthropic", apiKey: "test" }, }, {}, ) - expect(providers).toEqual(["custom-anthropic"]) + expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("custom-anthropic") }), ) it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, AnthropicPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("anthropic-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("claude-sonnet-4-5").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + }), package: "@ai-sdk/anthropic", options: { name: "anthropic", apiKey: "test" }, }, {}, ) - expect(providers).toEqual(["anthropic"]) + expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic") }), ) }) diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 6d9139c7336f..2c1c7ec87889 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -1,23 +1,73 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AzureCognitiveServicesPlugin.id, effect: AzureCognitiveServicesPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("AzureCognitiveServicesPlugin", () => { it.effect("maps the resource env var to the Azure SDK baseURL", () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } }) }) + yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) expect(result.api).toEqual({ type: "aisdk", @@ -33,14 +83,16 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("leaves baseURL unset without resource env and ignores other providers", () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) yield* catalog.transform((catalog) => { - const azure = provider("azure-cognitive-services", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services")), api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, }) - const openai = provider("openai") + const openai = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "test-provider" }, + }) catalog.provider.update(azure.id, (item) => { item.api = azure.api }) @@ -48,6 +100,7 @@ describe("AzureCognitiveServicesPlugin", () => { item.api = openai.api }) }) + yield* addPlugin() const azure = required(yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))) const openai = required(yield* catalog.provider.get(ProviderV2.ID.openai)) expect(azure.request.body.baseURL).toBeUndefined() @@ -62,11 +115,14 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true }, }, @@ -80,15 +136,29 @@ describe("AzureCognitiveServicesPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) const ignored = yield* plugin.trigger( "aisdk.language", - { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual(["responses:deployment"]) @@ -101,11 +171,17 @@ describe("AzureCognitiveServicesPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* addPlugin(plugin, AzureCognitiveServicesPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "messages-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("messages-deployment"), + ), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, options: {}, }, @@ -114,7 +190,10 @@ describe("AzureCognitiveServicesPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "chat-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), + api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, options: {}, }, @@ -123,7 +202,13 @@ describe("AzureCognitiveServicesPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("azure-cognitive-services", "language-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("azure-cognitive-services"), + ModelV2.ID.make("language-deployment"), + ), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: sdk.languageModel }, options: {}, }, diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index baa6d4f7394a..10c2a005dcca 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,23 +1,73 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, provider, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: AzurePlugin.id, effect: AzurePlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("AzurePlugin", () => { it.effect("resolves resourceName from env", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.azure, (item) => { item.api = { type: "aisdk", package: "@ai-sdk/azure" } }) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), @@ -26,10 +76,10 @@ describe("AzurePlugin", () => { it.effect("keeps explicit resourceName over env and ignores other providers", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { - const azure = provider("azure", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "from-config" } }, }) @@ -39,7 +89,7 @@ describe("AzurePlugin", () => { }) catalog.provider.update(ProviderV2.ID.openai, () => {}) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config") expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined() }), @@ -49,10 +99,10 @@ describe("AzurePlugin", () => { it.effect("falls back to env when configured resourceName is blank", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { - const azure = provider("azure", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: "" } }, }) @@ -61,7 +111,7 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), @@ -70,10 +120,10 @@ describe("AzurePlugin", () => { it.effect("falls back to env when configured resourceName is whitespace", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => { - const azure = provider("azure", { + const azure = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.azure), api: { type: "aisdk", package: "@ai-sdk/azure" }, request: { headers: {}, body: { resourceName: " " } }, }) @@ -82,7 +132,7 @@ describe("AzurePlugin", () => { item.request = azure.request }) }) - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env") }), ), @@ -92,11 +142,14 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("azure", "deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/azure", options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, }, @@ -111,11 +164,18 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() const exit = yield* plugin .trigger( "aisdk.sdk", - { model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/azure", + options: { name: "azure" }, + }, {}, ) .pipe(Effect.exit) @@ -128,10 +188,17 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }, {}, ) expect(calls).toEqual(["chat:deployment"]) @@ -142,10 +209,17 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }, {}, ) expect(calls).toEqual(["chat:deployment"]) @@ -156,11 +230,13 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure", "deployment", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { useCompletionUrls: true } }, }), sdk: fakeSelectorSdk(calls), @@ -176,15 +252,29 @@ describe("AzurePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) const ignored = yield* plugin.trigger( "aisdk.language", - { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual(["responses:deployment"]) @@ -200,11 +290,14 @@ describe("AzurePlugin", () => { calls.push(`${method}:${id}`) return { modelId: id, provider: method, specificationVersion: "v3" } } - yield* addPlugin(plugin, AzurePlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("azure", "messages-deployment"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, options: {}, }, @@ -212,7 +305,14 @@ describe("AzurePlugin", () => { ) yield* plugin.trigger( "aisdk.language", - { model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: make("languageModel") }, + options: {}, + }, {}, ) expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"]) diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index 5bcb9f7a0b69..5501ad39e3fc 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -1,12 +1,22 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const cerebrasOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CerebrasPlugin.id, effect: CerebrasPlugin.effect(host) }) +}) void mock.module("@ai-sdk/cerebras", () => ({ createCerebras: (options: Record) => { @@ -21,16 +31,15 @@ void mock.module("@ai-sdk/cerebras", () => ({ describe("CerebrasPlugin", () => { it.effect("applies the legacy integration header", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, CerebrasPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => { item.api = { type: "aisdk", package: "@ai-sdk/cerebras" } item.request.headers.Existing = "1" }) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras")))?.request.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode", }) @@ -39,11 +48,10 @@ describe("CerebrasPlugin", () => { it.effect("ignores non-Cerebras providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, CerebrasPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {})) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({}) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("groq")))?.request.headers).toEqual({}) }), ) @@ -51,11 +59,21 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CerebrasPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/cerebras", options: { name: "custom-cerebras", apiKey: "test" }, }, @@ -70,11 +88,21 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CerebrasPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/cerebras", options: { name: "configured-cerebras", apiKey: "test" }, }, @@ -88,11 +116,21 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CerebrasPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/groq", options: { name: "custom-cerebras", apiKey: "test" }, }, diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 2332a3ca27d8..31ce4448f018 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,8 +1,41 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" -import { addPlugin, it, model, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CloudflareAIGatewayPlugin.id, effect: CloudflareAIGatewayPlugin.effect(host) }) +}) + +function withEnv(vars: Record, fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + fx, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} const aiGatewayCalls: Record[] = [] const unifiedCalls: string[] = [] @@ -78,11 +111,14 @@ describe("CloudflareAIGatewayPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -98,12 +134,15 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -142,12 +181,15 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -171,12 +213,15 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -208,12 +253,15 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", @@ -239,12 +287,15 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -261,12 +312,15 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -284,12 +338,15 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -313,12 +370,15 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, }, @@ -336,12 +396,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), package: "ai-gateway-provider", options: { name: "cloudflare-ai-gateway" }, }, @@ -364,12 +434,15 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareAIGatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-ai-gateway", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-ai-gateway" }, }, diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 8e27781d07ad..f6da837d8bac 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -3,9 +3,59 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CloudflareWorkersAIPlugin.id, effect: CloudflareWorkersAIPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( @@ -37,12 +87,15 @@ describe("CloudflareWorkersAIPlugin", () => { provider.api = { type: "aisdk", package: "test-provider" } }), ) - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))) const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { api: provider.api }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { id: ModelV2.ID.make("@cf/model"), ...provider.api }, + }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, }, @@ -61,14 +114,13 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("preserves a configured endpoint URL instead of deriving one from account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" } }), ) - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", @@ -82,12 +134,18 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", + }, }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, @@ -102,7 +160,6 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("uses env account ID over configured account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { @@ -110,7 +167,7 @@ describe("CloudflareWorkersAIPlugin", () => { provider.request.body.accountId = "configured-acct" }), ) - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({ type: "aisdk", package: "test-provider", @@ -124,12 +181,18 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", + }, }), package: "@ai-sdk/openai-compatible", options: { @@ -153,12 +216,14 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), api: { + id: ModelV2.ID.make("@cf/model"), type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", @@ -183,11 +248,14 @@ describe("CloudflareWorkersAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("cloudflare-workers-ai", "alias", { api: { id: ModelV2.ID.make("@cf/api-model") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -202,12 +270,18 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CloudflareWorkersAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "@cf/model", { - api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/anthropic", + url: "https://proxy.example/v1", + }, }), package: "@ai-sdk/anthropic", options: { name: "cloudflare-workers-ai" }, diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts index c653f65a014a..f0d09b8411ea 100644 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ b/packages/core/test/plugin/provider-cohere.test.ts @@ -2,10 +2,34 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const cohereOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: CoherePlugin.id, effect: CoherePlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} void mock.module("@ai-sdk/cohere", () => ({ createCohere: (options: Record) => { @@ -24,18 +48,32 @@ describe("CoherePlugin", () => { it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CoherePlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", - { model: model("cohere", "command"), package: "@ai-sdk/openai-compatible", options: { name: "cohere" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cohere" }, + }, {}, ) expect(ignored.sdk).toBeUndefined() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("cohere", "command"), package: "@ai-sdk/cohere", options: { name: "cohere" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/cohere", + options: { name: "cohere" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -45,11 +83,14 @@ describe("CoherePlugin", () => { it.effect("uses the model provider ID as the bundled SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, CoherePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-cohere", "command-r-plus"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/cohere", options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, }, @@ -70,10 +111,17 @@ describe("CoherePlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) - yield* addPlugin(plugin, CoherePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }, {}, ) diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts index 7e2b6322f193..db7dd1042974 100644 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ b/packages/core/test/plugin/provider-deepinfra.test.ts @@ -1,20 +1,25 @@ import { describe, expect, mock } from "bun:test" -import { Effect, Layer } from "effect" -import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { addPlugin, it, model } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const itAISDK = testEffect( - Layer.provideMerge(AISDK.layer, PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), -) -const deepinfraOptions: Record[] = [] +const it = testEffect(PluginTestLayer) +const deepinfraOptions: Record[] = [] const deepinfraLanguageModels: string[] = [] +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: DeepInfraPlugin.id, effect: DeepInfraPlugin.effect(host) }) +}) + void mock.module("@ai-sdk/deepinfra", () => ({ - createDeepInfra: (options: Record) => { + createDeepInfra: (options: Record) => { const captured = { ...options } deepinfraOptions.push(captured) return { @@ -36,10 +41,17 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -50,11 +62,14 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-deepinfra", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), package: "@ai-sdk/deepinfra", options: { name: "custom-deepinfra", apiKey: "test" }, }, @@ -69,11 +84,14 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("deepinfra", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), package: "@ai-sdk/deepinfra", options: { name: "deepinfra", apiKey: "test" }, }, @@ -88,7 +106,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, DeepInfraPlugin) + yield* addPlugin() const packages = [ "unmatched-package", "@ai-sdk/deepinfra-compatible", @@ -98,7 +116,14 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { const ignored = yield* plugin.trigger( "aisdk.sdk", - { model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: item, + options: { name: "deepinfra" }, + }, {}, ) expect(ignored.sdk).toBeUndefined() @@ -106,7 +131,14 @@ describe("DeepInfraPlugin", () => { ) const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -114,17 +146,36 @@ describe("DeepInfraPlugin", () => { }), ) - itAISDK.effect("uses the default languageModel selection for DeepInfra models", () => + it.effect("uses the default languageModel selection for DeepInfra models", () => Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(plugin, DeepInfraPlugin) - const language = yield* aisdk.language( - model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", { - api: { type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), + yield* addPlugin() + const sdkEvent = yield* plugin.trigger( + "aisdk.sdk", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("deepinfra"), + ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), + ), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), + type: "aisdk", + package: "@ai-sdk/deepinfra", + }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }, + {}, + ) + const result = yield* plugin.trigger( + "aisdk.language", + { model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }, + {}, ) + const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.provider).toBe("deepinfra.chat") expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) }), diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index f3b0bf898f7e..150c9ea84d6c 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -1,43 +1,40 @@ import { Npm } from "@opencode-ai/core/npm" import { describe, expect } from "bun:test" -import { Cause, Effect, Layer, Option } from "effect" +import { Cause, Effect, Layer } from "effect" import fs from "fs/promises" import os from "os" import path from "path" import { fileURLToPath } from "url" import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { host } from "./host" -import { fixtureProvider, it, model, npmLayer } from "./provider-helper" +import { PluginTestLayer } from "./fixture" +const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href const fixtureProviderPath = fileURLToPath(fixtureProvider) -const itWithAISDK = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) +const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginTestLayer))) -function npmEntrypointLayer(entrypoint?: string) { - return Layer.succeed( - Npm.Service, - Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint }), - install: () => Effect.void, - which: () => Effect.succeed(undefined), - }), - ) +function npmEntrypoint(entrypoint?: string) { + return Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }) } -function dynamicPlugin(layer = npmLayer) { - return { +const addPlugin = Effect.fn(function* (npm?: Npm.Interface) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: DynamicProviderPlugin.id, - effect: Effect.gen(function* () { - yield* DynamicProviderPlugin.effect(host({ npm: yield* Npm.Service })) - }).pipe(Effect.provide(layer)), - } -} + effect: DynamicProviderPlugin.effect(npm ? { ...host, npm } : host), + }) +}) function tempEntrypoint(source: string) { return Effect.acquireRelease( @@ -55,11 +52,14 @@ describe("DynamicProviderPlugin", () => { it.effect("creates an SDK from a provider factory export", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), package: fixtureProvider, options: { name: "custom", marker: "dynamic" }, }, @@ -74,11 +74,14 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const sdk = { marker: "existing" } - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), package: fixtureProvider, options: { name: "custom", marker: "dynamic" }, }, @@ -91,11 +94,14 @@ describe("DynamicProviderPlugin", () => { it.effect("injects the provider ID as the SDK factory name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-provider", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), package: fixtureProvider, options: { name: "custom-provider", marker: "dynamic" }, }, @@ -108,11 +114,14 @@ describe("DynamicProviderPlugin", () => { it.effect("loads npm packages through their resolved import entrypoint", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(fixtureProviderPath))) + yield* addPlugin(npmEntrypoint(fixtureProviderPath)) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("npm-provider", "test-model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" }, + }), package: "fixture-provider", options: { name: "npm-provider", marker: "npm" }, }, @@ -126,9 +135,14 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin(npmEntrypointLayer())) + yield* addPlugin(npmEntrypoint()) const exit = yield* aisdk - .language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) + .language( + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + }), + ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") @@ -139,10 +153,13 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const exit = yield* aisdk .language( - model("bad-import", "alias", { api: { type: "aisdk", package: "file:///missing/provider-factory.js" } }), + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" }, + }), ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") @@ -155,9 +172,14 @@ describe("DynamicProviderPlugin", () => { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n") - yield* plugin.add(dynamicPlugin(npmEntrypointLayer(tmp.entrypoint))) + yield* addPlugin(npmEntrypoint(tmp.entrypoint)) const exit = yield* aisdk - .language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } })) + .language( + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" }, + }), + ) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") @@ -168,9 +190,10 @@ describe("DynamicProviderPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service - yield* plugin.add(dynamicPlugin()) + yield* addPlugin() const language = yield* aisdk.language( - model("custom", "alias", { + new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider }, }), ) diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts index 6627d185a58d..619e184d83ef 100644 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ b/packages/core/test/plugin/provider-gateway.test.ts @@ -1,11 +1,22 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const gatewayCalls: Record[] = [] const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GatewayPlugin.id, effect: GatewayPlugin.effect(host) }) +}) mock.module("@ai-sdk/gateway", () => ({ createGateway(options: Record) { @@ -27,10 +38,17 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "gateway" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -42,12 +60,19 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GatewayPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("vercel", "anthropic/claude-sonnet-4"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4"), + type: "aisdk", + package: "test-provider", + }, + }), package: "@ai-sdk/gateway", options: { name: "vercel", apiKey: "test-key" }, }, @@ -63,19 +88,33 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GatewayPlugin) + yield* addPlugin() for (const modelID of vercelGatewayModels) { const ignored = yield* plugin.trigger( "aisdk.sdk", - { model: model("vercel", modelID), package: "@ai-sdk/vercel", options: { name: "vercel" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/vercel", + options: { name: "vercel" }, + }, {}, ) expect(ignored.sdk).toBeUndefined() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("vercel", modelID), package: "@ai-sdk/gateway", options: { name: "vercel" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "vercel" }, + }, {}, ) expect(result.sdk).toBeDefined() diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index d23672f6fe95..b8f615f93374 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -3,19 +3,51 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GithubCopilotPlugin.id, effect: GithubCopilotPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("GithubCopilotPlugin", () => { it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", { - model: model("github-copilot", "gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "github-copilot" }, }, @@ -24,7 +56,10 @@ describe("GithubCopilotPlugin", () => { const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("github-copilot", "gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/github-copilot", options: { name: "github-copilot" }, }, @@ -39,11 +74,14 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "claude-sonnet-4"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -57,11 +95,14 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "alias", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, @@ -75,30 +116,65 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), + api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), + api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) yield* plugin.trigger( "aisdk.language", - { model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), + api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([ @@ -115,11 +191,14 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "default", { api: { id: ModelV2.ID.make("gpt-5") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -128,7 +207,10 @@ describe("GithubCopilotPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "small", { api: { id: ModelV2.ID.make("gpt-5-mini") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -137,7 +219,10 @@ describe("GithubCopilotPlugin", () => { yield* plugin.trigger( "aisdk.language", { - model: model("github-copilot", "sonnet", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -149,13 +234,12 @@ describe("GithubCopilotPlugin", () => { it.effect("disables gpt-5-chat-latest before Copilot language selection", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GithubCopilotPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) .enabled, @@ -165,13 +249,12 @@ describe("GithubCopilotPlugin", () => { it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GithubCopilotPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))) .enabled, @@ -183,10 +266,17 @@ describe("GithubCopilotPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GithubCopilotPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index b4277d140fb7..1940bba937d3 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,12 +1,43 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const gitlabSDKOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GitLabPlugin.id, effect: GitLabPlugin.effect(host) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} void mock.module("gitlab-ai-provider", () => ({ VERSION: "test-version", @@ -32,10 +63,17 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", - { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }, {}, ) expect(gitlabSDKOptions).toHaveLength(1) @@ -65,10 +103,17 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", - { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }, {}, ) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example") @@ -86,11 +131,14 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("gitlab", "claude"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), package: "gitlab-ai-provider", options: { name: "gitlab", @@ -127,10 +175,17 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "gitlab" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -142,11 +197,13 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "duo-workflow-custom", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { workflowRef: "ref", workflowDefinition: "definition" }, @@ -178,11 +235,14 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "duo-workflow-exact"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), + api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" }, + }), sdk: { workflowChat: (id: string, options: unknown) => { calls.push([id, options]) @@ -205,11 +265,13 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "duo-workflow-custom", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { featureFlags: { request_flag: true } }, @@ -234,11 +296,13 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: [string, unknown][] = [] - yield* addPlugin(plugin, GitLabPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("gitlab", "claude", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, request: { headers: { h: "v" }, body: {} }, }), sdk: { diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index 57c90e8148fe..fe9b0b0d9582 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -1,10 +1,50 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: definition.id, effect: definition.effect(host) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} + +function selector(calls: string[]) { + return (id: string) => { + calls.push(`languageModel:${id}`) + return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 + } +} describe("GoogleVertexAnthropicPlugin", () => { it.effect("resolves legacy project and location env on provider update", () => @@ -19,17 +59,19 @@ describe("GoogleVertexAnthropicPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } }), ) - const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))) - expect(provider.request.body.project).toBe("cloud-project") - expect(provider.request.body.location).toBe("cloud-location") + yield* addPlugin(GoogleVertexAnthropicPlugin) + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project, + ).toBe("cloud-project") + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, + ).toBe("cloud-location") }), ), ) @@ -37,9 +79,7 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured project and location over env fallback", () => withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" } @@ -47,9 +87,13 @@ describe("GoogleVertexAnthropicPlugin", () => { provider.request.body.location = "configured-location" }), ) - const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))) - expect(provider.request.body.project).toBe("configured-project") - expect(provider.request.body.location).toBe("configured-location") + yield* addPlugin(GoogleVertexAnthropicPlugin) + expect((yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.project).toBe( + "configured-project", + ) + expect( + (yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic")))?.request.body.location, + ).toBe("configured-location") }), ), ) @@ -67,11 +111,17 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex-anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex-anthropic" }, }, @@ -90,11 +140,17 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex-anthropic", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex-anthropic" }, }, @@ -110,11 +166,14 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "eu" }, }, @@ -129,11 +188,14 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured baseURL for google-vertex Anthropic models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "claude-sonnet-4-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, }, @@ -146,12 +208,15 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("selects google-vertex Anthropic language models through V2 plugins", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexPlugin) - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const sdkResult = yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", " claude-sonnet-4-5 "), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/google-vertex/anthropic", options: { name: "google-vertex", project: "project", location: "us" }, }, @@ -160,7 +225,10 @@ describe("GoogleVertexAnthropicPlugin", () => { const languageResult = yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex", " claude-sonnet-4-5 "), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), sdk: sdkResult.sdk, options: {}, }, @@ -178,12 +246,18 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex-anthropic", " claude-sonnet-4-5 "), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make(" claude-sonnet-4-5 "), + ), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, options: {}, }, {}, @@ -196,12 +270,15 @@ describe("GoogleVertexAnthropicPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GoogleVertexAnthropicPlugin) + yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex", "claude-sonnet-4-5"), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, options: {}, }, {}, diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index bebfa1dc85bc..f5f62f8df79c 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -1,13 +1,63 @@ import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, fakeSelectorSdk, it, model, required, withEnv } from "./provider-helper" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" const vertexOptions: Record[] = [] const googleAuthOptions: Record[] = [] +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GoogleVertexPlugin.id, effect: GoogleVertexPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} void mock.module("@ai-sdk/google-vertex", () => ({ createVertex: (options: Record) => { @@ -37,9 +87,7 @@ void mock.module("google-auth-library", () => ({ describe("GoogleVertexPlugin", () => { it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.opencode, (provider) => { provider.api = { @@ -49,6 +97,7 @@ describe("GoogleVertexPlugin", () => { } }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.opencode)) expect(provider.request.body).toEqual({}) @@ -67,9 +116,7 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -79,6 +126,7 @@ describe("GoogleVertexPlugin", () => { } }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("google-cloud-project") expect(provider.request.body.location).toBe("google-vertex-location") @@ -107,7 +155,6 @@ describe("GoogleVertexPlugin", () => { vertexOptions.length = 0 const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -117,12 +164,18 @@ describe("GoogleVertexPlugin", () => { } }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, }), package: "@ai-sdk/google-vertex", options: { name: "google-vertex" }, @@ -154,9 +207,7 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -168,6 +219,7 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "global" }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("global") @@ -182,9 +234,7 @@ describe("GoogleVertexPlugin", () => { it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { @@ -196,6 +246,7 @@ describe("GoogleVertexPlugin", () => { provider.request.body.location = "eu" }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.api).toEqual({ type: "aisdk", @@ -217,15 +268,14 @@ describe("GoogleVertexPlugin", () => { }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, GoogleVertexPlugin) yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" } provider.request.body.project = "config-project" }), ) + yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) expect(provider.request.body.project).toBe("config-project") expect(provider.request.body.location).toBe("us-central1") @@ -243,12 +293,17 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/google-vertex" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, }), package: "@ai-sdk/google-vertex", options: { name: "google-vertex" }, @@ -268,21 +323,17 @@ describe("GoogleVertexPlugin", () => { googleAuthOptions.length = 0 const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GoogleVertexPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("capture-openai-compatible"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.promise(async () => { - if (evt.model.providerID !== "google-vertex") return - if (evt.package !== "@ai-sdk/openai-compatible") return - expect(typeof evt.options.fetch).toBe("function") - await evt.options.fetch("https://vertex.example", { - headers: { "x-test": "1" }, - }) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (evt) => + Effect.promise(async () => { + if (evt.model.providerID !== "google-vertex") return + if (evt.package !== "@ai-sdk/openai-compatible") return + expect(typeof evt.options.fetch).toBe("function") + await evt.options.fetch("https://vertex.example", { + headers: { "x-test": "1" }, + }) }), - }) + ) const originalFetch = fetch ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async ( input: Parameters[0], @@ -297,8 +348,13 @@ describe("GoogleVertexPlugin", () => { plugin.trigger( "aisdk.sdk", { - model: model("google-vertex", "gemini", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + }, }), package: "@ai-sdk/openai-compatible", options: { name: "google-vertex" }, @@ -322,11 +378,14 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, GoogleVertexPlugin) + yield* addPlugin() yield* plugin.trigger( "aisdk.language", { - model: model("google-vertex", " gemini-2.5-pro "), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), + api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index c1fab4201e82..1197957f5a9c 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -1,26 +1,33 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { addPlugin, it, model } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const itWithAISDK = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GooglePlugin.id, effect: GooglePlugin.effect(host) }) +}) describe("GooglePlugin", () => { it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GooglePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-google", "gemini"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), package: "@ai-sdk/google", options: { name: "custom-google", apiKey: "test" }, }, @@ -34,34 +41,49 @@ describe("GooglePlugin", () => { it.effect("ignores non-Google SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GooglePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google" }, + }, {}, ) expect(result.sdk).toBeUndefined() }), ) - itWithAISDK.effect("uses default languageModel loading with provider ID parity", () => + it.effect("uses default languageModel loading with provider ID parity", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(plugin, GooglePlugin) - const language = yield* aisdk.language( - model("custom-google", "alias", { - api: { - id: ModelV2.ID.make("gemini-api"), - type: "aisdk", - package: "@ai-sdk/google", - }, - request: { - headers: {}, - body: { apiKey: "test" }, - }, - }), + yield* addPlugin() + const sdkEvent = yield* plugin.trigger( + "aisdk.sdk", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }, + {}, + ) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: sdkEvent.model, + sdk: sdkEvent.sdk, + options: sdkEvent.options, + }, + {}, ) + const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.modelId).toBe("gemini-api") expect(language.provider).toBe("custom-google") }), diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts index 71eb1eeabdfd..dbc97205b264 100644 --- a/packages/core/test/plugin/provider-groq.test.ts +++ b/packages/core/test/plugin/provider-groq.test.ts @@ -1,26 +1,37 @@ import { describe, expect } from "bun:test" import { createGroq } from "@ai-sdk/groq" -import { Effect, Layer } from "effect" -import { AISDK } from "@opencode-ai/core/aisdk" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const aisdkIt = testEffect( - AISDK.layer.pipe(Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))), -) +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: GroqPlugin.id, effect: GroqPlugin.effect(host) }) +}) describe("GroqPlugin", () => { it.effect("creates a Groq SDK for @ai-sdk/groq", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq", + options: { name: "groq" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -30,10 +41,17 @@ describe("GroqPlugin", () => { it.effect("ignores non-Groq SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "groq" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -43,10 +61,17 @@ describe("GroqPlugin", () => { it.effect("only matches the bundled @ai-sdk/groq package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq/compat", + options: { name: "groq" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -56,11 +81,14 @@ describe("GroqPlugin", () => { it.effect("matches the old bundled Groq SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, GroqPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-groq", "llama"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), package: "@ai-sdk/groq", options: { name: "custom-groq", apiKey: "test" }, }, @@ -75,26 +103,32 @@ describe("GroqPlugin", () => { }), ) - aisdkIt.effect("uses the default languageModel(api.id) behavior", () => + it.effect("uses the default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin(plugin, GroqPlugin) - const result = yield* aisdk.language( - model("groq", "alias", { - api: { - id: ModelV2.ID.make("llama-api"), - type: "aisdk", - package: "@ai-sdk/groq", - }, - request: { - headers: {}, - body: { apiKey: "test" }, - }, - }), + yield* addPlugin() + const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters[0] & { + name: string + }) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), + api: { + id: ModelV2.ID.make("llama-api"), + type: "aisdk", + package: "@ai-sdk/groq", + }, + }), + sdk, + options: { name: "groq", apiKey: "test" }, + }, + {}, ) - expect(result.modelId).toBe("llama-api") - expect(result.provider).toBe("groq.chat") + const language = result.language ?? sdk.languageModel(result.model.api.id) + expect(language.modelId).toBe("llama-api") + expect(language.provider).toBe("groq.chat") }), ) }) diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts deleted file mode 100644 index d30286bc0937..000000000000 --- a/packages/core/test/plugin/provider-helper.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { Npm } from "@opencode-ai/core/npm" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { expect } from "bun:test" -import { Effect, Layer, Option } from "effect" -import { Catalog } from "@opencode-ai/core/catalog" -import { Integration } from "@opencode-ai/core/integration" -import { Credential } from "@opencode-ai/core/credential" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { aisdkHost, catalogHost, host, integrationHost } from "./host" - -export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href - -export function required(value: T | undefined): T { - if (value === undefined) throw new Error("Expected value") - return value -} - -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("test") })), -) - -export const npmLayer = Layer.succeed( - Npm.Service, - Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint: undefined }), - install: () => Effect.void, - which: () => Effect.succeed(undefined), - }), -) - -export const catalogLayer = Layer.succeed( - Catalog.Service, - Catalog.Service.of({ - transform: (_transform) => Effect.die("unexpected catalog.transform"), - rebuild: () => Effect.die("unexpected catalog.rebuild"), - provider: { - get: () => Effect.die("unexpected provider.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - }, - model: { - get: () => Effect.die("unexpected model.get"), - all: () => Effect.succeed([]), - available: () => Effect.succeed([]), - default: () => Effect.succeed(undefined), - small: () => Effect.succeed(undefined), - }, - }), -) - -const integrations = Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide( - Layer.mock(Credential.Service)({ - create: () => Effect.die("unexpected credential creation"), - all: () => Effect.succeed([]), - list: () => Effect.succeed([]), - }), - ), -) - -export const it = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(integrations), - Layer.provideMerge( - Layer.mock(Credential.Service)({ - all: () => Effect.succeed([]), - }), - ), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge(npmLayer), - Layer.provideMerge(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))), - ), -) - -export function addPlugin(plugin: PluginV2.Interface, definition: Plugin) { - return Effect.gen(function* () { - const catalog = yield* Effect.serviceOption(Catalog.Service) - const integration = yield* Effect.serviceOption(Integration.Service) - const npm = yield* Effect.serviceOption(Npm.Service) - const effect = - typeof definition.effect === "function" - ? definition.effect( - host({ - aisdk: aisdkHost(plugin), - ...(Option.isSome(catalog) ? { catalog: catalogHost(catalog.value) } : {}), - ...(Option.isSome(integration) ? { integration: integrationHost(integration.value) } : {}), - ...(Option.isSome(npm) ? { npm: npm.value } : {}), - }), - ) - : definition.effect - yield* plugin.add({ id: definition.id, effect }) - }) -} - -type ProviderInput = Partial> & { - api?: ProviderV2.Api - request?: ProviderV2.Request -} - -type ModelInput = Partial> & { - api?: (ProviderV2.Api & { id?: ModelV2.ID }) | { id: ModelV2.ID } - request?: ModelV2.Info["request"] -} - -export function provider(providerID: string, options?: ProviderInput) { - return new ProviderV2.Info({ - ...ProviderV2.Info.empty(ProviderV2.ID.make(providerID)), - api: options?.api ?? { - type: "aisdk", - package: "test-provider", - }, - ...options, - request: { - headers: {}, - body: {}, - ...options?.request, - }, - }) -} - -export function model(providerID: string, modelID: string, options?: ModelInput) { - return new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), - ...options, - api: - options?.api && "type" in options.api - ? { id: ModelV2.ID.make(modelID), ...options.api } - : { - id: ModelV2.ID.make(modelID), - ...options?.api, - type: "aisdk", - package: "test-provider", - }, - request: { - headers: {}, - body: {}, - ...options?.request, - }, - }) -} - -export function withEnv(vars: Record, fx: () => Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) - for (const [key, value] of Object.entries(vars)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - return previous - }), - () => fx(), - (previous) => - Effect.sync(() => { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - }), - ) -} - -export function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -export function expectPluginRegistered(ids: string[], id: string) { - expect(ids).toContain(PluginV2.ID.make(id)) -} diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index d54bf31342b9..5e7a7c2d2bb7 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -2,96 +2,98 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: KiloPlugin.id, effect: KiloPlugin.effect(host) }) +}) describe("KiloPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "kilo", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))), ) it.effect("applies legacy referer headers only to kilo", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, KiloPlugin) yield* catalog.transform((catalog) => { - const kilo = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(kilo.id, (draft) => { - draft.api = kilo.api - draft.request = kilo.request + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) - catalog.provider.update(provider("openrouter").id, () => {}) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("uses the exact legacy Kilo header casing and set", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, KiloPlugin) yield* catalog.transform((catalog) => { - const item = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } }) }) + yield* addPlugin() - const result = required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))) - expect(result.request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(result.request.headers).not.toHaveProperty("http-referer") - expect(result.request.headers).not.toHaveProperty("x-title") - expect(result.request.headers).not.toHaveProperty("X-Source") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty( + "http-referer", + ) + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("x-title") + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).not.toHaveProperty("X-Source") }), ) it.effect("uses the legacy provider-id guard instead of endpoint package matching", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, KiloPlugin) yield* catalog.transform((catalog) => { - const kilo = provider("kilo", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" }, - }) - catalog.provider.update(kilo.id, (draft) => { - draft.api = kilo.api - }) - const custom = provider("custom-kilo", { - api: { type: "aisdk", package: "kilo" }, + catalog.provider.update(ProviderV2.ID.make("kilo"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/api/gateway", + } }) - catalog.provider.update(custom.id, (draft) => { - draft.api = custom.api + catalog.provider.update(ProviderV2.ID.make("custom-kilo"), (provider) => { + provider.api = { type: "aisdk", package: "kilo" } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 456880c194d2..0fc22c5235d0 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -3,36 +3,28 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { ProviderV2 } from "@opencode-ai/core/provider" -import { expectPluginRegistered, it, provider, required } from "./provider-helper" -import { catalogHost, host, integrationHost } from "./host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -describe("LLMGatewayPlugin", () => { - const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) { - const integrations = yield* Integration.Service - const catalog = yield* Catalog.Service - yield* plugin.add({ - ...LLMGatewayPlugin, - effect: LLMGatewayPlugin.effect( - host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) }), - ), - }) - }) +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: LLMGatewayPlugin.id, effect: LLMGatewayPlugin.effect(host) }) +}) +describe("LLMGatewayPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "llmgateway", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))), ) it.effect("applies legacy referer headers only to enabled llmgateway", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service const integrations = yield* Integration.Service yield* integrations.transform((editor) => { @@ -40,43 +32,48 @@ describe("LLMGatewayPlugin", () => { editor.update(Integration.ID.make("openrouter"), () => {}) }) yield* catalog.transform((catalog) => { - const llmgateway = provider("llmgateway", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(llmgateway.id, (draft) => { - draft.api = llmgateway.api - draft.request = llmgateway.request + catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.llmgateway.io/v1", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - yield* add(plugin) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-Source": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("does not apply legacy headers to a disabled llmgateway provider", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin) + const integrations = yield* Integration.Service + yield* integrations.transform((editor) => { + editor.update(Integration.ID.make("llmgateway"), () => {}) + }) yield* catalog.transform((catalog) => { - const item = provider("llmgateway", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + catalog.provider.update(ProviderV2.ID.make("llmgateway"), (provider) => { + provider.disabled = true + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.llmgateway.io/v1", + } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.disabled).toBe(true) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts index ea3b3a670969..f09e0e62c700 100644 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ b/packages/core/test/plugin/provider-mistral.test.ts @@ -1,18 +1,37 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: MistralPlugin.id, effect: MistralPlugin.effect(host) }) +}) describe("MistralPlugin", () => { it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, MistralPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -22,11 +41,14 @@ describe("MistralPlugin", () => { it.effect("ignores non-Mistral SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, MistralPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("mistral", "mistral-large"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "mistral" }, }, @@ -40,19 +62,22 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* addPlugin(plugin, MistralPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("mistral-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("mistral-large").provider) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (event) => + Effect.sync(() => { + providers.push(event.sdk.languageModel("mistral-large").provider) }), - }) + ) const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -64,20 +89,19 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const providers: string[] = [] - yield* addPlugin(plugin, MistralPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("mistral-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("mistral-large").provider) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (event) => + Effect.sync(() => { + providers.push(event.sdk.languageModel("mistral-large").provider) }), - }) + ) yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-mistral", "mistral-large"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/mistral", options: { name: "custom-mistral" }, }, @@ -91,11 +115,23 @@ describe("MistralPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - const sdk = fakeSelectorSdk(calls) - yield* addPlugin(plugin, MistralPlugin) + const sdk = { + languageModel: (id: string) => { + calls.push(`languageModel:${id}`) + return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 + }, + } + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }, {}, ) const language = result.language ?? sdk.languageModel(result.model.api.id) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index c5c986f62915..ee16e5a2beab 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -2,64 +2,66 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: NvidiaPlugin.id, effect: NvidiaPlugin.effect(host) }) +}) describe("NvidiaPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "nvidia", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))), ) it.effect("applies NVIDIA tracking headers only to nvidia", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, NvidiaPlugin) yield* catalog.transform((catalog) => { - const nvidia = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { headers: { Existing: "value" }, body: {} }, + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } + provider.request = { headers: { Existing: "value" }, body: {} } }) - catalog.provider.update(nvidia.id, (draft) => { - draft.api = nvidia.api - draft.request = nvidia.request - }) - catalog.provider.update(provider("openrouter").id, () => {}) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({}) }), ) it.effect("adds billing origin for custom NVIDIA endpoints", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, NvidiaPlugin) yield* catalog.transform((catalog) => { - const item = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { headers: {}, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "OpenCode", @@ -69,24 +71,23 @@ describe("NvidiaPlugin", () => { it.effect("preserves an explicit NVIDIA billing origin header", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, NvidiaPlugin) yield* catalog.transform((catalog) => { - const item = provider("nvidia", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" }, - request: { + catalog.provider.update(ProviderV2.ID.make("nvidia"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://integrate.api.nvidia.com/v1", + } + provider.request = { headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, body: { baseURL: "https://integrate.api.nvidia.com/v1" }, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + } }) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin", diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index 7e695c89c06a..c0601c2ba336 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -1,23 +1,45 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible" -import { addPlugin, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: OpenAICompatiblePlugin.id, effect: OpenAICompatiblePlugin.effect(host) }) +}) describe("OpenAICompatiblePlugin", () => { it.effect("preserves explicit includeUsage false and defaults it to true", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, OpenAICompatiblePlugin) + yield* addPlugin() const defaulted = yield* plugin.trigger( "aisdk.sdk", - { model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom" }, + }, {}, ) const disabled = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "custom", includeUsage: false }, }, @@ -31,11 +53,14 @@ describe("OpenAICompatiblePlugin", () => { it.effect("defaults includeUsage for OpenAI-compatible package matches", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, OpenAICompatiblePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", options: { name: "custom" }, }, @@ -49,20 +74,19 @@ describe("OpenAICompatiblePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const observed: string[] = [] - yield* addPlugin(plugin, OpenAICompatiblePlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), + yield* addPlugin() + yield* plugin.hook("aisdk.sdk", (event) => + Effect.sync(() => { + observed.push(event.sdk.languageModel("model").provider) }), - }) + ) yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-provider", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "custom-provider", baseURL: "https://example.com/v1" }, }, @@ -85,11 +109,14 @@ describe("OpenAICompatiblePlugin", () => { }), }), }) - yield* addPlugin(plugin, OpenAICompatiblePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("cloudflare-workers-ai", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "cloudflare-workers-ai" }, }, diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index d41a856ce75b..a9911d2cc8a6 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,28 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { fakeSelectorSdk, it, model, provider, required } from "./provider-helper" -import { host, integrationHost } from "./host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -function add(plugin: PluginV2.Interface, integrations: Integration.Interface) { - return plugin.add({ +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + const integrations = yield* Integration.Service + yield* plugin.add({ id: OpenAIPlugin.id, - effect: OpenAIPlugin.effect(host({ integration: integrationHost(integrations) })).pipe( - Effect.provideService(Integration.Service, integrations), - ), + effect: OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)), }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } } describe("OpenAIPlugin", () => { it.effect("registers browser and headless ChatGPT OAuth methods", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([ { id: Integration.MethodID.make("chatgpt-browser"), @@ -41,11 +63,14 @@ describe("OpenAIPlugin", () => { it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-openai", "gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai", options: { name: "custom-openai", apiKey: "test" }, }, @@ -58,10 +83,17 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "openai" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -72,11 +104,12 @@ describe("OpenAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("openai", "alias", { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, }), sdk: fakeSelectorSdk(calls), @@ -93,10 +126,17 @@ describe("OpenAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* add(plugin, yield* Integration.Service) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([]) @@ -106,17 +146,19 @@ describe("OpenAIPlugin", () => { it.effect("disables gpt-5-chat-latest during catalog transforms", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin, yield* Integration.Service) yield* catalog.transform((catalog) => { - const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } }) + const item = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "@ai-sdk/openai" }, + }) catalog.provider.update(item.id, (draft) => { draft.api = item.api }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true) expect( required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled, @@ -126,14 +168,18 @@ describe("OpenAIPlugin", () => { it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* add(plugin, yield* Integration.Service) yield* catalog.transform((catalog) => { - const item = provider("custom-openai") - catalog.provider.update(item.id, () => {}) + const item = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.make("custom-openai")), + api: { type: "aisdk", package: "test-provider" }, + }) + catalog.provider.update(item.id, (draft) => { + draft.api = item.api + }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( required(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))) .enabled, diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 9fe4be97332a..750b71463cfd 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,45 +1,72 @@ import { describe, expect } from "bun:test" -import { Effect, Layer, Option } from "effect" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Credential } from "@opencode-ai/core/credential" -import { EventV2 } from "@opencode-ai/core/event" import { Integration } from "@opencode-ai/core/integration" -import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { it, model, provider, required, withEnv } from "./provider-helper" -import { catalogHost, host, integrationHost } from "./host" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("test") })), -) - -const pluginWithIntegrations = (catalog: Catalog.Interface, integrations: Integration.Interface) => ({ - ...OpencodePlugin, - effect: OpencodePlugin.effect(host({ catalog: catalogHost(catalog), integration: integrationHost(integrations) })), +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: OpencodePlugin.id, effect: OpencodePlugin.effect(host) }) }) +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }), + ), + ) +} + +const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] + describe("OpencodePlugin", () => { it.effect("uses a public key and disables paid models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false) }), @@ -49,17 +76,23 @@ describe("OpencodePlugin", () => { it.effect("keeps free models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const free = model("opencode", "free", { cost: cost(0) }) - catalog.model.update(item.id, free.id, (draft) => { - draft.cost = [...free.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")), + api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" }, + cost: cost(0), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true) }), @@ -69,17 +102,23 @@ describe("OpencodePlugin", () => { it.effect("treats output-only cost as free without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) }) - catalog.model.update(item.id, outputOnly.id, (draft) => { - draft.cost = [...outputOnly.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")), + api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" }, + cost: cost(0, 1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe( true, @@ -91,17 +130,23 @@ describe("OpencodePlugin", () => { it.effect("uses OPENCODE_API_KEY as credentials", () => withEnv({ OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -111,10 +156,8 @@ describe("OpencodePlugin", () => { it.effect("uses configured provider env vars as credentials", () => withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - yield* plugin.add(pluginWithIntegrations(catalog, integrations)) yield* integrations.transform((editor) => { editor.method.update({ integrationID: Integration.ID.make("opencode"), @@ -122,13 +165,21 @@ describe("OpencodePlugin", () => { }) }) yield* catalog.transform((catalog) => { - const item = provider("opencode") - catalog.provider.update(item.id, () => {}) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -138,24 +189,29 @@ describe("OpencodePlugin", () => { it.effect("uses configured apiKey as credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("opencode", { + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + api: { type: "aisdk", package: "test-provider" }, request: { headers: {}, body: { apiKey: "configured" }, }, }) - catalog.provider.update(item.id, (draft) => { - draft.request = item.request + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, (draft) => { + draft.request = provider.request }) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured") expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -165,17 +221,23 @@ describe("OpencodePlugin", () => { it.effect("ignores non-opencode providers and models", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(pluginWithIntegrations(catalog, yield* Integration.Service)) yield* catalog.transform((catalog) => { - const item = provider("openai") - catalog.provider.update(item.id, () => {}) - const paid = model("openai", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] + const provider = new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "test-provider" }, + }) + const model = new ModelV2.Info({ + ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" }, + cost: cost(1), + }) + catalog.provider.update(provider.id, () => {}) + catalog.model.update(provider.id, model.id, (draft) => { + draft.cost = [...model.cost] }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined() expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true) }), @@ -206,8 +268,6 @@ describe("OpencodePlugin", () => { const selected = yield* catalog.model.small(providerID) expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano")) - }).pipe( - Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))), - ), + }), ) }) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 49b5875b5e08..5761827c4ea7 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -3,56 +3,59 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, expectPluginRegistered, it, model, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: OpenRouterPlugin.id, effect: OpenRouterPlugin.effect(host) }) +}) describe("OpenRouterPlugin", () => { it.effect("is registered so legacy OpenRouter behavior can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "openrouter", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))), ) it.effect("applies legacy referer headers only to openrouter", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, OpenRouterPlugin) yield* catalog.transform((catalog) => { - const openrouter = provider("openrouter", { - api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(openrouter.id, (item) => { - item.api = openrouter.api - item.request = openrouter.request + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } + provider.request = { headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {}) }) + yield* addPlugin() - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({ + expect((yield* catalog.provider.get(ProviderV2.ID.openrouter))?.request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode", }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({}) + expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia")))?.request.headers).toEqual({}) }), ) it.effect("creates an SDK only for the OpenRouter package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, OpenRouterPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", { - model: model("openrouter", "openai/gpt-5"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "openrouter" }, }, @@ -62,7 +65,14 @@ describe("OpenRouterPlugin", () => { const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@openrouter/ai-sdk-provider", + options: { name: "custom" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -71,52 +81,37 @@ describe("OpenRouterPlugin", () => { it.effect("filters OpenRouter's gpt-5 chat alias", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, OpenRouterPlugin) yield* catalog.transform((catalog) => { - const openrouter = provider("openrouter", { - api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" }, - }) - catalog.provider.update(openrouter.id, (item) => { - item.api = openrouter.api + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } }) catalog.provider.update(ProviderV2.ID.openai, () => {}) - for (const item of [ - model("openrouter", "openai/gpt-5-chat"), - model("openrouter", "openai/gpt-5"), - model("openai", "openai/gpt-5-chat"), - ]) { - catalog.model.update(item.providerID, item.id, () => {}) - } + catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) + catalog.model.update(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5"), () => {}) + catalog.model.update(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"), () => {}) }) + yield* addPlugin() - expect( - required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))) - .enabled, - ).toBe(false) - expect( - required(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled, - ).toBe(true) - expect( - required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled, - ).toBe(true) + expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe( + false, + ) + expect((yield* catalog.model.get(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")))?.enabled).toBe(true) + expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat")))?.enabled).toBe(true) }), ) it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, OpenRouterPlugin) yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {}) catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {}) }) + yield* addPlugin() expect( - required( - yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")), - ).enabled, + (yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"))) + ?.enabled, ).toBe(true) }), ) diff --git a/packages/core/test/plugin/provider-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts index 35498d5e9e81..eeb00093ebf4 100644 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ b/packages/core/test/plugin/provider-perplexity.test.ts @@ -1,18 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: PerplexityPlugin.id, effect: PerplexityPlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("PerplexityPlugin", () => { it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, PerplexityPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -22,11 +54,14 @@ describe("PerplexityPlugin", () => { it.effect("ignores packages that are not the bundled Perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, PerplexityPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("perplexity", "sonar"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/perplexity-compatible", options: { name: "perplexity" }, }, @@ -39,50 +74,40 @@ describe("PerplexityPlugin", () => { it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, PerplexityPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("perplexity-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("sonar").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }, {}, ) - expect(providers).toEqual(["perplexity"]) + expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - yield* addPlugin(plugin, PerplexityPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("custom-perplexity-sdk-inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - providers.push(evt.sdk.languageModel("sonar").provider) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-perplexity", "sonar"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/perplexity", options: { name: "custom-perplexity" }, }, {}, ) - expect(providers).toEqual(["perplexity"]) + expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) @@ -90,11 +115,14 @@ describe("PerplexityPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, PerplexityPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("perplexity", "alias", { api: { id: ModelV2.ID.make("sonar") } }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 51103167b562..6892aaf6aa1c 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -1,16 +1,54 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { Npm } from "@opencode-ai/core/npm" import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" -import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper" -import { host } from "./host" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" -const pluginWithNpm = { - id: SapAICorePlugin.id, - effect: Effect.gen(function* () { - yield* SapAICorePlugin.effect(host({ npm: yield* Npm.Service })) - }).pipe(Effect.provide(npmLayer)), +const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href +const it = testEffect(PluginTestLayer) +const npm = Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), +}) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: SapAICorePlugin.id, effect: SapAICorePlugin.effect({ ...host, npm }) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + for (const [key, value] of Object.entries(vars)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + return previous + }), + effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) +} + +function model(providerID: string) { + return new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")), + api: { id: ModelV2.ID.make("sap-model"), type: "aisdk", package: fixtureProvider }, + }) } describe("SapAICorePlugin", () => { @@ -20,11 +58,11 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("sap-ai-core", "sap-model"), + model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core", serviceKey: "service-key" }, }, @@ -46,11 +84,11 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("sap-ai-core", "sap-model"), + model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core", serviceKey: "option-service-key" }, }, @@ -68,10 +106,10 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", - { model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } }, + { model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core" } }, {}, ) expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() @@ -83,17 +121,13 @@ describe("SapAICorePlugin", () => { it.effect("uses the callable SDK for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), { languageModel() { throw new Error("SAP AI Core should call the SDK directly") }, }) - const language = yield* plugin.trigger( - "aisdk.language", - { model: model("sap-ai-core", "sap-model"), sdk, options: {} }, - {}, - ) + const language = yield* plugin.trigger("aisdk.language", { model: model("sap-ai-core"), sdk, options: {} }, {}) expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" }) }), ) @@ -104,11 +138,11 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* plugin.add(pluginWithNpm) + yield* addPlugin() const sdk = yield* plugin.trigger( "aisdk.sdk", { - model: model("openai", "sap-model"), + model: model("openai"), package: fixtureProvider, options: { name: "openai", serviceKey: "service-key" }, }, @@ -117,7 +151,7 @@ describe("SapAICorePlugin", () => { const language = yield* plugin.trigger( "aisdk.language", { - model: model("openai", "sap-model"), + model: model("openai"), sdk: () => { throw new Error("SAP AI Core should ignore other providers") }, diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index 5de7ae06588e..ca839fff5196 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -1,18 +1,48 @@ import { describe, expect, it as bun_it } from "bun:test" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" -import { addPlugin, expectPluginRegistered, it, model, withEnv } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: SnowflakeCortexPlugin.id, effect: SnowflakeCortexPlugin.effect(host) }) +}) + +function withEnv(vars: Record, effect: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) + Object.entries(vars).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + effect, + (previous) => + Effect.sync(() => { + Object.entries(previous).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + }), + ) +} describe("SnowflakeCortexPlugin", () => { it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => Effect.sync(() => { - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "snowflake-cortex", - ) - const ids = ProviderPlugins.map((p) => p.id as string) + expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex")) + const ids = ProviderPlugins.map((p) => p.id) expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible")) }), ) @@ -20,10 +50,17 @@ describe("SnowflakeCortexPlugin", () => { it.effect("ignores non-snowflake-cortex providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("openai", "gpt-4"), package: "@ai-sdk/openai", options: { name: "openai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), + api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "openai" }, + }, {}, ) expect(result.sdk).toBeUndefined() @@ -34,11 +71,14 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, }, @@ -53,11 +93,14 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", @@ -76,11 +119,14 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, }, @@ -95,11 +141,14 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, SnowflakeCortexPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", @@ -118,27 +167,20 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const captured: Record[] = [] - yield* addPlugin(plugin, SnowflakeCortexPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - captured.push({ ...evt.options }) - }), - }), - }) - yield* plugin.trigger( + yield* addPlugin() + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("snowflake-cortex", "claude-sonnet-4-6"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/openai-compatible", options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, }, {}, ) - expect(captured[0]?.includeUsage).toBe(true) + expect(result.options.includeUsage).toBe(true) }), ), ) diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts index 19757e126eb1..b780124a6b0c 100644 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ b/packages/core/test/plugin/provider-togetherai.test.ts @@ -1,17 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: TogetherAIPlugin.id, effect: TogetherAIPlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("TogetherAIPlugin", () => { it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, TogetherAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -21,12 +54,15 @@ describe("TogetherAIPlugin", () => { it.effect("matches the old bundled provider package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, TogetherAIPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", { - model: model("togetherai", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "file:///tmp/@ai-sdk/togetherai-provider.js", options: { name: "togetherai" }, }, @@ -36,7 +72,14 @@ describe("TogetherAIPlugin", () => { const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -46,29 +89,22 @@ describe("TogetherAIPlugin", () => { it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const observed: string[] = [] - yield* addPlugin(plugin, TogetherAIPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), - }), - }) + yield* addPlugin() - yield* plugin.trigger( + const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-togetherai", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "@ai-sdk/togetherai", options: { name: "custom-togetherai" }, }, {}, ) - expect(observed).toEqual(["togetherai.chat"]) + expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat") }), ) @@ -76,12 +112,22 @@ describe("TogetherAIPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, TogetherAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("togetherai"), + ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + ), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + type: "aisdk", + package: "test-provider", + }, + }), sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, options: {}, }, diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts index 148a30ee46ef..639543af5bcb 100644 --- a/packages/core/test/plugin/provider-venice.test.ts +++ b/packages/core/test/plugin/provider-venice.test.ts @@ -1,17 +1,50 @@ import { describe, expect } from "bun:test" +import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" -import { addPlugin, fakeSelectorSdk, it, model } from "./provider-helper" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: VenicePlugin.id, effect: VenicePlugin.effect(host) }) +}) + +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} describe("VenicePlugin", () => { it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, VenicePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", - { model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "venice-ai-sdk-provider", + options: { name: "venice" }, + }, {}, ) expect(result.sdk).toBeDefined() @@ -21,39 +54,35 @@ describe("VenicePlugin", () => { it.effect("uses the model provider ID as the bundled Venice SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const observed: string[] = [] - yield* addPlugin(plugin, VenicePlugin) - yield* plugin.add({ - id: PluginV2.ID.make("inspector"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - observed.push(evt.sdk.languageModel("model").provider) - }), - }), - }) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.sdk", { - model: model("custom-venice", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "venice-ai-sdk-provider", options: { name: "custom-venice", apiKey: "test" }, }, {}, ) expect(result.sdk).toBeDefined() - expect(observed).toEqual(["custom-venice.chat"]) + expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat") }), ) it.effect("only handles the bundled venice-ai-sdk-provider package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, VenicePlugin) + yield* addPlugin() const similar = yield* plugin.trigger( "aisdk.sdk", { - model: model("venice", "model"), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), package: "file:///tmp/venice-ai-sdk-provider.js", options: { name: "venice" }, }, @@ -61,7 +90,14 @@ describe("VenicePlugin", () => { ) const other = yield* plugin.trigger( "aisdk.sdk", - { model: model("venice", "model"), package: "@ai-sdk/openai-compatible", options: { name: "venice" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "venice" }, + }, {}, ) expect(similar.sdk).toBeUndefined() @@ -73,10 +109,17 @@ describe("VenicePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, VenicePlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", - { model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, {}, ) expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index c958d139e46b..5abc737dd021 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -1,28 +1,34 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, it, model, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: VercelPlugin.id, effect: VercelPlugin.effect(host) }) +}) describe("VercelPlugin", () => { it.effect("applies legacy lower-case referer headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, VercelPlugin) yield* catalog.transform((catalog) => { - const item = provider("vercel", { - api: { type: "aisdk", package: "@ai-sdk/vercel" }, - request: { headers: { Existing: "1" }, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } + provider.request.headers.Existing = "1" }) }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).toEqual({ + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).toEqual({ Existing: "1", "http-referer": "https://opencode.ai/", "x-title": "opencode", @@ -32,31 +38,34 @@ describe("VercelPlugin", () => { it.effect("does not add legacy upper-case referer headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, VercelPlugin) - yield* catalog.transform((catalog) => { - const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - }) - }) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( - "HTTP-Referer", + yield* catalog.transform((catalog) => + catalog.provider.update(ProviderV2.ID.make("vercel"), (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/vercel" } + }), ) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty( - "X-Title", + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty( + "HTTP-Referer", ) + expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel")))?.request.headers).not.toHaveProperty("X-Title") }), ) it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, VercelPlugin) + yield* addPlugin() const event = yield* plugin.trigger( "aisdk.sdk", - { model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), + api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" }, + }), + package: "@ai-sdk/vercel", + options: { name: "custom-vercel" }, + }, {}, ) expect(event.sdk).toBeDefined() @@ -66,11 +75,10 @@ describe("VercelPlugin", () => { it.effect("ignores non-Vercel providers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, VercelPlugin) - yield* catalog.transform((catalog) => catalog.provider.update(provider("gateway").id, () => {})) - expect(required(yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({}) + yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gateway"), () => {})) + yield* addPlugin() + expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway")))?.request.headers).toEqual({}) }), ) }) diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index 4ac5cf34f18e..a978381dea57 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -1,37 +1,66 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" -import { addPlugin, fakeSelectorSdk } from "./provider-helper" +import { PluginTestLayer } from "./fixture" -const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer))) +const it = testEffect(PluginTestLayer) -const model = new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), - api: { - id: ModelV2.ID.make("grok-4"), - type: "aisdk", - package: "@ai-sdk/xai", - }, +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: XAIPlugin.id, effect: XAIPlugin.effect(host) }) }) +function fakeSelectorSdk(calls: string[]) { + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 + } + return { + responses: make("responses"), + messages: make("messages"), + chat: make("chat"), + languageModel: make("languageModel"), + } +} + describe("XAIPlugin", () => { it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - yield* addPlugin(plugin, XAIPlugin) + yield* addPlugin() const ignored = yield* plugin.trigger( "aisdk.sdk", - { model, package: "@ai-sdk/openai-compatible", options: {} }, + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/openai-compatible", + options: {}, + }, {}, ) - const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {}) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/xai", + options: {}, + }, + {}, + ) expect(ignored.sdk).toBeUndefined() expect(typeof result.sdk?.responses).toBe("function") @@ -41,32 +70,22 @@ describe("XAIPlugin", () => { it.effect("creates xAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service - const providers: string[] = [] - - yield* addPlugin(plugin, XAIPlugin) - yield* plugin.add({ - id: PluginV2.ID.make("xai-sdk-name-observer"), - effect: Effect.gen(function* () { - return { - "aisdk.sdk": Effect.fn(function* (evt) { - if (!evt.sdk) return - providers.push(evt.sdk.responses("grok-4").provider) - }), - } - }), - }) - - yield* plugin.trigger( + yield* addPlugin() + + const result = yield* plugin.trigger( "aisdk.sdk", { - model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), package: "@ai-sdk/xai", options: {}, }, {}, ) - expect(providers).toEqual(["xai.responses"]) + expect(result.sdk.responses("grok-4").provider).toBe("xai.responses") }), ) @@ -75,11 +94,14 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, XAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias") }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, @@ -96,11 +118,14 @@ describe("XAIPlugin", () => { const plugin = yield* PluginV2.Service const calls: string[] = [] - yield* addPlugin(plugin, XAIPlugin) + yield* addPlugin() const result = yield* plugin.trigger( "aisdk.language", { - model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }), + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), sdk: fakeSelectorSdk(calls), options: {}, }, diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 4bfdc7b0e14d..3313cd048aee 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -2,34 +2,44 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" import { ProviderV2 } from "@opencode-ai/core/provider" -import { addPlugin, expectPluginRegistered, it, provider, required } from "./provider-helper" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make() + yield* plugin.add({ id: ZenmuxPlugin.id, effect: ZenmuxPlugin.effect(host) }) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} describe("ZenmuxPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => - expectPluginRegistered( - ProviderPlugins.map((item) => item.id), - "zenmux", - ), - ), + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))), ) it.effect("applies the exact legacy Zenmux headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } }) }) + yield* addPlugin() const result = required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))) expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) @@ -38,19 +48,18 @@ describe("ZenmuxPlugin", () => { it.effect("merges legacy Zenmux headers with existing headers", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - request: { headers: { Existing: "value" }, body: {} }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } + provider.request.headers.Existing = "value" }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ Existing: "value", @@ -62,22 +71,18 @@ describe("ZenmuxPlugin", () => { it.effect("lets configured Zenmux legacy headers override defaults", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("zenmux", { - api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }, - request: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.api = item.api - draft.request = item.request + catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => { + provider.api = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://zenmux.ai/api/v1", + } + provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", @@ -88,20 +93,13 @@ describe("ZenmuxPlugin", () => { it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* addPlugin(plugin, ZenmuxPlugin) yield* catalog.transform((catalog) => { - const item = provider("openrouter", { - request: { - headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, - body: {}, - }, - }) - catalog.provider.update(item.id, (draft) => { - draft.request = item.request + catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { + provider.request.headers = { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" } }) }) + yield* addPlugin() expect(required(yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({ "HTTP-Referer": "https://example.com/", diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts new file mode 100644 index 000000000000..8a7fd8ca7f28 --- /dev/null +++ b/packages/core/test/preload.ts @@ -0,0 +1 @@ +process.env.OPENCODE_DB = ":memory:" diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index 47f2176c376e..8c01e92c5018 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -16,17 +16,16 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const databaseLayer = Database.layerFromPath(":memory:") -const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer)) -const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) const copyLayer = ProjectCopy.layer.pipe( - Layer.provide(databaseLayer), - Layer.provide(directoriesLayer), - Layer.provide(eventLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(ProjectDirectories.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), ) -const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer)) +const it = testEffect( + Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer), +) function abs(input: string) { return AbsolutePath.make(input) diff --git a/packages/core/test/project-directories.test.ts b/packages/core/test/project-directories.test.ts index c1d2d8801f98..491c2e260221 100644 --- a/packages/core/test/project-directories.test.ts +++ b/packages/core/test/project-directories.test.ts @@ -8,10 +8,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, directories)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer)) const projectID = Project.ID.make("project-directories") const directory = AbsolutePath.make("/tmp/project-directories") diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 45fba0d18ffc..8c52f4a98ae7 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -13,20 +13,7 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const databaseLayer = Database.layerFromPath(":memory:") -const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer)) -const it = testEffect( - Layer.mergeAll( - ProjectV2.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(directoriesLayer), - Layer.provide(databaseLayer), - ), - databaseLayer, - directoriesLayer, - ), -) +const it = testEffect(Layer.mergeAll(ProjectV2.defaultLayer, Database.defaultLayer, ProjectDirectories.defaultLayer)) function remoteID(remote: string) { return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) diff --git a/packages/core/test/question.test.ts b/packages/core/test/question.test.ts index 57bf399669a8..3ad95456f782 100644 --- a/packages/core/test/question.test.ts +++ b/packages/core/test/question.test.ts @@ -6,10 +6,8 @@ import { QuestionV2 } from "@opencode-ai/core/question" import { SessionV2 } from "@opencode-ai/core/session" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const questions = QuestionV2.layer.pipe(Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, questions)) +const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, questions)) const sessionID = SessionV2.ID.make("ses_question_test") const question: QuestionV2.Info = { diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 471e86ff9234..6fd80c60da1d 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -25,8 +25,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { testEffect } from "./lib/effect" import { tmpdir } from "./fixture/tmpdir" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) const projects = Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ @@ -35,17 +33,23 @@ const projects = Layer.succeed( commit: () => Effect.void, }), ) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(projects), Layer.provide(SessionExecution.noopLayer), ) const it = testEffect( - Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions), + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + projects, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + SessionExecution.noopLayer, + sessions, + ), ) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const id = SessionV2.ID.create() @@ -336,7 +340,34 @@ describe("SessionV2.create", () => { expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill") - expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent") + }), + ) + + it.effect("switches the selected agent through the durable Session event", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + + yield* session.switchAgent({ sessionID: created.id, agent: "plan" }) + + expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) + expect( + Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)), + ).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }]) + }), + ) + + it.effect("rejects an agent switch for a missing Session", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const missing = SessionV2.ID.make("ses_missing_agent_switch") + + expect( + yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe( + Effect.flip, + Effect.map((error) => error._tag), + ), + ).toBe("Session.NotFoundError") }), ) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index df9ac731b012..a0894d07eb0f 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -21,10 +21,7 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const it = testEffect(Layer.mergeAll(database, events, projector)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer)) const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } @@ -113,10 +110,10 @@ describe("SessionProjector", () => { }).pipe( Effect.provide( SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), Layer.provide(Project.defaultLayer), - Layer.provide(SessionStore.layer.pipe(Layer.provide(database))), + Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionExecution.noopLayer), ), ), diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index b2aec228e553..c84a3ab304ea 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -18,10 +18,6 @@ import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode- import { SessionStore } from "@opencode-ai/core/session/store" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const executionCalls: SessionV2.ID[] = [] const interruptCalls: SessionV2.ID[] = [] const interruptSeqs: Array = [] @@ -47,13 +43,22 @@ const execution = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(execution), ) -const it = testEffect(Layer.mergeAll(database, events, projector, store, execution, sessions)) +const it = testEffect( + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + execution, + sessions, + ), +) const sessionID = SessionV2.ID.make("ses_prompt_test") const messageID = SessionMessage.ID.create() diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 708fd9e7f8ef..cc515742dda8 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -14,6 +14,39 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) describe("toLLMMessages", () => { + test("omits empty assistant turns", () => { + const assistant = (value: string, content: SessionMessage.Assistant["content"]) => + new SessionMessage.Assistant({ + id: id(value), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content, + time: { created, completed: created }, + }) + const messages = toLLMMessages( + [ + assistant("empty", []), + assistant("empty-text", [new SessionMessage.AssistantText({ type: "text", id: "empty", text: "" })]), + assistant("empty-reasoning", [ + new SessionMessage.AssistantReasoning({ type: "reasoning", id: "empty-reasoning", text: "" }), + ]), + assistant("text", [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Partial" })]), + assistant("reasoning", [ + new SessionMessage.AssistantReasoning({ + type: "reasoning", + id: "reasoning", + text: "", + providerMetadata: { anthropic: { signature: "sig_1" } }, + }), + ]), + ], + model, + ) + + expect(messages.map((message) => message.id)).toEqual([id("text"), id("reasoning")]) + }) + test("maps every top-level V2 Session message type", () => { const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) const messages = toLLMMessages( diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index a03ec77f031d..50e60a3616ab 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -268,7 +268,7 @@ describe("SessionRunnerModel", () => { it.effect("prefers stored credentials over configured auth", () => Effect.gen(function* () { - const credential = new Credential.Stored({ + const credential = new Credential.Info({ id: Credential.ID.create(), integrationID: Integration.ID.make("test-provider"), label: "Work", diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index e8da56a3a5c1..91d7a24475d6 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -32,10 +32,6 @@ import { Effect, Layer } from "effect" import path from "node:path" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) const cassette = process.env.RECORD === "true" ? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", { @@ -74,9 +70,9 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) const runner = SessionRunnerLLM.defaultLayer.pipe( - Layer.provide(database), - Layer.provide(store), - Layer.provide(events), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(client), Layer.provide(registry), Layer.provide(models), @@ -101,18 +97,18 @@ const execution = Layer.effect( ), ).pipe(Layer.provide(coordinator)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(execution), ) const it = testEffect( Layer.mergeAll( - database, - events, - projector, - store, + Database.defaultLayer, + EventV2.defaultLayer, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, executor, client, permission, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 393f5526ad84..862bb56d33d6 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -56,11 +56,7 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const questions = QuestionV2.layer.pipe(Layer.provide(events)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const store = SessionStore.layer.pipe(Layer.provide(database)) +const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) const requests: LLMRequest[] = [] let response: LLMEvent[] = [] let responses: LLMEvent[][] | undefined @@ -235,9 +231,9 @@ const config = Layer.succeed( }), ) const runner = SessionRunnerLLM.layer.pipe( - Layer.provide(database), - Layer.provide(store), - Layer.provide(events), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), + Layer.provide(EventV2.defaultLayer), Layer.provide(client), Layer.provide(registry), Layer.provide(models), @@ -262,19 +258,19 @@ const execution = Layer.effect( ), ).pipe(Layer.provide(coordinator)) const sessions = SessionV2.layer.pipe( - Layer.provide(events), - Layer.provide(database), - Layer.provide(store), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(execution), ) const it = testEffect( Layer.mergeAll( - database, - events, + Database.defaultLayer, + EventV2.defaultLayer, questions, - projector, - store, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, client, permission, applications, @@ -547,6 +543,8 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => { type: "user", text: prompt }, { type: "assistant", + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, content: [ kind === "tool input" ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } } diff --git a/packages/core/test/session-todo.test.ts b/packages/core/test/session-todo.test.ts index d1d656af38a9..ff10c405001f 100644 --- a/packages/core/test/session-todo.test.ts +++ b/packages/core/test/session-todo.test.ts @@ -11,10 +11,7 @@ import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql" import { SessionTodo } from "@opencode-ai/core/session/todo" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) -const it = testEffect(Layer.mergeAll(database, events, todos)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer)) const sessionID = SessionV2.ID.make("ses_todo_test") const setup = Effect.gen(function* () { diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 09cc159a20ef..85dcb604a9bf 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -16,10 +16,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) -const it = testEffect(Layer.mergeAll(database, events, projector)) +const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer)) const timestamp = DateTime.makeUnsafe(1) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 57a354fc7c0b..d8f96a580364 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -404,7 +404,7 @@ test("keeps the locked edit schema, semantics docstring, and deferred TODOs visi expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"]) expect(source).toContain( - "Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.", + "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", ) for (const todo of [ "Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.", diff --git a/packages/core/test/tool-read-filesystem.test.ts b/packages/core/test/tool-read-filesystem.test.ts new file mode 100644 index 000000000000..c897786e8915 --- /dev/null +++ b/packages/core/test/tool-read-filesystem.test.ts @@ -0,0 +1,117 @@ +import { describe, expect } from "bun:test" +import { NodeFileSystem } from "@effect/platform-node" +import path from "path" +import { Effect, FileSystem, Layer } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" +import { testEffect } from "./lib/effect" + +const it = testEffect(Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer)) +const fixture = Effect.gen(function* () { + const fs = yield* FSUtil.Service + const files = yield* FileSystem.FileSystem + const directory = yield* files.makeTempDirectoryScoped() + return { fs, files, directory } +}) + +describe("ReadToolFileSystem", () => { + it.effect("fails with a typed filesystem error when a resolved file disappears", () => + Effect.gen(function* () { + const { fs, directory } = yield* fixture + const file = path.join(directory, "missing.txt") + + const error = yield* ReadToolFileSystem.read(fs, file, "missing.txt").pipe(Effect.flip) + + expect(error).toMatchObject({ _tag: "PlatformError" }) + }), + ) + + it.effect("fails when a file becomes the wrong path kind", () => + Effect.gen(function* () { + const { fs, directory } = yield* fixture + + const error = yield* ReadToolFileSystem.read(fs, directory, "folder").pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError) + }), + ) + + it.effect("fails with a typed filesystem error when directory listing fails", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "file.txt") + yield* files.writeFileString(file, "hello") + + const error = yield* ReadToolFileSystem.list(fs, file).pipe(Effect.flip) + + expect(error).toBeInstanceOf(FSUtil.FileSystemError) + if (error instanceof FSUtil.FileSystemError) expect(error.method).toBe("readDirectoryEntries") + }), + ) + + it.effect("reports binary and malformed UTF-8 content as typed errors", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const binary = path.join(directory, "archive.dat") + const malformed = path.join(directory, "malformed.txt") + yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3)) + const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97) + malformedContent[64 * 1024] = 0x80 + yield* files.writeFile(malformed, malformedContent) + + const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip) + const malformedError = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt").pipe(Effect.flip) + + expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError) + expect(binaryError.message).toBe("Cannot read binary file: archive.dat") + expect(malformedError).toBeInstanceOf(ReadToolFileSystem.MalformedUtf8Error) + }), + ) + + it.effect("reports out-of-range pagination as a typed error", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "short.txt") + yield* files.writeFileString(file, "one\n") + + const error = yield* ReadToolFileSystem.read(fs, file, "short.txt", { offset: 2 }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError) + expect(error.message).toBe("Offset 2 is out of range") + }), + ) + + it.effect("stops reading after the requested page is complete", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const prefix = new TextEncoder().encode("one\n") + for (const [name, trailing] of [ + ["malformed.txt", 0x80], + ["nul.txt", 0], + ] as const) { + const file = path.join(directory, name) + yield* files.writeFile(file, Uint8Array.from([...prefix, trailing])) + + const result = yield* ReadToolFileSystem.read(fs, file, name, { limit: 1 }) + + expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 }) + } + }), + ) + + it.effect("preserves the media ingestion limit message", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "oversized.png") + yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) + yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1) + + const error = yield* ReadToolFileSystem.read(fs, file, "oversized.png").pipe(Effect.flip) + + expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError) + expect(error.message).toBe( + `Media exceeds ${ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES} byte ingestion limit: oversized.png`, + ) + }), + ) +}) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 605a1e17da56..fcbec061b2d8 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect } from "bun:test" -import { Effect, Exit, Layer } from "effect" +import path from "path" +import { Effect, Exit, Layer, PlatformError } from "effect" import { Config } from "@opencode-ai/core/config" import { ConfigAttachments } from "@opencode-ai/core/config/attachments" import { FileSystem } from "@opencode-ai/core/filesystem" @@ -18,6 +19,8 @@ import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const assertions: PermissionV2.AssertInput[] = [] +const missingPath = "__missing_read_target__.txt" +const missingAbsolutePath = path.join(process.cwd(), missingPath) const readCalls: { input: AbsolutePath page: ReadToolFileSystem.PageInput @@ -32,7 +35,7 @@ let readResult: FileSystem.Content | ReadToolFileSystem.TextPage = { encoding: "utf8", mime: "text/plain", } -let readFailure: unknown +let readFailure: ReadToolFileSystem.ReadError | undefined let configEntries: Config.Entry[] = [] const reader = Layer.succeed( ReadToolFileSystem.Service, @@ -40,7 +43,7 @@ const reader = Layer.succeed( inspect: () => (resolveFailure === undefined ? Effect.succeed(resolvedType) : Effect.die(resolveFailure)), read: (input, _resource, page = {}) => { readCalls.push({ input, page }) - if (readFailure !== undefined) return Effect.die(readFailure) + if (readFailure !== undefined) return Effect.fail(readFailure) return Effect.succeed(readResult) }, list: (_path, input = {}) => @@ -70,7 +73,24 @@ const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => const image = Image.layer.pipe(Layer.provide(config)) const testFileSystem = Layer.effect( FSUtil.Service, - FSUtil.Service.use((fs) => Effect.succeed(FSUtil.Service.of({ ...fs, realPath: (path) => Effect.succeed(path) }))), + FSUtil.Service.use((fs) => + Effect.succeed( + FSUtil.Service.of({ + ...fs, + realPath: (path) => + path === missingAbsolutePath + ? Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: path, + }), + ) + : Effect.succeed(path), + }), + ), + ), ).pipe(Layer.provide(FSUtil.defaultLayer)) const infrastructure = Layer.mergeAll( testFileSystem, @@ -145,7 +165,12 @@ describe("ReadTool", () => { }, }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }]) - expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/README.md`), page: {} }]) + expect(readCalls).toEqual([ + { + input: AbsolutePath.make(path.join(process.cwd(), "README.md")), + page: { offset: undefined, limit: undefined }, + }, + ]) }), ) @@ -174,7 +199,12 @@ describe("ReadTool", () => { { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" }, ], }) - expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/pixel.png`), page: {} }]) + expect(readCalls).toEqual([ + { + input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")), + page: { offset: undefined, limit: undefined }, + }, + ]) const settled = yield* settleTool(registry, { sessionID, @@ -412,9 +442,32 @@ describe("ReadTool", () => { }), ) + it.effect("returns expected filesystem failures to the model", () => + Effect.gen(function* () { + readFailure = new ReadToolFileSystem.BinaryFileError({ resource: "archive.dat" }) + const registry = yield* ToolRegistry.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-binary", + name: "read", + input: { path: "archive.dat", offset: 2, limit: 1 }, + }, + }), + ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) + expect(readCalls).toEqual([ + { input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } }, + ]) + }), + ) + it.effect("preserves unexpected filesystem defects", () => Effect.gen(function* () { - readFailure = new ReadToolFileSystem.BinaryFileError("archive.dat") + resolveFailure = new Error("unexpected") const registry = yield* ToolRegistry.Service expect( @@ -422,18 +475,10 @@ describe("ReadTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { - type: "tool-call", - id: "call-binary", - name: "read", - input: { path: "archive.dat", offset: 2, limit: 1 }, - }, + call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } }, }).pipe(Effect.exit), ), ).toBe(true) - expect(readCalls).toEqual([ - { input: AbsolutePath.make(`${process.cwd()}/archive.dat`), page: { offset: 2, limit: 1 } }, - ]) }), ) @@ -453,6 +498,22 @@ describe("ReadTool", () => { }), ) + it.effect("returns missing paths as model-visible tool failures", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + + expect( + yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } }, + }), + ).toEqual({ type: "error", value: `Unable to read ${missingPath}` }) + expect(assertions).toEqual([]) + expect(readCalls).toEqual([]) + }), + ) + it.effect("lists a bounded directory page through read", () => Effect.gen(function* () { resolvedType = "directory" @@ -539,7 +600,7 @@ describe("ReadTool", () => { value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, }) expect(readCalls).toEqual([ - { input: AbsolutePath.make(`${process.cwd()}/large.txt`), page: { offset: 2, limit: 1 } }, + { input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } }, ]) }), ) diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index c8d1799010a9..38ba1900aafa 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -32,12 +32,15 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const database = Database.layerFromPath(":memory:") -const events = EventV2.layer.pipe(Layer.provide(database)) -const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos)) -const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool)) +const tool = TodoWriteTool.layer.pipe( + Layer.provide(registry), + Layer.provide(permission), + Layer.provide(SessionTodo.defaultLayer), +) +const it = testEffect( + Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer, permission, registry, tool), +) const setup = Effect.gen(function* () { assertions.length = 0 diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index b2541e3e21f8..5a856ffaf428 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -176,6 +176,25 @@ describe("WebFetchTool registration", () => { }), ) + it.effect("returns an error result when HTML-to-Markdown conversion throws", () => + Effect.gen(function* () { + reset() + respond = () => + Effect.succeed( + new Response("
".repeat(10_000) + "content" + "
".repeat(10_000), { + headers: { "content-type": "text/html" }, + }), + ) + const registry = yield* ToolRegistry.Service + const url = "https://1.1.1.1/deep-html" + + expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({ + type: "error", + value: `Unable to fetch ${url}`, + }) + }), + ) + it.effect("rejects declared and streamed oversized bodies", () => Effect.gen(function* () { reset() diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index dc38a9c35020..9715dd5c7f23 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { beforeEach, describe, expect, test } from "bun:test" import { Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { PermissionV2 } from "@opencode-ai/core/permission" @@ -66,8 +66,14 @@ interface Request { const requests: Request[] = [] const assertions: PermissionV2.AssertInput[] = [] let responseBody = payload("search results") +let makeResponse = () => new Response(responseBody, { status: 200 }) let config: WebSearchTool.Config = { enableExa: false, enableParallel: false } +beforeEach(() => { + responseBody = payload("search results") + makeResponse = () => new Response(responseBody, { status: 200 }) +}) + const http = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -78,7 +84,7 @@ const http = Layer.succeed( headers: request.headers, body: JSON.parse(new TextDecoder().decode(request.body.body)), }) - return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 })) + return HttpClientResponse.fromWeb(request, makeResponse()) }), ), ) @@ -270,7 +276,22 @@ describe("WebSearchTool registration", () => { Effect.gen(function* () { requests.length = 0 assertions.length = 0 - responseBody = "x".repeat(WebSearchTool.MAX_RESPONSE_BYTES + 1) + let chunksRead = 0 + let cancelled = false + makeResponse = () => + new Response( + new ReadableStream({ + pull(controller) { + chunksRead++ + if (chunksRead === 10) throw new Error("response was not stopped at the byte limit") + controller.enqueue(new Uint8Array(64 * 1024)) + }, + cancel() { + cancelled = true + }, + }), + { status: 200 }, + ) config = { provider: "exa", enableExa: false, enableParallel: false } const registry = yield* ToolRegistry.Service @@ -281,6 +302,8 @@ describe("WebSearchTool registration", () => { call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } }, }), ).toEqual({ type: "error", value: "Unable to search the web for too much" }) + expect(chunksRead).toBeLessThan(10) + expect(cancelled).toBe(true) }), ) }) diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index de5c7c264aac..a81b32d37d30 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -279,7 +279,7 @@ test("keeps the locked write schema, semantics docstring, and deferred UX TODOs expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["content", "path"]) expect(source).toContain( - "Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.", + "absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval.", ) for (const todo of [ "Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.", diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index b1f7bd8b7250..5febf9cb202c 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -955,6 +955,24 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(200, data(object)), + http.protected + .post("/api/session/{sessionID}/agent", "v2.session.switchAgent") + .seeded((ctx) => ctx.session({ title: "Switch agent" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/agent", { sessionID: ctx.state.id }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { agent: "plan" }, + })) + .status(204, undefined, "none"), + http.protected + .post("/api/session/{sessionID}/model", "v2.session.switchModel") + .seeded((ctx) => ctx.session({ title: "Switch model" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/model", { sessionID: ctx.state.id }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { model: { providerID: "opencode", id: "big-pickle" } }, + })) + .status(204, undefined, "none"), http.protected .get("/api/session/{sessionID}/context", "v2.session.context") .at((ctx) => ({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 7c1c9108d95e..7bf19806e367 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -353,6 +353,10 @@ import type { V2SessionQuestionRejectResponses, V2SessionQuestionReplyErrors, V2SessionQuestionReplyResponses, + V2SessionSwitchAgentErrors, + V2SessionSwitchAgentResponses, + V2SessionSwitchModelErrors, + V2SessionSwitchModelResponses, V2SessionWaitErrors, V2SessionWaitResponses, V2SkillListErrors, @@ -5337,6 +5341,88 @@ export class Session3 extends HeyApiClient { }) } + /** + * Switch session agent + * + * Switch the agent used by subsequent session activity. + */ + public switchAgent( + parameters: { + sessionID: string + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchAgentResponses, + V2SessionSwitchAgentErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/agent", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Switch session model + * + * Switch the model used by subsequent session activity. + */ + public switchModel( + parameters: { + sessionID: string + model?: { + id: string + providerID: string + variant?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchModelResponses, + V2SessionSwitchModelErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/model", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send message * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 1c4fd8f5dfd3..d2c9e2989482 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -9521,6 +9521,84 @@ export type V2SessionGetResponses = { export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses] +export type V2SessionSwitchAgentData = { + body: { + agent: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/agent" +} + +export type V2SessionSwitchAgentErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchAgentError = V2SessionSwitchAgentErrors[keyof V2SessionSwitchAgentErrors] + +export type V2SessionSwitchAgentResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchAgentResponse = V2SessionSwitchAgentResponses[keyof V2SessionSwitchAgentResponses] + +export type V2SessionSwitchModelData = { + body: { + model: { + id: string + providerID: string + variant?: string + } + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/model" +} + +export type V2SessionSwitchModelErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchModelError = V2SessionSwitchModelErrors[keyof V2SessionSwitchModelErrors] + +export type V2SessionSwitchModelResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V2SessionSwitchModelResponses] + export type V2SessionPromptData = { body: { id?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0b43276400c9..04165386c725 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10325,6 +10325,189 @@ ] } }, + "/api/session/{sessionID}/agent": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.switchAgent", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the agent used by subsequent session activity.", + "summary": "Switch session agent", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agent": { + "type": "string" + } + }, + "required": ["agent"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.switchAgent({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/model": { + "post": { + "tags": ["sessions"], + "operationId": "v2.session.switchModel", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Switch the model used by subsequent session activity.", + "summary": "Switch session model", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + } + }, + "required": ["model"], + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.switchModel({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/prompt": { "post": { "tags": ["sessions"], diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index a208de82f031..ac4418d39f7a 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -140,6 +140,38 @@ export const SessionGroup = HttpApiGroup.make("server.session") }), ), ) + .add( + HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", { + params: { sessionID: SessionV2.ID }, + payload: Schema.Struct({ agent: AgentV2.ID }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(SessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchAgent", + summary: "Switch session agent", + description: "Switch the agent used by subsequent session activity.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", { + params: { sessionID: SessionV2.ID }, + payload: Schema.Struct({ model: ModelV2.Ref }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(SessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchModel", + summary: "Switch session model", + description: "Switch the model used by subsequent session activity.", + }), + ), + ) .add( HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { params: { sessionID: SessionV2.ID }, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 66383cfbfffa..1fe860e5284e 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -92,6 +92,38 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.switchAgent", + Effect.fn(function* (ctx) { + yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.switchModel", + Effect.fn(function* (ctx) { + yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.prompt", Effect.fn(function* (ctx) { diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index a068ed0054fd..223218fcbbba 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -330,7 +330,7 @@ function CatalogDatum(props: { label: string; value: string }) { function ModelOverview(props: { data: StatsModelData | null }) { return (
- + (
+ -export class AthenaQueryError extends Schema.TaggedErrorClass()("AthenaQueryError", { - message: Schema.String, - queryExecutionId: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect()), -}) {} +export class AthenaQueryError extends Error { + readonly _tag = "AthenaQueryError" + readonly queryExecutionId?: string -export class AthenaQueryTimeoutError extends Schema.TaggedErrorClass()( - "AthenaQueryTimeoutError", - { - message: Schema.String, - queryExecutionId: Schema.String, - }, -) {} + constructor(input: { message: string; queryExecutionId?: string; cause?: unknown }) { + super(input.message, { cause: input.cause }) + this.name = "AthenaQueryError" + this.queryExecutionId = input.queryExecutionId + } +} + +export class AthenaQueryTimeoutError extends Error { + readonly _tag = "AthenaQueryTimeoutError" + readonly queryExecutionId: string + + constructor(input: { message: string; queryExecutionId: string }) { + super(input.message) + this.name = "AthenaQueryTimeoutError" + this.queryExecutionId = input.queryExecutionId + } +} export declare namespace Athena { export interface Service { @@ -57,7 +65,7 @@ export class Athena extends Context.Service()("@opencode }) const queryExecutionId = started.QueryExecutionId if (!queryExecutionId) - return yield* new AthenaQueryError({ message: "Athena did not return a query execution id" }) + return yield* Effect.fail(new AthenaQueryError({ message: "Athena did not return a query execution id" })) yield* poll(client, queryExecutionId) return yield* results(client, queryExecutionId) @@ -87,16 +95,20 @@ const poll: ( if (status?.State === "SUCCEEDED") return if (status?.State === "FAILED" || status?.State === "CANCELLED") - return yield* new AthenaQueryError({ - message: `Athena stats query ${status.State.toLowerCase()}: ${status.StateChangeReason ?? "unknown reason"}`, - queryExecutionId, - }) + return yield* Effect.fail( + new AthenaQueryError({ + message: `Athena stats query ${status.State.toLowerCase()}: ${status.StateChangeReason ?? "unknown reason"}`, + queryExecutionId, + }), + ) if (attempt >= ATHENA_MAX_POLL_ATTEMPTS - 1) - return yield* new AthenaQueryTimeoutError({ - message: `Athena stats query ${queryExecutionId} did not complete`, - queryExecutionId, - }) + return yield* Effect.fail( + new AthenaQueryTimeoutError({ + message: `Athena stats query ${queryExecutionId} did not complete`, + queryExecutionId, + }), + ) return yield* poll(client, queryExecutionId, attempt + 1) }) diff --git a/packages/stats/core/src/database.ts b/packages/stats/core/src/database.ts index 9edb717bc714..2d55ee7f8eba 100644 --- a/packages/stats/core/src/database.ts +++ b/packages/stats/core/src/database.ts @@ -44,16 +44,29 @@ export class DrizzleClient extends Context.Service()("@o ) } -export class DatabaseError extends Schema.TaggedErrorClass()("DatabaseError", { - cause: Schema.Defect(), -}) {} +export class DatabaseError extends Error { + readonly _tag = "DatabaseError" + + constructor(input: { cause: unknown }) { + super("Database operation failed", { cause: input.cause }) + this.name = "DatabaseError" + } + + static make(input: { cause: unknown }) { + return new DatabaseError(input) + } +} export const catchDbError = Effect.mapError((cause) => DatabaseError.make({ cause })) -export class MigrationError extends Schema.TaggedErrorClass()("MigrationError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) {} +export class MigrationError extends Error { + readonly _tag = "MigrationError" + + constructor(input: { message: string; cause?: unknown }) { + super(input.message, { cause: input.cause }) + this.name = "MigrationError" + } +} export const migrate = Effect.fn("Database.migrate")(function* () { const settings = yield* DatabaseConfig @@ -68,9 +81,11 @@ export const migrate = Effect.fn("Database.migrate")(function* () { catch: (cause) => new MigrationError({ message: "Failed to apply database migrations", cause }), }) if (result) - return yield* new MigrationError({ - message: `Failed to initialize database migrations: ${result.exitCode}`, - }) + return yield* Effect.fail( + new MigrationError({ + message: `Failed to initialize database migrations: ${result.exitCode}`, + }), + ) yield* Effect.logInfo("database migrations complete").pipe( Effect.annotateLogs({ migrationsDir: settings.migrationsDir }), ) diff --git a/packages/stats/core/src/domain/home.ts b/packages/stats/core/src/domain/home.ts index 0a152af29fae..f0d4994bb76f 100644 --- a/packages/stats/core/src/domain/home.ts +++ b/packages/stats/core/src/domain/home.ts @@ -54,6 +54,7 @@ export type StatsModelData = { tokenChange: number totals: { sessions: number + uniqueUsers: number tokens: number cost: number tokensPerSession: number @@ -381,6 +382,7 @@ function buildStatsModelData( tokenChange: percentChange(current.totalTokens, previous.totalTokens), totals: { sessions: current.sessions, + uniqueUsers: current.uniqueUsers, tokens: current.totalTokens, cost: round(microcentsToDollars(current.totalCostMicrocents), 2), tokensPerSession: current.sessions > 0 ? Math.round(current.totalTokens / current.sessions) : 0, diff --git a/packages/stats/core/src/domain/inference.ts b/packages/stats/core/src/domain/inference.ts index a7e4c0037e82..3fc97aba3398 100644 --- a/packages/stats/core/src/domain/inference.ts +++ b/packages/stats/core/src/domain/inference.ts @@ -17,6 +17,8 @@ export type StatDimension = "model" | "provider" | "geo" | "geo_model" export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: StatDimension) { const periodStartValue = sqlString(periodStart.toISOString()) const periodEndValue = sqlString(periodEnd.toISOString()) + const periodStartDateValue = sqlString(periodStart.toISOString().slice(0, 10)) + const periodEndDateValue = sqlString(periodEnd.toISOString().slice(0, 10)) const sourceTable = [Resource.InferenceEvent.catalog, Resource.InferenceEvent.database, Resource.InferenceEvent.table] .map(sqlIdentifier) .join(".") @@ -95,6 +97,9 @@ WITH normalized AS ( WHERE event_type = 'completions' AND model IS NOT NULL AND model <> '' + AND source = 'lite' + AND event_date >= ${periodStartDateValue} + AND event_date <= ${periodEndDateValue} AND event_timestamp >= ${periodStartValue} AND event_timestamp < ${periodEndValue} ), filtered AS ( diff --git a/packages/stats/server/src/ingest.ts b/packages/stats/server/src/ingest.ts index 763742d9c991..eda662d98961 100644 --- a/packages/stats/server/src/ingest.ts +++ b/packages/stats/server/src/ingest.ts @@ -1,6 +1,6 @@ import { Buffer } from "node:buffer" import { FirehoseClient, PutRecordBatchCommand } from "@aws-sdk/client-firehose" -import { Effect, Layer, Schema } from "effect" +import { Effect, Layer } from "effect" import * as Context from "effect/Context" import { Resource } from "sst/resource" @@ -12,11 +12,16 @@ type IngestEvent = Record type LakeRoute = { database: string; table: string } type FirehoseRecord = { Data: Uint8Array } -export class IngestError extends Schema.TaggedErrorClass()("IngestError", { - message: Schema.String, - failed: Schema.Number, - cause: Schema.optional(Schema.Defect()), -}) {} +export class IngestError extends Error { + readonly _tag = "IngestError" + readonly failed: number + + constructor(input: { message: string; failed: number; cause?: unknown }) { + super(input.message, { cause: input.cause }) + this.name = "IngestError" + this.failed = input.failed + } +} export declare namespace Ingest { export interface Service { @@ -37,10 +42,12 @@ export class Ingest extends Context.Service()("@opencode yield* Effect.logWarning( `lake ingest rejected ${JSON.stringify({ records: counts.records, unsupported: counts.unsupported })}`, ) - return yield* new IngestError({ - message: "Unsupported lake event type", - failed: counts.unsupported, - }) + return yield* Effect.fail( + new IngestError({ + message: "Unsupported lake event type", + failed: counts.unsupported, + }), + ) } if (counts.records === 0) return { records: 0 } @@ -66,7 +73,7 @@ export class Ingest extends Context.Service()("@opencode if (failed > 0) { yield* Effect.logWarning(`lake ingest incomplete ${JSON.stringify({ records: counts.records, failed })}`) - return yield* new IngestError({ message: "Failed to ingest all lake records", failed }) + return yield* Effect.fail(new IngestError({ message: "Failed to ingest all lake records", failed })) } yield* Effect.logInfo(`lake ingest complete ${JSON.stringify({ records: counts.records, batches })}`) diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index fa675f7a74dc..e962a6e7c3e0 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -1,7 +1,10 @@ +import { TextAttributes } from "@opentui/core" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" -import { createResource, createMemo } from "solid-js" +import { createResource, createMemo, createSignal } from "solid-js" import { useDialog } from "../ui/dialog" import { useSDK } from "../context/sdk" +import { useTheme } from "../context/theme" +import { errorMessage } from "../util/error" export type DialogSkillProps = { onSelect: (skill: string) => void @@ -10,14 +13,27 @@ export type DialogSkillProps = { export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() + const { theme } = useTheme() dialog.setSize("large") - const [skills] = createResource(async () => { - const result = await sdk.client.app.skills() - return result.data ?? [] - }) + const [loadError, setLoadError] = createSignal() + + const [skills] = createResource(() => + sdk.client.app + .skills({}, { throwOnError: true }) + .then((result) => result.data ?? []) + // Catch so the rejected resource never reaches the memo below: reading + // skills() in an errored state re-throws and tears down the dialog. + .catch((error) => { + setLoadError(error) + return undefined + }), + ) + + const showError = createMemo(() => Boolean(loadError())) const options = createMemo[]>(() => { + if (showError()) return [] const list = skills() ?? [] const maxWidth = Math.max(0, ...list.map((s) => s.name.length)) return list.map((skill) => ({ @@ -32,5 +48,23 @@ export function DialogSkill(props: DialogSkillProps) { })) }) - return + return ( + + + Could not load skills + + {errorMessage(loadError())} + + ) : undefined + } + /> + ) } diff --git a/turbo.json b/turbo.json index 220cd96e584e..c9255902c503 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,10 @@ "outputs": [], "passThroughEnv": ["*"] }, + "@opencode-ai/core#test": { + "dependsOn": ["^build"], + "outputs": [] + }, "@opencode-ai/app#test": { "dependsOn": ["^build"], "outputs": []