diff --git a/AGENTS.md b/AGENTS.md index 7913ddabd2..0b1998ec50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,29 @@ function foo() { } ``` +### Complex Logic + +When a function has several validation branches or supporting details, make the main function read as the happy path and move supporting details into small helpers below it. + +```ts +// Good +export function loadThing(input: unknown) { + const config = requireConfig(input) + const metadata = readMetadata(input) + return createThing({ config, metadata }) +} + +function requireConfig(input: unknown) { + ... +} +``` + +- Keep helpers close to the code they support, below the main export when that improves readability. +- Do not over-abstract simple expressions into many single-use helpers; extract only when it names a real concept like `requireConfig` or `readMetadata`. +- Do not return `Effect` from helpers unless they actually perform effectful work. Synchronous parsing, validation, and option building should stay synchronous. +- Prefer Effect schema helpers such as `Schema.UnknownFromJsonString` and `Schema.decodeUnknownOption` over manual `JSON.parse` wrapped in `Effect.try` when parsing untrusted JSON strings. +- Add comments for non-obvious constraints and surprising behavior, not for obvious assignments or control flow. + ### Schema Definitions (Drizzle) Use snake_case for field names so column names don't need to be redefined as strings. diff --git a/bun.lock b/bun.lock index 2ccbe6f7a2..45ea405229 100644 --- a/bun.lock +++ b/bun.lock @@ -197,22 +197,50 @@ "opencode": "./bin/opencode", }, "dependencies": { + "@ai-sdk/alibaba": "1.0.17", + "@ai-sdk/amazon-bedrock": "4.0.96", + "@ai-sdk/anthropic": "3.0.71", + "@ai-sdk/azure": "3.0.49", + "@ai-sdk/cerebras": "2.0.41", + "@ai-sdk/cohere": "3.0.27", + "@ai-sdk/deepinfra": "2.0.41", + "@ai-sdk/gateway": "3.0.104", + "@ai-sdk/google": "3.0.63", + "@ai-sdk/google-vertex": "4.0.112", + "@ai-sdk/groq": "3.0.31", + "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai-compatible": "2.0.41", + "@ai-sdk/perplexity": "3.0.26", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@ai-sdk/togetherai": "2.0.41", + "@ai-sdk/vercel": "2.0.39", + "@ai-sdk/xai": "3.0.82", + "@aws-sdk/credential-providers": "3.993.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", + "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", + "ai-gateway-provider": "3.1.2", "cross-spawn": "catalog:", "effect": "catalog:", + "gitlab-ai-provider": "6.6.0", "glob": "13.0.5", + "google-auth-library": "10.5.0", + "immer": "11.1.4", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "semver": "^7.6.3", + "venice-ai-sdk-provider": "2.0.1", "xdg-basedir": "5.1.0", + "zod": "catalog:", }, "devDependencies": { "@tsconfig/bun": "catalog:", @@ -380,7 +408,6 @@ "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.82", @@ -5493,6 +5520,12 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@opencode-ai/core/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], + + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + + "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], diff --git a/nix/hashes.json b/nix/hashes.json index 0bba38a2c6..876b969608 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-xZyIgqow1wVh0Kfpb5GLUUHsE3jyfqJfrZ9Qykml008=", - "aarch64-linux": "sha256-tbbne63KImq4EQrPi45l9YG1dY/SO7b1ZKkLjDfZhWg=", - "aarch64-darwin": "sha256-PYsiSMkASbcZxqMXb7UfbkRTiQae6xzseMNhDP+/y5g=", - "x86_64-darwin": "sha256-Qnj9FAgXWyiB6U5NyIsRw7aNVNexAagETr07Jwde908=" + "x86_64-linux": "sha256-cRhvzZoW6gBbE0sQm1+e+6/WgajuA6MSIL5iroFsfqs=", + "aarch64-linux": "sha256-0knZfxBULqkt5u6sXFx+a/vqw2rc6IC1+LeAd4TNFhM=", + "aarch64-darwin": "sha256-jL4tO+EHSmUF+gQGEaLzAbTxxjkL8OyhTk13vsbomgM=", + "x86_64-darwin": "sha256-bsa7IpS3GaxagcigTa0yqZTkf4e/nbcTQ9aZeb+5eHQ=" } } diff --git a/packages/core/package.json b/packages/core/package.json index 6bcef68dc5..4c47fea8b9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,27 @@ "@types/semver": "catalog:" }, "dependencies": { + "@ai-sdk/alibaba": "1.0.17", + "@ai-sdk/amazon-bedrock": "4.0.96", + "@ai-sdk/anthropic": "3.0.71", + "@ai-sdk/azure": "3.0.49", + "@ai-sdk/cerebras": "2.0.41", + "@ai-sdk/cohere": "3.0.27", + "@ai-sdk/deepinfra": "2.0.41", + "@ai-sdk/gateway": "3.0.104", + "@ai-sdk/google": "3.0.63", + "@ai-sdk/google-vertex": "4.0.112", + "@ai-sdk/groq": "3.0.31", + "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/openai": "3.0.53", + "@ai-sdk/openai-compatible": "2.0.41", + "@ai-sdk/perplexity": "3.0.26", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@ai-sdk/togetherai": "2.0.41", + "@ai-sdk/vercel": "2.0.39", + "@ai-sdk/xai": "3.0.82", + "@aws-sdk/credential-providers": "3.993.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@npmcli/arborist": "9.4.0", @@ -34,14 +55,21 @@ "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", - "effect": "catalog:", + "@openrouter/ai-sdk-provider": "2.8.1", + "ai-gateway-provider": "3.1.2", "cross-spawn": "catalog:", + "effect": "catalog:", + "gitlab-ai-provider": "6.6.0", "glob": "13.0.5", + "google-auth-library": "10.5.0", + "immer": "11.1.4", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "semver": "^7.6.3", - "xdg-basedir": "5.1.0" + "venice-ai-sdk-provider": "2.0.1", + "xdg-basedir": "5.1.0", + "zod": "catalog:" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts new file mode 100644 index 0000000000..5fa2294309 --- /dev/null +++ b/packages/core/src/aisdk.ts @@ -0,0 +1,172 @@ +export * as AISDK from "./aisdk" + +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { Cause, Context, Effect, Layer, Schema } from "effect" +import { ModelV2 } from "./model" +import { PluginV2 } from "./plugin" +import { ProviderV2 } from "./provider" + +type SDK = any + +function wrapSSE(res: Response, ms: number, ctl: AbortController) { + if (typeof ms !== "number" || ms <= 0) return res + if (!res.body) return res + if (!res.headers.get("content-type")?.includes("text/event-stream")) return res + + const reader = res.body.getReader() + const body = new ReadableStream({ + async pull(ctrl) { + const part = await new Promise>>((resolve, reject) => { + const id = setTimeout(() => { + const err = new Error("SSE read timed out") + ctl.abort(err) + void reader.cancel(err) + reject(err) + }, ms) + + reader.read().then( + (part) => { + clearTimeout(id) + resolve(part) + }, + (err) => { + clearTimeout(id) + reject(err) + }, + ) + }) + + if (part.done) { + ctrl.close() + return + } + + ctrl.enqueue(part.value) + }, + async cancel(reason) { + ctl.abort(reason) + await reader.cancel(reason) + }, + }) + + return new Response(body, { + headers: new Headers(res.headers), + status: res.status, + statusText: res.statusText, + }) +} + +function prepareOptions(model: ModelV2.Info, pkg: string) { + const options: Record = { name: model.providerID, ...model.options.aisdk.provider } + if (model.endpoint.type === "aisdk" && model.endpoint.url) options.baseURL = model.endpoint.url + + const customFetch = options.fetch + const chunkTimeout = options.chunkTimeout + delete options.chunkTimeout + options.fetch = async (input: Parameters[0], init?: RequestInit) => { + const opts = { ...(init ?? {}) } + const signals = [ + opts.signal, + typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined, + options.timeout !== undefined && options.timeout !== null && options.timeout !== false + ? AbortSignal.timeout(options.timeout) + : undefined, + ].filter((item): item is AbortSignal | AbortController => Boolean(item)) + const chunkAbortCtl = signals.find((item): item is AbortController => item instanceof AbortController) + const abortSignals = signals.map((item) => (item instanceof AbortController ? item.signal : item)) + if (abortSignals.length === 1) opts.signal = abortSignals[0] + if (abortSignals.length > 1) opts.signal = AbortSignal.any(abortSignals) + + if ((pkg === "@ai-sdk/openai" || pkg === "@ai-sdk/azure") && opts.body && opts.method === "POST") { + const body = JSON.parse(opts.body as string) + if (body.store !== true && Array.isArray(body.input)) { + for (const item of body.input) { + if ("id" in item) delete item.id + } + opts.body = JSON.stringify(body) + } + } + + const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, { + ...opts, + timeout: false, + }) + if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res + return wrapSSE(res, chunkTimeout, chunkAbortCtl) + } + + return options +} + +export class InitError extends Schema.TaggedErrorClass()("AISDK.InitError", { + providerID: ProviderV2.ID, + cause: Schema.Defect, +}) {} + +function initError(providerID: ProviderV2.ID) { + return Effect.catchCause((cause) => Effect.fail(new InitError({ providerID, cause: Cause.squash(cause) }))) +} + +export interface Interface { + readonly language: (model: ModelV2.Info) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/AISDK") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const languages = new Map() + const sdks = new Map() + + return Service.of({ + language: Effect.fn("AISDK.language")(function* (model) { + const key = `${model.providerID}/${model.id}/${model.options.variant ?? "default"}` + const existing = languages.get(key) + if (existing) return existing + if (model.endpoint.type !== "aisdk") + return yield* new InitError({ + providerID: model.providerID, + cause: new Error(`Unsupported endpoint ${model.endpoint.type}`), + }) + + const options = prepareOptions(model, model.endpoint.package) + const sdkKey = JSON.stringify({ + providerID: model.providerID, + endpoint: model.endpoint, + options, + }) + const sdk = + sdks.get(sdkKey) ?? + (yield* plugin + .trigger("aisdk.sdk", { model, package: model.endpoint.package, options }, {}) + .pipe(initError(model.providerID))).sdk + if (!sdk) + return yield* new InitError({ + providerID: model.providerID, + cause: new Error("No AISDK provider plugin returned an SDK"), + }) + sdks.set(sdkKey, sdk) + const result = yield* plugin + .trigger( + "aisdk.language", + { + model, + sdk, + options, + }, + {}, + ) + .pipe(initError(model.providerID)) + const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.apiID)).pipe( + initError(model.providerID), + ) + languages.set(key, language) + return language + }), + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer)) diff --git a/packages/opencode/src/v2/auth.ts b/packages/core/src/auth.ts similarity index 86% rename from packages/opencode/src/v2/auth.ts rename to packages/core/src/auth.ts index 0ac6223a66..843c9504b4 100644 --- a/packages/opencode/src/v2/auth.ts +++ b/packages/core/src/auth.ts @@ -1,9 +1,9 @@ import path from "path" import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" -import { Identifier } from "@opencode-ai/core/util/identifier" -import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" -import { Global } from "@opencode-ai/core/global" -import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Identifier } from "./util/identifier" +import { NonNegativeInt, withStatics } from "./schema" +import { Global } from "./global" +import { AppFileSystem } from "./filesystem" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" @@ -106,27 +106,45 @@ export const layer = Layer.effect( const fsys = yield* AppFileSystem.Service const global = yield* Global.Service const file = path.join(global.data, "auth-v2.json") + const legacyFile = path.join(global.data, "auth.json") - const load: () => Effect.Effect = Effect.fnUntraced(function* () { - if (process.env.OPENCODE_AUTH_CONTENT) { - try { - return JSON.parse(process.env.OPENCODE_AUTH_CONTENT) - } catch {} - } - - const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) - - if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} } - - if ("version" in raw && raw.version === 2) return raw as Writable - - const migrated = migrate(raw as Record) + const writeMigrated = Effect.fnUntraced(function* (raw: Record) { + const migrated = migrate(raw) yield* fsys .writeJson(file, migrated, 0o600) .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause }))) return migrated }) + const parseAuthContent = () => { + try { + return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "") + } catch {} + } + + const load: () => Effect.Effect = Effect.fnUntraced(function* () { + if (process.env.OPENCODE_AUTH_CONTENT) { + const raw = parseAuthContent() + if (raw && typeof raw === "object") { + if ("version" in raw && raw.version === 2) return raw as Writable + return yield* writeMigrated(raw as Record) + } + return { version: 2, accounts: {}, active: {} } + } + + const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null)) + if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record) + + const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + + if (raw && typeof raw === "object") { + if ("version" in raw && raw.version === 2) return raw as Writable + return yield* writeMigrated(raw as Record) + } + + return { version: 2, accounts: {}, active: {} } + }) + const write = (data: Writable) => fsys .writeJson(file, data, 0o600) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts new file mode 100644 index 0000000000..3aa5915420 --- /dev/null +++ b/packages/core/src/catalog.ts @@ -0,0 +1,258 @@ +export * as Catalog from "./catalog" + +import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect" +import { produce, type Draft } from "immer" +import { ModelV2 } from "./model" +import { PluginV2 } from "./plugin" +import { ProviderV2 } from "./provider" + +type ProviderRecord = { + provider: ProviderV2.Info + models: HashMap.HashMap +} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "CatalogV2.ProviderNotFound", + { + providerID: ProviderV2.ID, + }, +) {} + +export class ModelNotFoundError extends Schema.TaggedErrorClass()("CatalogV2.ModelNotFound", { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, +}) {} + +export interface Interface { + readonly provider: { + readonly get: (providerID: ProviderV2.ID) => Effect.Effect + readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => Effect.Effect + readonly all: () => Effect.Effect + readonly available: () => Effect.Effect + } + readonly model: { + readonly get: ( + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + ) => Effect.Effect + readonly update: ( + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + fn: (model: Draft) => void, + ) => Effect.Effect + readonly all: () => Effect.Effect + readonly available: () => Effect.Effect + readonly default: () => Effect.Effect> + readonly setDefault: ( + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + ) => Effect.Effect + readonly small: (providerID: ProviderV2.ID) => Effect.Effect> + } +} + +export class Service extends Context.Service()("@opencode/v2/Catalog") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + let records = HashMap.empty() + let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined + const plugin = yield* PluginV2.Service + + const resolve = (model: ModelV2.Info) => { + const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider + const endpoint = + model.endpoint.type === "unknown" + ? provider.endpoint + : model.endpoint.type === "aisdk" && provider.endpoint.type === "aisdk" && !model.endpoint.url + ? { ...model.endpoint, url: provider.endpoint.url } + : model.endpoint + const options = { + headers: { + ...provider.options.headers, + ...model.options.headers, + }, + body: { + ...provider.options.body, + ...model.options.body, + }, + aisdk: { + provider: { + ...provider.options.aisdk.provider, + ...model.options.aisdk.provider, + }, + request: model.options.aisdk.request, + }, + variant: model.options.variant, + } + return new ModelV2.Info({ + ...model, + endpoint, + options, + }) + } + + function* getRecord(providerID: ProviderV2.ID) { + const match = HashMap.get(records, providerID) + if (!match.valueOrUndefined) return yield* new ProviderNotFoundError({ providerID }) + return match.value + } + + const result: Interface = { + provider: { + get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { + const record = yield* getRecord(providerID) + return record.provider + }), + + update: Effect.fnUntraced(function* (providerID, fn) { + const current = Option.getOrUndefined(HashMap.get(records, providerID)) + const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => { + fn(draft) + if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") { + draft.endpoint.url = draft.options.aisdk.provider.baseURL + delete draft.options.aisdk.provider.baseURL + } + }) + const updated = yield* plugin.trigger("provider.update", {}, { provider, cancel: false }) + records = HashMap.set(records, providerID, { + provider: updated.provider, + models: current?.models ?? HashMap.empty(), + }) + }), + + all: Effect.fn("CatalogV2.provider.all")(function* () { + return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider) + }), + + available: Effect.fn("CatalogV2.provider.available")(function* () { + return globalThis.Array.from(HashMap.values(records)) + .map((record) => record.provider) + .filter((provider) => provider.enabled) + }), + }, + + model: { + get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) { + const record = yield* getRecord(providerID) + const model = Option.getOrUndefined(HashMap.get(record.models, modelID)) + if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) + return resolve(model) + }), + + update: Effect.fnUntraced(function* (providerID, modelID, fn) { + const record = yield* getRecord(providerID) + const model = produce( + HashMap.get(record.models, modelID).pipe(Option.getOrElse(() => ModelV2.Info.empty(providerID, modelID))), + (draft) => { + fn(draft) + if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") { + draft.endpoint.url = draft.options.aisdk.provider.baseURL + delete draft.options.aisdk.provider.baseURL + } + }, + ) + const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false }) + if (updated.cancel) return + records = HashMap.set(records, providerID, { + provider: record.provider, + models: HashMap.set( + record.models, + modelID, + new ModelV2.Info({ ...updated.model, id: modelID, providerID }), + ), + }) + return + }), + + all: Effect.fn("CatalogV2.model.all")(function* () { + return pipe( + records, + HashMap.toValues, + Array.flatMap((record) => HashMap.toValues(record.models)), + Array.map(resolve), + Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + ) + }), + + available: Effect.fn("CatalogV2.model.available")(function* () { + return (yield* result.model.all()).filter((model) => { + const record = Option.getOrUndefined(HashMap.get(records, model.providerID)) + return record?.provider.enabled !== false && model.enabled + }) + }), + + default: Effect.fn("CatalogV2.model.default")(function* () { + if (defaultModel) { + const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) + if (Option.isSome(model) && model.value.enabled) return model + } + + return pipe( + yield* result.model.available(), + Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + Array.head, + ) + }), + + setDefault: Effect.fn("CatalogV2.model.setDefault")(function* (providerID, modelID) { + yield* result.model.get(providerID, modelID) + defaultModel = { providerID, modelID } + }), + + small: Effect.fn("CatalogV2.model.small")(function* (providerID) { + const record = Option.getOrUndefined(HashMap.get(records, providerID)) + if (!record) return Option.none() + + if (providerID === ProviderV2.ID.opencode) { + const gpt5Nano = Option.getOrUndefined(HashMap.get(record.models, ModelV2.ID.make("gpt-5-nano"))) + if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano)) + } + + const candidates = pipe( + HashMap.toValues(record.models), + Array.filter( + (model) => + model.providerID === providerID && + model.enabled && + model.status === "active" && + model.capabilities.input.some((item) => item.startsWith("text")) && + model.capabilities.output.some((item) => item.startsWith("text")), + ), + Array.map((model) => ({ + model, + cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999, + age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30), + small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()), + })), + Array.filter((item) => item.cost > 0 && item.age <= 18), + ) + + const pick = (items: typeof candidates) => { + const maxCost = Math.max(...items.map((item) => item.cost), 0.01) + const maxAge = Math.max(...items.map((item) => item.age), 0.01) + return pipe( + items, + Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number), + Array.map((item) => resolve(item.model)), + Array.head, + ) + } + + return pipe( + candidates, + Array.filter((item) => item.small), + (items) => (items.length > 0 ? pick(items) : pick(candidates)), + ) + }), + }, + } + + return Service.of(result) + }), +) + +const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ + +export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer)) diff --git a/packages/opencode/src/provider/sdk/copilot/README.md b/packages/core/src/github-copilot/README.md similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/README.md rename to packages/core/src/github-copilot/README.md diff --git a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts b/packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts rename to packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/get-response-metadata.ts b/packages/core/src/github-copilot/chat/get-response-metadata.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/get-response-metadata.ts rename to packages/core/src/github-copilot/chat/get-response-metadata.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts b/packages/core/src/github-copilot/chat/map-openai-compatible-finish-reason.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts rename to packages/core/src/github-copilot/chat/map-openai-compatible-finish-reason.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts b/packages/core/src/github-copilot/chat/openai-compatible-api-types.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts rename to packages/core/src/github-copilot/chat/openai-compatible-api-types.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts b/packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts rename to packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts b/packages/core/src/github-copilot/chat/openai-compatible-chat-options.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts rename to packages/core/src/github-copilot/chat/openai-compatible-chat-options.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts b/packages/core/src/github-copilot/chat/openai-compatible-metadata-extractor.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts rename to packages/core/src/github-copilot/chat/openai-compatible-metadata-extractor.ts diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts b/packages/core/src/github-copilot/chat/openai-compatible-prepare-tools.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts rename to packages/core/src/github-copilot/chat/openai-compatible-prepare-tools.ts diff --git a/packages/opencode/src/provider/sdk/copilot/copilot-provider.ts b/packages/core/src/github-copilot/copilot-provider.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/copilot-provider.ts rename to packages/core/src/github-copilot/copilot-provider.ts diff --git a/packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts b/packages/core/src/github-copilot/openai-compatible-error.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts rename to packages/core/src/github-copilot/openai-compatible-error.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts rename to packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/map-openai-responses-finish-reason.ts b/packages/core/src/github-copilot/responses/map-openai-responses-finish-reason.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/map-openai-responses-finish-reason.ts rename to packages/core/src/github-copilot/responses/map-openai-responses-finish-reason.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-config.ts b/packages/core/src/github-copilot/responses/openai-config.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-config.ts rename to packages/core/src/github-copilot/responses/openai-config.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts b/packages/core/src/github-copilot/responses/openai-error.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts rename to packages/core/src/github-copilot/responses/openai-error.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-api-types.ts b/packages/core/src/github-copilot/responses/openai-responses-api-types.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-responses-api-types.ts rename to packages/core/src/github-copilot/responses/openai-responses-api-types.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts b/packages/core/src/github-copilot/responses/openai-responses-language-model.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts rename to packages/core/src/github-copilot/responses/openai-responses-language-model.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-prepare-tools.ts b/packages/core/src/github-copilot/responses/openai-responses-prepare-tools.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-responses-prepare-tools.ts rename to packages/core/src/github-copilot/responses/openai-responses-prepare-tools.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-settings.ts b/packages/core/src/github-copilot/responses/openai-responses-settings.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/openai-responses-settings.ts rename to packages/core/src/github-copilot/responses/openai-responses-settings.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/code-interpreter.ts b/packages/core/src/github-copilot/responses/tool/code-interpreter.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/code-interpreter.ts rename to packages/core/src/github-copilot/responses/tool/code-interpreter.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/file-search.ts b/packages/core/src/github-copilot/responses/tool/file-search.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/file-search.ts rename to packages/core/src/github-copilot/responses/tool/file-search.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/image-generation.ts b/packages/core/src/github-copilot/responses/tool/image-generation.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/image-generation.ts rename to packages/core/src/github-copilot/responses/tool/image-generation.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/local-shell.ts b/packages/core/src/github-copilot/responses/tool/local-shell.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/local-shell.ts rename to packages/core/src/github-copilot/responses/tool/local-shell.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/web-search-preview.ts b/packages/core/src/github-copilot/responses/tool/web-search-preview.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/web-search-preview.ts rename to packages/core/src/github-copilot/responses/tool/web-search-preview.ts diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/web-search.ts b/packages/core/src/github-copilot/responses/tool/web-search.ts similarity index 100% rename from packages/opencode/src/provider/sdk/copilot/responses/tool/web-search.ts rename to packages/core/src/github-copilot/responses/tool/web-search.ts diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts new file mode 100644 index 0000000000..77b8c60ebe --- /dev/null +++ b/packages/core/src/model.ts @@ -0,0 +1,116 @@ +import { DateTime, Schema } from "effect" +import { DateTimeUtcFromMillis } from "effect/Schema" +import { ProviderV2 } from "./provider" + +export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +export type ID = typeof ID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +// Grouping of models, eg claude opus, claude sonnet +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + // mime patterns, image, audio, video/*, text/* + input: Schema.String.pipe(Schema.Array), + output: Schema.String.pipe(Schema.Array), +}) +export type Capabilities = typeof Capabilities.Type + +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +export const Ref = Schema.Struct({ + id: ID, + providerID: ProviderV2.ID, + variant: VariantID, +}) +export type Ref = typeof Ref.Type + +export class Info extends Schema.Class("ModelV2.Info")({ + id: ID, + apiID: ID, + providerID: ProviderV2.ID, + family: Family.pipe(Schema.optional), + name: Schema.String, + endpoint: ProviderV2.Endpoint, + capabilities: Capabilities, + options: Schema.Struct({ + ...ProviderV2.Options.fields, + variant: Schema.String.pipe(Schema.optional), + }), + variants: Schema.Struct({ + id: VariantID, + ...ProviderV2.Options.fields, + }).pipe(Schema.Array), + time: Schema.Struct({ + released: DateTimeUtcFromMillis, + }), + cost: Cost.pipe(Schema.Array), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + enabled: Schema.Boolean, + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, + }), +}) { + static empty(providerID: ProviderV2.ID, modelID: ID) { + return new Info({ + id: modelID, + apiID: modelID, + providerID, + name: modelID, + endpoint: { + type: "unknown", + }, + capabilities: { + tools: false, + input: [], + output: [], + }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + variants: [], + time: { + released: DateTime.makeUnsafe(0), + }, + cost: [], + status: "active", + enabled: true, + limit: { + context: 0, + output: 0, + }, + }) + } +} + +export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { + const [providerID, ...modelID] = input.split("/") + return { + providerID: ProviderV2.ID.make(providerID), + modelID: ID.make(modelID.join("/")), + } +} + +export * as ModelV2 from "./model" diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts new file mode 100644 index 0000000000..dfcae94685 --- /dev/null +++ b/packages/core/src/plugin.ts @@ -0,0 +1,146 @@ +export * as PluginV2 from "./plugin" + +import { createDraft, finishDraft, type Draft } from "immer" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { type ProviderV2 } from "./provider" +import { Context, Effect, Layer, Schema } from "effect" +import type { ModelV2 } from "./model" + +export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) +export type ID = typeof ID.Type + +type HookSpec = { + "provider.update": { + input: {} + output: { + provider: ProviderV2.Info + cancel: boolean + } + } + "model.update": { + input: {} + output: { + model: ModelV2.Info + cancel: boolean + } + } + "aisdk.language": { + input: { + model: ModelV2.Info + sdk: any + options: Record + } + output: { + language?: LanguageModelV3 + } + } + "aisdk.sdk": { + input: { + model: ModelV2.Info + package: string + options: Record + } + output: { + sdk?: any + } + } +} + +export type Hooks = { + [Name in keyof HookSpec]: Readonly & { + -readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object + ? Draft + : HookSpec[Name]["output"][Field] + } +} + +export type HookFunctions = { + [key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect +} + +export type HookInput = HookSpec[Name]["input"] +export type HookOutput = HookSpec[Name]["output"] + +export type Effect = Effect.Effect + +export function define(input: { id: ID; effect: Effect.Effect }) { + return input +} + +export interface Interface { + readonly add: (input: { id: ID; effect: Effect }) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect + readonly trigger: ( + name: Name, + input: HookInput, + output: HookOutput, + ) => Effect.Effect & HookOutput> +} + +export class Service extends Context.Service()("@opencode/v2/Plugin") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + let hooks: { + id: ID + hooks: HookFunctions + }[] = [] + + const svc = Service.of({ + add: Effect.fn("Plugin.add")(function* (input) { + const result = yield* input.effect + if (!result) return + hooks = [ + ...hooks.filter((item) => item.id !== input.id), + { + id: input.id, + hooks: result, + }, + ] + }), + trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { + const draftEntries = new Map>() + const event = { + ...input, + ...output, + } as Record + + for (const [field, value] of Object.entries(output)) { + if (value && typeof value === "object") { + draftEntries.set(field, createDraft(value)) + event[field] = draftEntries.get(field) + } + } + + for (const item of hooks) { + const match = item.hooks[name] + if (!match) continue + yield* match(event as any).pipe( + Effect.withSpan(`Plugin.hook.${name}`, { + attributes: { + plugin: item.id, + hook: name, + }, + }), + ) + } + + for (const [field, draft] of draftEntries) { + event[field] = finishDraft(draft) + } + + return event as any + }), + remove: Effect.fn("Plugin.remove")(function* (id) { + hooks = hooks.filter((item) => item.id !== id) + }), + }) + return svc + }), +) + +export const defaultLayer = layer + +// opencode +// sdcok diff --git a/packages/core/src/plugin/auth.ts b/packages/core/src/plugin/auth.ts new file mode 100644 index 0000000000..81cbfbe3f7 --- /dev/null +++ b/packages/core/src/plugin/auth.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import { AuthV2 } from "../auth" +import { PluginV2 } from "../plugin" + +export const AuthPlugin = PluginV2.define({ + id: PluginV2.ID.make("auth"), + effect: Effect.gen(function* () { + const auth = yield* AuthV2.Service + return { + "provider.update": Effect.fn(function* (evt) { + const account = yield* auth.active(AuthV2.ServiceID.make(evt.provider.id)).pipe(Effect.orDie) + if (!account) return + evt.provider.enabled = { + via: "auth", + service: account.serviceID, + } + if (account.credential.type === "api") { + evt.provider.options.aisdk.provider.apiKey = account.credential.key + Object.assign(evt.provider.options.aisdk.provider, account.credential.metadata ?? {}) + } + if (account.credential.type === "oauth") { + evt.provider.options.aisdk.provider.apiKey = account.credential.access + } + }), + } + }), +}) diff --git a/packages/core/src/plugin/env.ts b/packages/core/src/plugin/env.ts new file mode 100644 index 0000000000..d63936fa13 --- /dev/null +++ b/packages/core/src/plugin/env.ts @@ -0,0 +1,18 @@ +import { Effect } from "effect" +import { PluginV2 } from "../plugin" + +export const EnvPlugin = PluginV2.define({ + id: PluginV2.ID.make("env"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + const key = evt.provider.env.find((item) => process.env[item]) + if (!key) return + evt.provider.enabled = { + via: "env", + name: key, + } + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts new file mode 100644 index 0000000000..1880787495 --- /dev/null +++ b/packages/core/src/plugin/provider.ts @@ -0,0 +1 @@ +export { ProviderPlugins } from "./provider/index" diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts new file mode 100644 index 0000000000..fa5c0a91cf --- /dev/null +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const AlibabaPlugin = PluginV2.define({ + id: PluginV2.ID.make("alibaba"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/alibaba") return + const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) + evt.sdk = mod.createAlibaba(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts new file mode 100644 index 0000000000..366548a0a3 --- /dev/null +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -0,0 +1,94 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +// Bedrock cross-region inference profiles require regional prefixes only for +// specific model/region combinations. Keep the mapping narrow and avoid +// double-prefixing model IDs that models.dev already marks as global/us/eu/etc. +function resolveModelID(modelID: string, region: string | undefined) { + const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."] + if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) return modelID + + const resolvedRegion = region ?? "us-east-1" + const regionPrefix = resolvedRegion.split("-")[0] + if (regionPrefix === "us") { + const requiresPrefix = ["nova-micro", "nova-lite", "nova-pro", "nova-premier", "nova-2", "claude", "deepseek"].some( + (item) => modelID.includes(item), + ) + if (requiresPrefix && !resolvedRegion.startsWith("us-gov")) return `${regionPrefix}.${modelID}` + return modelID + } + if (regionPrefix === "eu") { + const regionRequiresPrefix = [ + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-south-1", + "eu-south-2", + ].some((item) => resolvedRegion.includes(item)) + const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((item) => + modelID.includes(item), + ) + return regionRequiresPrefix && modelRequiresPrefix ? `${regionPrefix}.${modelID}` : modelID + } + if (regionPrefix !== "ap") return modelID + + const australia = ["ap-southeast-2", "ap-southeast-4"].includes(resolvedRegion) + if (australia && ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((item) => modelID.includes(item))) { + return `au.${modelID}` + } + + const prefix = resolvedRegion === "ap-northeast-1" ? "jp" : "apac" + return ["claude", "nova-lite", "nova-micro", "nova-pro"].some((item) => modelID.includes(item)) + ? `${prefix}.${modelID}` + : modelID +} + +export const AmazonBedrockPlugin = PluginV2.define({ + id: PluginV2.ID.make("amazon-bedrock"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.amazonBedrock) return + if (evt.provider.endpoint.type !== "aisdk") return + if (typeof evt.provider.options.aisdk.provider.endpoint !== "string") return + // The AI SDK expects a base URL, but users configure Bedrock private/VPC + // endpoints as `endpoint`; move it into the catalog endpoint URL once. + evt.provider.endpoint.url = evt.provider.options.aisdk.provider.endpoint + delete evt.provider.options.aisdk.provider.endpoint + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/amazon-bedrock") return + const options = { ...evt.options } + const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE + const region = typeof options.region === "string" ? options.region : (process.env.AWS_REGION ?? "us-east-1") + const bearerToken = + process.env.AWS_BEARER_TOKEN_BEDROCK ?? + (typeof options.bearerToken === "string" ? options.bearerToken : undefined) + if (bearerToken && !process.env.AWS_BEARER_TOKEN_BEDROCK) process.env.AWS_BEARER_TOKEN_BEDROCK = bearerToken + const containerCreds = Boolean( + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, + ) + + options.region = region + if (typeof options.endpoint === "string") options.baseURL = options.endpoint + if (!bearerToken && options.credentialProvider === undefined) { + // Do not gate SDK creation on explicit AWS env vars. The default chain + // also handles ~/.aws/credentials, SSO, process creds, and instance roles. + const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers")) + options.credentialProvider = fromNodeProviderChain(profile ? { profile } : {}) + } + + const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock")) + evt.sdk = mod.createAmazonBedrock(options) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return + const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION + evt.language = evt.sdk.languageModel(resolveModelID(evt.model.apiID, region)) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts new file mode 100644 index 0000000000..14851c4a31 --- /dev/null +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const AnthropicPlugin = PluginV2.define({ + id: PluginV2.ID.make("anthropic"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.anthropic) return + evt.provider.options.headers["anthropic-beta"] = + "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/anthropic") return + const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) + evt.sdk = mod.createAnthropic(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts new file mode 100644 index 0000000000..6c29a16103 --- /dev/null +++ b/packages/core/src/plugin/provider/azure.ts @@ -0,0 +1,64 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +function selectLanguage(sdk: any, modelID: string, useChat: boolean) { + if (useChat && sdk.chat) return sdk.chat(modelID) + if (sdk.responses) return sdk.responses(modelID) + if (sdk.messages) return sdk.messages(modelID) + if (sdk.chat) return sdk.chat(modelID) + return sdk.languageModel(modelID) +} + +export const AzurePlugin = PluginV2.define({ + id: PluginV2.ID.make("azure"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.azure) return + const configured = evt.provider.options.aisdk.provider.resourceName + const resourceName = + typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME + if (resourceName) evt.provider.options.aisdk.provider.resourceName = resourceName + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/azure") return + if (evt.model.providerID === ProviderV2.ID.azure) { + if ( + !evt.options.resourceName && + !evt.options.baseURL && + (evt.model.endpoint.type !== "aisdk" || !evt.model.endpoint.url) + ) { + throw new Error( + "AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it", + ) + } + } + const mod = yield* Effect.promise(() => import("@ai-sdk/azure")) + evt.sdk = mod.createAzure(evt.options) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.azure) return + evt.language = selectLanguage(evt.sdk, evt.model.apiID, Boolean(evt.options.useCompletionUrls)) + }), + } + }), +}) + +export const AzureCognitiveServicesPlugin = PluginV2.define({ + id: PluginV2.ID.make("azure-cognitive-services"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("azure-cognitive-services")) return + const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME + if (resourceName) + evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return + evt.language = selectLanguage(evt.sdk, evt.model.apiID, Boolean(evt.options.useCompletionUrls)) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts new file mode 100644 index 0000000000..b2fadd8bf1 --- /dev/null +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -0,0 +1,20 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const CerebrasPlugin = PluginV2.define({ + id: PluginV2.ID.make("cerebras"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("cerebras")) return + evt.provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/cerebras") return + const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) + evt.sdk = mod.createCerebras(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts new file mode 100644 index 0000000000..ffcd4adcf4 --- /dev/null +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -0,0 +1,81 @@ +import os from "os" +import { InstallationVersion } from "../../installation/version" +import { Effect, Option, Schema } from "effect" +import { PluginV2 } from "../../plugin" + +export const CloudflareAIGatewayPlugin = PluginV2.define({ + id: PluginV2.ID.make("cloudflare-ai-gateway"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "ai-gateway-provider") return + if (evt.options.baseURL) return + + const config = gatewayConfig(evt.options) + if (!config) return + const metadata = gatewayMetadata(evt.options) + const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")).pipe(Effect.orDie) + const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")).pipe( + Effect.orDie, + ) + const gateway = createAiGateway({ + accountId: config.accountId, + gateway: config.gatewayId, + apiKey: config.apiKey, + options: gatewayOptions(evt.options, metadata), + } as any) + const unified = createUnified() + evt.sdk = { + languageModel(modelID: string) { + return gateway(unified(modelID)) + }, + } + }), + } + }), +}) + +type GatewayConfig = { + accountId: string + gatewayId: string + apiKey: string +} + +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) + +function gatewayConfig(options: Record): GatewayConfig | undefined { + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") + // AuthPlugin copies CLI prompt metadata into options. The prompt stores the + // gateway as gatewayId, while older config examples may use gateway. + const gatewayId = + process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway") + const apiKey = process.env.CLOUDFLARE_API_TOKEN ?? process.env.CF_AIG_TOKEN ?? stringOption(options, "apiKey") + if (!accountId || !gatewayId || !apiKey) return undefined + + return { accountId, gatewayId, apiKey } +} + +function gatewayMetadata(options: Record) { + // Preserve the legacy cf-aig-metadata header escape hatch for gateway logging + // metadata, but prefer the typed metadata option when present. + if (options.metadata !== undefined) return options.metadata + const raw = (options.headers as Record | undefined)?.["cf-aig-metadata"] + return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined +} + +function gatewayOptions(options: Record, metadata: unknown) { + return { + metadata, + cacheTtl: options.cacheTtl, + cacheKey: options.cacheKey, + skipCache: options.skipCache, + collectLog: options.collectLog, + headers: { + "User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, + }, + } +} + +function stringOption(options: Record, key: string) { + return typeof options[key] === "string" ? options[key] : undefined +} diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts new file mode 100644 index 0000000000..f39869b57d --- /dev/null +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -0,0 +1,69 @@ +import os from "os" +import { InstallationVersion } from "../../installation/version" +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +const providerID = ProviderV2.ID.make("cloudflare-workers-ai") + +export const CloudflareWorkersAIPlugin = PluginV2.define({ + id: PluginV2.ID.make("cloudflare-workers-ai"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== providerID) return + if (evt.provider.endpoint.type !== "aisdk") return + if (evt.provider.endpoint.url) return + + const accountId = resolveAccountId(evt.provider.options.aisdk.provider) + if (accountId) evt.provider.endpoint.url = workersEndpoint(accountId) + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.model.providerID !== providerID) return + if (evt.package !== "@ai-sdk/openai-compatible") return + + if (!hasWorkersEndpoint(evt.model.endpoint)) return + const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) + evt.sdk = mod.createOpenAICompatible(sdkOptions(evt.options) as any) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== providerID) return + evt.language = evt.sdk.languageModel(evt.model.apiID) + }), + } + }), +}) + +function resolveAccountId(options: Record) { + return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") +} + +function workersEndpoint(accountId: string) { + return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1` +} + +function hasWorkersEndpoint(endpoint: ProviderV2.Endpoint) { + return endpoint.type === "aisdk" && Boolean(endpoint.url) +} + +function sdkOptions(options: Record) { + return { + ...options, + baseURL: expandAccountId(options.baseURL), + apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey, + headers: { + "User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, + ...options.headers, + }, + name: providerID, + } +} + +function expandAccountId(baseURL: unknown) { + if (typeof baseURL !== "string") return baseURL + return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}") +} + +function stringOption(options: Record, key: string) { + return typeof options[key] === "string" ? options[key] : undefined +} diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts new file mode 100644 index 0000000000..991c370d17 --- /dev/null +++ b/packages/core/src/plugin/provider/cohere.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const CoherePlugin = PluginV2.define({ + id: PluginV2.ID.make("cohere"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/cohere") return + const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) + evt.sdk = mod.createCohere(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts new file mode 100644 index 0000000000..bbd42f6e28 --- /dev/null +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const DeepInfraPlugin = PluginV2.define({ + id: PluginV2.ID.make("deepinfra"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/deepinfra") return + const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) + evt.sdk = mod.createDeepInfra(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts new file mode 100644 index 0000000000..e5abc7009e --- /dev/null +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -0,0 +1,31 @@ +import { Npm } from "../../npm" +import { Effect, Option } from "effect" +import { pathToFileURL } from "url" +import { PluginV2 } from "../../plugin" + +export const DynamicProviderPlugin = PluginV2.define({ + id: PluginV2.ID.make("dynamic-provider"), + effect: Effect.gen(function* () { + const npm = yield* Npm.Service + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.sdk) return + + const installedPath = evt.package.startsWith("file://") + ? evt.package + : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) + + const mod = yield* Effect.promise(async () => { + return (await import( + installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href + )) as Record any> + }).pipe(Effect.orDie) + const match = Object.keys(mod).find((name) => name.startsWith("create")) + if (!match) throw new Error(`Package ${evt.package} has no provider factory export`) + + evt.sdk = mod[match](evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts new file mode 100644 index 0000000000..5b08ad9ef5 --- /dev/null +++ b/packages/core/src/plugin/provider/gateway.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const GatewayPlugin = PluginV2.define({ + id: PluginV2.ID.make("gateway"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/gateway") return + const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) + evt.sdk = mod.createGateway(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts new file mode 100644 index 0000000000..31e57ba12a --- /dev/null +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -0,0 +1,44 @@ +import { Effect } from "effect" +import { ModelV2 } from "../../model" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +function shouldUseResponses(modelID: string) { + // Copilot supports Responses for GPT-5 class models, except mini variants + // which still need the chat-completions endpoint. + const match = /^gpt-(\d+)/.exec(modelID) + if (!match) return false + return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") +} + +export const GithubCopilotPlugin = PluginV2.define({ + id: PluginV2.ID.make("github-copilot"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.githubCopilot) return + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/github-copilot") return + const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) + evt.sdk = mod.createOpenaiCompatible(evt.options) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return + if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { + evt.language = evt.sdk.languageModel(evt.model.apiID) + return + } + evt.language = shouldUseResponses(evt.model.apiID) + ? evt.sdk.responses(evt.model.apiID) + : evt.sdk.chat(evt.model.apiID) + }), + "model.update": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return + // This chat-only alias conflicts with the Copilot GPT-5 Responses route, + // so hide it only for Copilot rather than for every provider catalog. + if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts new file mode 100644 index 0000000000..226f5a45eb --- /dev/null +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -0,0 +1,65 @@ +import os from "os" +import { InstallationVersion } from "../../installation/version" +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const GitLabPlugin = PluginV2.define({ + id: PluginV2.ID.make("gitlab"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "gitlab-ai-provider") return + const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) + evt.sdk = mod.createGitLab({ + ...evt.options, + instanceUrl: + typeof evt.options.instanceUrl === "string" + ? evt.options.instanceUrl + : (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"), + apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN, + aiGatewayHeaders: { + "User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, + "anthropic-beta": "context-1m-2025-08-07", + ...evt.options.aiGatewayHeaders, + }, + featureFlags: { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + ...evt.options.featureFlags, + }, + }) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.gitlab) return + const featureFlags = + typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {} + if (evt.model.apiID.startsWith("duo-workflow-")) { + const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie) + const workflowRef = + typeof evt.model.options.aisdk.request.workflowRef === "string" + ? evt.model.options.aisdk.request.workflowRef + : undefined + const workflowDefinition = + typeof evt.model.options.aisdk.request.workflowDefinition === "string" + ? evt.model.options.aisdk.request.workflowDefinition + : undefined + const language = evt.sdk.workflowChat( + gitlab.isWorkflowModel(evt.model.apiID) ? evt.model.apiID : "duo-workflow", + { + featureFlags, + workflowDefinition, + }, + ) + if (workflowRef) language.selectedModelRef = workflowRef + evt.language = language + return + } + evt.language = evt.sdk.agenticChat(evt.model.apiID, { + aiGatewayHeaders: evt.options.aiGatewayHeaders, + featureFlags, + }) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts new file mode 100644 index 0000000000..0c335df931 --- /dev/null +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -0,0 +1,141 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +function resolveProject(options: Record) { + // models.dev advertises GOOGLE_VERTEX_PROJECT for Vertex, while Google SDKs + // and ADC examples commonly use the broader Google Cloud project aliases. + return ( + options.project ?? + process.env.GOOGLE_VERTEX_PROJECT ?? + process.env.GOOGLE_CLOUD_PROJECT ?? + process.env.GCP_PROJECT ?? + process.env.GCLOUD_PROJECT + ) +} + +function resolveLocation(options: Record) { + return ( + options.location ?? + process.env.GOOGLE_VERTEX_LOCATION ?? + process.env.GOOGLE_CLOUD_LOCATION ?? + process.env.VERTEX_LOCATION ?? + "us-central1" + ) +} + +function vertexEndpoint(location: string) { + return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com` +} + +function replaceVertexVars(value: string, project: string | undefined, location: string) { + // Vertex OpenAI-compatible endpoints are stored as templates in the catalog; + // expand them after provider config/env project and location have been resolved. + return value + .replaceAll("${GOOGLE_VERTEX_PROJECT}", project ?? "${GOOGLE_VERTEX_PROJECT}") + .replaceAll("${GOOGLE_VERTEX_LOCATION}", location) + .replaceAll("${GOOGLE_VERTEX_ENDPOINT}", vertexEndpoint(location)) +} + +function authFetch(fetchWithRuntimeOptions?: unknown) { + // Native Vertex SDKs handle ADC internally. OpenAI-compatible Vertex endpoints + // do not, so inject a Google access token into their fetch path. + return async (input: Parameters[0], init?: RequestInit) => { + const { GoogleAuth } = await import("google-auth-library") + const auth = new GoogleAuth() + const client = await auth.getApplicationDefault() + const token = await client.credential.getAccessToken() + const headers = new Headers(init?.headers) + headers.set("Authorization", `Bearer ${token.token}`) + return typeof fetchWithRuntimeOptions === "function" + ? fetchWithRuntimeOptions(input, { ...init, headers }) + : fetch(input, { ...init, headers }) + } +} + +export const GoogleVertexPlugin = PluginV2.define({ + id: PluginV2.ID.make("google-vertex"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.googleVertex) return + const project = resolveProject(evt.provider.options.aisdk.provider) + const location = String(resolveLocation(evt.provider.options.aisdk.provider)) + if (project) evt.provider.options.aisdk.provider.project = project + evt.provider.options.aisdk.provider.location = location + if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.url) { + evt.provider.endpoint.url = replaceVertexVars(evt.provider.endpoint.url, project, location) + } + if ( + evt.provider.endpoint.type === "aisdk" && + evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible") + ) { + evt.provider.options.aisdk.provider.fetch = authFetch(evt.provider.options.aisdk.provider.fetch) + } + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { + evt.options.fetch = authFetch(evt.options.fetch) + return + } + if (evt.package !== "@ai-sdk/google-vertex") return + const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex")) + const project = resolveProject(evt.options) + const location = resolveLocation(evt.options) + const options = { ...evt.options } + delete options.fetch + evt.sdk = mod.createVertex({ + ...options, + project, + location, + }) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.googleVertex) return + evt.language = evt.sdk.languageModel(String(evt.model.apiID).trim()) + }), + } + }), +}) + +export const GoogleVertexAnthropicPlugin = PluginV2.define({ + id: PluginV2.ID.make("google-vertex-anthropic"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("google-vertex-anthropic")) return + const project = + evt.provider.options.aisdk.provider.project ?? + process.env.GOOGLE_CLOUD_PROJECT ?? + process.env.GCP_PROJECT ?? + process.env.GCLOUD_PROJECT + const location = + evt.provider.options.aisdk.provider.location ?? + process.env.GOOGLE_CLOUD_LOCATION ?? + process.env.VERTEX_LOCATION ?? + "global" + if (project) evt.provider.options.aisdk.provider.project = project + evt.provider.options.aisdk.provider.location = location + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/google-vertex/anthropic") return + const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) + evt.sdk = mod.createVertexAnthropic({ + ...evt.options, + project: + typeof evt.options.project === "string" + ? evt.options.project + : (process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCP_PROJECT ?? process.env.GCLOUD_PROJECT), + location: + typeof evt.options.location === "string" + ? evt.options.location + : (process.env.GOOGLE_CLOUD_LOCATION ?? process.env.VERTEX_LOCATION ?? "global"), + }) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return + evt.language = evt.sdk.languageModel(String(evt.model.apiID).trim()) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts new file mode 100644 index 0000000000..47e29c6b5d --- /dev/null +++ b/packages/core/src/plugin/provider/google.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const GooglePlugin = PluginV2.define({ + id: PluginV2.ID.make("google"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/google") return + const mod = yield* Effect.promise(() => import("@ai-sdk/google")) + evt.sdk = mod.createGoogleGenerativeAI(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts new file mode 100644 index 0000000000..f2052afd1a --- /dev/null +++ b/packages/core/src/plugin/provider/groq.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const GroqPlugin = PluginV2.define({ + id: PluginV2.ID.make("groq"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/groq") return + const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) + evt.sdk = mod.createGroq(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/index.ts b/packages/core/src/plugin/provider/index.ts new file mode 100644 index 0000000000..fd02d322a1 --- /dev/null +++ b/packages/core/src/plugin/provider/index.ts @@ -0,0 +1,67 @@ +import { AlibabaPlugin } from "./alibaba" +import { AmazonBedrockPlugin } from "./amazon-bedrock" +import { AnthropicPlugin } from "./anthropic" +import { AzureCognitiveServicesPlugin, AzurePlugin } from "./azure" +import { CerebrasPlugin } from "./cerebras" +import { CloudflareAIGatewayPlugin } from "./cloudflare-ai-gateway" +import { CloudflareWorkersAIPlugin } from "./cloudflare-workers-ai" +import { CoherePlugin } from "./cohere" +import { DeepInfraPlugin } from "./deepinfra" +import { DynamicProviderPlugin } from "./dynamic" +import { GatewayPlugin } from "./gateway" +import { GithubCopilotPlugin } from "./github-copilot" +import { GitLabPlugin } from "./gitlab" +import { GooglePlugin } from "./google" +import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./google-vertex" +import { GroqPlugin } from "./groq" +import { KiloPlugin } from "./kilo" +import { LLMGatewayPlugin } from "./llmgateway" +import { MistralPlugin } from "./mistral" +import { NvidiaPlugin } from "./nvidia" +import { OpenAIPlugin } from "./openai" +import { OpenAICompatiblePlugin } from "./openai-compatible" +import { OpencodePlugin } from "./opencode" +import { OpenRouterPlugin } from "./openrouter" +import { PerplexityPlugin } from "./perplexity" +import { SapAICorePlugin } from "./sap-ai-core" +import { TogetherAIPlugin } from "./togetherai" +import { VercelPlugin } from "./vercel" +import { VenicePlugin } from "./venice" +import { XAIPlugin } from "./xai" +import { ZenmuxPlugin } from "./zenmux" + +export const ProviderPlugins = [ + AlibabaPlugin, + AmazonBedrockPlugin, + AnthropicPlugin, + AzureCognitiveServicesPlugin, + AzurePlugin, + CerebrasPlugin, + CloudflareAIGatewayPlugin, + CloudflareWorkersAIPlugin, + CoherePlugin, + DeepInfraPlugin, + GatewayPlugin, + GithubCopilotPlugin, + GitLabPlugin, + GooglePlugin, + GoogleVertexAnthropicPlugin, + GoogleVertexPlugin, + GroqPlugin, + KiloPlugin, + LLMGatewayPlugin, + MistralPlugin, + NvidiaPlugin, + OpencodePlugin, + OpenAICompatiblePlugin, + OpenAIPlugin, + OpenRouterPlugin, + PerplexityPlugin, + SapAICorePlugin, + TogetherAIPlugin, + VercelPlugin, + VenicePlugin, + XAIPlugin, + ZenmuxPlugin, + DynamicProviderPlugin, +] diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts new file mode 100644 index 0000000000..47b8ec99cd --- /dev/null +++ b/packages/core/src/plugin/provider/kilo.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const KiloPlugin = PluginV2.define({ + id: PluginV2.ID.make("kilo"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("kilo")) return + evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + evt.provider.options.headers["X-Title"] = "opencode" + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts new file mode 100644 index 0000000000..da1ab282bd --- /dev/null +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -0,0 +1,18 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const LLMGatewayPlugin = PluginV2.define({ + id: PluginV2.ID.make("llmgateway"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("llmgateway")) return + if (evt.provider.enabled === false) return + evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + evt.provider.options.headers["X-Title"] = "opencode" + evt.provider.options.headers["X-Source"] = "opencode" + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts new file mode 100644 index 0000000000..e7f0decb79 --- /dev/null +++ b/packages/core/src/plugin/provider/mistral.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const MistralPlugin = PluginV2.define({ + id: PluginV2.ID.make("mistral"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/mistral") return + const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) + evt.sdk = mod.createMistral(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts new file mode 100644 index 0000000000..b227e5cef3 --- /dev/null +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const NvidiaPlugin = PluginV2.define({ + id: PluginV2.ID.make("nvidia"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return + evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + evt.provider.options.headers["X-Title"] = "opencode" + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts new file mode 100644 index 0000000000..76c3373706 --- /dev/null +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const OpenAICompatiblePlugin = PluginV2.define({ + id: PluginV2.ID.make("openai-compatible"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.sdk) return + if (!evt.package.includes("@ai-sdk/openai-compatible")) return + if (evt.options.includeUsage !== false) evt.options.includeUsage = true + const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) + evt.sdk = mod.createOpenAICompatible(evt.options as any) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts new file mode 100644 index 0000000000..a81455f198 --- /dev/null +++ b/packages/core/src/plugin/provider/openai.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import { ModelV2 } from "../../model" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const OpenAIPlugin = PluginV2.define({ + id: PluginV2.ID.make("openai"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/openai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) + evt.sdk = mod.createOpenAI(evt.options) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.openai) return + evt.language = evt.sdk.responses(evt.model.apiID) + }), + "model.update": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.openai) return + // OpenAIPlugin sends OpenAI models through Responses; this alias is a + // chat-completions-only model, so remove it only from OpenAI's catalog. + if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts new file mode 100644 index 0000000000..10bbb62dad --- /dev/null +++ b/packages/core/src/plugin/provider/opencode.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const OpencodePlugin = PluginV2.define({ + id: PluginV2.ID.make("opencode"), + effect: Effect.gen(function* () { + let hasKey = false + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.opencode) return + hasKey = Boolean( + process.env.OPENCODE_API_KEY || + evt.provider.env.some((item) => process.env[item]) || + evt.provider.options.aisdk.provider.apiKey || + (evt.provider.enabled && evt.provider.enabled.via === "auth"), + ) + if (!hasKey) evt.provider.options.aisdk.provider.apiKey = "public" + }), + "model.update": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.opencode) return + if (hasKey) return + if (evt.model.cost.some((item) => item.input > 0)) evt.cancel = true + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts new file mode 100644 index 0000000000..976eea8c05 --- /dev/null +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -0,0 +1,29 @@ +import { Effect } from "effect" +import { ModelV2 } from "../../model" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const OpenRouterPlugin = PluginV2.define({ + id: PluginV2.ID.make("openrouter"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.openrouter) return + evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" + evt.provider.options.headers["X-Title"] = "opencode" + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@openrouter/ai-sdk-provider") return + const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) + evt.sdk = mod.createOpenRouter(evt.options) + }), + "model.update": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.openrouter) return + // These are OpenRouter-specific OpenAI chat aliases that do not work on + // the generic path. Keep custom providers with matching IDs untouched. + if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true + if (evt.model.id === ModelV2.ID.make("openai/gpt-5-chat")) evt.cancel = true + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts new file mode 100644 index 0000000000..2415ab7c1a --- /dev/null +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const PerplexityPlugin = PluginV2.define({ + id: PluginV2.ID.make("perplexity"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/perplexity") return + const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) + evt.sdk = mod.createPerplexity(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts new file mode 100644 index 0000000000..7c57b785bf --- /dev/null +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -0,0 +1,44 @@ +import { Npm } from "../../npm" +import { Effect, Option } from "effect" +import { pathToFileURL } from "url" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const SapAICorePlugin = PluginV2.define({ + id: PluginV2.ID.make("sap-ai-core"), + effect: Effect.gen(function* () { + const npm = yield* Npm.Service + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return + const serviceKey = + process.env.AICORE_SERVICE_KEY ?? + (typeof evt.options.serviceKey === "string" ? evt.options.serviceKey : undefined) + if (serviceKey && !process.env.AICORE_SERVICE_KEY) process.env.AICORE_SERVICE_KEY = serviceKey + + const installedPath = evt.package.startsWith("file://") + ? evt.package + : Option.getOrUndefined((yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint) + if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) + + const mod = yield* Effect.promise(async () => { + return (await import( + installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href + )) as Record any> + }).pipe(Effect.orDie) + const match = Object.keys(mod).find((name) => name.startsWith("create")) + if (!match) throw new Error(`Package ${evt.package} has no provider factory export`) + + evt.sdk = mod[match]( + serviceKey + ? { deploymentId: process.env.AICORE_DEPLOYMENT_ID, resourceGroup: process.env.AICORE_RESOURCE_GROUP } + : {}, + ) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return + evt.language = evt.sdk(evt.model.apiID) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts new file mode 100644 index 0000000000..b1870f2662 --- /dev/null +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const TogetherAIPlugin = PluginV2.define({ + id: PluginV2.ID.make("togetherai"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/togetherai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) + evt.sdk = mod.createTogetherAI(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts new file mode 100644 index 0000000000..8a3b950245 --- /dev/null +++ b/packages/core/src/plugin/provider/venice.ts @@ -0,0 +1,15 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" + +export const VenicePlugin = PluginV2.define({ + id: PluginV2.ID.make("venice"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "venice-ai-sdk-provider") return + const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) + evt.sdk = mod.createVenice(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts new file mode 100644 index 0000000000..2108542b16 --- /dev/null +++ b/packages/core/src/plugin/provider/vercel.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const VercelPlugin = PluginV2.define({ + id: PluginV2.ID.make("vercel"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("vercel")) return + evt.provider.options.headers["http-referer"] = "https://opencode.ai/" + evt.provider.options.headers["x-title"] = "opencode" + }), + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/vercel") return + const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) + evt.sdk = mod.createVercel(evt.options) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts new file mode 100644 index 0000000000..b54aa7374c --- /dev/null +++ b/packages/core/src/plugin/provider/xai.ts @@ -0,0 +1,20 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const XAIPlugin = PluginV2.define({ + id: PluginV2.ID.make("xai"), + effect: Effect.gen(function* () { + return { + "aisdk.sdk": Effect.fn(function* (evt) { + if (evt.package !== "@ai-sdk/xai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) + evt.sdk = mod.createXai(evt.options) + }), + "aisdk.language": Effect.fn(function* (evt) { + if (evt.model.providerID !== ProviderV2.ID.make("xai")) return + evt.language = evt.sdk.responses(evt.model.apiID) + }), + } + }), +}) diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts new file mode 100644 index 0000000000..6bdd426010 --- /dev/null +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect" +import { PluginV2 } from "../../plugin" +import { ProviderV2 } from "../../provider" + +export const ZenmuxPlugin = PluginV2.define({ + id: PluginV2.ID.make("zenmux"), + effect: Effect.gen(function* () { + return { + "provider.update": Effect.fn(function* (evt) { + if (evt.provider.id !== ProviderV2.ID.make("zenmux")) return + evt.provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/" + evt.provider.options.headers["X-Title"] ??= "opencode" + }), + } + }), +}) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index 2da8eb834f..76ea9cf3f0 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -16,6 +16,7 @@ export interface RunOptions { readonly maxErrorBytes?: number readonly signal?: AbortSignal readonly timeout?: Duration.Input + readonly stdin?: string | Uint8Array | Stream.Stream } export interface RunStreamOptions { @@ -96,6 +97,15 @@ const waitForAbort = (signal: AbortSignal) => return Effect.sync(() => signal.removeEventListener("abort", onabort)) }) +const normalizeStdin = ( + input: string | Uint8Array | Stream.Stream, +): Stream.Stream => + typeof input === "string" + ? Stream.make(new TextEncoder().encode(input)) + : input instanceof Uint8Array + ? Stream.make(input) + : input + const collectStream = (stream: Stream.Stream, maxOutputBytes: number | undefined) => Stream.runFold( stream, @@ -119,7 +129,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner - const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) { + const runCommand = (command: ChildProcess.Command, options?: RunOptions) => { const description = describeCommand(command) const collect = Effect.scoped( Effect.gen(function* () { @@ -154,7 +164,22 @@ export const layer = Layer.effect( ), ) : timed - return yield* aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause)))) + return aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause)))) + } + + const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) { + if (options?.stdin === undefined) return yield* runCommand(command, options) + if (command._tag !== "StandardCommand") { + return yield* new AppProcessError({ + command: describeCommand(command), + cause: new Error("stdin option only supports StandardCommand; received PipedCommand"), + }) + } + const next = ChildProcess.make(command.command, command.args, { + ...command.options, + stdin: normalizeStdin(options.stdin), + }) + return yield* runCommand(next, options) }) const runStream = ( diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts new file mode 100644 index 0000000000..7c1c966654 --- /dev/null +++ b/packages/core/src/provider.ts @@ -0,0 +1,120 @@ +export * as ProviderV2 from "./provider" + +import { withStatics } from "./schema" +import { Schema } from "effect" + +export const ID = Schema.String.pipe( + Schema.brand("ProviderV2.ID"), + withStatics((schema) => ({ + // Well-known providers + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ID = typeof ID.Type + +const OpenAIResponses = Schema.Struct({ + type: Schema.Literal("openai/responses"), + url: Schema.String, + websocket: Schema.optional(Schema.Boolean), +}) + +const OpenAICompletions = Schema.Struct({ + type: Schema.Literal("openai/completions"), + url: Schema.String, + reasoning: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("reasoning_content"), + }), + Schema.Struct({ + type: Schema.Literal("reasoning_details"), + }), + ]).pipe(Schema.optional), +}) +export type OpenAICompletions = typeof OpenAICompletions.Type + +const AISDK = Schema.Struct({ + type: Schema.Literal("aisdk"), + package: Schema.String, + url: Schema.String.pipe(Schema.optional), +}) + +const AnthropicMessages = Schema.Struct({ + type: Schema.Literal("anthropic/messages"), + url: Schema.String, +}) + +const UnknownEndpoint = Schema.Struct({ + type: Schema.Literal("unknown"), +}) + +export const Endpoint = Schema.Union([ + UnknownEndpoint, + OpenAIResponses, + OpenAICompletions, + AnthropicMessages, + AISDK, +]).pipe(Schema.toTaggedUnion("type")) +export type Endpoint = typeof Endpoint.Type + +export const Options = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), + aisdk: Schema.Struct({ + provider: Schema.Record(Schema.String, Schema.Any), + request: Schema.Record(Schema.String, Schema.Any), + }), +}) +export type Options = typeof Options.Type + +export class Info extends Schema.Class("ProviderV2.Info")({ + id: ID, + name: Schema.String, + enabled: Schema.Union([ + Schema.Literal(false), + Schema.Struct({ + via: Schema.Literal("env"), + name: Schema.String, + }), + Schema.Struct({ + via: Schema.Literal("auth"), + service: Schema.String, + }), + Schema.Struct({ + via: Schema.Literal("custom"), + data: Schema.Record(Schema.String, Schema.Any), + }), + ]), + env: Schema.String.pipe(Schema.Array), + endpoint: Endpoint, + options: Options, +}) { + static empty(providerID: ID) { + return new Info({ + id: providerID, + name: providerID, + enabled: false, + env: [], + endpoint: { + type: "unknown", + }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + }) + } +} diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/core/src/session-prompt.ts similarity index 100% rename from packages/opencode/src/v2/session-prompt.ts rename to packages/core/src/session-prompt.ts diff --git a/packages/opencode/src/v2/tool-output.ts b/packages/core/src/tool-output.ts similarity index 100% rename from packages/opencode/src/v2/tool-output.ts rename to packages/core/src/tool-output.ts diff --git a/packages/opencode/src/v2/schema.ts b/packages/core/src/v2-schema.ts similarity index 88% rename from packages/opencode/src/v2/schema.ts rename to packages/core/src/v2-schema.ts index 44587b838a..a34b0b1516 100644 --- a/packages/opencode/src/v2/schema.ts +++ b/packages/core/src/v2-schema.ts @@ -7,4 +7,4 @@ export const DateTimeUtcFromMillis = Schema.Finite.pipe( }), ) -export * as V2Schema from "./schema" +export * as V2Schema from "./v2-schema" diff --git a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts b/packages/core/test/github-copilot/convert-to-copilot-messages.test.ts similarity index 99% rename from packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts rename to packages/core/test/github-copilot/convert-to-copilot-messages.test.ts index 6f874db6d2..65f4b6a536 100644 --- a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts +++ b/packages/core/test/github-copilot/convert-to-copilot-messages.test.ts @@ -1,4 +1,4 @@ -import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages" +import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages" import { describe, test, expect } from "bun:test" describe("system messages", () => { diff --git a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts b/packages/core/test/github-copilot/copilot-chat-model.test.ts similarity index 99% rename from packages/opencode/test/provider/copilot/copilot-chat-model.test.ts rename to packages/core/test/github-copilot/copilot-chat-model.test.ts index 389a72bb37..bc1e2ecd95 100644 --- a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts +++ b/packages/core/test/github-copilot/copilot-chat-model.test.ts @@ -1,4 +1,4 @@ -import { OpenAICompatibleChatLanguageModel } from "@/provider/sdk/copilot/chat/openai-compatible-chat-language-model" +import { OpenAICompatibleChatLanguageModel } from "@opencode-ai/core/github-copilot/chat/openai-compatible-chat-language-model" import { describe, test, expect, mock } from "bun:test" import type { LanguageModelV3Prompt } from "@ai-sdk/provider" diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts new file mode 100644 index 0000000000..c825f7b8ec --- /dev/null +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -0,0 +1,188 @@ +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 { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" +import { fakeSelectorSdk, it, model } from "../v2/plugin/provider-helper" + +describe("GithubCopilotPlugin", () => { + it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GithubCopilotPlugin) + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("github-copilot", "gpt-5"), + package: "@ai-sdk/openai-compatible", + options: { name: "github-copilot" }, + }, + {}, + ) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("github-copilot", "gpt-5"), + package: "@ai-sdk/github-copilot", + options: { name: "github-copilot" }, + }, + {}, + ) + expect(ignored.sdk).toBeUndefined() + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("selects languageModel when responses and chat are absent", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GithubCopilotPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("github-copilot", "claude-sonnet-4"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual(["languageModel:claude-sonnet-4"]) + }), + ) + + it.effect("selects languageModel with the API model ID when responses and chat are absent", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GithubCopilotPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("github-copilot", "alias", { apiID: ModelV2.ID.make("claude-sonnet-4") }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual(["languageModel:claude-sonnet-4"]) + }), + ) + + it.effect("uses responses for gpt-5 models except gpt-5-mini", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GithubCopilotPlugin) + yield* plugin.trigger( + "aisdk.language", + { model: model("github-copilot", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { model: model("github-copilot", "gpt-5.1-codex"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { model: model("github-copilot", "gpt-4o"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { model: model("github-copilot", "gpt-5-mini"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { model: model("github-copilot", "gpt-5-mini-2025-08-07"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual([ + "responses:gpt-5", + "responses:gpt-5.1-codex", + "chat:gpt-4o", + "chat:gpt-5-mini", + "chat:gpt-5-mini-2025-08-07", + ]) + }), + ) + + it.effect("uses the API model ID when selecting responses or chat", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GithubCopilotPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("github-copilot", "default", { apiID: ModelV2.ID.make("gpt-5") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("github-copilot", "small", { apiID: ModelV2.ID.make("gpt-5-mini") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("github-copilot", "sonnet", { apiID: ModelV2.ID.make("claude-sonnet-4") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + expect(calls).toEqual(["responses:gpt-5", "chat:gpt-5-mini", "chat:claude-sonnet-4"]) + }), + ) + + it.effect("filters gpt-5-chat-latest before Copilot language selection", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GithubCopilotPlugin) + const result = yield* plugin.trigger( + "model.update", + {}, + { model: model("github-copilot", "gpt-5-chat-latest"), cancel: false }, + ) + expect(result.cancel).toBe(true) + }), + ) + + it.effect("does not filter gpt-5-chat-latest for non-Copilot providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GithubCopilotPlugin) + const result = yield* plugin.trigger( + "model.update", + {}, + { model: model("custom-copilot", "gpt-5-chat-latest"), cancel: false }, + ) + expect(result.cancel).toBe(false) + }), + ) + + it.effect("ignores non-Copilot providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GithubCopilotPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { model: model("openai", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts index 726c3c4d8d..5cc73e6169 100644 --- a/packages/core/test/process/process.test.ts +++ b/packages/core/test/process/process.test.ts @@ -1,4 +1,6 @@ import { describe, expect } from "bun:test" +import { realpathSync } from "node:fs" +import { tmpdir } from "node:os" import { Effect, Exit, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { AppProcess } from "@opencode-ai/core/process" @@ -123,6 +125,82 @@ describe("AppProcess", () => { ) }) + describe("run with stdin option", () => { + const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))" + + it.effect( + "feeds a string to stdin and returns it on stdout", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "hello" }) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString("utf8")).toBe("hello") + }), + ) + + it.effect( + "feeds a Uint8Array to stdin", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const bytes = new TextEncoder().encode("bytes") + const result = yield* svc.run(cmd("-e", echoStdin), { stdin: bytes }) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString("utf8")).toBe("bytes") + }), + ) + + it.effect( + "feeds a Stream of Uint8Array chunks to stdin", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const enc = new TextEncoder() + const stream = Stream.fromIterable([enc.encode("one"), enc.encode("-two"), enc.encode("-three")]) + const result = yield* svc.run(cmd("-e", echoStdin), { stdin: stream }) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString("utf8")).toBe("one-two-three") + }), + ) + + it.effect( + "completes correctly with empty input", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "" }) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString("utf8")).toBe("") + }), + ) + + it.effect( + "carries existing Command options like env", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const script = + "process.stdout.write(process.env.FEED + ':'); process.stdin.on('data', c => process.stdout.write(c))" + const command = ChildProcess.make(NODE, ["-e", script], { env: { FEED: "envset" }, extendEnv: true }) + const result = yield* svc.run(command, { stdin: "payload" }) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString("utf8")).toBe("envset:payload") + }), + ) + + it.effect( + "carries existing Command options like cwd", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const dir = realpathSync(tmpdir()) + const script = + "process.stdout.write(process.cwd() + '|'); process.stdin.on('data', c => process.stdout.write(c))" + const command = ChildProcess.make(NODE, ["-e", script], { cwd: dir }) + const result = yield* svc.run(command, { stdin: "ok" }) + expect(result.exitCode).toBe(0) + const [cwd, stdin] = result.stdout.toString("utf8").split("|") + expect(realpathSync(cwd)).toBe(dir) + expect(stdin).toBe("ok") + }), + ) + }) + describe("runStream", () => { it.live( "emits lines incrementally and ends cleanly on exit 0", @@ -136,11 +214,17 @@ describe("AppProcess", () => { ) it.live( - "fails with AppProcessError when exit not in okExitCodes", + "okExitCodes determines whether a non-zero exit fails the stream", Effect.gen(function* () { const svc = yield* AppProcess.Service + const allowed = yield* svc + .runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] }) + .pipe(Stream.runCollect) + expect(Array.from(allowed)).toEqual(["only"]) const exit = yield* Effect.exit( - svc.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] }).pipe(Stream.runCollect), + svc + .runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] }) + .pipe(Stream.runCollect), ) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { @@ -152,17 +236,6 @@ describe("AppProcess", () => { }), ) - it.live( - "okExitCodes allowlist treats non-zero as success", - Effect.gen(function* () { - const svc = yield* AppProcess.Service - const result = yield* svc - .runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] }) - .pipe(Stream.runCollect) - expect(Array.from(result)).toEqual(["only"]) - }), - ) - it.live( "without okExitCodes, never fails on exit code", Effect.gen(function* () { @@ -177,12 +250,10 @@ describe("AppProcess", () => { Effect.gen(function* () { const svc = yield* AppProcess.Service const controller = new AbortController() - setTimeout(() => controller.abort(), 50) + controller.abort() const exit = yield* Effect.exit( svc - .runStream(cmd("-e", "setInterval(() => console.log('tick'), 100); setTimeout(() => {}, 60_000)"), { - signal: controller.signal, - }) + .runStream(cmd("-e", "setInterval(() => {}, 60_000)"), { signal: controller.signal }) .pipe(Stream.runCollect), ) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/core/test/v2/catalog.test.ts b/packages/core/test/v2/catalog.test.ts new file mode 100644 index 0000000000..cba3405bce --- /dev/null +++ b/packages/core/test/v2/catalog.test.ts @@ -0,0 +1,199 @@ +import { describe, expect } from "bun:test" +import { DateTime, Effect, Layer, Option } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" + +const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer))) + +describe("CatalogV2", () => { + it.effect("normalizes provider baseURL into endpoint url", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + + yield* catalog.provider.update(providerID, (provider) => { + provider.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://default.example.com", + } + provider.options.aisdk.provider.baseURL = "https://override.example.com" + }) + + const provider = yield* catalog.provider.get(providerID) + + expect(provider.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://override.example.com", + }) + expect(provider.options.aisdk.provider.baseURL).toBeUndefined() + }), + ) + + it.effect("normalizes model baseURL into endpoint url", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + const modelID = ModelV2.ID.make("model") + + yield* catalog.provider.update(providerID, (provider) => { + provider.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://provider.example.com", + } + }) + yield* catalog.model.update(providerID, modelID, (model) => { + model.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://model.example.com", + } + model.options.aisdk.provider.baseURL = "https://override.example.com" + }) + + const model = yield* catalog.model.get(providerID, modelID) + + expect(model.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://override.example.com", + }) + expect(model.options.aisdk.provider.baseURL).toBeUndefined() + }), + ) + + it.effect("resolves unknown model endpoint from provider endpoint", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + const modelID = ModelV2.ID.make("model") + + yield* catalog.provider.update(providerID, (provider) => { + provider.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://provider.example.com", + } + }) + yield* catalog.model.update(providerID, modelID, () => {}) + + const model = yield* catalog.model.get(providerID, modelID) + + expect(model.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://provider.example.com", + }) + }), + ) + + it.effect("runs provider hooks after baseURL is normalized", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const providerID = ProviderV2.ID.make("test") + const seen: unknown[] = [] + + yield* plugin.add({ + id: PluginV2.ID.make("test"), + effect: Effect.succeed({ + "provider.update": (evt) => + Effect.sync(() => { + seen.push(evt.provider.endpoint.type) + if (evt.provider.endpoint.type === "aisdk") seen.push(evt.provider.endpoint.url) + seen.push(evt.provider.options.aisdk.provider.baseURL) + }), + }), + }) + yield* catalog.provider.update(providerID, (provider) => { + provider.endpoint = { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + } + provider.options.aisdk.provider.baseURL = "https://provider.example.com" + }) + + expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined]) + }), + ) + + it.effect("resolves provider and model option merges", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + const modelID = ModelV2.ID.make("model") + + yield* catalog.provider.update(providerID, (provider) => { + provider.options.headers.provider = "provider" + provider.options.headers.shared = "provider" + provider.options.body.provider = true + provider.options.aisdk.provider.provider = true + }) + yield* catalog.model.update(providerID, modelID, (model) => { + model.options.headers.model = "model" + model.options.headers.shared = "model" + model.options.body.model = true + model.options.aisdk.provider.model = true + model.options.aisdk.request.request = true + }) + + const model = yield* catalog.model.get(providerID, modelID) + + expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) + expect(model.options.body).toEqual({ provider: true, model: true }) + expect(model.options.aisdk.provider).toEqual({ provider: true, model: true }) + expect(model.options.aisdk.request).toEqual({ request: true }) + }), + ) + + it.effect("falls back to newest available model when no default is configured", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + + yield* catalog.provider.update(providerID, (provider) => { + provider.enabled = { via: "custom", data: {} } + }) + yield* catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { + model.time.released = DateTime.makeUnsafe(1000) + }) + yield* catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { + model.time.released = DateTime.makeUnsafe(2000) + }) + + const model = yield* catalog.model.default() + + expect(Option.getOrUndefined(model)?.id).toMatch("new") + }), + ) + + it.effect("small model prefers small keyword candidates before cost scoring", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + + yield* catalog.provider.update(providerID, () => {}) + yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] + model.time.released = DateTime.makeUnsafe(Date.now()) + }) + yield* catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] + model.time.released = DateTime.makeUnsafe(Date.now()) + }) + + const model = yield* catalog.model.small(providerID) + + expect(Option.getOrUndefined(model)?.id).toMatch("expensive-mini") + }), + ) +}) diff --git a/packages/core/test/v2/plugin/fixtures/provider-factory.ts b/packages/core/test/v2/plugin/fixtures/provider-factory.ts new file mode 100644 index 0000000000..7278c231dd --- /dev/null +++ b/packages/core/test/v2/plugin/fixtures/provider-factory.ts @@ -0,0 +1,9 @@ +export function createFixtureProvider(options: Record) { + const captured = Object.fromEntries(Object.entries(options)) + return Object.assign((modelID: string) => ({ modelID, options: captured }), { + options: captured, + languageModel(modelID: string) { + return { modelID, options: captured } + }, + }) +} diff --git a/packages/core/test/v2/plugin/provider-alibaba.test.ts b/packages/core/test/v2/plugin/provider-alibaba.test.ts new file mode 100644 index 0000000000..06e6f969fd --- /dev/null +++ b/packages/core/test/v2/plugin/provider-alibaba.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +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 { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" +import { it, model } from "./provider-helper" + +describe("AlibabaPlugin", () => { + it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AlibabaPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("alibaba", "qwen"), package: "@ai-sdk/alibaba", options: { name: "alibaba" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("ignores non-Alibaba SDK packages", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AlibabaPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("alibaba", "qwen"), package: "@ai-sdk/openai-compatible", options: { name: "alibaba" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("matches the old bundled Alibaba SDK provider naming", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AlibabaPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-alibaba", "qwen"), + package: "@ai-sdk/alibaba", + options: { name: "custom-alibaba", apiKey: "test" }, + }, + {}, + ) + const expected = createAlibaba({ apiKey: "test", ...{ name: "custom-alibaba" } }).languageModel("qwen") + const actual = result.sdk?.languageModel("qwen") + expect(actual?.provider).toBe(expected.provider) + expect(actual?.modelId).toBe(expected.modelId) + }), + ) + + it.effect("uses the old default languageModel(apiID) behavior", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AlibabaPlugin) + const item = model("alibaba", "alias", { apiID: ModelV2.ID.make("qwen-plus") }) + const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) + const language = result.sdk?.languageModel(item.apiID) + expect(language?.modelId).toBe("qwen-plus") + expect(language?.provider).toBe("alibaba.chat") + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts new file mode 100644 index 0000000000..c70ada08d9 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts @@ -0,0 +1,465 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" +import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" + +function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { + const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) + return (language as { config: { baseUrl: () => string } }).config.baseUrl() +} + +function bedrockFetch(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { + const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID) + return ( + language as { config: { fetch: (input: Parameters[0], init?: RequestInit) => Promise } } + ).config.fetch +} + +describe("AmazonBedrockPlugin", () => { + it.effect("moves endpoint option to endpoint URL", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("amazon-bedrock", { + options: { + headers: {}, + body: {}, + aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} }, + }, + }), + cancel: false, + }, + ) + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + url: "https://bedrock.example", + }) + expect(result.provider.options.aisdk.provider.endpoint).toBeUndefined() + }), + ) + + it.effect("prefers endpoint over baseURL for SDK base URL", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + endpoint: "https://endpoint.example", + region: "us-east-1", + }, + }, + {}, + ) + expect(bedrockBaseURL(result.sdk)).toBe("https://endpoint.example") + }), + ), + ) + + it.effect("uses baseURL as SDK base URL", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + region: "us-east-1", + }, + }, + {}, + ) + expect(bedrockBaseURL(result.sdk)).toBe("https://base.example") + }), + ), + ) + + it.effect("creates SDK without explicit credential env so the default AWS chain can resolve credentials", () => + withEnv( + { + AWS_ACCESS_KEY_ID: undefined, + AWS_BEARER_TOKEN_BEDROCK: undefined, + AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined, + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined, + AWS_PROFILE: undefined, + AWS_REGION: undefined, + AWS_WEB_IDENTITY_TOKEN_FILE: undefined, + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") + }), + ), + ) + + it.effect("uses config region over AWS_REGION for SDK base URL", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock", region: "eu-west-1" }, + }, + {}, + ) + expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") + }), + ), + ) + + it.effect("uses AWS_REGION for SDK base URL when config region is absent", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }, + {}, + ) + expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") + }), + ), + ) + + it.effect("defaults SDK region to us-east-1", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }, + {}, + ) + expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") + }), + ), + ) + + it.effect("loads bearer token option into env and uses bearer auth", () => + withEnv({ AWS_ACCESS_KEY_ID: undefined, AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const headers: Array = [] + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") + }, + }, + }, + {}, + ) + yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) + expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("option-token") + expect(headers).toEqual(["Bearer option-token"]) + }), + ), + ) + + it.effect("prefers bearer token env over bearer token option", () => + withEnv({ AWS_BEARER_TOKEN_BEDROCK: "env-token" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const headers: Array = [] + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") + }, + }, + }, + {}, + ) + yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) + expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("env-token") + expect(headers).toEqual(["Bearer env-token"]) + }), + ), + ) + + it.effect("uses SigV4 credential env when bearer token is absent", () => + withEnv( + { + AWS_ACCESS_KEY_ID: "test-access-key", + AWS_BEARER_TOKEN_BEDROCK: undefined, + AWS_REGION: "us-east-1", + AWS_SECRET_ACCESS_KEY: "test-secret-key", + AWS_SESSION_TOKEN: "test-session-token", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const headers: Array = [] + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") + }, + }, + }, + {}, + ) + yield* Effect.promise(() => + bedrockFetch(result.sdk)("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", { + body: "{}", + method: "POST", + }), + ) + expect(headers[0]?.startsWith("AWS4-HMAC-SHA256 ")).toBe(true) + }), + ), + ) + + it.effect("applies legacy cross-region inference prefixes", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AmazonBedrockPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-northeast-1" }, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-southeast-2" }, + }, + {}, + ) + expect(calls).toEqual([ + "languageModel:us.anthropic.claude-sonnet-4-5", + "languageModel:eu.anthropic.claude-sonnet-4-5", + "languageModel:global.anthropic.claude-sonnet-4-5", + "languageModel:jp.anthropic.claude-sonnet-4-5", + "languageModel:au.anthropic.claude-sonnet-4-5", + ]) + }), + ) + + it.effect("uses AWS_REGION for language prefixes when region option is absent", () => + withEnv({ AWS_REGION: "eu-west-1" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AmazonBedrockPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual(["languageModel:eu.anthropic.claude-sonnet-4-5"]) + }), + ), + ) + + it.effect("applies the full legacy cross-region prefix matrix", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + const cases = [ + { region: "us-east-1", modelID: "amazon.nova-micro-v1:0", expected: "us.amazon.nova-micro-v1:0" }, + { region: "us-east-1", modelID: "amazon.nova-lite-v1:0", expected: "us.amazon.nova-lite-v1:0" }, + { region: "us-east-1", modelID: "amazon.nova-pro-v1:0", expected: "us.amazon.nova-pro-v1:0" }, + { region: "us-east-1", modelID: "amazon.nova-premier-v1:0", expected: "us.amazon.nova-premier-v1:0" }, + { region: "us-east-1", modelID: "amazon.nova-2-lite-v1:0", expected: "us.amazon.nova-2-lite-v1:0" }, + { region: "us-east-1", modelID: "anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" }, + { region: "us-east-1", modelID: "deepseek.r1-v1:0", expected: "us.deepseek.r1-v1:0" }, + { region: "us-gov-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" }, + { region: "us-east-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" }, + { region: "eu-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" }, + { region: "eu-west-2", modelID: "amazon.nova-lite-v1:0", expected: "eu.amazon.nova-lite-v1:0" }, + { region: "eu-west-3", modelID: "amazon.nova-micro-v1:0", expected: "eu.amazon.nova-micro-v1:0" }, + { + region: "eu-north-1", + modelID: "meta.llama3-70b-instruct-v1:0", + expected: "eu.meta.llama3-70b-instruct-v1:0", + }, + { region: "eu-central-1", modelID: "mistral.pixtral-large-v1:0", expected: "eu.mistral.pixtral-large-v1:0" }, + { region: "eu-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" }, + { region: "eu-south-2", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" }, + { region: "eu-central-2", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" }, + { region: "eu-west-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" }, + { + region: "ap-southeast-2", + modelID: "anthropic.claude-sonnet-4-5", + expected: "au.anthropic.claude-sonnet-4-5", + }, + { + region: "ap-southeast-4", + modelID: "anthropic.claude-haiku-v1:0", + expected: "au.anthropic.claude-haiku-v1:0", + }, + { region: "ap-southeast-2", modelID: "anthropic.claude-opus-4", expected: "apac.anthropic.claude-opus-4" }, + { + region: "ap-northeast-1", + modelID: "anthropic.claude-sonnet-4-5", + expected: "jp.anthropic.claude-sonnet-4-5", + }, + { region: "ap-northeast-1", modelID: "amazon.nova-pro-v1:0", expected: "jp.amazon.nova-pro-v1:0" }, + { region: "ap-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "apac.anthropic.claude-sonnet-4-5" }, + { region: "ap-south-1", modelID: "amazon.nova-lite-v1:0", expected: "apac.amazon.nova-lite-v1:0" }, + { region: "ca-central-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" }, + { + region: "us-east-1", + modelID: "global.anthropic.claude-sonnet-4-5", + expected: "global.anthropic.claude-sonnet-4-5", + }, + { region: "us-east-1", modelID: "us.anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" }, + { region: "eu-west-1", modelID: "eu.anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" }, + { + region: "ap-northeast-1", + modelID: "jp.anthropic.claude-sonnet-4-5", + expected: "jp.anthropic.claude-sonnet-4-5", + }, + { + region: "ap-south-1", + modelID: "apac.anthropic.claude-sonnet-4-5", + expected: "apac.anthropic.claude-sonnet-4-5", + }, + { + region: "ap-southeast-2", + modelID: "au.anthropic.claude-sonnet-4-5", + expected: "au.anthropic.claude-sonnet-4-5", + }, + ] + yield* plugin.add(AmazonBedrockPlugin) + for (const item of cases) { + yield* plugin.trigger( + "aisdk.language", + { + model: model("amazon-bedrock", item.modelID), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: item.region }, + }, + {}, + ) + } + expect(calls).toEqual(cases.map((item) => `languageModel:${item.expected}`)) + }), + ) + + it.effect("ignores non-Bedrock providers for language selection", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AmazonBedrockPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("openai", "anthropic.claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-anthropic.test.ts b/packages/core/test/v2/plugin/provider-anthropic.test.ts new file mode 100644 index 0000000000..bbea4a3721 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-anthropic.test.ts @@ -0,0 +1,91 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" +import { it, model, provider } from "./provider-helper" + +describe("AnthropicPlugin", () => { + it.effect("applies legacy beta headers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AnthropicPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("anthropic", { + options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.headers["anthropic-beta"]).toBe( + "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", + ) + expect(result.provider.options.headers.Existing).toBe("1") + }), + ) + + it.effect("ignores non-Anthropic providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AnthropicPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) + expect(result.provider.options.headers["anthropic-beta"]).toBeUndefined() + }), + ) + + 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* plugin.add(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( + "aisdk.sdk", + { + model: model("custom-anthropic", "claude-sonnet-4-5"), + package: "@ai-sdk/anthropic", + options: { name: "custom-anthropic", apiKey: "test" }, + }, + {}, + ) + expect(providers).toEqual(["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* plugin.add(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( + "aisdk.sdk", + { + model: model("anthropic", "claude-sonnet-4-5"), + package: "@ai-sdk/anthropic", + options: { name: "anthropic", apiKey: "test" }, + }, + {}, + ) + expect(providers).toEqual(["anthropic"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/v2/plugin/provider-azure-cognitive-services.test.ts new file mode 100644 index 0000000000..b835cbeeff --- /dev/null +++ b/packages/core/test/v2/plugin/provider-azure-cognitive-services.test.ts @@ -0,0 +1,127 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" +import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" + +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 + yield* plugin.add(AzureCognitiveServicesPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("azure-cognitive-services"), cancel: false }, + ) + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + }) + expect(result.provider.options.aisdk.provider.baseURL).toBe( + "https://cognitive.cognitiveservices.azure.com/openai", + ) + expect(result.provider.options.aisdk.provider.resourceName).toBeUndefined() + }), + ), + ) + + 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 + yield* plugin.add(AzureCognitiveServicesPlugin) + const azure = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("azure-cognitive-services"), cancel: false }, + ) + const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) + expect(azure.provider.options.aisdk.provider.baseURL).toBeUndefined() + expect(azure.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" }) + expect(other.provider.options.aisdk.provider.baseURL).toBeUndefined() + expect(other.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" }) + }), + ), + ) + + it.effect("selects chat only for completion URLs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzureCognitiveServicesPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure-cognitive-services", "deployment"), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }, + {}, + ) + expect(calls).toEqual(["chat:deployment"]) + }), + ) + + it.effect("uses the legacy Azure selector order and provider guard", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzureCognitiveServicesPlugin) + yield* plugin.trigger( + "aisdk.language", + { model: model("azure-cognitive-services", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + const ignored = yield* plugin.trigger( + "aisdk.language", + { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual(["responses:deployment"]) + expect(ignored.language).toBeUndefined() + }), + ) + + it.effect("falls back from responses to messages, chat, then languageModel", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + const sdk = fakeSelectorSdk(calls) + yield* plugin.add(AzureCognitiveServicesPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure-cognitive-services", "messages-deployment"), + sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure-cognitive-services", "chat-deployment"), + sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure-cognitive-services", "language-deployment"), + sdk: { languageModel: sdk.languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual([ + "messages:messages-deployment", + "chat:chat-deployment", + "languageModel:language-deployment", + ]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-azure.test.ts b/packages/core/test/v2/plugin/provider-azure.test.ts new file mode 100644 index 0000000000..12d8363e73 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-azure.test.ts @@ -0,0 +1,245 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AuthV2 } from "@opencode-ai/core/auth" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AuthPlugin } from "@opencode-ai/core/plugin/auth" +import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" +import { testEffect } from "../../lib/effect" +import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" + +const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) + +describe("AzurePlugin", () => { + it.effect("resolves resourceName from env", () => + withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AzurePlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false }) + expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") + }), + ), + ) + + 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 + yield* plugin.add(AzurePlugin) + const azure = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("azure", { + options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } }, + }), + cancel: false, + }, + ) + const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) + expect(azure.provider.options.aisdk.provider.resourceName).toBe("from-config") + expect(other.provider.options.aisdk.provider.resourceName).toBeUndefined() + }), + ), + ) + + itWithAuth.effect("prefers auth resourceName over env", () => + withEnv( + { + AZURE_RESOURCE_NAME: "from-env", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const auth = yield* AuthV2.Service + yield* auth.create({ + serviceID: AuthV2.ServiceID.make("azure"), + credential: new AuthV2.ApiKeyCredential({ + type: "api", + key: "key", + metadata: { resourceName: "from-auth" }, + }), + active: true, + }) + yield* plugin.add({ + ...AuthPlugin, + effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + }) + yield* plugin.add(AzurePlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false }) + expect(result.provider.options.aisdk.provider.resourceName).toBe("from-auth") + }), + ), + ) + + 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 + yield* plugin.add(AzurePlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("azure", { + options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") + }), + ), + ) + + 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 + yield* plugin.add(AzurePlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("azure", { + options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") + }), + ), + ) + + it.effect("allows configured baseURL without resourceName", () => + withEnv({ AZURE_RESOURCE_NAME: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AzurePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("azure", "deployment"), + package: "@ai-sdk/azure", + options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ), + ) + + it.effect("rejects missing resourceName when baseURL is not configured", () => + withEnv({ AZURE_RESOURCE_NAME: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(AzurePlugin) + const exit = yield* plugin + .trigger( + "aisdk.sdk", + { model: model("azure", "deployment"), package: "@ai-sdk/azure", options: { name: "azure" } }, + {}, + ) + .pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), + ), + ) + + it.effect("selects chat only for completion URLs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzurePlugin) + yield* plugin.trigger( + "aisdk.language", + { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, + {}, + ) + expect(calls).toEqual(["chat:deployment"]) + }), + ) + + it.effect("selects chat from per-call useCompletionUrls", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzurePlugin) + yield* plugin.trigger( + "aisdk.language", + { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: { useCompletionUrls: true } }, + {}, + ) + expect(calls).toEqual(["chat:deployment"]) + }), + ) + + it.effect("ignores model useCompletionUrls when per-call option is unset", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzurePlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure", "deployment", { + options: { headers: {}, body: {}, aisdk: { provider: {}, request: { useCompletionUrls: true } } }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + expect(calls).toEqual(["responses:deployment"]) + }), + ) + + it.effect("uses the legacy Azure selector order and provider guard", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(AzurePlugin) + yield* plugin.trigger( + "aisdk.language", + { model: model("azure", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + const ignored = yield* plugin.trigger( + "aisdk.language", + { model: model("openai", "deployment"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual(["responses:deployment"]) + expect(ignored.language).toBeUndefined() + }), + ) + + it.effect("falls back through the legacy Azure selector order", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + const make = (method: string) => (id: string) => { + calls.push(`${method}:${id}`) + return { modelId: id, provider: method, specificationVersion: "v3" } + } + yield* plugin.add(AzurePlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("azure", "messages-deployment"), + sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, + options: {}, + }, + {}, + ) + yield* plugin.trigger( + "aisdk.language", + { model: model("azure", "language-deployment"), sdk: { languageModel: make("languageModel") }, options: {} }, + {}, + ) + expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-cerebras.test.ts b/packages/core/test/v2/plugin/provider-cerebras.test.ts new file mode 100644 index 0000000000..7270d5367a --- /dev/null +++ b/packages/core/test/v2/plugin/provider-cerebras.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, mock } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" +import { it, model, provider } from "./provider-helper" + +const cerebrasOptions: Record[] = [] + +void mock.module("@ai-sdk/cerebras", () => ({ + createCerebras: (options: Record) => { + const snapshot = { ...options } + cerebrasOptions.push(snapshot) + return { + languageModel: (modelID: string) => ({ modelID, provider: snapshot.name, specificationVersion: "v3" }), + } + }, +})) + +describe("CerebrasPlugin", () => { + it.effect("applies the legacy integration header", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CerebrasPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("cerebras", { + options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" }) + }), + ) + + it.effect("ignores non-Cerebras providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CerebrasPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("groq"), cancel: false }) + expect(result.provider.options.headers).toEqual({}) + }), + ) + + it.effect("creates a bundled Cerebras SDK with the model provider ID as the SDK name", () => + Effect.gen(function* () { + cerebrasOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(CerebrasPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + package: "@ai-sdk/cerebras", + options: { name: "custom-cerebras", apiKey: "test" }, + }, + {}, + ) + expect(cerebrasOptions).toEqual([{ name: "custom-cerebras", apiKey: "test" }]) + expect(result.sdk.languageModel("llama-4-scout-17b-16e-instruct").provider).toBe("custom-cerebras") + }), + ) + + it.effect("preserves an explicit bundled Cerebras SDK name option", () => + Effect.gen(function* () { + cerebrasOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(CerebrasPlugin) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + package: "@ai-sdk/cerebras", + options: { name: "configured-cerebras", apiKey: "test" }, + }, + {}, + ) + expect(cerebrasOptions).toEqual([{ name: "configured-cerebras", apiKey: "test" }]) + }), + ) + + it.effect("ignores non-Cerebras SDK packages", () => + Effect.gen(function* () { + cerebrasOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(CerebrasPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-cerebras", "llama-4-scout-17b-16e-instruct"), + package: "@ai-sdk/groq", + options: { name: "custom-cerebras", apiKey: "test" }, + }, + {}, + ) + expect(cerebrasOptions).toEqual([]) + expect(result.sdk).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/v2/plugin/provider-cloudflare-ai-gateway.test.ts new file mode 100644 index 0000000000..72ad5da33f --- /dev/null +++ b/packages/core/test/v2/plugin/provider-cloudflare-ai-gateway.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, mock } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway" +import { it, model, withEnv } from "./provider-helper" + +const aiGatewayCalls: Record[] = [] +const unifiedCalls: string[] = [] +const gatewayModelCalls: unknown[] = [] + +function captureAiGatewayOptions(options: Record) { + const nested = + options.options && typeof options.options === "object" ? (options.options as Record) : undefined + return { + ...options, + ...(nested + ? { + options: { + ...nested, + headers: + nested.headers && typeof nested.headers === "object" + ? { ...(nested.headers as Record) } + : nested.headers, + }, + } + : {}), + } +} + +function resetCalls() { + aiGatewayCalls.length = 0 + unifiedCalls.length = 0 + gatewayModelCalls.length = 0 +} + +function cloudflareEnv(overrides: Record = {}) { + return { + CLOUDFLARE_ACCOUNT_ID: "env-account", + CLOUDFLARE_GATEWAY_ID: "env-gateway", + CLOUDFLARE_API_TOKEN: "env-token", + CF_AIG_TOKEN: undefined, + ...overrides, + } +} + +mock.module("ai-gateway-provider", () => ({ + createAiGateway(options: Record) { + aiGatewayCalls.push(captureAiGatewayOptions(options)) + return (input: unknown) => { + gatewayModelCalls.push(input) + return { + modelId: input, + provider: "cloudflare-ai-gateway", + specificationVersion: "v3", + } + } + }, +})) + +mock.module("ai-gateway-provider/providers/unified", () => ({ + createUnified() { + return (modelID: string) => { + unifiedCalls.push(modelID) + return { unifiedModelID: modelID } + } + }, +})) + +describe("CloudflareAIGatewayPlugin", () => { + it.effect("requires account, gateway, and token before creating the unified SDK", () => + withEnv( + { + CLOUDFLARE_ACCOUNT_ID: "acct", + CLOUDFLARE_GATEWAY_ID: "gateway", + CLOUDFLARE_API_TOKEN: "token", + CF_AIG_TOKEN: undefined, + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined() + }), + ), + ) + + it.effect("passes legacy metadata, cache, log, and User-Agent values under the AI Gateway options key", () => + withEnv(cloudflareEnv(), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + metadata: { invoked_by: "test", project: "opencode" }, + cacheTtl: 300, + cacheKey: "cache-key", + skipCache: true, + collectLog: false, + }, + }, + {}, + ) + + expect(aiGatewayCalls).toHaveLength(1) + expect(aiGatewayCalls[0]).toEqual({ + accountId: "env-account", + gateway: "env-gateway", + apiKey: "env-token", + options: { + metadata: { invoked_by: "test", project: "opencode" }, + cacheTtl: 300, + cacheKey: "cache-key", + skipCache: true, + collectLog: false, + headers: { + "User-Agent": expect.stringContaining("opencode/"), + }, + }, + }) + }), + ), + ) + + it.effect("parses legacy cf-aig-metadata header when metadata option is absent", () => + withEnv(cloudflareEnv(), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + headers: { + "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), + }, + }, + }, + {}, + ) + + expect(aiGatewayCalls[0]?.options).toMatchObject({ + metadata: { invoked_by: "header", project: "opencode" }, + }) + }), + ), + ) + + it.effect("prefers Cloudflare env values over auth/config-derived options", () => + withEnv(cloudflareEnv(), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gateway: "auth-gateway", + apiKey: "auth-token", + }, + }, + {}, + ) + + expect(aiGatewayCalls[0]).toMatchObject({ + accountId: "env-account", + gateway: "env-gateway", + apiKey: "env-token", + }) + }), + ), + ) + + it.effect("accepts gatewayId metadata copied from auth into provider options", () => + withEnv( + cloudflareEnv({ + CLOUDFLARE_ACCOUNT_ID: undefined, + CLOUDFLARE_GATEWAY_ID: undefined, + CLOUDFLARE_API_TOKEN: undefined, + }), + () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gatewayId: "auth-gateway", + apiKey: "auth-token", + }, + }, + {}, + ) + + expect(aiGatewayCalls[0]).toMatchObject({ + accountId: "auth-account", + gateway: "auth-gateway", + apiKey: "auth-token", + }) + }), + ), + ) + + it.effect("falls back to CF_AIG_TOKEN when CLOUDFLARE_API_TOKEN is unset", () => + withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + + expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" }) + }), + ), + ) + + it.effect("does not create an SDK when account and gateway IDs are missing", () => + withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + + expect(result.sdk).toBeUndefined() + expect(aiGatewayCalls).toHaveLength(0) + }), + ), + ) + + it.effect("does not create an SDK when the token is missing", () => + withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + + expect(result.sdk).toBeUndefined() + expect(aiGatewayCalls).toHaveLength(0) + }), + ), + ) + + it.effect("does not replace a configured baseURL with the Cloudflare AI Gateway SDK", () => + withEnv( + cloudflareEnv({ + CLOUDFLARE_ACCOUNT_ID: undefined, + CLOUDFLARE_GATEWAY_ID: undefined, + CLOUDFLARE_API_TOKEN: undefined, + }), + () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, + }, + {}, + ) + + expect(result.sdk).toBeUndefined() + expect(aiGatewayCalls).toHaveLength(0) + }), + ), + ) + + it.effect("maps provider/model IDs through the unified Cloudflare provider unchanged", () => + withEnv(cloudflareEnv(), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5"), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + + expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({ + modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" }, + provider: "cloudflare-ai-gateway", + specificationVersion: "v3", + }) + expect(unifiedCalls).toEqual(["anthropic/claude-sonnet-4-5"]) + expect(gatewayModelCalls).toEqual([{ unifiedModelID: "anthropic/claude-sonnet-4-5" }]) + }), + ), + ) + + it.effect("ignores non Cloudflare AI Gateway packages", () => + withEnv(cloudflareEnv(), () => + Effect.gen(function* () { + resetCalls() + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareAIGatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-ai-gateway", "openai/gpt-5"), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-ai-gateway" }, + }, + {}, + ) + + expect(result.sdk).toBeUndefined() + expect(aiGatewayCalls).toHaveLength(0) + }), + ), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/v2/plugin/provider-cloudflare-workers-ai.test.ts new file mode 100644 index 0000000000..3aed2a17b8 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-cloudflare-workers-ai.test.ts @@ -0,0 +1,267 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AuthV2 } from "@opencode-ai/core/auth" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AuthPlugin } from "@opencode-ai/core/plugin/auth" +import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" +import { testEffect } from "../../lib/effect" +import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" + +const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) + +function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { + return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( + modelID, + ) +} + +type CloudflareConfig = { + url: (input: { path: string; modelId: string }) => string + headers: () => Record | Promise> +} + +function cloudflareURL(sdk: unknown, modelID = "@cf/model") { + return cloudflareLanguage(sdk, modelID).config.url({ path: "/chat/completions", modelId: modelID }) +} + +function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") { + return cloudflareLanguage(sdk, modelID).config.headers() +} + +describe("CloudflareWorkersAIPlugin", () => { + it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("cloudflare-workers-ai"), cancel: false }, + ) + const sdk = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "@cf/model", { endpoint: updated.provider.endpoint }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, + }, + {}, + ) + expect(updated.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1", + }) + expect(sdk.sdk).toBeDefined() + }), + ), + ) + + 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 + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("cloudflare-workers-ai", { + endpoint: { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }, + }), + cancel: false, + }, + ) + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + url: "https://proxy.example/v1", + }) + }), + ), + ) + + it.effect("allows a configured baseURL without account ID", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "@cf/model", { + endpoint: { 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" }, + }, + {}, + ) + expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions") + }), + ), + ) + + itWithAuth.effect("falls back to auth account metadata when account env is absent", () => + withEnv( + { + CLOUDFLARE_ACCOUNT_ID: undefined, + CLOUDFLARE_API_KEY: undefined, + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const auth = yield* AuthV2.Service + yield* auth.create({ + serviceID: AuthV2.ServiceID.make("cloudflare-workers-ai"), + credential: new AuthV2.ApiKeyCredential({ + type: "api", + key: "auth-key", + metadata: { accountId: "auth-acct" }, + }), + active: true, + }) + yield* plugin.add({ + ...AuthPlugin, + effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + }) + yield* plugin.add(CloudflareWorkersAIPlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("cloudflare-workers-ai"), cancel: false }, + ) + expect(updated.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + url: "https://api.cloudflare.com/client/v4/accounts/auth-acct/ai/v1", + }) + }), + ), + ) + + it.effect("uses env account ID over configured account ID", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("cloudflare-workers-ai", { + options: { headers: {}, body: {}, aisdk: { provider: { accountId: "configured-acct" }, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "test-provider", + url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1", + }) + }), + ), + ) + + it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "@cf/model", { + endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + apiKey: "auth-key", + baseURL: "https://proxy.example/v1", + headers: { custom: "header" }, + }, + }, + {}, + ) + const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk))) + expect(headers.authorization).toBe("Bearer env-key") + expect(headers.custom).toBe("header") + expect(headers["user-agent"]).toMatch(/^opencode\/.* cloudflare-workers-ai \(.+\) ai-sdk\/openai-compatible\//) + }), + ), + ) + + it.effect("expands account ID vars in endpoint URLs", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "@cf/model", { + endpoint: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + }, + }, + {}, + ) + expect(cloudflareURL(result.sdk)).toBe( + "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions", + ) + }), + ), + ) + + it.effect("selects languageModel with the API model ID", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("cloudflare-workers-ai", "alias", { apiID: ModelV2.ID.make("@cf/api-model") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + expect(result.language).toBeDefined() + expect(calls).toEqual(["languageModel:@cf/api-model"]) + }), + ) + + it.effect("does not create an SDK for non OpenAI-compatible packages", () => + withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CloudflareWorkersAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "@cf/model", { + endpoint: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" }, + }), + package: "@ai-sdk/anthropic", + options: { name: "cloudflare-workers-ai" }, + }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-cohere.test.ts b/packages/core/test/v2/plugin/provider-cohere.test.ts new file mode 100644 index 0000000000..54bec2cec4 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-cohere.test.ts @@ -0,0 +1,86 @@ +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 { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +const cohereOptions: Record[] = [] + +void mock.module("@ai-sdk/cohere", () => ({ + createCohere: (options: Record) => { + cohereOptions.push({ ...options }) + return { + languageModel: (modelID: string) => ({ + modelID, + provider: `${options.name ?? "cohere"}.chat`, + specificationVersion: "v3", + }), + } + }, +})) + +describe("CoherePlugin", () => { + it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CoherePlugin) + + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { model: model("cohere", "command"), 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" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("uses the model provider ID as the bundled SDK name", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(CoherePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-cohere", "command-r-plus"), + package: "@ai-sdk/cohere", + options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, + }, + {}, + ) + + expect(cohereOptions.at(-1)).toEqual({ + name: "custom-cohere", + apiKey: "test", + baseURL: "https://cohere.example", + }) + expect(result.sdk?.languageModel("command-r-plus").provider).toBe("custom-cohere.chat") + }), + ) + + it.effect("leaves language selection to the default languageModel fallback", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + const sdk = fakeSelectorSdk(calls) + yield* plugin.add(CoherePlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { model: model("cohere", "alias", { apiID: ModelV2.ID.make("command-r-plus") }), sdk, options: {} }, + {}, + ) + + expect(result.language).toBeUndefined() + expect(calls).toEqual([]) + expect(result.language ?? sdk.languageModel("command-r-plus")).toBeDefined() + expect(calls).toEqual(["languageModel:command-r-plus"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-deepinfra.test.ts b/packages/core/test/v2/plugin/provider-deepinfra.test.ts new file mode 100644 index 0000000000..1195b8c184 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-deepinfra.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, mock } from "bun:test" +import { Effect, Layer } from "effect" +import { AISDK } from "@opencode-ai/core/aisdk" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" +import { testEffect } from "../../lib/effect" +import { it, model } from "./provider-helper" + +const itAISDK = testEffect(Layer.provideMerge(AISDK.layer, PluginV2.defaultLayer)) +const deepinfraOptions: Record[] = [] +const deepinfraLanguageModels: string[] = [] + +void mock.module("@ai-sdk/deepinfra", () => ({ + createDeepInfra: (options: Record) => { + const captured = { ...options } + deepinfraOptions.push(captured) + return { + languageModel: (modelID: string) => { + deepinfraLanguageModels.push(modelID) + return { modelID, provider: `${captured.name ?? "deepinfra"}.chat`, specificationVersion: "v3" } + }, + } + }, +})) + +function resetDeepInfraMock() { + deepinfraOptions.length = 0 + deepinfraLanguageModels.length = 0 +} + +describe("DeepInfraPlugin", () => { + it.effect("creates a DeepInfra SDK for @ai-sdk/deepinfra", () => + Effect.gen(function* () { + resetDeepInfraMock() + const plugin = yield* PluginV2.Service + yield* plugin.add(DeepInfraPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("passes the model provider ID as the bundled DeepInfra SDK name", () => + Effect.gen(function* () { + resetDeepInfraMock() + const plugin = yield* PluginV2.Service + yield* plugin.add(DeepInfraPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-deepinfra", "model"), + package: "@ai-sdk/deepinfra", + options: { name: "custom-deepinfra", apiKey: "test" }, + }, + {}, + ) + expect(result.sdk.languageModel("model").provider).toBe("custom-deepinfra.chat") + expect(deepinfraOptions).toEqual([{ name: "custom-deepinfra", apiKey: "test" }]) + }), + ) + + it.effect("uses the canonical provider ID as the bundled DeepInfra SDK name", () => + Effect.gen(function* () { + resetDeepInfraMock() + const plugin = yield* PluginV2.Service + yield* plugin.add(DeepInfraPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("deepinfra", "model"), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra", apiKey: "test" }, + }, + {}, + ) + expect(result.sdk.languageModel("model").provider).toBe("deepinfra.chat") + expect(deepinfraOptions).toEqual([{ name: "deepinfra", apiKey: "test" }]) + }), + ) + + it.effect("matches only the exact bundled DeepInfra package", () => + Effect.gen(function* () { + resetDeepInfraMock() + const plugin = yield* PluginV2.Service + yield* plugin.add(DeepInfraPlugin) + const packages = [ + "unmatched-package", + "@ai-sdk/deepinfra-compatible", + "file:///tmp/@ai-sdk/deepinfra-provider.js", + ] + yield* Effect.forEach(packages, (item) => + Effect.gen(function* () { + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { model: model("deepinfra", "model"), package: item, options: { name: "deepinfra" } }, + {}, + ) + expect(ignored.sdk).toBeUndefined() + }), + ) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("deepinfra", "model"), package: "@ai-sdk/deepinfra", options: { name: "deepinfra" } }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(deepinfraOptions).toEqual([{ name: "deepinfra" }]) + }), + ) + + itAISDK.effect("uses the default languageModel selection for DeepInfra models", () => + Effect.gen(function* () { + resetDeepInfraMock() + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(DeepInfraPlugin) + const language = yield* aisdk.language( + model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", { + endpoint: { type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + ) + expect(language.provider).toBe("deepinfra.chat") + expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-dynamic.test.ts b/packages/core/test/v2/plugin/provider-dynamic.test.ts new file mode 100644 index 0000000000..cca331b110 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-dynamic.test.ts @@ -0,0 +1,172 @@ +import { Npm } from "@opencode-ai/core/npm" +import { describe, expect } from "bun:test" +import { Cause, Effect, Layer, Option } 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 { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic" +import { testEffect } from "../../lib/effect" +import { fixtureProvider, it, model, npmLayer } from "./provider-helper" + +const fixtureProviderPath = fileURLToPath(fixtureProvider) +const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer))) + +function npmEntrypointLayer(entrypoint: Option.Option) { + return Layer.succeed( + Npm.Service, + Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint }), + install: () => Effect.void, + which: () => Effect.succeed(Option.none()), + }), + ) +} + +function dynamicPlugin(layer = npmLayer) { + return { id: DynamicProviderPlugin.id, effect: DynamicProviderPlugin.effect.pipe(Effect.provide(layer)) } +} + +function tempEntrypoint(source: string) { + return Effect.acquireRelease( + Effect.promise(async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-dynamic-")) + const entrypoint = path.join(directory, "provider.mjs") + await Bun.write(entrypoint, source) + return { directory, entrypoint } + }), + (tmp) => Effect.promise(() => fs.rm(tmp.directory, { recursive: true, force: true })), + ) +} + +describe("DynamicProviderPlugin", () => { + it.effect("creates an SDK from a provider factory export", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(dynamicPlugin()) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom", "test-model"), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + }, + {}, + ) + expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom" }) + expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "dynamic", name: "custom" } }) + }), + ) + + it.effect("does not override an SDK already supplied by an earlier plugin", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const sdk = { marker: "existing" } + yield* plugin.add(dynamicPlugin()) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom", "test-model"), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + }, + { sdk }, + ) + expect(result.sdk).toBe(sdk) + }), + ) + + it.effect("injects the provider ID as the SDK factory name", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(dynamicPlugin()) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-provider", "test-model"), + package: fixtureProvider, + options: { name: "custom-provider", marker: "dynamic" }, + }, + {}, + ) + expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" }) + }), + ) + + it.effect("loads npm packages through their resolved import entrypoint", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(fixtureProviderPath)))) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("npm-provider", "test-model"), + package: "fixture-provider", + options: { name: "npm-provider", marker: "npm" }, + }, + {}, + ) + expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "npm", name: "npm-provider" } }) + }), + ) + + itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.none()))) + const exit = yield* aisdk + .language(model("missing-entrypoint", "alias", { endpoint: { 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") + }), + ) + + itWithAISDK.effect("wraps dynamic import failures as AISDK init errors", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(dynamicPlugin()) + const exit = yield* aisdk + .language( + model("bad-import", "alias", { endpoint: { type: "aisdk", package: "file:///missing/provider-factory.js" } }), + ) + .pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError") + }), + ) + + itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n") + yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(tmp.entrypoint)))) + const exit = yield* aisdk + .language(model("missing-factory", "alias", { endpoint: { 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") + }), + ) + + itWithAISDK.effect("uses the model apiID for the default language model", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(dynamicPlugin()) + const language = yield* aisdk.language( + model("custom", "alias", { + apiID: ModelV2.ID.make("test-model-api"), + endpoint: { type: "aisdk", package: fixtureProvider }, + }), + ) + expect(language).toMatchObject({ modelID: "test-model-api", options: { name: "custom" } }) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-gateway.test.ts b/packages/core/test/v2/plugin/provider-gateway.test.ts new file mode 100644 index 0000000000..8ee69b7dd4 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-gateway.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, mock } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" +import { it, model } from "./provider-helper" + +const gatewayCalls: Record[] = [] +const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] + +mock.module("@ai-sdk/gateway", () => ({ + createGateway(options: Record) { + gatewayCalls.push({ ...options }) + return { + languageModel(modelID: string) { + return { + modelId: modelID, + provider: options.name, + specificationVersion: "v3", + } + }, + } + }, +})) + +describe("GatewayPlugin", () => { + it.effect("creates a Gateway SDK for @ai-sdk/gateway", () => + Effect.gen(function* () { + gatewayCalls.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GatewayPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("gateway", "model"), package: "@ai-sdk/gateway", options: { name: "gateway" } }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(gatewayCalls).toHaveLength(1) + }), + ) + + it.effect("passes the model providerID as the Gateway SDK name", () => + Effect.gen(function* () { + gatewayCalls.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GatewayPlugin) + + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("vercel", "anthropic/claude-sonnet-4"), + package: "@ai-sdk/gateway", + options: { name: "vercel", apiKey: "test-key" }, + }, + {}, + ) + + expect(gatewayCalls).toEqual([{ name: "vercel", apiKey: "test-key" }]) + expect(result.sdk.languageModel("anthropic/claude-sonnet-4").provider).toBe("vercel") + }), + ) + + it.effect("matches Vercel AI Gateway models by their @ai-sdk/gateway package", () => + Effect.gen(function* () { + gatewayCalls.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GatewayPlugin) + + for (const modelID of vercelGatewayModels) { + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { model: model("vercel", modelID), 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" } }, + {}, + ) + expect(result.sdk).toBeDefined() + } + + expect(gatewayCalls).toHaveLength(3) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-gitlab.test.ts b/packages/core/test/v2/plugin/provider-gitlab.test.ts new file mode 100644 index 0000000000..0b71310e0f --- /dev/null +++ b/packages/core/test/v2/plugin/provider-gitlab.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, mock } from "bun:test" +import { Effect, Layer } from "effect" +import { AuthV2 } from "@opencode-ai/core/auth" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AuthPlugin } from "@opencode-ai/core/plugin/auth" +import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" +import { testEffect } from "../../lib/effect" +import { it, model, npmLayer, provider, withEnv } from "./provider-helper" + +const gitlabSDKOptions: Record[] = [] + +void mock.module("gitlab-ai-provider", () => ({ + VERSION: "test-version", + createGitLab: (options: Record) => { + gitlabSDKOptions.push(options) + return { + agenticChat: (id: string, options: unknown) => ({ id, options, type: "agentic" }), + workflowChat: (id: string, options: unknown) => ({ id, options, type: "workflow" }), + } + }, + discoverWorkflowModels: async () => ({ models: [], project: undefined }), + isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact", +})) + +const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) + +describe("GitLabPlugin", () => { + it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () => + withEnv( + { + GITLAB_INSTANCE_URL: undefined, + GITLAB_TOKEN: "env-token", + }, + () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GitLabPlugin) + yield* plugin.trigger( + "aisdk.sdk", + { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, + {}, + ) + expect(gitlabSDKOptions).toHaveLength(1) + expect(gitlabSDKOptions[0].instanceUrl).toBe("https://gitlab.com") + expect(gitlabSDKOptions[0].apiKey).toBe("env-token") + expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({ + "anthropic-beta": "context-1m-2025-08-07", + }) + expect(String((gitlabSDKOptions[0].aiGatewayHeaders as Record)["User-Agent"])).toContain( + "gitlab-ai-provider/test-version", + ) + expect(gitlabSDKOptions[0].featureFlags).toEqual({ + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + }) + }), + ), + ) + + it.effect("uses GITLAB_INSTANCE_URL when instanceUrl is not configured", () => + withEnv( + { + GITLAB_INSTANCE_URL: "https://env.gitlab.example", + GITLAB_TOKEN: undefined, + }, + () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GitLabPlugin) + yield* plugin.trigger( + "aisdk.sdk", + { model: model("gitlab", "claude"), package: "gitlab-ai-provider", options: { name: "gitlab" } }, + {}, + ) + expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example") + }), + ), + ) + + it.effect("keeps configured instance URL, apiKey, aiGatewayHeaders, and featureFlags over env/defaults", () => + withEnv( + { + GITLAB_INSTANCE_URL: "https://env.gitlab.example", + GITLAB_TOKEN: "env-token", + }, + () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GitLabPlugin) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("gitlab", "claude"), + package: "gitlab-ai-provider", + options: { + name: "gitlab", + instanceUrl: "https://configured.gitlab.example", + apiKey: "configured-token", + aiGatewayHeaders: { + "anthropic-beta": "configured-beta", + "x-gitlab-test": "1", + }, + featureFlags: { + duo_agent_platform: false, + custom_flag: true, + }, + }, + }, + {}, + ) + expect(gitlabSDKOptions[0].instanceUrl).toBe("https://configured.gitlab.example") + expect(gitlabSDKOptions[0].apiKey).toBe("configured-token") + expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({ + "anthropic-beta": "configured-beta", + "x-gitlab-test": "1", + }) + expect(gitlabSDKOptions[0].featureFlags).toEqual({ + duo_agent_platform_agentic_chat: true, + duo_agent_platform: false, + custom_flag: true, + }) + }), + ), + ) + + it.effect("ignores non-GitLab SDK packages", () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GitLabPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("gitlab", "claude"), package: "@ai-sdk/openai", options: { name: "gitlab" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + expect(gitlabSDKOptions).toHaveLength(0) + }), + ) + + itWithAuth.effect("uses active API auth token over GITLAB_TOKEN", () => + withEnv( + { + GITLAB_TOKEN: "env-token", + }, + () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + const auth = yield* AuthV2.Service + yield* auth.create({ + serviceID: AuthV2.ServiceID.make("gitlab"), + credential: new AuthV2.ApiKeyCredential({ type: "api", key: "auth-token" }), + active: true, + }) + yield* plugin.add({ + ...AuthPlugin, + effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + }) + yield* plugin.add(GitLabPlugin) + const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false }) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("gitlab", "claude"), + package: "gitlab-ai-provider", + options: updated.provider.options.aisdk.provider, + }, + {}, + ) + expect(gitlabSDKOptions[0].apiKey).toBe("auth-token") + }), + ), + ) + + itWithAuth.effect("uses active OAuth access token when no API auth exists", () => + withEnv( + { + GITLAB_TOKEN: undefined, + }, + () => + Effect.gen(function* () { + gitlabSDKOptions.length = 0 + const plugin = yield* PluginV2.Service + const auth = yield* AuthV2.Service + yield* auth.create({ + serviceID: AuthV2.ServiceID.make("gitlab"), + credential: new AuthV2.OAuthCredential({ + type: "oauth", + refresh: "refresh-token", + access: "oauth-token", + expires: 9999999999999, + }), + active: true, + }) + yield* plugin.add({ + ...AuthPlugin, + effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), + }) + yield* plugin.add(GitLabPlugin) + const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false }) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("gitlab", "claude"), + package: "gitlab-ai-provider", + options: updated.provider.options.aisdk.provider, + }, + {}, + ) + expect(gitlabSDKOptions[0].apiKey).toBe("oauth-token") + }), + ), + ) + + it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: [string, unknown][] = [] + yield* plugin.add(GitLabPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("gitlab", "duo-workflow-custom", { + options: { + headers: {}, + body: {}, + aisdk: { provider: {}, request: { workflowRef: "ref", workflowDefinition: "definition" } }, + }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, + }, + options: { featureFlags: { configured: true } }, + }, + {}, + ) + expect(calls).toEqual([ + ["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: "definition" }], + ]) + expect(result.language as unknown).toEqual({ + id: "duo-workflow", + options: calls[0]?.[1], + selectedModelRef: "ref", + }) + }), + ) + + it.effect("uses exact static workflow model ids when the provider recognizes them", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: [string, unknown][] = [] + yield* plugin.add(GitLabPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("gitlab", "duo-workflow-exact"), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, + }, + options: { featureFlags: { configured: true } }, + }, + {}, + ) + expect(calls).toEqual([ + ["duo-workflow-exact", { featureFlags: { configured: true }, workflowDefinition: undefined }], + ]) + expect(result.language as unknown).toEqual({ id: "duo-workflow-exact", options: calls[0]?.[1] }) + }), + ) + + it.effect("uses provider feature flags instead of request feature flags", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: [string, unknown][] = [] + yield* plugin.add(GitLabPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("gitlab", "duo-workflow-custom", { + options: { + headers: {}, + body: {}, + aisdk: { provider: {}, request: { featureFlags: { request_flag: true } } }, + }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, + }, + options: { featureFlags: { configured: true } }, + }, + {}, + ) + expect(calls).toEqual([["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: undefined }]]) + }), + ) + + it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: [string, unknown][] = [] + yield* plugin.add(GitLabPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("gitlab", "claude", { + options: { headers: { h: "v" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + sdk: { + workflowChat: () => undefined, + agenticChat: (id: string, options: unknown) => { + const selected = options as { + aiGatewayHeaders?: Record + featureFlags?: Record + } + calls.push([ + id, + { aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } }, + ]) + }, + }, + options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }, + }, + {}, + ) + expect(calls).toEqual([ + ["claude", { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }], + ]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/v2/plugin/provider-google-vertex-anthropic.test.ts new file mode 100644 index 0000000000..6bcece53c9 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-google-vertex-anthropic.test.ts @@ -0,0 +1,147 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" +import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" + +describe("GoogleVertexAnthropicPlugin", () => { + it.effect("resolves legacy project and location env on provider update", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: "cloud-project", + GCP_PROJECT: "gcp-project", + GCLOUD_PROJECT: "gcloud-project", + GOOGLE_CLOUD_LOCATION: "cloud-location", + VERTEX_LOCATION: "vertex-location", + GOOGLE_VERTEX_LOCATION: "google-vertex-location", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexAnthropicPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("google-vertex-anthropic"), cancel: false }, + ) + expect(result.provider.options.aisdk.provider.project).toBe("cloud-project") + expect(result.provider.options.aisdk.provider.location).toBe("cloud-location") + }), + ), + ) + + 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 + yield* plugin.add(GoogleVertexAnthropicPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("google-vertex-anthropic", { + options: { + headers: {}, + body: {}, + aisdk: { provider: { project: "configured-project", location: "configured-location" }, request: {} }, + }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.project).toBe("configured-project") + expect(result.provider.options.aisdk.provider.location).toBe("configured-location") + }), + ), + ) + + it.effect("creates SDKs from legacy env fallback and default location", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: undefined, + GCP_PROJECT: "gcp-project", + GCLOUD_PROJECT: "gcloud-project", + GOOGLE_CLOUD_LOCATION: undefined, + VERTEX_LOCATION: undefined, + GOOGLE_VERTEX_LOCATION: "ignored-location", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexAnthropicPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("google-vertex-anthropic", "claude-sonnet-4-5"), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }, + {}, + ) + expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( + "https://aiplatform.googleapis.com/v1/projects/gcp-project/locations/global/publishers/anthropic/models", + ) + }), + ), + ) + + it.effect("uses GOOGLE_CLOUD_LOCATION before VERTEX_LOCATION when creating SDKs", () => + withEnv( + { GOOGLE_CLOUD_PROJECT: "project", GOOGLE_CLOUD_LOCATION: "cloud-location", VERTEX_LOCATION: "vertex-location" }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexAnthropicPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("google-vertex-anthropic", "claude-sonnet-4-5"), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }, + {}, + ) + expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( + "https://cloud-location-aiplatform.googleapis.com/v1/projects/project/locations/cloud-location/publishers/anthropic/models", + ) + }), + ), + ) + + it.effect("trims model IDs before selecting language models", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GoogleVertexAnthropicPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("google-vertex-anthropic", " claude-sonnet-4-5 "), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual(["languageModel:claude-sonnet-4-5"]) + }), + ) + + it.effect("ignores non Vertex Anthropic providers for language selection", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GoogleVertexAnthropicPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("google-vertex", "claude-sonnet-4-5"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-google-vertex.test.ts b/packages/core/test/v2/plugin/provider-google-vertex.test.ts new file mode 100644 index 0000000000..3bd60fd721 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-google-vertex.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, mock } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" +import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" + +const vertexOptions: Record[] = [] + +void mock.module("@ai-sdk/google-vertex", () => ({ + createVertex: (options: Record) => { + vertexOptions.push(options) + return { + languageModel: (modelID: string) => ({ modelID, provider: "google-vertex", specificationVersion: "v3" }), + } + }, +})) + +void mock.module("google-auth-library", () => ({ + GoogleAuth: class { + async getApplicationDefault() { + return { + credential: { + async getAccessToken() { + return { token: "vertex-token" } + }, + }, + } + } + }, +})) + +describe("GoogleVertexPlugin", () => { + it.effect("resolves project and location from env using legacy precedence", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: "google-cloud-project", + GCP_PROJECT: "gcp-project", + GCLOUD_PROJECT: "gcloud-project", + GOOGLE_VERTEX_LOCATION: "google-vertex-location", + GOOGLE_CLOUD_LOCATION: "google-cloud-location", + VERTEX_LOCATION: "vertex-location", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("google-vertex", { + endpoint: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.project).toBe("google-cloud-project") + expect(result.provider.options.aisdk.provider.location).toBe("google-vertex-location") + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location", + }) + }), + ), + ) + + it.effect("resolves the advertised GOOGLE_VERTEX_PROJECT env for provider updates and SDKs", () => + withEnv( + { + GOOGLE_VERTEX_PROJECT: "vertex-project", + GOOGLE_CLOUD_PROJECT: undefined, + GCP_PROJECT: undefined, + GCLOUD_PROJECT: undefined, + GOOGLE_VERTEX_LOCATION: "europe-west4", + GOOGLE_CLOUD_LOCATION: undefined, + VERTEX_LOCATION: undefined, + }, + () => + Effect.gen(function* () { + vertexOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexPlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("google-vertex", { + endpoint: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + }, + }), + cancel: false, + }, + ) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("google-vertex", "gemini", { + endpoint: { type: "aisdk", package: "@ai-sdk/google-vertex" }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }, + {}, + ) + + expect(updated.provider.options.aisdk.provider.project).toBe("vertex-project") + expect(updated.provider.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4", + }) + expect(vertexOptions[0].project).toBe("vertex-project") + expect(vertexOptions[0].location).toBe("europe-west4") + }), + ), + ) + + it.effect("keeps configured project and location over env and uses global endpoint", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: "env-project", + GCP_PROJECT: "env-gcp-project", + GCLOUD_PROJECT: "env-gcloud-project", + GOOGLE_VERTEX_LOCATION: "env-location", + GOOGLE_CLOUD_LOCATION: "env-google-cloud-location", + VERTEX_LOCATION: "env-vertex-location", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("google-vertex", { + endpoint: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", + }, + options: { + headers: {}, + body: {}, + aisdk: { provider: { project: "config-project", location: "global" }, request: {} }, + }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.project).toBe("config-project") + expect(result.provider.options.aisdk.provider.location).toBe("global") + expect(result.provider.endpoint).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global", + }) + }), + ), + ) + + it.effect("defaults location to us-central1 when only project is configured", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: undefined, + GCP_PROJECT: undefined, + GCLOUD_PROJECT: undefined, + GOOGLE_VERTEX_LOCATION: undefined, + GOOGLE_CLOUD_LOCATION: undefined, + VERTEX_LOCATION: undefined, + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("google-vertex", { + options: { headers: {}, body: {}, aisdk: { provider: { project: "config-project" }, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.aisdk.provider.project).toBe("config-project") + expect(result.provider.options.aisdk.provider.location).toBe("us-central1") + }), + ), + ) + + it.effect("does not pass Google auth fetch to the native Vertex SDK", () => + withEnv( + { + GOOGLE_CLOUD_PROJECT: "env-project", + GOOGLE_VERTEX_LOCATION: "env-location", + }, + () => + Effect.gen(function* () { + vertexOptions.length = 0 + const plugin = yield* PluginV2.Service + yield* plugin.add(GoogleVertexPlugin) + yield* plugin.trigger( + "aisdk.sdk", + { + model: model("google-vertex", "gemini", { + endpoint: { type: "aisdk", package: "@ai-sdk/google-vertex" }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }, + {}, + ) + expect(vertexOptions).toHaveLength(1) + expect(vertexOptions[0].project).toBe("env-project") + expect(vertexOptions[0].location).toBe("env-location") + expect(vertexOptions[0].fetch).toBeUndefined() + }), + ), + ) + + it.effect("keeps Google auth fetch for OpenAI-compatible Vertex endpoints", () => + Effect.gen(function* () { + const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] + const plugin = yield* PluginV2.Service + yield* plugin.add(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" }, + }) + }), + }), + }) + const originalFetch = fetch + ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = (async ( + input: Parameters[0], + init?: RequestInit, + ) => { + fetchCalls.push({ input, init }) + return new Response("ok") + }) as typeof fetch + yield* Effect.acquireUseRelease( + Effect.void, + () => + plugin.trigger( + "aisdk.sdk", + { + model: model("google-vertex", "gemini", { + endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "google-vertex" }, + }, + {}, + ), + () => + Effect.sync(() => { + ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = originalFetch + }), + ) + expect(fetchCalls).toHaveLength(1) + expect(fetchCalls[0].input).toBe("https://vertex.example") + expect(new Headers(fetchCalls[0].init?.headers).get("authorization")).toBe("Bearer vertex-token") + expect(new Headers(fetchCalls[0].init?.headers).get("x-test")).toBe("1") + }), + ) + + it.effect("trims model IDs before selecting language models", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(GoogleVertexPlugin) + yield* plugin.trigger( + "aisdk.language", + { + model: model("google-vertex", " gemini-2.5-pro "), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + expect(calls).toEqual(["languageModel:gemini-2.5-pro"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-google.test.ts b/packages/core/test/v2/plugin/provider-google.test.ts new file mode 100644 index 0000000000..ee33b980b8 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-google.test.ts @@ -0,0 +1,70 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AISDK } from "@opencode-ai/core/aisdk" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google" +import { testEffect } from "../../lib/effect" +import { it, model } from "./provider-helper" + +const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer))) + +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* plugin.add(GooglePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-google", "gemini"), + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(result.sdk?.languageModel("gemini").provider).toBe("custom-google") + }), + ) + + it.effect("ignores non-Google SDK packages", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GooglePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("google", "gemini"), package: "@ai-sdk/google-vertex", options: { name: "google" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + itWithAISDK.effect("uses default languageModel loading with provider ID parity", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(GooglePlugin) + const language = yield* aisdk.language( + model("custom-google", "alias", { + apiID: ModelV2.ID.make("gemini-api"), + endpoint: { + type: "aisdk", + package: "@ai-sdk/google", + }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: { apiKey: "test" }, + request: {}, + }, + }, + }), + ) + expect(language.modelId).toBe("gemini-api") + expect(language.provider).toBe("custom-google") + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-groq.test.ts b/packages/core/test/v2/plugin/provider-groq.test.ts new file mode 100644 index 0000000000..14c10b6513 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-groq.test.ts @@ -0,0 +1,101 @@ +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 { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" +import { it, model } from "./provider-helper" +import { testEffect } from "../../lib/effect" + +const aisdkIt = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer))) + +describe("GroqPlugin", () => { + it.effect("creates a Groq SDK for @ai-sdk/groq", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GroqPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("groq", "llama"), package: "@ai-sdk/groq", options: { name: "groq" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("ignores non-Groq SDK packages", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GroqPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("groq", "llama"), package: "@ai-sdk/openai-compatible", options: { name: "groq" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("only matches the bundled @ai-sdk/groq package exactly", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GroqPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("groq", "llama"), package: "@ai-sdk/groq/compat", options: { name: "groq" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("matches the old bundled Groq SDK provider naming", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(GroqPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-groq", "llama"), + package: "@ai-sdk/groq", + options: { name: "custom-groq", apiKey: "test" }, + }, + {}, + ) + const expected = createGroq({ name: "custom-groq", apiKey: "test" } as Parameters[0] & { + name: string + }).languageModel("llama") + const actual = result.sdk?.languageModel("llama") + expect(actual?.provider).toBe(expected.provider) + expect(actual?.modelId).toBe(expected.modelId) + }), + ) + + aisdkIt.effect("uses the default languageModel(apiID) behavior", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + yield* plugin.add(GroqPlugin) + const result = yield* aisdk.language( + model("groq", "alias", { + apiID: ModelV2.ID.make("llama-api"), + endpoint: { + type: "aisdk", + package: "@ai-sdk/groq", + }, + options: { + headers: {}, + body: {}, + aisdk: { + provider: { apiKey: "test" }, + request: {}, + }, + }, + }), + ) + expect(result.modelId).toBe("llama-api") + expect(result.provider).toBe("groq.chat") + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-helper.ts b/packages/core/test/v2/plugin/provider-helper.ts new file mode 100644 index 0000000000..84a3044bfb --- /dev/null +++ b/packages/core/test/v2/plugin/provider-helper.ts @@ -0,0 +1,100 @@ +import { Npm } from "@opencode-ai/core/npm" +import type { LanguageModelV3 } from "@ai-sdk/provider" +import { expect } from "bun:test" +import { Effect, Layer, Option } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../../lib/effect" + +export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href + +export const npmLayer = Layer.succeed( + Npm.Service, + Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: Option.none() }), + install: () => Effect.void, + which: () => Effect.succeed(Option.none()), + }), +) + +export const it = testEffect(Layer.mergeAll(PluginV2.defaultLayer, npmLayer)) + +export function provider(providerID: string, options?: Partial) { + return new ProviderV2.Info({ + ...ProviderV2.Info.empty(ProviderV2.ID.make(providerID)), + endpoint: { + type: "aisdk", + package: "test-provider", + }, + ...options, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + ...options?.options, + }, + }) +} + +export function model(providerID: string, modelID: string, options?: Partial) { + return new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + apiID: ModelV2.ID.make(modelID), + endpoint: { + type: "aisdk", + package: "test-provider", + }, + ...options, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + ...options?.options, + }, + }) +} + +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/v2/plugin/provider-kilo.test.ts b/packages/core/test/v2/plugin/provider-kilo.test.ts new file mode 100644 index 0000000000..4261ae1328 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-kilo.test.ts @@ -0,0 +1,90 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" +import { expectPluginRegistered, it, provider } from "./provider-helper" + +describe("KiloPlugin", () => { + it.effect("is registered so legacy referer headers can be applied", () => + Effect.sync(() => + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "kilo", + ), + ), + ) + + it.effect("applies legacy referer headers only to kilo", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(KiloPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("kilo", { + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) + expect(result.provider.options.headers).toEqual({ + Existing: "value", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(ignored.provider.options.headers).toEqual({}) + }), + ) + + it.effect("uses the exact legacy Kilo header casing and set", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(KiloPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("kilo"), cancel: false }) + + expect(result.provider.options.headers).toEqual({ + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(result.provider.options.headers).not.toHaveProperty("http-referer") + expect(result.provider.options.headers).not.toHaveProperty("x-title") + expect(result.provider.options.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 + yield* plugin.add(KiloPlugin) + const matchingID = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("kilo", { + endpoint: { type: "aisdk", package: "not-kilo" }, + }), + cancel: false, + }, + ) + const matchingPackage = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("custom-kilo", { + endpoint: { type: "aisdk", package: "kilo" }, + }), + cancel: false, + }, + ) + + expect(matchingID.provider.options.headers).toEqual({ + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(matchingPackage.provider.options.headers).toEqual({}) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-llmgateway.test.ts b/packages/core/test/v2/plugin/provider-llmgateway.test.ts new file mode 100644 index 0000000000..1ffea96bcb --- /dev/null +++ b/packages/core/test/v2/plugin/provider-llmgateway.test.ts @@ -0,0 +1,63 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" +import { expectPluginRegistered, it, provider } from "./provider-helper" + +describe("LLMGatewayPlugin", () => { + it.effect("is registered so legacy referer headers can be applied", () => + Effect.sync(() => + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "llmgateway", + ), + ), + ) + + it.effect("applies legacy referer headers only to enabled llmgateway", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(LLMGatewayPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("llmgateway", { + enabled: { via: "env", name: "LLMGATEWAY_API_KEY" }, + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + const ignored = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("openrouter", { + enabled: { via: "env", name: "OPENROUTER_API_KEY" }, + }), + cancel: false, + }, + ) + expect(result.provider.options.headers).toEqual({ + Existing: "value", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + "X-Source": "opencode", + }) + expect(ignored.provider.options.headers).toEqual({}) + }), + ) + + it.effect("does not apply legacy headers to a disabled llmgateway provider", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(LLMGatewayPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("llmgateway"), cancel: false }) + + expect(result.provider.enabled).toBe(false) + expect(result.provider.options.headers).toEqual({}) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-mistral.test.ts b/packages/core/test/v2/plugin/provider-mistral.test.ts new file mode 100644 index 0000000000..f24ff53e5b --- /dev/null +++ b/packages/core/test/v2/plugin/provider-mistral.test.ts @@ -0,0 +1,106 @@ +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 { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +describe("MistralPlugin", () => { + it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(MistralPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("ignores non-Mistral SDK packages", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(MistralPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("mistral", "mistral-large"), + package: "@ai-sdk/openai-compatible", + options: { name: "mistral" }, + }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("matches the old bundled Mistral SDK provider name for the bundled provider ID", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const providers: string[] = [] + yield* plugin.add(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) + }), + }), + }) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("mistral", "mistral-large"), package: "@ai-sdk/mistral", options: { name: "mistral" } }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(providers).toEqual(["mistral.chat"]) + }), + ) + + it.effect("matches the old bundled Mistral SDK provider name for custom provider IDs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const providers: string[] = [] + yield* plugin.add(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* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-mistral", "mistral-large"), + package: "@ai-sdk/mistral", + options: { name: "custom-mistral" }, + }, + {}, + ) + expect(providers).toEqual(["mistral.chat"]) + }), + ) + + it.effect("leaves Mistral language selection on the default sdk.languageModel(apiID) path", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + const sdk = fakeSelectorSdk(calls) + yield* plugin.add(MistralPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { model: model("mistral", "alias", { apiID: ModelV2.ID.make("mistral-large") }), sdk, options: {} }, + {}, + ) + const language = result.language ?? sdk.languageModel(result.model.apiID) + expect(calls).toEqual(["languageModel:mistral-large"]) + expect(language).toBeDefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-nvidia.test.ts b/packages/core/test/v2/plugin/provider-nvidia.test.ts new file mode 100644 index 0000000000..0e06356cd5 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-nvidia.test.ts @@ -0,0 +1,41 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" +import { expectPluginRegistered, it, provider } from "./provider-helper" + +describe("NvidiaPlugin", () => { + it.effect("is registered so legacy referer headers can be applied", () => + Effect.sync(() => + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "nvidia", + ), + ), + ) + + it.effect("applies legacy referer headers only to nvidia", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(NvidiaPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("nvidia", { + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) + expect(result.provider.options.headers).toEqual({ + Existing: "value", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(ignored.provider.options.headers).toEqual({}) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-openai-compatible.test.ts b/packages/core/test/v2/plugin/provider-openai-compatible.test.ts new file mode 100644 index 0000000000..e8bf1f7575 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-openai-compatible.test.ts @@ -0,0 +1,101 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { OpenAICompatiblePlugin } from "@opencode-ai/core/plugin/provider/openai-compatible" +import { it, model } from "./provider-helper" + +describe("OpenAICompatiblePlugin", () => { + it.effect("preserves explicit includeUsage false and defaults it to true", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenAICompatiblePlugin) + const defaulted = yield* plugin.trigger( + "aisdk.sdk", + { model: model("custom", "model"), package: "@ai-sdk/openai-compatible", options: { name: "custom" } }, + {}, + ) + const disabled = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom", "model"), + package: "@ai-sdk/openai-compatible", + options: { name: "custom", includeUsage: false }, + }, + {}, + ) + expect(defaulted.options.includeUsage).toBe(true) + expect(disabled.options.includeUsage).toBe(false) + }), + ) + + it.effect("defaults includeUsage for OpenAI-compatible package matches", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenAICompatiblePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom", "model"), + package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", + options: { name: "custom" }, + }, + {}, + ) + expect(result.options.includeUsage).toBe(true) + }), + ) + + it.effect("uses the provider ID as the OpenAI-compatible provider name", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const observed: string[] = [] + yield* plugin.add(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* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-provider", "model"), + package: "@ai-sdk/openai-compatible", + options: { name: "custom-provider", baseURL: "https://example.com/v1" }, + }, + {}, + ) + expect(observed).toEqual(["custom-provider.chat"]) + }), + ) + + it.effect("does not overwrite an SDK created by an earlier provider-specific plugin", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const sentinel = { languageModel: (modelID: string) => ({ modelID }) } + yield* plugin.add({ + id: PluginV2.ID.make("sentinel"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + evt.sdk = sentinel + }), + }), + }) + yield* plugin.add(OpenAICompatiblePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("cloudflare-workers-ai", "model"), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai" }, + }, + {}, + ) + expect(result.sdk).toBe(sentinel) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-openai.test.ts b/packages/core/test/v2/plugin/provider-openai.test.ts new file mode 100644 index 0000000000..31d6dd0b6d --- /dev/null +++ b/packages/core/test/v2/plugin/provider-openai.test.ts @@ -0,0 +1,100 @@ +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 { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +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* plugin.add(OpenAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-openai", "gpt-5"), + package: "@ai-sdk/openai", + options: { name: "custom-openai", apiKey: "test" }, + }, + {}, + ) + expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses") + }), + ) + + it.effect("ignores non-OpenAI SDK packages", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + it.effect("uses the Responses API for language models", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(OpenAIPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("openai", "alias", { apiID: ModelV2.ID.make("gpt-5") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + expect(calls).toEqual(["responses:gpt-5"]) + expect(result.language).toBeDefined() + }), + ) + + it.effect("ignores non-OpenAI providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(OpenAIPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) + + it.effect("cancels gpt-5-chat-latest during model updates", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenAIPlugin) + const normal = yield* plugin.trigger("model.update", {}, { model: model("openai", "gpt-5"), cancel: false }) + const filtered = yield* plugin.trigger( + "model.update", + {}, + { model: model("openai", "gpt-5-chat-latest"), cancel: false }, + ) + expect(normal.cancel).toBe(false) + expect(filtered.cancel).toBe(true) + }), + ) + + it.effect("does not cancel gpt-5-chat-latest for non-OpenAI providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenAIPlugin) + const result = yield* plugin.trigger( + "model.update", + {}, + { model: model("custom-openai", "gpt-5-chat-latest"), cancel: false }, + ) + expect(result.cancel).toBe(false) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-opencode.test.ts b/packages/core/test/v2/plugin/provider-opencode.test.ts new file mode 100644 index 0000000000..f080776d40 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-opencode.test.ts @@ -0,0 +1,195 @@ +import { describe, expect } from "bun:test" +import { DateTime, Effect, Option } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { it, model, provider, withEnv } from "./provider-helper" + +const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] + +describe("OpencodePlugin", () => { + it.effect("uses a public key and cancels paid models without credentials", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBe("public") + expect(paid.cancel).toBe(true) + }), + ), + ) + + it.effect("keeps free models without credentials", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) + const free = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "free", { cost: cost(0) }), cancel: false }, + ) + expect(free.cancel).toBe(false) + }), + ), + ) + + it.effect("treats output-only cost as free without credentials", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) + const outputOnly = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "output-only", { cost: cost(0, 1) }), cancel: false }, + ) + expect(outputOnly.cancel).toBe(false) + }), + ), + ) + + it.effect("uses OPENCODE_API_KEY as credentials", () => + withEnv({ OPENCODE_API_KEY: "secret" }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() + expect(paid.cancel).toBe(false) + }), + ), + ) + + 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 + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }), cancel: false }, + ) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() + expect(paid.cancel).toBe(false) + }), + ), + ) + + it.effect("uses configured apiKey as credentials", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("opencode", { + options: { + headers: {}, + body: {}, + aisdk: { + provider: { apiKey: "configured" }, + request: {}, + }, + }, + }), + cancel: false, + }, + ) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBe("configured") + expect(paid.cancel).toBe(false) + }), + ), + ) + + it.effect("uses auth-enabled providers as credentials", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger( + "provider.update", + {}, + { provider: provider("opencode", { enabled: { via: "auth", service: "opencode" } }), cancel: false }, + ) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() + expect(paid.cancel).toBe(false) + }), + ), + ) + + it.effect("ignores non-opencode providers and models", () => + withEnv({ OPENCODE_API_KEY: undefined }, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpencodePlugin) + const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) + const paid = yield* plugin.trigger( + "model.update", + {}, + { model: model("openai", "paid", { cost: cost(1) }), cancel: false }, + ) + expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() + expect(paid.cancel).toBe(false) + }), + ), + ) + + it.effect("prefers gpt-5-nano as the opencode small model", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.opencode + + yield* catalog.provider.update(providerID, () => {}) + yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = cost(1, 1) + model.time.released = DateTime.makeUnsafe(Date.now()) + }) + yield* catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = cost(10, 10) + model.time.released = DateTime.makeUnsafe(Date.now()) + }) + + const selected = yield* catalog.model.small(providerID) + + expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano")) + }).pipe(Effect.provide(Catalog.defaultLayer)), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-openrouter.test.ts b/packages/core/test/v2/plugin/provider-openrouter.test.ts new file mode 100644 index 0000000000..3d143ac7f2 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-openrouter.test.ts @@ -0,0 +1,105 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" +import { expectPluginRegistered, it, model, provider } from "./provider-helper" + +describe("OpenRouterPlugin", () => { + it.effect("is registered so legacy OpenRouter behavior can be applied", () => + Effect.sync(() => + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "openrouter", + ), + ), + ) + + it.effect("applies legacy referer headers only to openrouter", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenRouterPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("openrouter", { + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("nvidia"), cancel: false }) + expect(result.provider.options.headers).toEqual({ + Existing: "value", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + expect(ignored.provider.options.headers).toEqual({}) + }), + ) + + it.effect("creates an SDK only for the OpenRouter package", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenRouterPlugin) + + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("openrouter", "openai/gpt-5"), + package: "@ai-sdk/openai-compatible", + options: { name: "openrouter" }, + }, + {}, + ) + expect(ignored.sdk).toBeUndefined() + + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("custom", "openai/gpt-5"), package: "@openrouter/ai-sdk-provider", options: { name: "custom" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("filters OpenRouter's gpt-5 chat alias", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenRouterPlugin) + const result = yield* plugin.trigger( + "model.update", + {}, + { model: model("openrouter", "openai/gpt-5-chat"), cancel: false }, + ) + const regular = yield* plugin.trigger( + "model.update", + {}, + { model: model("openrouter", "openai/gpt-5"), cancel: false }, + ) + const ignored = yield* plugin.trigger( + "model.update", + {}, + { model: model("openai", "openai/gpt-5-chat"), cancel: false }, + ) + + expect(result.cancel).toBe(true) + expect(regular.cancel).toBe(false) + expect(ignored.cancel).toBe(false) + }), + ) + + it.effect("does not filter gpt-5-chat-latest for non-OpenRouter providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(OpenRouterPlugin) + const result = yield* plugin.trigger( + "model.update", + {}, + { model: model("custom-openrouter", "gpt-5-chat-latest"), cancel: false }, + ) + expect(result.cancel).toBe(false) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-perplexity.test.ts b/packages/core/test/v2/plugin/provider-perplexity.test.ts new file mode 100644 index 0000000000..d03f583375 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-perplexity.test.ts @@ -0,0 +1,107 @@ +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 { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +describe("PerplexityPlugin", () => { + it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(PerplexityPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("ignores packages that are not the bundled Perplexity package", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(PerplexityPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("perplexity", "sonar"), + package: "@ai-sdk/perplexity-compatible", + options: { name: "perplexity" }, + }, + {}, + ) + expect(result.sdk).toBeUndefined() + }), + ) + + 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* plugin.add(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( + "aisdk.sdk", + { model: model("perplexity", "sonar"), package: "@ai-sdk/perplexity", options: { name: "perplexity" } }, + {}, + ) + expect(providers).toEqual(["perplexity"]) + }), + ) + + it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const providers: string[] = [] + yield* plugin.add(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( + "aisdk.sdk", + { + model: model("custom-perplexity", "sonar"), + package: "@ai-sdk/perplexity", + options: { name: "custom-perplexity" }, + }, + {}, + ) + expect(providers).toEqual(["perplexity"]) + }), + ) + + it.effect("leaves Perplexity language selection to the default languageModel fallback", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(PerplexityPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("perplexity", "alias", { apiID: ModelV2.ID.make("sonar") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-sap-ai-core.test.ts b/packages/core/test/v2/plugin/provider-sap-ai-core.test.ts new file mode 100644 index 0000000000..565b9280ab --- /dev/null +++ b/packages/core/test/v2/plugin/provider-sap-ai-core.test.ts @@ -0,0 +1,127 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" +import { fixtureProvider, it, model, npmLayer, withEnv } from "./provider-helper" + +const pluginWithNpm = { id: SapAICorePlugin.id, effect: SapAICorePlugin.effect.pipe(Effect.provide(npmLayer)) } + +describe("SapAICorePlugin", () => { + it.effect("copies serviceKey option into AICORE_SERVICE_KEY but keeps SDK options to deployment metadata", () => + withEnv( + { AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(pluginWithNpm) + const sdk = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("sap-ai-core", "sap-model"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "service-key" }, + }, + {}, + ) + expect(process.env.AICORE_SERVICE_KEY).toBe("service-key") + expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) + }), + ), + ) + + it.effect("preserves existing AICORE_SERVICE_KEY over serviceKey option", () => + withEnv( + { + AICORE_SERVICE_KEY: "env-service-key", + AICORE_DEPLOYMENT_ID: "deployment", + AICORE_RESOURCE_GROUP: "resource-group", + }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(pluginWithNpm) + const sdk = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("sap-ai-core", "sap-model"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "option-service-key" }, + }, + {}, + ) + expect(process.env.AICORE_SERVICE_KEY).toBe("env-service-key") + expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) + }), + ), + ) + + it.effect("omits deployment and resourceGroup SDK options when no service key is available", () => + withEnv( + { AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(pluginWithNpm) + const sdk = yield* plugin.trigger( + "aisdk.sdk", + { model: model("sap-ai-core", "sap-model"), package: fixtureProvider, options: { name: "sap-ai-core" } }, + {}, + ) + expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() + expect(sdk.sdk.options).toEqual({}) + }), + ), + ) + + it.effect("uses the callable SDK for language selection", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(pluginWithNpm) + 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: {} }, + {}, + ) + expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" }) + }), + ) + + it.effect("ignores non-SAP AI Core providers", () => + withEnv( + { AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" }, + () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(pluginWithNpm) + const sdk = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("openai", "sap-model"), + package: fixtureProvider, + options: { name: "openai", serviceKey: "service-key" }, + }, + {}, + ) + const language = yield* plugin.trigger( + "aisdk.language", + { + model: model("openai", "sap-model"), + sdk: () => { + throw new Error("SAP AI Core should ignore other providers") + }, + options: {}, + }, + {}, + ) + expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() + expect(sdk.sdk).toBeUndefined() + expect(language.language).toBeUndefined() + }), + ), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-togetherai.test.ts b/packages/core/test/v2/plugin/provider-togetherai.test.ts new file mode 100644 index 0000000000..65090037be --- /dev/null +++ b/packages/core/test/v2/plugin/provider-togetherai.test.ts @@ -0,0 +1,97 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +describe("TogetherAIPlugin", () => { + it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(TogetherAIPlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("matches the old bundled provider package exactly", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(TogetherAIPlugin) + + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("togetherai", "model"), + package: "file:///tmp/@ai-sdk/togetherai-provider.js", + options: { name: "togetherai" }, + }, + {}, + ) + expect(ignored.sdk).toBeUndefined() + + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("togetherai", "model"), package: "@ai-sdk/togetherai", options: { name: "togetherai" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const observed: string[] = [] + yield* plugin.add(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* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-togetherai", "model"), + package: "@ai-sdk/togetherai", + options: { name: "custom-togetherai" }, + }, + {}, + ) + + expect(observed).toEqual(["togetherai.chat"]) + }), + ) + + it.effect("defaults language selection to sdk.languageModel with the model API ID", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(TogetherAIPlugin) + + const result = yield* plugin.trigger( + "aisdk.language", + { + model: model("togetherai", "meta-llama/Llama-3.3-70B-Instruct-Turbo"), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }, + {}, + ) + + expect(result.language).toBeUndefined() + expect(calls).toEqual([]) + expect(result.language ?? fakeSelectorSdk(calls).languageModel(result.model.apiID)).toBeDefined() + expect(calls).toEqual(["languageModel:meta-llama/Llama-3.3-70B-Instruct-Turbo"]) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-venice.test.ts b/packages/core/test/v2/plugin/provider-venice.test.ts new file mode 100644 index 0000000000..ff4a922ab1 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-venice.test.ts @@ -0,0 +1,86 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" +import { fakeSelectorSdk, it, model } from "./provider-helper" + +describe("VenicePlugin", () => { + it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(VenicePlugin) + const result = yield* plugin.trigger( + "aisdk.sdk", + { model: model("venice", "model"), package: "venice-ai-sdk-provider", options: { name: "venice" } }, + {}, + ) + expect(result.sdk).toBeDefined() + }), + ) + + 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* plugin.add(VenicePlugin) + yield* plugin.add({ + id: PluginV2.ID.make("inspector"), + effect: Effect.succeed({ + "aisdk.sdk": (evt) => + Effect.sync(() => { + observed.push(evt.sdk.languageModel("model").provider) + }), + }), + }) + const result = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("custom-venice", "model"), + package: "venice-ai-sdk-provider", + options: { name: "custom-venice", apiKey: "test" }, + }, + {}, + ) + expect(result.sdk).toBeDefined() + expect(observed).toEqual(["custom-venice.chat"]) + }), + ) + + it.effect("only handles the bundled venice-ai-sdk-provider package", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(VenicePlugin) + const similar = yield* plugin.trigger( + "aisdk.sdk", + { + model: model("venice", "model"), + package: "file:///tmp/venice-ai-sdk-provider.js", + options: { name: "venice" }, + }, + {}, + ) + const other = yield* plugin.trigger( + "aisdk.sdk", + { model: model("venice", "model"), package: "@ai-sdk/openai-compatible", options: { name: "venice" } }, + {}, + ) + expect(similar.sdk).toBeUndefined() + expect(other.sdk).toBeUndefined() + }), + ) + + it.effect("leaves Venice language selection to the default languageModel fallback", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + yield* plugin.add(VenicePlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { model: model("venice", "alias"), sdk: fakeSelectorSdk(calls), options: {} }, + {}, + ) + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-vercel.test.ts b/packages/core/test/v2/plugin/provider-vercel.test.ts new file mode 100644 index 0000000000..3134a7b83c --- /dev/null +++ b/packages/core/test/v2/plugin/provider-vercel.test.ts @@ -0,0 +1,62 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" +import { it, model, provider } from "./provider-helper" + +describe("VercelPlugin", () => { + it.effect("applies legacy lower-case referer headers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(VercelPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("vercel", { + options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + expect(result.provider.options.headers).toEqual({ + Existing: "1", + "http-referer": "https://opencode.ai/", + "x-title": "opencode", + }) + }), + ) + + it.effect("does not add legacy upper-case referer headers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(VercelPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("vercel"), cancel: false }) + expect(result.provider.options.headers).not.toHaveProperty("HTTP-Referer") + expect(result.provider.options.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* plugin.add(VercelPlugin) + const event = yield* plugin.trigger( + "aisdk.sdk", + { model: model("custom-vercel", "v0-1.0-md"), package: "@ai-sdk/vercel", options: { name: "custom-vercel" } }, + {}, + ) + expect(event.sdk).toBeDefined() + expect(event.sdk.languageModel("v0-1.0-md").provider).toBe("vercel.chat") + }), + ) + + it.effect("ignores non-Vercel providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(VercelPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("gateway"), cancel: false }) + expect(result.provider.options.headers).toEqual({}) + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-xai.test.ts b/packages/core/test/v2/plugin/provider-xai.test.ts new file mode 100644 index 0000000000..bb2828ff4d --- /dev/null +++ b/packages/core/test/v2/plugin/provider-xai.test.ts @@ -0,0 +1,115 @@ +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 { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../../lib/effect" +import { fakeSelectorSdk } from "./provider-helper" + +const it = testEffect(PluginV2.defaultLayer) + +const model = new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + apiID: ModelV2.ID.make("grok-4"), + endpoint: { + type: "aisdk", + package: "@ai-sdk/xai", + }, +}) + +describe("XAIPlugin", () => { + it.effect("creates an xAI SDK only for @ai-sdk/xai", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(XAIPlugin) + + const ignored = yield* plugin.trigger( + "aisdk.sdk", + { model, package: "@ai-sdk/openai-compatible", options: {} }, + {}, + ) + + const result = yield* plugin.trigger("aisdk.sdk", { model, package: "@ai-sdk/xai", options: {} }, {}) + + expect(ignored.sdk).toBeUndefined() + expect(typeof result.sdk?.responses).toBe("function") + }), + ) + + it.effect("creates xAI SDKs for custom provider IDs", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const providers: string[] = [] + + yield* plugin.add(XAIPlugin) + yield* plugin.add( + PluginV2.define({ + 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( + "aisdk.sdk", + { + model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.make("custom-xai") }), + package: "@ai-sdk/xai", + options: {}, + }, + {}, + ) + + expect(providers).toEqual(["xai.responses"]) + }), + ) + + it.effect("uses responses with the model apiID for xAI language models", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + + yield* plugin.add(XAIPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias"), apiID: ModelV2.ID.make("grok-4") }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + + expect(calls).toEqual(["responses:grok-4"]) + expect(result.language).toBeDefined() + }), + ) + + it.effect("ignores non-xAI providers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const calls: string[] = [] + + yield* plugin.add(XAIPlugin) + const result = yield* plugin.trigger( + "aisdk.language", + { + model: new ModelV2.Info({ ...model, providerID: ProviderV2.ID.openai }), + sdk: fakeSelectorSdk(calls), + options: {}, + }, + {}, + ) + + expect(calls).toEqual([]) + expect(result.language).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/v2/plugin/provider-zenmux.test.ts b/packages/core/test/v2/plugin/provider-zenmux.test.ts new file mode 100644 index 0000000000..2b7730e6c7 --- /dev/null +++ b/packages/core/test/v2/plugin/provider-zenmux.test.ts @@ -0,0 +1,103 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" +import { expectPluginRegistered, it, provider } from "./provider-helper" + +describe("ZenmuxPlugin", () => { + it.effect("is registered so legacy referer headers can be applied", () => + Effect.sync(() => + expectPluginRegistered( + ProviderPlugins.map((item) => item.id), + "zenmux", + ), + ), + ) + + it.effect("applies the exact legacy Zenmux headers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(ZenmuxPlugin) + const result = yield* plugin.trigger("provider.update", {}, { provider: provider("zenmux"), cancel: false }) + expect(result.provider.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) + expect(Object.keys(result.provider.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) + expect(result.cancel).toBe(false) + }), + ) + + it.effect("merges legacy Zenmux headers with existing headers", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(ZenmuxPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("zenmux", { + options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, + }), + cancel: false, + }, + ) + + expect(result.provider.options.headers).toEqual({ + Existing: "value", + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }) + }), + ) + + it.effect("lets configured Zenmux legacy headers override defaults", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(ZenmuxPlugin) + const result = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("zenmux", { + options: { + headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + }), + cancel: false, + }, + ) + + expect(result.provider.options.headers).toEqual({ + "HTTP-Referer": "https://example.com/", + "X-Title": "custom-title", + }) + }), + ) + + it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + yield* plugin.add(ZenmuxPlugin) + const ignored = yield* plugin.trigger( + "provider.update", + {}, + { + provider: provider("openrouter", { + options: { + headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + }), + cancel: false, + }, + ) + + expect(ignored.provider.options.headers).toEqual({ + "HTTP-Referer": "https://example.com/", + "X-Title": "custom-title", + }) + }), + ) +}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 48cb7b3450..f3cea500c0 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -91,7 +91,6 @@ "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.82", diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 6e2643f688..67ea51af6d 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -16,6 +16,7 @@ import { SkillCommand } from "./skill" import { SnapshotCommand } from "./snapshot" import { AgentCommand } from "./agent" import { StartupCommand } from "./startup" +import { V2Command } from "./v2" export const DebugCommand = cmd({ command: "debug", @@ -31,6 +32,7 @@ export const DebugCommand = cmd({ .command(SnapshotCommand) .command(StartupCommand) .command(AgentCommand) + .command(V2Command) .command(InfoCommand) .command(PathsCommand) .command(WaitCommand) diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts new file mode 100644 index 0000000000..a4e69cc494 --- /dev/null +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -0,0 +1,40 @@ +import { EOL } from "os" +import { Effect, Layer, Option } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { effectCmd } from "../../effect-cmd" +import { PluginBoot } from "@/v2/plugin-boot" + +const layer = Catalog.defaultLayer.pipe(Layer.provide(PluginBoot.defaultLayer)) + +export const V2Command = effectCmd({ + command: "v2", + describe: "debug v2 catalog and built-in plugins", + instance: false, + handler: Effect.fn("Cli.debug.v2")(function* () { + const result = yield* Effect.gen(function* () { + const catalog = yield* Catalog.Service + + const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id)) + const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id)) + return { + providers, + default: catalog.model + .default() + .pipe(Effect.map(Option.map((item) => item.id)), Effect.map(Option.getOrUndefined)), + small: Object.fromEntries( + yield* Effect.all( + all.map((provider) => + Effect.map( + catalog.model.small(provider.id), + (model) => [provider.id, Option.getOrUndefined(Option.map(model, (item) => item.id))] as const, + ), + ), + { concurrency: "unbounded" }, + ), + ), + } + }).pipe(Effect.provide(layer), Effect.orDie) + + process.stdout.write(JSON.stringify(result, null, 2) + EOL) + }), +}) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 95d1b072f1..b5e8e10283 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -64,7 +64,6 @@ import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" import { SubagentFooter } from "./subagent-footer.tsx" -import { Flag } from "@opencode-ai/core/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" import * as Clipboard from "../../util/clipboard" @@ -1529,29 +1528,15 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess return ( - - - - - - - - + ) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 3a9996902d..be3cec14c6 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -3,9 +3,21 @@ import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" import fs from "fs/promises" +import { Effect } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { AppProcess } from "@opencode-ai/core/process" import * as Filesystem from "../../../../util/filesystem" import * as Process from "../../../../util/process" +const writeWithStdin = (cmd: string[], text: string): Promise => + Effect.runPromise( + AppProcess.Service.use((svc) => svc.run(ChildProcess.make(cmd[0]!, cmd.slice(1)), { stdin: text })).pipe( + Effect.provide(AppProcess.defaultLayer), + Effect.catch(() => Effect.void), + Effect.asVoid, + ), + ).catch(() => undefined) + // Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup const getWhich = lazy(async () => { const { which } = await import("../../../../util/which") @@ -125,49 +137,23 @@ const getCopyMethod = lazy(async () => { if (os === "linux") { if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) { console.log("clipboard: using wl-copy") - return async (text: string) => { - const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } + return (text: string) => writeWithStdin(["wl-copy"], text) } if (which("xclip")) { console.log("clipboard: using xclip") - return async (text: string) => { - const proc = Process.spawn(["xclip", "-selection", "clipboard"], { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } + return (text: string) => writeWithStdin(["xclip", "-selection", "clipboard"], text) } if (which("xsel")) { console.log("clipboard: using xsel") - return async (text: string) => { - const proc = Process.spawn(["xsel", "--clipboard", "--input"], { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }) - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } + return (text: string) => writeWithStdin(["xsel", "--clipboard", "--input"], text) } } if (os === "win32") { console.log("clipboard: using powershell") - return async (text: string) => { + return (text: string) => // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) - const proc = Process.spawn( + writeWithStdin( [ "powershell.exe", "-NonInteractive", @@ -175,18 +161,8 @@ const getCopyMethod = lazy(async () => { "-Command", "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", ], - { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }, + text, ) - - if (!proc.stdin) return - proc.stdin.write(text) - proc.stdin.end() - await proc.exited.catch(() => {}) - } } console.log("clipboard: no native support") diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e7e65f8901..4a21e2e65e 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -10,8 +10,8 @@ import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" import { SyncEvent } from "@/sync" import { EventSequenceTable, EventTable } from "@/sync/event.sql" -import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" +import { RuntimeFlags } from "@/effect/runtime-flags" import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" @@ -175,6 +175,7 @@ export const layer = Layer.effect( const http = yield* HttpClient.HttpClient const sync = yield* SyncEvent.Service const vcs = yield* Vcs.Service + const flags = yield* RuntimeFlags.Service const connections = new Map() const syncFibers = yield* FiberMap.make() @@ -482,7 +483,7 @@ export const layer = Layer.effect( }) const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return + if (!flags.experimentalWorkspaces) return const adapter = getAdapter(space.projectID, space.type) const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe( @@ -1040,6 +1041,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Project.defaultLayer), Layer.provide(Vcs.defaultLayer), Layer.provide(FetchHttpClient.layer), + Layer.provide(RuntimeFlags.defaultLayer), ) const TIMEOUT = 5000 diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index b1b8ab25ac..4d184c43b3 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -23,6 +23,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"), experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), + experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), }) {} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d4d28088d9..ca87c40b7d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -24,7 +24,6 @@ import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" import { optionalOmitUndefined } from "@opencode-ai/core/schema" - import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" import { ModelStatus } from "./model-status" @@ -112,7 +111,8 @@ const BUNDLED_PROVIDERS: Record Promise<(opts: any) => BundledSDK> "@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel), "@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba), "gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab), - "@ai-sdk/github-copilot": () => import("./sdk/copilot/copilot-provider").then((m) => m.createOpenaiCompatible), + "@ai-sdk/github-copilot": () => + import("@opencode-ai/core/github-copilot/copilot-provider").then((m) => m.createOpenaiCompatible), "venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice), } @@ -449,8 +449,14 @@ function custom(dep: CustomDep): Record { }), "google-vertex": Effect.fnUntraced(function* (provider: Info) { const env = yield* dep.env() + // models.dev advertises GOOGLE_VERTEX_PROJECT for Vertex; keep the wider + // Google Cloud project env names as fallbacks for existing ADC setups. const project = - provider.options?.project ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"] + provider.options?.project ?? + env["GOOGLE_VERTEX_PROJECT"] ?? + env["GOOGLE_CLOUD_PROJECT"] ?? + env["GCP_PROJECT"] ?? + env["GCLOUD_PROJECT"] const location = String( provider.options?.location ?? @@ -739,6 +745,7 @@ function custom(dep: CustomDep): Record { const auth = yield* dep.auth(input.id) const env = yield* dep.env() const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined) + // The Cloudflare auth prompt stores this value as gatewayId metadata. const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined) if (!accountId || !gateway) { @@ -1097,11 +1104,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { } } -const layer: Layer.Layer< - Service, - never, - Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service | ModelsDev.Service -> = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service @@ -1392,7 +1395,12 @@ const layer: Layer.Layer< for (const [modelID, model] of Object.entries(provider.models)) { model.api.id = model.api.id ?? model.id ?? modelID if ( - modelID === "gpt-5-chat-latest" || + // These chat aliases are invalid for the special handling in the + // built-in providers below, but custom providers may support them. + (modelID === "gpt-5-chat-latest" && + (providerID === ProviderID.openai || + providerID === ProviderID.githubCopilot || + providerID === ProviderID.openrouter)) || (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat") ) delete provider.models[modelID] diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts index 05da5b720d..532ccce51d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts @@ -1,10 +1,14 @@ import { HttpApi, OpenApi } from "effect/unstable/httpapi" import { MessageGroup } from "./v2/message" +import { ModelGroup } from "./v2/model" +import { ProviderGroup } from "./v2/provider" import { SessionGroup } from "./v2/session" export const V2Api = HttpApi.make("v2") .add(SessionGroup) .add(MessageGroup) + .add(ModelGroup) + .add(ProviderGroup) .annotateMerge( OpenApi.annotations({ title: "opencode experimental HttpApi", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts new file mode 100644 index 0000000000..35e7aeb850 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts @@ -0,0 +1,24 @@ +import { ModelV2 } from "@opencode-ai/core/model" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const ModelGroup = HttpApiGroup.make("v2.model") + .add( + HttpApiEndpoint.get("models", "/api/model", { + success: Schema.Array(ModelV2.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.model.list", + summary: "List v2 models", + description: "Retrieve available v2 models ordered by release date.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 models", + description: "Experimental v2 model routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts new file mode 100644 index 0000000000..6d92100886 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts @@ -0,0 +1,38 @@ +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { ApiNotFoundError } from "../../errors" +import { Authorization } from "../../middleware/authorization" + +export const ProviderGroup = HttpApiGroup.make("v2.provider") + .add( + HttpApiEndpoint.get("providers", "/api/provider", { + success: Schema.Array(ProviderV2.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.provider.list", + summary: "List v2 providers", + description: "Retrieve active v2 AI providers so clients can show provider availability and configuration.", + }), + ), + ) + .add( + HttpApiEndpoint.get("provider", "/api/provider/:providerID", { + params: { providerID: ProviderV2.ID }, + success: ProviderV2.Info, + error: ApiNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.provider.get", + summary: "Get v2 provider", + description: "Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 providers", + description: "Experimental v2 provider routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 231f1915bb..3776f5c72a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,6 +1,6 @@ import { SessionID } from "@/session/schema" import { SessionMessage } from "@/v2/session-message" -import { Prompt } from "@/v2/session-prompt" +import { Prompt } from "@opencode-ai/core/session-prompt" import { SessionV2 } from "@/v2/session" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts index 55cb534581..b277e77016 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -1,6 +1,12 @@ +import { Catalog } from "@opencode-ai/core/catalog" import { SessionV2 } from "@/v2/session" import { Layer } from "effect" import { messageHandlers } from "./v2/message" +import { modelHandlers } from "./v2/model" +import { providerHandlers } from "./v2/provider" import { sessionHandlers } from "./v2/session" -export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers).pipe(Layer.provide(SessionV2.defaultLayer)) +export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe( + Layer.provide(Catalog.defaultLayer), + Layer.provide(SessionV2.defaultLayer), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts new file mode 100644 index 0000000000..7eb1b310b3 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts @@ -0,0 +1,12 @@ +import { Catalog } from "@opencode-ai/core/catalog" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + + return handlers.handle("models", () => catalog.model.available()) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts new file mode 100644 index 0000000000..c19213f5bc --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts @@ -0,0 +1,22 @@ +import { Catalog } from "@opencode-ai/core/catalog" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" +import { notFound } from "../../errors" + +export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provider", (handlers) => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + + return handlers + .handle("providers", () => catalog.provider.available()) + .handle( + "provider", + Effect.fn(function* (ctx) { + return yield* catalog.provider + .get(ctx.params.providerID) + .pipe(Effect.catchTag("CatalogV2.ProviderNotFound", () => Effect.fail(notFound("Provider not found")))) + }), + ) + }), +) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c7990d1b35..c31545a3d6 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -19,7 +19,6 @@ import { Bus } from "@/bus" import { Wildcard } from "@/util/wildcard" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" -import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { EffectBridge } from "@/effect/bridge" import * as Option from "effect/Option" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index c731239b62..9765175e9e 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -23,7 +23,8 @@ import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" -import { Modelv2 } from "@/v2/model" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -484,9 +485,9 @@ export const layer: Layer.Layer< sessionID: ctx.sessionID, agent: input.assistantMessage.agent, model: { - id: Modelv2.ID.make(ctx.model.id), - providerID: Modelv2.ProviderID.make(ctx.model.providerID), - variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), + id: ModelV2.ID.make(ctx.model.id), + providerID: ProviderV2.ID.make(ctx.model.providerID), + variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"), }, snapshot: ctx.snapshot, timestamp: DateTime.makeUnsafe(Date.now()), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index cae0dd3845..bc58fbdf35 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -53,8 +53,9 @@ import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" -import { Modelv2 } from "@/v2/model" -import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@/v2/session-prompt" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session-prompt" import { Reference } from "@/reference/reference" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" @@ -1143,9 +1144,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), model: { - id: Modelv2.ID.make(info.model.modelID), - providerID: Modelv2.ProviderID.make(info.model.providerID), - variant: Modelv2.VariantID.make(info.model.variant ?? "default"), + id: ModelV2.ID.make(info.model.modelID), + providerID: ProviderV2.ID.make(info.model.providerID), + variant: ModelV2.VariantID.make(info.model.variant ?? "default"), }, }) } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index df173e895b..85486480aa 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -4,7 +4,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" import { type ProviderMetadata, type LanguageModelUsage } from "ai" -import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Database } from "@/storage/db" @@ -38,6 +37,7 @@ import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" +import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "session" }) @@ -507,12 +507,17 @@ export type Patch = Types.DeepMutable["dat const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer< + Service, + never, + Bus.Service | Storage.Service | SyncEvent.Service | RuntimeFlags.Service +> = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service const storage = yield* Storage.Service const sync = yield* SyncEvent.Service + const flags = yield* RuntimeFlags.Service const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -550,7 +555,7 @@ export const layer: Layer.Layer()("@opencode/Snapshot") {} -export const layer: Layer.Layer< - Service, - never, - AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service -> = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const config = yield* Config.Service - const locks = new Map() +export const layer: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const appProcess = yield* AppProcess.Service + const config = yield* Config.Service + const locks = new Map() - const lock = (key: string) => { - const hit = locks.get(key) - if (hit) return hit + const lock = (key: string) => { + const hit = locks.get(key) + if (hit) return hit - const next = Semaphore.makeUnsafe(1) - locks.set(key, next) - return next - } + const next = Semaphore.makeUnsafe(1) + locks.set(key, next) + return next + } - const state = yield* InstanceState.make( - Effect.fn("Snapshot.state")(function* (ctx) { - const state = { - directory: ctx.directory, - worktree: ctx.worktree, - gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)), - vcs: ctx.project.vcs, - } + const state = yield* InstanceState.make( + Effect.fn("Snapshot.state")(function* (ctx) { + const state = { + directory: ctx.directory, + worktree: ctx.worktree, + gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)), + vcs: ctx.project.vcs, + } - const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] + const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd] - const enc = new TextEncoder() - const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0")) + const feed = (list: string[]) => list.join("\0") + "\0" - const git = Effect.fnUntraced( - function* ( - cmd: string[], - opts?: { cwd?: string; env?: Record; stdin?: ChildProcess.CommandInput }, - ) { - const proc = ChildProcess.make("git", cmd, { - cwd: opts?.cwd, - env: opts?.env, - extendEnv: true, - stdin: opts?.stdin, + const git = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record; stdin?: string }) { + const result = yield* appProcess.run( + ChildProcess.make("git", cmd, { cwd: opts?.cwd, env: opts?.env, extendEnv: true }), + { stdin: opts?.stdin }, + ) + return { + code: ChildProcessSpawner.ExitCode(result.exitCode), + text: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + } satisfies GitResult + }, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: err instanceof Error ? err.message : String(err), + }), + ), + ) + + const ignore = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return new Set() + const check = yield* git( + [ + ...quote, + "--git-dir", + path.join(state.worktree, ".git"), + "--work-tree", + state.worktree, + "check-ignore", + "--no-index", + "--stdin", + "-z", + ], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + if (check.code !== 0 && check.code !== 1) return new Set() + return new Set(check.text.split("\0").filter(Boolean)) + }) + + const drop = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return + yield* git( + [ + ...cfg, + ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), + ], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + }) + + const stage = Effect.fnUntraced(function* (files: string[]) { + if (!files.length) return + const result = yield* git( + [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])], + { + cwd: state.directory, + stdin: feed(files), + }, + ) + if (result.code === 0) return + log.warn("failed to add snapshot files", { + exitCode: result.code, + stderr: result.stderr, }) - const handle = yield* spawner.spawn(proc) - const [text, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + }) + + const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) + const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) + const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) + const locked = (fx: Effect.Effect) => lock(state.gitdir).withPermits(1)(fx) + + const enabled = Effect.fnUntraced(function* () { + if (state.vcs !== "git") return false + return (yield* config.get()).snapshot !== false + }) + + const excludes = Effect.fnUntraced(function* () { + const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { + cwd: state.worktree, + }) + const file = result.text.trim() + if (!file) return + if (!(yield* exists(file))) return + return file + }) + + const sync = Effect.fnUntraced(function* (list: string[] = []) { + const file = yield* excludes() + const target = path.join(state.gitdir, "info", "exclude") + const text = [ + file ? (yield* read(file)).trimEnd() : "", + ...list.map((item) => `/${item.replaceAll("\\", "/")}`), + ] + .filter(Boolean) + .join("\n") + yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie) + yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie) + }) + + const add = Effect.fnUntraced(function* () { + yield* sync() + const [diff, other] = yield* Effect.all( + [ + git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], { + cwd: state.directory, + }), + git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], { + cwd: state.directory, + }), + ], { concurrency: 2 }, ) - const code = yield* handle.exitCode - return { code, text, stderr } satisfies GitResult - }, - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - code: ChildProcessSpawner.ExitCode(1), - text: "", - stderr: err instanceof Error ? err.message : String(err), - }), - ), - ) + if (diff.code !== 0 || other.code !== 0) { + log.warn("failed to list snapshot files", { + diffCode: diff.code, + diffStderr: diff.stderr, + otherCode: other.code, + otherStderr: other.stderr, + }) + return + } - const ignore = Effect.fnUntraced(function* (files: string[]) { - if (!files.length) return new Set() - const check = yield* git( - [ - ...quote, - "--git-dir", - path.join(state.worktree, ".git"), - "--work-tree", - state.worktree, - "check-ignore", - "--no-index", - "--stdin", - "-z", - ], - { - cwd: state.directory, - stdin: feed(files), - }, - ) - if (check.code !== 0 && check.code !== 1) return new Set() - return new Set(check.text.split("\0").filter(Boolean)) - }) + const tracked = diff.text.split("\0").filter(Boolean) + const untracked = other.text.split("\0").filter(Boolean) + const all = Array.from(new Set([...tracked, ...untracked])) + if (!all.length) return - const drop = Effect.fnUntraced(function* (files: string[]) { - if (!files.length) return - yield* git( - [ - ...cfg, - ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), - ], - { - cwd: state.directory, - stdin: feed(files), - }, - ) - }) + // Resolve source-repo ignore rules against the exact candidate set. + // --no-index keeps this pattern-based even when a path is already tracked. + const ignored = yield* ignore(all) - const stage = Effect.fnUntraced(function* (files: string[]) { - if (!files.length) return - const result = yield* git( - [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])], - { - cwd: state.directory, - stdin: feed(files), - }, - ) - if (result.code === 0) return - log.warn("failed to add snapshot files", { - exitCode: result.code, - stderr: result.stderr, + // Remove newly-ignored files from snapshot index to prevent re-adding + if (ignored.size > 0) { + const ignoredFiles = Array.from(ignored) + log.info("removing gitignored files from snapshot", { count: ignoredFiles.length }) + yield* drop(ignoredFiles) + } + + const allow = all.filter((item) => !ignored.has(item)) + if (!allow.length) return + + const large = new Set( + (yield* Effect.all( + allow.map((item) => + fs + .stat(path.join(state.directory, item)) + .pipe(Effect.catch(() => Effect.void)) + .pipe( + Effect.map((stat) => { + if (!stat || stat.type !== "File") return + const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size + return size > limit ? item : undefined + }), + ), + ), + { concurrency: 8 }, + )).filter((item): item is string => Boolean(item)), + ) + const block = new Set(untracked.filter((item) => large.has(item))) + yield* sync(Array.from(block)) + // Stage only the allowed candidate paths so snapshot updates stay scoped. + yield* stage(allow.filter((item) => !block.has(item))) }) - }) - const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) - const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) - const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) - const locked = (fx: Effect.Effect) => lock(state.gitdir).withPermits(1)(fx) - - const enabled = Effect.fnUntraced(function* () { - if (state.vcs !== "git") return false - return (yield* config.get()).snapshot !== false - }) - - const excludes = Effect.fnUntraced(function* () { - const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { - cwd: state.worktree, + const cleanup = Effect.fnUntraced(function* () { + return yield* locked( + Effect.gen(function* () { + if (!(yield* enabled())) return + if (!(yield* exists(state.gitdir))) return + const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory }) + if (result.code !== 0) { + log.warn("cleanup failed", { + exitCode: result.code, + stderr: result.stderr, + }) + return + } + log.info("cleanup", { prune }) + }), + ) }) - const file = result.text.trim() - if (!file) return - if (!(yield* exists(file))) return - return file - }) - const sync = Effect.fnUntraced(function* (list: string[] = []) { - const file = yield* excludes() - const target = path.join(state.gitdir, "info", "exclude") - const text = [ - file ? (yield* read(file)).trimEnd() : "", - ...list.map((item) => `/${item.replaceAll("\\", "/")}`), - ] - .filter(Boolean) - .join("\n") - yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie) - yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie) - }) - - const add = Effect.fnUntraced(function* () { - yield* sync() - const [diff, other] = yield* Effect.all( - [ - git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], { - cwd: state.directory, + const track = Effect.fnUntraced(function* () { + return yield* locked( + Effect.gen(function* () { + if (!(yield* enabled())) return + const existed = yield* exists(state.gitdir) + yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, + }) + yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"]) + yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"]) + yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"]) + log.info("initialized") + } + yield* add() + const result = yield* git(args(["write-tree"]), { cwd: state.directory }) + const hash = result.text.trim() + log.info("tracking", { hash, cwd: state.directory, git: state.gitdir }) + return hash }), - git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], { - cwd: state.directory, + ) + }) + + const patch = Effect.fnUntraced(function* (hash: string) { + return yield* locked( + Effect.gen(function* () { + yield* add() + const result = yield* git( + [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])], + { + cwd: state.directory, + }, + ) + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }) + return { hash, files: [] } + } + const files = result.text + .trim() + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + + // Hide ignored-file removals from the user-facing patch output. + const ignored = yield* ignore(files) + + return { + hash, + files: files + .filter((item) => !ignored.has(item)) + .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), + } }), - ], - { concurrency: 2 }, - ) - if (diff.code !== 0 || other.code !== 0) { - log.warn("failed to list snapshot files", { - diffCode: diff.code, - diffStderr: diff.stderr, - otherCode: other.code, - otherStderr: other.stderr, - }) - return - } + ) + }) - const tracked = diff.text.split("\0").filter(Boolean) - const untracked = other.text.split("\0").filter(Boolean) - const all = Array.from(new Set([...tracked, ...untracked])) - if (!all.length) return - - // Resolve source-repo ignore rules against the exact candidate set. - // --no-index keeps this pattern-based even when a path is already tracked. - const ignored = yield* ignore(all) - - // Remove newly-ignored files from snapshot index to prevent re-adding - if (ignored.size > 0) { - const ignoredFiles = Array.from(ignored) - log.info("removing gitignored files from snapshot", { count: ignoredFiles.length }) - yield* drop(ignoredFiles) - } - - const allow = all.filter((item) => !ignored.has(item)) - if (!allow.length) return - - const large = new Set( - (yield* Effect.all( - allow.map((item) => - fs - .stat(path.join(state.directory, item)) - .pipe(Effect.catch(() => Effect.void)) - .pipe( - Effect.map((stat) => { - if (!stat || stat.type !== "File") return - const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size - return size > limit ? item : undefined - }), - ), - ), - { concurrency: 8 }, - )).filter((item): item is string => Boolean(item)), - ) - const block = new Set(untracked.filter((item) => large.has(item))) - yield* sync(Array.from(block)) - // Stage only the allowed candidate paths so snapshot updates stay scoped. - yield* stage(allow.filter((item) => !block.has(item))) - }) - - const cleanup = Effect.fnUntraced(function* () { - return yield* locked( - Effect.gen(function* () { - if (!(yield* enabled())) return - if (!(yield* exists(state.gitdir))) return - const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory }) - if (result.code !== 0) { - log.warn("cleanup failed", { + const restore = Effect.fnUntraced(function* (snapshot: string) { + return yield* locked( + Effect.gen(function* () { + log.info("restore", { commit: snapshot }) + const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree }) + if (result.code === 0) { + const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { + cwd: state.worktree, + }) + if (checkout.code === 0) return + log.error("failed to restore snapshot", { + snapshot, + exitCode: checkout.code, + stderr: checkout.stderr, + }) + return + } + log.error("failed to restore snapshot", { + snapshot, exitCode: result.code, stderr: result.stderr, }) - return - } - log.info("cleanup", { prune }) - }), - ) - }) + }), + ) + }) - const track = Effect.fnUntraced(function* () { - return yield* locked( - Effect.gen(function* () { - if (!(yield* enabled())) return - const existed = yield* exists(state.gitdir) - yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) - if (!existed) { - yield* git(["init"], { - env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, - }) - yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"]) - yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"]) - yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"]) - yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"]) - log.info("initialized") - } - yield* add() - const result = yield* git(args(["write-tree"]), { cwd: state.directory }) - const hash = result.text.trim() - log.info("tracking", { hash, cwd: state.directory, git: state.gitdir }) - return hash - }), - ) - }) - - const patch = Effect.fnUntraced(function* (hash: string) { - return yield* locked( - Effect.gen(function* () { - yield* add() - const result = yield* git( - [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])], - { - cwd: state.directory, - }, - ) - if (result.code !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.code }) - return { hash, files: [] } - } - const files = result.text - .trim() - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - - // Hide ignored-file removals from the user-facing patch output. - const ignored = yield* ignore(files) - - return { - hash, - files: files - .filter((item) => !ignored.has(item)) - .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")), - } - }), - ) - }) - - const restore = Effect.fnUntraced(function* (snapshot: string) { - return yield* locked( - Effect.gen(function* () { - log.info("restore", { commit: snapshot }) - const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree }) - if (result.code === 0) { - const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { - cwd: state.worktree, - }) - if (checkout.code === 0) return - log.error("failed to restore snapshot", { - snapshot, - exitCode: checkout.code, - stderr: checkout.stderr, - }) - return - } - log.error("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr, - }) - }), - ) - }) - - const revert = Effect.fnUntraced(function* (patches: Patch[]) { - return yield* locked( - Effect.gen(function* () { - const ops: { hash: string; file: string; rel: string }[] = [] - const seen = new Set() - for (const item of patches) { - for (const file of item.files) { - if (seen.has(file)) continue - seen.add(file) - ops.push({ - hash: item.hash, - file, - rel: path.relative(state.worktree, file).replaceAll("\\", "/"), - }) - } - } - - const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) { - log.info("reverting", { file: op.file, hash: op.hash }) - const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], { - cwd: state.worktree, - }) - if (result.code === 0) return - const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], { - cwd: state.worktree, - }) - if (tree.code === 0 && tree.text.trim()) { - log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash }) - return - } - log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) - yield* remove(op.file) - }) - - const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) - - for (let i = 0; i < ops.length; ) { - const first = ops[i]! - const run = [first] - let j = i + 1 - // Only batch adjacent files when their paths cannot affect each other. - while (j < ops.length && run.length < 100) { - const next = ops[j]! - if (next.hash !== first.hash) break - if (run.some((item) => clash(item.rel, next.rel))) break - run.push(next) - j += 1 - } - - if (run.length === 1) { - yield* single(first) - i = j - continue - } - - const tree = yield* git( - [...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])], - { - cwd: state.worktree, - }, - ) - - if (tree.code !== 0) { - log.info("batched ls-tree failed, falling back to single-file revert", { - hash: first.hash, - files: run.length, - }) - for (const op of run) { - yield* single(op) + const revert = Effect.fnUntraced(function* (patches: Patch[]) { + return yield* locked( + Effect.gen(function* () { + const ops: { hash: string; file: string; rel: string }[] = [] + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + seen.add(file) + ops.push({ + hash: item.hash, + file, + rel: path.relative(state.worktree, file).replaceAll("\\", "/"), + }) } - i = j - continue } - const have = new Set( - tree.text - .trim() - .split("\n") - .map((item) => item.trim()) - .filter(Boolean), - ) - const list = run.filter((item) => have.has(item.rel)) - if (list.length) { - log.info("reverting", { hash: first.hash, files: list.length }) - const result = yield* git( - [...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])], + const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) { + log.info("reverting", { file: op.file, hash: op.hash }) + const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], { + cwd: state.worktree, + }) + if (result.code === 0) return + const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], { + cwd: state.worktree, + }) + if (tree.code === 0 && tree.text.trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash }) + return + } + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + yield* remove(op.file) + }) + + const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) + + for (let i = 0; i < ops.length; ) { + const first = ops[i]! + const run = [first] + let j = i + 1 + // Only batch adjacent files when their paths cannot affect each other. + while (j < ops.length && run.length < 100) { + const next = ops[j]! + if (next.hash !== first.hash) break + if (run.some((item) => clash(item.rel, next.rel))) break + run.push(next) + j += 1 + } + + if (run.length === 1) { + yield* single(first) + i = j + continue + } + + const tree = yield* git( + [...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])], { cwd: state.worktree, }, ) - if (result.code !== 0) { - log.info("batched checkout failed, falling back to single-file revert", { + + if (tree.code !== 0) { + log.info("batched ls-tree failed, falling back to single-file revert", { hash: first.hash, - files: list.length, + files: run.length, }) for (const op of run) { yield* single(op) @@ -473,301 +433,328 @@ export const layer: Layer.Layer< i = j continue } - } - for (const op of run) { - if (have.has(op.rel)) continue - log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) - yield* remove(op.file) - } - - i = j - } - }), - ) - }) - - const diff = Effect.fnUntraced(function* (hash: string) { - return yield* locked( - Effect.gen(function* () { - yield* add() - const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], { - cwd: state.worktree, - }) - if (result.code !== 0) { - log.warn("failed to get diff", { - hash, - exitCode: result.code, - stderr: result.stderr, - }) - return "" - } - return result.text.trim() - }), - ) - }) - - const diffFull = Effect.fnUntraced(function* (from: string, to: string) { - return yield* locked( - Effect.gen(function* () { - type Row = { - file: string - status: "added" | "deleted" | "modified" - binary: boolean - additions: number - deletions: number - } - - type Ref = { - file: string - side: "before" | "after" - ref: string - } - - const show = Effect.fnUntraced(function* (row: Row) { - if (row.binary) return ["", ""] - if (row.status === "added") { - return [ - "", - yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), - ] - } - if (row.status === "deleted") { - return [ - yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe( - Effect.map((item) => item.text), - ), - "", - ] - } - return yield* Effect.all( - [ - git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)), - git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), - ], - { concurrency: 2 }, - ) - }) - - const load = Effect.fnUntraced( - function* (rows: Row[]) { - const refs = rows.flatMap((row) => { - if (row.binary) return [] - if (row.status === "added") - return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref] - if (row.status === "deleted") { - return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref] - } - return [ - { file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref, - { file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref, - ] - }) - if (!refs.length) return new Map() - - const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], { - cwd: state.directory, - extendEnv: true, - stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")), - }) - const handle = yield* spawner.spawn(proc) - const [out, err] = yield* Effect.all( - [Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, + const have = new Set( + tree.text + .trim() + .split("\n") + .map((item) => item.trim()) + .filter(Boolean), ) - const code = yield* handle.exitCode - if (code !== 0) { - log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", { - stderr: err, - refs: refs.length, - }) - return - } - - const fail = (msg: string, extra?: Record) => { - log.info(msg, { ...extra, refs: refs.length }) - return undefined - } - - const map = new Map() - const dec = new TextDecoder() - let i = 0 - for (const ref of refs) { - let end = i - while (end < out.length && out[end] !== 10) end += 1 - if (end >= out.length) { - return fail( - "git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show", - ) - } - - const head = dec.decode(out.slice(i, end)) - i = end + 1 - const hit = map.get(ref.file) ?? { before: "", after: "" } - if (head.endsWith(" missing")) { - map.set(ref.file, hit) + const list = run.filter((item) => have.has(item.rel)) + if (list.length) { + log.info("reverting", { hash: first.hash, files: list.length }) + const result = yield* git( + [...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])], + { + cwd: state.worktree, + }, + ) + if (result.code !== 0) { + log.info("batched checkout failed, falling back to single-file revert", { + hash: first.hash, + files: list.length, + }) + for (const op of run) { + yield* single(op) + } + i = j continue } - - const match = head.match(/^[0-9a-f]+ blob (\d+)$/) - if (!match) { - return fail( - "git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show", - { head }, - ) - } - - const size = Number(match[1]) - if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) { - return fail( - "git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show", - { head }, - ) - } - - const text = dec.decode(out.slice(i, i + size)) - if (ref.side === "before") hit.before = text - if (ref.side === "after") hit.after = text - map.set(ref.file, hit) - i += size + 1 } - if (i !== out.length) { - return fail( - "git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show", - ) + for (const op of run) { + if (have.has(op.rel)) continue + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + yield* remove(op.file) } - return map - }, - Effect.scoped, - Effect.catch(() => - Effect.succeed | undefined>(undefined), - ), - ) + i = j + } + }), + ) + }) - const result: FileDiff[] = [] - const status = new Map() + const diff = Effect.fnUntraced(function* (hash: string) { + return yield* locked( + Effect.gen(function* () { + yield* add() + const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], { + cwd: state.worktree, + }) + if (result.code !== 0) { + log.warn("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }) + return "" + } + return result.text.trim() + }), + ) + }) - const statuses = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], - { cwd: state.directory }, - ) + const diffFull = Effect.fnUntraced(function* (from: string, to: string) { + return yield* locked( + Effect.gen(function* () { + type Row = { + file: string + status: "added" | "deleted" | "modified" + binary: boolean + additions: number + deletions: number + } - for (const line of statuses.text.trim().split("\n")) { - if (!line) continue - const [code, file] = line.split("\t") - if (!code || !file) continue - status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") - } + type Ref = { + file: string + side: "before" | "after" + ref: string + } - const numstat = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], - { - cwd: state.directory, - }, - ) - - const rows = numstat.text - .trim() - .split("\n") - .filter(Boolean) - .flatMap((line) => { - const [adds, dels, file] = line.split("\t") - if (!file) return [] - const binary = adds === "-" && dels === "-" - const additions = binary ? 0 : parseInt(adds) - const deletions = binary ? 0 : parseInt(dels) - return [ - { - file, - status: status.get(file) ?? "modified", - binary, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - } satisfies Row, - ] + const show = Effect.fnUntraced(function* (row: Row) { + if (row.binary) return ["", ""] + if (row.status === "added") { + return [ + "", + yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe( + Effect.map((item) => item.text), + ), + ] + } + if (row.status === "deleted") { + return [ + yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe( + Effect.map((item) => item.text), + ), + "", + ] + } + return yield* Effect.all( + [ + git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)), + git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)), + ], + { concurrency: 2 }, + ) }) - // Hide ignored-file removals from the user-facing diff output. - const ignored = yield* ignore(rows.map((r) => r.file)) - if (ignored.size > 0) { - const filtered = rows.filter((r) => !ignored.has(r.file)) - rows.length = 0 - rows.push(...filtered) - } + const load = Effect.fnUntraced( + function* (rows: Row[]) { + const refs = rows.flatMap((row) => { + if (row.binary) return [] + if (row.status === "added") + return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref] + if (row.status === "deleted") { + return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref] + } + return [ + { file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref, + { file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref, + ] + }) + if (!refs.length) return new Map() - const step = 100 - const patch = (file: string, before: string, after: string) => - formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) + const batch = yield* appProcess.run( + ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], { + cwd: state.directory, + extendEnv: true, + }), + { stdin: refs.map((item) => item.ref).join("\n") + "\n" }, + ) + if (batch.exitCode !== 0) { + log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", { + stderr: batch.stderr.toString("utf8"), + refs: refs.length, + }) + return + } + const out = batch.stdout - for (let i = 0; i < rows.length; i += step) { - const run = rows.slice(i, i + step) - const text = yield* load(run) + const fail = (msg: string, extra?: Record) => { + log.info(msg, { ...extra, refs: refs.length }) + return undefined + } - for (const row of run) { - const hit = text?.get(row.file) ?? { before: "", after: "" } - const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row) - result.push({ - file: row.file, - patch: row.binary ? "" : patch(row.file, before, after), - additions: row.additions, - deletions: row.deletions, - status: row.status, - }) + const map = new Map() + const dec = new TextDecoder() + let i = 0 + for (const ref of refs) { + let end = i + while (end < out.length && out[end] !== 10) end += 1 + if (end >= out.length) { + return fail( + "git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show", + ) + } + + const head = dec.decode(out.slice(i, end)) + i = end + 1 + const hit = map.get(ref.file) ?? { before: "", after: "" } + if (head.endsWith(" missing")) { + map.set(ref.file, hit) + continue + } + + const match = head.match(/^[0-9a-f]+ blob (\d+)$/) + if (!match) { + return fail( + "git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show", + { head }, + ) + } + + const size = Number(match[1]) + if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) { + return fail( + "git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show", + { head }, + ) + } + + const text = dec.decode(out.slice(i, i + size)) + if (ref.side === "before") hit.before = text + if (ref.side === "after") hit.after = text + map.set(ref.file, hit) + i += size + 1 + } + + if (i !== out.length) { + return fail( + "git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show", + ) + } + + return map + }, + Effect.scoped, + Effect.catch(() => + Effect.succeed | undefined>(undefined), + ), + ) + + const result: FileDiff[] = [] + const status = new Map() + + const statuses = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], + { cwd: state.directory }, + ) + + for (const line of statuses.text.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") } - } - return result + const numstat = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], + { + cwd: state.directory, + }, + ) + + const rows = numstat.text + .trim() + .split("\n") + .filter(Boolean) + .flatMap((line) => { + const [adds, dels, file] = line.split("\t") + if (!file) return [] + const binary = adds === "-" && dels === "-" + const additions = binary ? 0 : parseInt(adds) + const deletions = binary ? 0 : parseInt(dels) + return [ + { + file, + status: status.get(file) ?? "modified", + binary, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Row, + ] + }) + + // Hide ignored-file removals from the user-facing diff output. + const ignored = yield* ignore(rows.map((r) => r.file)) + if (ignored.size > 0) { + const filtered = rows.filter((r) => !ignored.has(r.file)) + rows.length = 0 + rows.push(...filtered) + } + + const step = 100 + const patch = (file: string, before: string, after: string) => + formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) + + for (let i = 0; i < rows.length; i += step) { + const run = rows.slice(i, i + step) + const text = yield* load(run) + + for (const row of run) { + const hit = text?.get(row.file) ?? { before: "", after: "" } + const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row) + result.push({ + file: row.file, + patch: row.binary ? "" : patch(row.file, before, after), + additions: row.additions, + deletions: row.deletions, + status: row.status, + }) + } + } + + return result + }), + ) + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) + return Effect.void }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, ) - }) - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) - return Effect.void - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.delay(Duration.minutes(1)), - Effect.forkScoped, - ) + return { cleanup, track, patch, restore, revert, diff, diffFull } + }), + ) - return { cleanup, track, patch, restore, revert, diff, diffFull } - }), - ) - - return Service.of({ - init: Effect.fn("Snapshot.init")(function* () { - yield* InstanceState.get(state) - }), - cleanup: Effect.fn("Snapshot.cleanup")(function* () { - return yield* InstanceState.useEffect(state, (s) => s.cleanup()) - }), - track: Effect.fn("Snapshot.track")(function* () { - return yield* InstanceState.useEffect(state, (s) => s.track()) - }), - patch: Effect.fn("Snapshot.patch")(function* (hash: string) { - return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) - }), - restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { - return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) - }), - revert: Effect.fn("Snapshot.revert")(function* (patches: Patch[]) { - return yield* InstanceState.useEffect(state, (s) => s.revert(patches)) - }), - diff: Effect.fn("Snapshot.diff")(function* (hash: string) { - return yield* InstanceState.useEffect(state, (s) => s.diff(hash)) - }), - diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { - return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to)) - }), - }) - }), -) + return Service.of({ + init: Effect.fn("Snapshot.init")(function* () { + yield* InstanceState.get(state) + }), + cleanup: Effect.fn("Snapshot.cleanup")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.cleanup()) + }), + track: Effect.fn("Snapshot.track")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.track()) + }), + patch: Effect.fn("Snapshot.patch")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.patch(hash)) + }), + restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) { + return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot)) + }), + revert: Effect.fn("Snapshot.revert")(function* (patches: Patch[]) { + return yield* InstanceState.useEffect(state, (s) => s.revert(patches)) + }), + diff: Effect.fn("Snapshot.diff")(function* (hash: string) { + return yield* InstanceState.useEffect(state, (s) => s.diff(hash)) + }), + diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { + return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to)) + }), + }) + }), + ) export const defaultLayer = layer.pipe( - Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(AppProcess.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Config.defaultLayer), ) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 5c29101b6c..7f9b8eeef1 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -7,11 +7,11 @@ import type { InstanceContext } from "@/project/instance" import { EventSequenceTable, EventTable } from "./event.sql" import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" -import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" import type { DeepMutable } from "@opencode-ai/core/schema" import { serviceUse } from "@/effect/service-use" import { InstanceState } from "@/effect/instance-state" +import { RuntimeFlags } from "@/effect/runtime-flags" // Keep `Event["data"]` mutable because projectors mutate the persisted shape // when writing to the database. Bus payloads (`Properties`) stay readonly — @@ -69,6 +69,8 @@ export class Service extends Context.Service()("@opencode/Sy export const layer = Layer.effect(Service)( Effect.gen(function* () { + const flags = yield* RuntimeFlags.Service + const replay: Interface["replay"] = Effect.fn("SyncEvent.replay")(function* (event, options) { const def = registry.get(event.type) if (!def) { @@ -104,7 +106,12 @@ export const layer = Layer.effect(Service)( workspace: yield* InstanceState.workspaceID, } : undefined - process(def, event, { publish, context, ownerID: options?.ownerID }) + process(def, event, { + publish, + context, + ownerID: options?.ownerID, + experimentalWorkspaces: flags.experimentalWorkspaces, + }) }) const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { @@ -160,7 +167,7 @@ export const layer = Layer.effect(Service)( const seq = row?.seq != null ? row.seq + 1 : 0 const event = { id, seq, aggregateID: agg, data } - process(def, event, { publish, context }) + process(def, event, { publish, context, experimentalWorkspaces: flags.experimentalWorkspaces }) }, { behavior: "immediate", @@ -197,7 +204,7 @@ export const layer = Layer.effect(Service)( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(RuntimeFlags.defaultLayer)) export const use = serviceUse(Service) @@ -279,7 +286,7 @@ export function project( function process( def: Def, event: Event, - options: { publish: boolean; context?: PublishContext; ownerID?: string }, + options: { publish: boolean; context?: PublishContext; ownerID?: string; experimentalWorkspaces: boolean }, ) { if (projectors == null) { throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") @@ -293,7 +300,7 @@ function process( Database.transaction((tx) => { projector(tx, event.data, event) - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + if (options.experimentalWorkspaces) { tx.insert(EventSequenceTable) .values({ aggregate_id: event.aggregateID, diff --git a/packages/opencode/src/util/color.ts b/packages/opencode/src/util/color.ts deleted file mode 100644 index 3752fd19bf..0000000000 --- a/packages/opencode/src/util/color.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function isValidHex(hex?: string): hex is string { - if (!hex) return false - return /^#[0-9a-fA-F]{6}$/.test(hex) -} - -export function hexToRgb(hex: string): { r: number; g: number; b: number } { - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) - return { r, g, b } -} - -export function hexToAnsiBold(hex?: string): string | undefined { - if (!isValidHex(hex)) return undefined - const { r, g, b } = hexToRgb(hex) - return `\x1b[38;2;${r};${g};${b}m\x1b[1m` -} - -export * as Color from "./color" diff --git a/packages/opencode/src/util/lock.ts b/packages/opencode/src/util/lock.ts deleted file mode 100644 index 15635996ee..0000000000 --- a/packages/opencode/src/util/lock.ts +++ /dev/null @@ -1,98 +0,0 @@ -const locks = new Map< - string, - { - readers: number - writer: boolean - waitingReaders: (() => void)[] - waitingWriters: (() => void)[] - } ->() - -function get(key: string) { - if (!locks.has(key)) { - locks.set(key, { - readers: 0, - writer: false, - waitingReaders: [], - waitingWriters: [], - }) - } - return locks.get(key)! -} - -function process(key: string) { - const lock = locks.get(key) - if (!lock || lock.writer || lock.readers > 0) return - - // Prioritize writers to prevent starvation - if (lock.waitingWriters.length > 0) { - const nextWriter = lock.waitingWriters.shift()! - nextWriter() - return - } - - // Wake up all waiting readers - while (lock.waitingReaders.length > 0) { - const nextReader = lock.waitingReaders.shift()! - nextReader() - } - - // Clean up empty locks - if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) { - locks.delete(key) - } -} - -export async function read(key: string): Promise { - const lock = get(key) - - return new Promise((resolve) => { - if (!lock.writer && lock.waitingWriters.length === 0) { - lock.readers++ - resolve({ - [Symbol.dispose]: () => { - lock.readers-- - process(key) - }, - }) - } else { - lock.waitingReaders.push(() => { - lock.readers++ - resolve({ - [Symbol.dispose]: () => { - lock.readers-- - process(key) - }, - }) - }) - } - }) -} - -export async function write(key: string): Promise { - const lock = get(key) - - return new Promise((resolve) => { - if (!lock.writer && lock.readers === 0) { - lock.writer = true - resolve({ - [Symbol.dispose]: () => { - lock.writer = false - process(key) - }, - }) - } else { - lock.waitingWriters.push(() => { - lock.writer = true - resolve({ - [Symbol.dispose]: () => { - lock.writer = false - process(key) - }, - }) - }) - } - }) -} - -export * as Lock from "./lock" diff --git a/packages/opencode/src/util/network.ts b/packages/opencode/src/util/network.ts deleted file mode 100644 index 69e5d17588..0000000000 --- a/packages/opencode/src/util/network.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function online() { - const nav = globalThis.navigator - if (!nav || typeof nav.onLine !== "boolean") return true - return nav.onLine -} - -export function proxied() { - return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) -} diff --git a/packages/opencode/src/util/scrap.ts b/packages/opencode/src/util/scrap.ts deleted file mode 100644 index 554dba1c54..0000000000 --- a/packages/opencode/src/util/scrap.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const foo: string = "42" -export const bar: number = 123 - -export function dummyFunction(): void { - console.log("This is a dummy function") -} - -export function randomHelper(): boolean { - return Math.random() > 0.5 -} diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts deleted file mode 100644 index 56357ab400..0000000000 --- a/packages/opencode/src/v2/model.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { withStatics } from "@opencode-ai/core/schema" -import { ModelStatus } from "@/provider/model-status" -import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" -import { DateTimeUtcFromMillis } from "effect/Schema" - -export const ID = Schema.String.pipe(Schema.brand("Model.ID")) -export type ID = typeof ID.Type - -export const ProviderID = Schema.String.pipe( - Schema.brand("Model.ProviderID"), - withStatics((schema) => ({ - // Well-known providers - opencode: schema.make("opencode"), - anthropic: schema.make("anthropic"), - openai: schema.make("openai"), - google: schema.make("google"), - googleVertex: schema.make("google-vertex"), - githubCopilot: schema.make("github-copilot"), - amazonBedrock: schema.make("amazon-bedrock"), - azure: schema.make("azure"), - openrouter: schema.make("openrouter"), - mistral: schema.make("mistral"), - gitlab: schema.make("gitlab"), - })), -) -export type ProviderID = typeof ProviderID.Type - -export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) -export type VariantID = typeof VariantID.Type - -// Grouping of models, eg claude opus, claude sonnet -export const Family = Schema.String.pipe(Schema.brand("Family")) -export type Family = typeof Family.Type - -const OpenAIResponses = Schema.Struct({ - type: Schema.Literal("openai/responses"), - url: Schema.String, - websocket: Schema.optional(Schema.Boolean), -}) - -const OpenAICompletions = Schema.Struct({ - type: Schema.Literal("openai/completions"), - url: Schema.String, - reasoning: Schema.Union([ - Schema.Struct({ - type: Schema.Literal("reasoning_content"), - }), - Schema.Struct({ - type: Schema.Literal("reasoning_details"), - }), - ]).pipe(Schema.optional), -}) -export type OpenAICompletions = typeof OpenAICompletions.Type - -const AnthropicMessages = Schema.Struct({ - type: Schema.Literal("anthropic/messages"), - url: Schema.String, -}) - -export const Endpoint = Schema.Union([OpenAIResponses, OpenAICompletions, AnthropicMessages]).pipe( - Schema.toTaggedUnion("type"), -) -export type Endpoint = typeof Endpoint.Type - -export const Capabilities = Schema.Struct({ - tools: Schema.Boolean, - // mime patterns, image, audio, video/*, text/* - input: Schema.String.pipe(Schema.Array), - output: Schema.String.pipe(Schema.Array), -}) -export type Capabilities = typeof Capabilities.Type - -export const Options = Schema.Struct({ - headers: Schema.Record(Schema.String, Schema.String), - body: Schema.Record(Schema.String, Schema.Any), -}) -export type Options = typeof Options.Type - -export const Cost = Schema.Struct({ - tier: Schema.Struct({ - type: Schema.Literal("context"), - size: Schema.Int, - }).pipe(Schema.optional), - input: Schema.Finite, - output: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), -}) - -export const Ref = Schema.Struct({ - id: ID, - providerID: ProviderID, - variant: VariantID, -}) -export type Ref = typeof Ref.Type - -export class Info extends Schema.Class("Model.Info")({ - id: ID, - providerID: ProviderID, - family: Family.pipe(Schema.optional), - name: Schema.String, - endpoint: Endpoint, - capabilities: Capabilities, - options: Schema.Struct({ - ...Options.fields, - variant: Schema.String.pipe(Schema.optional), - }), - variants: Schema.Struct({ - id: VariantID, - ...Options.fields, - }).pipe(Schema.Array), - time: Schema.Struct({ - released: DateTimeUtcFromMillis, - }), - cost: Cost.pipe(Schema.Array), - status: ModelStatus, - limit: Schema.Struct({ - context: Schema.Int, - input: Schema.Int.pipe(Schema.optional), - output: Schema.Int, - }), -}) {} - -export function parse(input: string): { providerID: ProviderID; modelID: ID } { - const [providerID, ...modelID] = input.split("/") - return { - providerID: ProviderID.make(providerID), - modelID: ID.make(modelID.join("/")), - } -} - -export interface Interface { - readonly get: (providerID: ProviderID, modelID: ID) => Effect.Effect> - readonly add: (model: Info) => Effect.Effect - readonly remove: (providerID: ProviderID, modelID: ID) => Effect.Effect - readonly all: () => Effect.Effect - readonly default: () => Effect.Effect> - readonly small: (provider: ProviderID) => Effect.Effect> -} - -export class Service extends Context.Service()("@opencode/v2/Model") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - let models = HashMap.empty() - - function key(providerID: ProviderID, modelID: ID) { - return `${providerID}/${modelID}` - } - - const result: Interface = { - get: Effect.fn("V2Model.get")(function* (providerID, modelID) { - return HashMap.get(models, key(providerID, modelID)) - }), - - add: Effect.fn("V2Model.add")(function* (model) { - models = HashMap.set(models, key(model.providerID, model.id), model) - }), - - remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) { - models = HashMap.remove(models, key(providerID, modelID)) - }), - - all: Effect.fn("V2Model.all")(function* () { - return pipe( - models, - HashMap.toValues, - Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), - ) - }), - - default: Effect.fn("V2Model.default")(function* () { - const all = yield* result.all() - return Option.fromUndefinedOr(all[0]) - }), - - small: Effect.fn("V2Model.small")(function* (providerID) { - const all = yield* result.all() - const match = all.find((model) => model.providerID === providerID && model.id.toLowerCase().includes("small")) - return Option.fromUndefinedOr(match) - }), - } - - return Service.of(result) - }), -) - -export const defaultLayer = layer - -export * as Modelv2 from "./model" diff --git a/packages/opencode/src/v2/plugin-boot.ts b/packages/opencode/src/v2/plugin-boot.ts new file mode 100644 index 0000000000..d19872f2d2 --- /dev/null +++ b/packages/opencode/src/v2/plugin-boot.ts @@ -0,0 +1,50 @@ +export * as PluginBoot from "./plugin-boot" + +import { Npm } from "@opencode-ai/core/npm" +import { Effect, Layer } from "effect" +import { AuthV2 } from "@opencode-ai/core/auth" +import { Catalog } from "@opencode-ai/core/catalog" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { AuthPlugin } from "@opencode-ai/core/plugin/auth" +import { EnvPlugin } from "@opencode-ai/core/plugin/env" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { ModelsDevPlugin } from "./plugin/models-dev" + +type Plugin = { + id: PluginV2.ID + effect: Effect.Effect +} + +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const plugin = yield* PluginV2.Service + const auth = yield* AuthV2.Service + const npm = yield* Npm.Service + + const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { + yield* plugin.add({ + id: input.id, + effect: input.effect.pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(AuthV2.Service, auth), + Effect.provideService(Npm.Service, npm), + ), + }) + }) + + yield* add(EnvPlugin) + yield* add(AuthPlugin) + for (const item of ProviderPlugins) { + yield* add(item) + } + yield* add(ModelsDevPlugin) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Catalog.defaultLayer), + Layer.provide(PluginV2.defaultLayer), + Layer.provide(Layer.orDie(AuthV2.defaultLayer)), + Layer.provide(Npm.defaultLayer), +) diff --git a/packages/opencode/src/v2/plugin/models-dev.ts b/packages/opencode/src/v2/plugin/models-dev.ts new file mode 100644 index 0000000000..7c0e902c79 --- /dev/null +++ b/packages/opencode/src/v2/plugin/models-dev.ts @@ -0,0 +1,108 @@ +import { DateTime, Effect } from "effect" +import { Catalog } from "@opencode-ai/core/catalog" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelsDev } from "@/provider/models" +import { PluginV2 } from "@opencode-ai/core/plugin" + +function released(date: string) { + const time = Date.parse(date) + return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0) +} + +function cost(input: ModelsDev.Model["cost"]) { + const base = { + input: input?.input ?? 0, + output: input?.output ?? 0, + cache: { + read: input?.cache_read ?? 0, + write: input?.cache_write ?? 0, + }, + } + if (!input?.context_over_200k) return [base] + return [ + base, + { + tier: { + type: "context" as const, + size: 200_000, + }, + input: input.context_over_200k.input, + output: input.context_over_200k.output, + cache: { + read: input.context_over_200k.cache_read ?? 0, + write: input.context_over_200k.cache_write ?? 0, + }, + }, + ] +} + +function variants(model: ModelsDev.Model) { + return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({ + id: ModelV2.VariantID.make(id), + headers: { ...(item.provider?.headers ?? {}) }, + body: { ...(item.provider?.body ?? {}) }, + aisdk: { + provider: {}, + request: {}, + }, + })) +} + +export const ModelsDevPlugin = PluginV2.define({ + id: PluginV2.ID.make("models-dev"), + effect: Effect.gen(function* () { + const catalog = yield* Catalog.Service + const modelsDev = yield* ModelsDev.Service + for (const item of Object.values(yield* modelsDev.get())) { + const providerID = ProviderV2.ID.make(item.id) + yield* catalog.provider.update(providerID, (provider) => { + provider.name = item.name + provider.env = [...item.env] + provider.endpoint = item.npm + ? { + type: "aisdk", + package: item.npm, + url: item.api, + } + : { + type: "unknown", + } + }) + + for (const model of Object.values(item.models)) { + const modelID = ModelV2.ID.make(model.id) + yield* catalog.model + .update(providerID, modelID, (draft) => { + draft.name = model.name + draft.family = model.family ? ModelV2.Family.make(model.family) : undefined + draft.endpoint = model.provider?.npm + ? { + type: "aisdk", + package: model.provider?.npm, + url: model.provider.api, + } + : { + type: "unknown", + } + draft.capabilities = { + tools: model.tool_call, + input: [...(model.modalities?.input ?? [])], + output: [...(model.modalities?.output ?? [])], + } + draft.variants = variants(model) + draft.time.released = released(model.release_date) + draft.cost = cost(model.cost) + draft.status = model.status ?? "active" + draft.enabled = true + draft.limit = { + context: model.limit.context, + input: model.limit.input, + output: model.limit.output, + } + }) + .pipe(Effect.orDie) + } + } + }).pipe(Effect.provide(ModelsDev.defaultLayer)), +}) diff --git a/packages/opencode/src/v2/provider-parity-checklist.md b/packages/opencode/src/v2/provider-parity-checklist.md new file mode 100644 index 0000000000..e3a599d8ec --- /dev/null +++ b/packages/opencode/src/v2/provider-parity-checklist.md @@ -0,0 +1,95 @@ +# Unported Provider Logic Checklist + +This tracks legacy provider behavior from `packages/opencode/src/provider/provider.ts` that still needs to be ported into the v2 provider plugins under `packages/opencode/src/v2/plugin/provider/`. Keep entries checked only when v2 has equivalent behavior or when the item is intentionally skipped. + +## Provider Setup + +- [x] Cloudflare AI Gateway custom SDK construction with `createAiGateway` / `createUnified`. +- [x] Google Vertex authenticated `fetch` injection. +- [x] Amazon Bedrock AWS credential chain setup. +- [x] Amazon Bedrock bearer token setup. +- [x] SAP AI Core service key setup. + +## Provider Options + +- [x] Azure resource name resolution. +- [x] Azure missing-resource error. +- [x] Azure Cognitive Services baseURL resolution. +- [x] Cloudflare Workers AI account ID validation. +- [x] Cloudflare Workers AI account ID vars. +- [x] Cloudflare AI Gateway account ID validation. +- [x] Cloudflare AI Gateway gateway ID validation. +- [x] Cloudflare AI Gateway token validation. +- [x] Amazon Bedrock region precedence. +- [x] Amazon Bedrock profile precedence. +- [x] Amazon Bedrock endpoint precedence. +- [x] Google Vertex project resolution. +- [x] Google Vertex location resolution. +- [x] GitLab instance URL resolution. +- [x] GitLab token resolution. +- [x] GitLab AI gateway headers. +- [x] GitLab feature flags. +- [x] Opencode unauthenticated paid-model filtering. +- [x] Opencode public API key fallback. + +## Request Behavior + +- [x] Request timeout handling. +- [x] Chunk timeout handling. +- [x] SSE timeout wrapping. +- [x] OpenAI response item ID stripping. +- [x] Azure response item ID stripping. +- [x] OpenAI-compatible `includeUsage` defaulting. + +## Dynamic Models + +- [ ] GitLab workflow model discovery. + +## Model Filtering + +- [ ] Experimental alpha model filtering. +- [ ] Deprecated model filtering. +- [ ] Config whitelist filtering. +- [ ] Config blacklist filtering. +- [ ] `gpt-5-chat-latest` filtering. +- [ ] OpenRouter `openai/gpt-5-chat` filtering. + +## Default Models + +- [x] Configured default model selection. Replaced by explicit `Catalog.model.setDefault`. +- [SKIP] Recent-history default model selection — not porting to server-side v2 catalog. +- [x] Default model fallback sorting. Uses newest available model, not legacy hard-coded priority. + +## Small Models + +- [SKIP] Configured `small_model` selection — not porting config-driven selection to server-side v2 catalog. +- [x] Provider-specific small model priority. Replaced by cheapest output cost selection. +- [x] Opencode small model priority. Replaced by cheapest output cost selection. +- [x] GitHub Copilot small model priority. Replaced by cheapest output cost selection. +- [x] Amazon Bedrock region-aware small model selection. Replaced by cheapest output cost selection. + +## URL And Env Vars + +- [SKIP] BaseURL `${VAR}` interpolation — not porting generic URL templating; provider plugins should construct concrete URLs. +- [x] Azure `AZURE_RESOURCE_NAME` vars. Handled by Azure provider plugins. +- [x] Google Vertex vars. Handled by Google Vertex provider plugins. +- [x] Cloudflare Workers AI vars. Handled by Cloudflare Workers AI provider plugin. + +## Auth + +- [ ] Auth-derived provider API keys. +- [ ] OpenAI OAuth/API auth distinction. +- [ ] GitLab OAuth token selection. +- [ ] GitLab API token selection. +- [ ] Azure auth metadata resource name. +- [ ] Cloudflare auth metadata account ID. +- [ ] Cloudflare auth metadata gateway ID. + +## Config And Plugin Parity + +- [ ] Legacy plugin auth loader behavior. +- [ ] Config provider merge behavior. +- [ ] Config model merge behavior. +- [ ] Variant generation from model metadata. +- [ ] Config variant merge behavior. +- [ ] Config variant disable behavior. diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index fa211bd8c4..1fd0f909d5 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,12 +1,12 @@ import { SessionID } from "@/session/schema" import { NonNegativeInt } from "@opencode-ai/core/schema" import { EventV2 } from "./event" -import { FileAttachment, Prompt } from "./session-prompt" +import { FileAttachment, Prompt } from "@opencode-ai/core/session-prompt" import { Schema } from "effect" export { FileAttachment } -import { ToolOutput } from "./tool-output" -import { V2Schema } from "./schema" -import { Modelv2 } from "./model" +import { ToolOutput } from "@opencode-ai/core/tool-output" +import { V2Schema } from "@opencode-ai/core/v2-schema" +import { ModelV2 } from "@opencode-ai/core/model" export const Source = Schema.Struct({ start: NonNegativeInt, @@ -47,7 +47,7 @@ export const ModelSwitched = EventV2.define({ version: 1, schema: { ...Base, - model: Modelv2.Ref, + model: ModelV2.Ref, }, }) export type ModelSwitched = Schema.Schema.Type @@ -104,7 +104,7 @@ export namespace Step { schema: { ...Base, agent: Schema.String, - model: Modelv2.Ref, + model: ModelV2.Ref, snapshot: Schema.String.pipe(Schema.optional), }, }) diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 62fc75fc83..fa7c299ae5 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" -import { Prompt } from "./session-prompt" +import { Prompt } from "@opencode-ai/core/session-prompt" import { SessionEvent } from "./session-event" import { EventV2 } from "./event" -import { ToolOutput } from "./tool-output" -import { V2Schema } from "./schema" -import { Modelv2 } from "./model" +import { ToolOutput } from "@opencode-ai/core/tool-output" +import { V2Schema } from "@opencode-ai/core/v2-schema" +import { ModelV2 } from "@opencode-ai/core/model" export const ID = EventV2.ID export type ID = Schema.Schema.Type @@ -26,7 +26,7 @@ export class AgentSwitched extends Schema.Class("Session.Message. export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ ...Base, type: Schema.Literal("model-switched"), - model: Modelv2.Ref, + model: ModelV2.Ref, }) {} export class User extends Schema.Class("Session.Message.User")({ diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index f6084cb4c0..97c31d39b2 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -5,14 +5,15 @@ import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/s import * as Database from "@/storage/db" import { Context, DateTime, Effect, Layer, Option, Schema } from "effect" import { SessionMessage } from "./session-message" -import type { Prompt } from "./session-prompt" +import type { Prompt } from "@opencode-ai/core/session-prompt" import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" import { SessionEvent } from "./session-event" -import { V2Schema } from "./schema" +import { V2Schema } from "@opencode-ai/core/v2-schema" import { optionalOmitUndefined } from "@opencode-ai/core/schema" -import { Modelv2 } from "./model" import { SyncEvent } from "@/sync" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", @@ -28,7 +29,7 @@ export class Info extends Schema.Class("Session.Info")({ workspaceID: optionalOmitUndefined(WorkspaceID), path: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String), - model: Modelv2.Ref.pipe(optionalOmitUndefined), + model: ModelV2.Ref.pipe(optionalOmitUndefined), cost: Schema.Finite, tokens: Schema.Struct({ input: Schema.Finite, @@ -67,7 +68,7 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses export interface Interface { readonly create: (input?: { agent?: string - model?: Modelv2.Ref + model?: ModelV2.Ref parentID?: SessionID workspaceID?: WorkspaceID }) => Effect.Effect @@ -111,10 +112,10 @@ export interface Interface { parentID: SessionID prompt: Prompt agent: string - model?: Modelv2.Ref + model?: ModelV2.Ref }) => Effect.Effect readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect - readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => Effect.Effect + readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect readonly compact: (sessionID: SessionID) => Effect.Effect readonly wait: (sessionID: SessionID) => Effect.Effect } @@ -141,9 +142,9 @@ export const layer = Layer.effect( agent: row.agent ?? undefined, model: row.model ? { - id: Modelv2.ID.make(row.model.id), - providerID: Modelv2.ProviderID.make(row.model.providerID), - variant: Modelv2.VariantID.make(row.model.variant ?? "default"), + id: ModelV2.ID.make(row.model.id), + providerID: ProviderV2.ID.make(row.model.providerID), + variant: ModelV2.VariantID.make(row.model.variant ?? "default"), } : undefined, cost: row.cost, @@ -164,7 +165,7 @@ export const layer = Layer.effect( }) } - const result: Interface = { + const result = Service.of({ create: Effect.fn("V2Session.create")(function* (_input) { return {} as any }), @@ -306,7 +307,7 @@ export const layer = Layer.effect( }), subagent: Effect.fn("V2Session.subagent")(function* (input) { const parent = yield* result.get(input.parentID) - const session = yield* result.create({ + const child = yield* result.create({ agent: input.agent, model: input.model, parentID: input.parentID, @@ -314,11 +315,11 @@ export const layer = Layer.effect( }) yield* result.prompt({ prompt: input.prompt, - sessionID: session.id, + sessionID: child.id, }) yield* Effect.gen(function* () { - yield* result.wait(session.id) - const messages = yield* result.messages({ sessionID: session.id, order: "desc" }) + yield* result.wait(child.id) + const messages = yield* result.messages({ sessionID: child.id, order: "desc" }) const assistant = messages.find((msg) => msg.type === "assistant") if (!assistant) return const text = assistant.content.findLast((part) => part.type === "text") @@ -327,9 +328,9 @@ export const layer = Layer.effect( }), compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), - } + }) - return Service.of(result) + return result }), ) diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index 369b3a1fd1..d198080591 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -1,9 +1,8 @@ -import { test, expect } from "bun:test" +import { expect } from "bun:test" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Config } from "@/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" -import { Color } from "@/util/color" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer)) @@ -46,16 +45,3 @@ it.instance( }, }, ) - -test("Color.hexToAnsiBold converts valid hex to ANSI", () => { - const result = Color.hexToAnsiBold("#FFA500") - expect(result).toBe("\x1b[38;2;255;165;0m\x1b[1m") -}) - -test("Color.hexToAnsiBold returns undefined for invalid hex", () => { - expect(Color.hexToAnsiBold(undefined)).toBeUndefined() - expect(Color.hexToAnsiBold("")).toBeUndefined() - expect(Color.hexToAnsiBold("#FFF")).toBeUndefined() - expect(Color.hexToAnsiBold("FFA500")).toBeUndefined() - expect(Color.hexToAnsiBold("#GGGGGG")).toBeUndefined() -}) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 3c4837e318..01304e8050 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -6,7 +6,7 @@ import path from "node:path" import { setTimeout as delay } from "node:timers/promises" import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer, Schema } from "effect" -import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" @@ -16,13 +16,14 @@ import { ProjectID } from "@/project/schema" import { ProjectTable } from "@/project/project.sql" import { Instance } from "@/project/instance" import { WithInstance } from "../../src/project/with-instance" +import { InstanceRef } from "@/effect/instance-ref" import { Session as SessionNs } from "@/session/session" import { SessionID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" import { SyncEvent } from "@/sync" import { EventSequenceTable } from "@/sync/event.sql" import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, TestInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" @@ -32,24 +33,43 @@ import * as Workspace from "../../src/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" +import { Auth } from "@/auth" +import { SessionPrompt } from "@/session/prompt" +import { Project } from "@/project/project" +import { Vcs } from "@/project/vcs" +import { RuntimeFlags } from "@/effect/runtime-flags" void Log.init({ print: false }) -const testServerLayer = Layer.mergeAll( - NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), - Workspace.defaultLayer.pipe(Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer)), - SessionNs.defaultLayer, -) -const it = testEffect(testServerLayer) - const originalWorkspacesFlag = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const originalEnv = { OPENCODE_AUTH_CONTENT: process.env.OPENCODE_AUTH_CONTENT, + OPENCODE_EXPERIMENTAL_WORKSPACES: process.env.OPENCODE_EXPERIMENTAL_WORKSPACES, OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } +const workspaceLayer = (experimentalWorkspaces: boolean) => + Workspace.layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(SessionNs.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(SessionPrompt.defaultLayer), + Layer.provide(Project.defaultLayer), + Layer.provide(Vcs.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), + Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))), + ) + +const testServerLayer = Layer.mergeAll( + NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), + workspaceLayer(true), + SessionNs.defaultLayer, +) +const it = testEffect(testServerLayer) + type RecordedCreate = { info: WorkspaceInfo env: Record @@ -93,6 +113,7 @@ beforeEach(() => { Database.close() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true restoreEnv() + process.env.OPENCODE_EXPERIMENTAL_WORKSPACES = "true" }) afterEach(async () => { @@ -105,7 +126,7 @@ afterEach(async () => { async function withInstance(fn: (dir: string) => T | Promise) { await using tmp = await tmpdir({ git: true }) - return WithInstance.provide({ + return await WithInstance.provide({ directory: tmp.path, fn: () => fn(tmp.path), }) @@ -140,6 +161,12 @@ const isWorkspaceSyncing = (id: WorkspaceID) => const startWorkspaceSyncing = (projectID: ProjectID) => { void runWorkspace(Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID))) } +const startWorkspaceSyncingWithFlag = (projectID: ProjectID, experimentalWorkspaces: boolean) => + Effect.runPromise( + Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)).pipe( + Effect.provide(workspaceLayer(experimentalWorkspaces)), + ), + ) const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) => runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))) @@ -979,7 +1006,6 @@ describe("workspace CRUD", () => { describe("workspace sync state", () => { test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => { await withInstance(async (dir) => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const type = unique("flag-disabled") const info = workspaceInfo(Instance.project.id, type) const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) @@ -987,38 +1013,50 @@ describe("workspace sync state", () => { insertWorkspace(info) registerAdapter(Instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter) - startWorkspaceSyncing(Instance.project.id) + await startWorkspaceSyncingWithFlag(Instance.project.id, false) await delay(25) expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() }) }) - test("startWorkspaceSyncing starts all workspaces", async () => { - await withInstance(async (dir) => { - const firstType = unique("first") - const secondType = unique("second") - const first = workspaceInfo(Instance.project.id, firstType) - const second = workspaceInfo(Instance.project.id, secondType) - await fs.mkdir(path.join(dir, "first"), { recursive: true }) - await fs.mkdir(path.join(dir, "second"), { recursive: true }) - insertWorkspace(first) - insertWorkspace(second) - registerAdapter(Instance.project.id, firstType, localAdapter(path.join(dir, "first")).adapter) - registerAdapter(Instance.project.id, secondType, localAdapter(path.join(dir, "second")).adapter) + it.instance( + "startWorkspaceSyncing starts all workspaces", + () => + Effect.gen(function* () { + const { directory: dir } = yield* TestInstance + const instance = yield* InstanceRef + if (!instance) return yield* Effect.die(new Error("missing test instance")) + const workspace = yield* Workspace.Service + const projectID = instance.project.id + const firstType = unique("first") + const secondType = unique("second") + const first = workspaceInfo(projectID, firstType) + const second = workspaceInfo(projectID, secondType) + yield* Effect.promise(() => fs.mkdir(path.join(dir, "first"), { recursive: true })) + yield* Effect.promise(() => fs.mkdir(path.join(dir, "second"), { recursive: true })) + yield* Effect.sync(() => { + insertWorkspace(first) + insertWorkspace(second) + registerAdapter(projectID, firstType, localAdapter(path.join(dir, "first")).adapter) + registerAdapter(projectID, secondType, localAdapter(path.join(dir, "second")).adapter) + }) + yield* Effect.addFinalizer(() => + Effect.all([workspace.remove(first.id), workspace.remove(second.id)], { discard: true }).pipe(Effect.ignore), + ) - startWorkspaceSyncing(Instance.project.id) + yield* workspace.startWorkspaceSyncing(projectID) - await eventually(() => - workspaceStatus().then((status) => { - expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected") - expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected") - }), - ) - await removeWorkspace(first.id) - await removeWorkspace(second.id) - }) - }) + yield* eventuallyEffect( + Effect.gen(function* () { + const status = yield* workspace.status() + expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected") + expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected") + }), + ) + }), + { git: true }, + ) test("local start reports error when the target directory is missing", async () => { await withInstance(async (dir) => { diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index c213990172..2f5fa25abf 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -34,6 +34,7 @@ describe("RuntimeFlags", () => { expect(flags.experimentalLspTool).toBe(true) expect(flags.experimentalPlanMode).toBe(true) expect(flags.experimentalEventSystem).toBe(true) + expect(flags.experimentalWorkspaces).toBe(true) expect(flags.client).toBe("desktop") }), ) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 7e47c51351..6276e58f29 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -1,15 +1,13 @@ -import { $ } from "bun" -import { afterEach, describe, expect, test } from "bun:test" -import fs from "fs/promises" +import { describe, expect } from "bun:test" import path from "path" -import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { Bus } from "../../src/bus" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { ConfigProvider, Deferred, Effect, Layer, Option } from "effect" +import { TestInstance, provideInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Config } from "@/config/config" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip @@ -25,43 +23,43 @@ const watcherConfigLayer = ConfigProvider.layer( }), ) +const watcherLayer = FileWatcher.layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(watcherConfigLayer), +) + +const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, Git.defaultLayer)) + type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } /** Run `body` with a live FileWatcher service. */ -function withWatcher(directory: string, body: Effect.Effect) { - return WithInstance.provide({ - directory, - fn: async () => { - const layer: Layer.Layer = FileWatcher.layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(watcherConfigLayer), - ) - const rt = ManagedRuntime.make(layer) - try { - await rt.runPromise(FileWatcher.Service.use((s) => s.init())) - await Effect.runPromise(ready(directory)) - await Effect.runPromise(body) - } finally { - await rt.dispose() - } - }, - }) +function withWatcher(directory: string, body: Effect.Effect) { + return Effect.gen(function* () { + const watcher = yield* FileWatcher.Service + yield* watcher.init() + yield* ready(directory) + return yield* body + }).pipe(Effect.provide(watcherLayer), provideInstance(directory), Effect.scoped) } function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) { let done = false - const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => { + const on = (evt: GlobalEvent) => { if (done) return - if (!check(evt.properties)) return - hit(evt.properties) - }) + if (evt.directory !== directory) return + if (evt.payload.type !== FileWatcher.Event.Updated.type) return + if (!check(evt.payload.properties)) return + hit(evt.payload.properties) + } + + GlobalBus.on("event", on) return () => { if (done) return done = true - unsub() + GlobalBus.off("event", on) } } @@ -72,7 +70,7 @@ function wait(directory: string, check: (evt: WatcherEvent) => boolean) { let off = () => {} off = listen(directory, check, (evt) => { off() - Deferred.doneUnsafe(deferred, Effect.succeed(evt)) + Effect.runFork(Deferred.succeed(deferred, evt)) }) return off }) @@ -86,7 +84,12 @@ function nextUpdate(directory: string, check: (evt: WatcherEvent) => boolean, ({ deferred }) => Effect.gen(function* () { yield* trigger - return yield* Deferred.await(deferred).pipe(Effect.timeout("5 seconds")) + return yield* Deferred.await(deferred).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new Error("timed out waiting for file watcher update")), + }), + ) }), ({ cleanup }) => Effect.sync(cleanup), ) @@ -104,7 +107,11 @@ function noUpdate( ({ deferred }) => Effect.gen(function* () { yield* trigger - expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none()) + const result = yield* Deferred.await(deferred).pipe( + Effect.map((evt) => Option.some(evt)), + Effect.timeoutOrElse({ duration: `${ms} millis`, orElse: () => Effect.succeed(Option.none()) }), + ) + expect(result).toEqual(Option.none()) }), ({ cleanup }) => Effect.sync(cleanup), ) @@ -115,29 +122,25 @@ function ready(directory: string) { const head = path.join(directory, ".git", "HEAD") return Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + yield* nextUpdate( directory, (evt) => evt.file === file && evt.event === "add", - Effect.promise(() => fs.writeFile(file, "ready")), - ).pipe(Effect.ensuring(Effect.promise(() => fs.rm(file, { force: true }).catch(() => undefined))), Effect.asVoid) + fs.writeFileString(file, "ready"), + ).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid) - const git = yield* Effect.promise(() => - fs - .stat(head) - .then(() => true) - .catch(() => false), - ) - if (!git) return + if (!(yield* fs.existsSafe(head))) return const branch = `watch-${Math.random().toString(36).slice(2)}` - const hash = yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(directory).quiet().text()) + const hash = (yield* git.run(["rev-parse", "HEAD"], { cwd: directory })).text() yield* nextUpdate( directory, (evt) => evt.file === head && evt.event !== "unlink", - Effect.promise(async () => { - await fs.writeFile(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n") - await fs.writeFile(head, `ref: refs/heads/${branch}\n`) - }), + fs + .writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n") + .pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`))), ).pipe(Effect.asVoid) }) } @@ -147,104 +150,114 @@ function ready(directory: string) { // --------------------------------------------------------------------------- describeWatcher("FileWatcher", () => { - afterEach(async () => { - await disposeAllInstances() - }) + it.instance( + "publishes root create, update, and delete events", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* AppFileSystem.Service + const file = path.join(test.directory, "watch.txt") + const cases = [ + { event: "add" as const, trigger: fs.writeFileString(file, "a") }, + { event: "change" as const, trigger: fs.writeFileString(file, "b") }, + { event: "unlink" as const, trigger: fs.remove(file) }, + ] - test("publishes root create, update, and delete events", async () => { - await using tmp = await tmpdir({ git: true }) - const file = path.join(tmp.path, "watch.txt") - const dir = tmp.path - const cases = [ - { event: "add" as const, trigger: Effect.promise(() => fs.writeFile(file, "a")) }, - { event: "change" as const, trigger: Effect.promise(() => fs.writeFile(file, "b")) }, - { event: "unlink" as const, trigger: Effect.promise(() => fs.unlink(file)) }, - ] - - await withWatcher( - dir, - Effect.forEach(cases, ({ event, trigger }) => - nextUpdate(dir, (evt) => evt.file === file && evt.event === event, trigger).pipe( - Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))), - ), - ), - ) - }) - - test("watches non-git roots", async () => { - await using tmp = await tmpdir() - const file = path.join(tmp.path, "plain.txt") - const dir = tmp.path - - await withWatcher( - dir, - nextUpdate( - dir, - (e) => e.file === file && e.event === "add", - Effect.promise(() => fs.writeFile(file, "plain")), - ).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))), - ) - }) - - test("cleanup stops publishing events", async () => { - await using tmp = await tmpdir({ git: true }) - const file = path.join(tmp.path, "after-dispose.txt") - - // Start and immediately stop the watcher (withWatcher disposes on exit) - await withWatcher(tmp.path, Effect.void) - - // Now write a file — no watcher should be listening - await WithInstance.provide({ - directory: tmp.path, - fn: () => - Effect.runPromise( - noUpdate( - tmp.path, - (e) => e.file === file, - Effect.promise(() => fs.writeFile(file, "gone")), + yield* withWatcher( + test.directory, + Effect.forEach(cases, ({ event, trigger }) => + nextUpdate(test.directory, (evt) => evt.file === file && evt.event === event, trigger).pipe( + Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))), + ), ), + ) + }), + { git: true }, + ) + + it.instance("watches non-git roots", () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* AppFileSystem.Service + const file = path.join(test.directory, "plain.txt") + + yield* withWatcher( + test.directory, + nextUpdate(test.directory, (e) => e.file === file && e.event === "add", fs.writeFileString(file, "plain")).pipe( + Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" }))), ), - }) - }) + ) + }), + ) - test("ignores .git/index changes", async () => { - await using tmp = await tmpdir({ git: true }) - const gitIndex = path.join(tmp.path, ".git", "index") - const edit = path.join(tmp.path, "tracked.txt") + it.instance( + "cleanup stops publishing events", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* AppFileSystem.Service + const file = path.join(test.directory, "after-dispose.txt") - await withWatcher( - tmp.path, - noUpdate( - tmp.path, - (e) => e.file === gitIndex, - Effect.promise(async () => { - await fs.writeFile(edit, "a") - await $`git add .`.cwd(tmp.path).quiet().nothrow() - }), - ), - ) - }) + // Start and immediately stop the watcher (withWatcher disposes on exit). + yield* withWatcher(test.directory, Effect.void) - test("publishes .git/HEAD events", async () => { - await using tmp = await tmpdir({ git: true }) - const head = path.join(tmp.path, ".git", "HEAD") - const branch = `watch-${Math.random().toString(36).slice(2)}` - await $`git branch ${branch}`.cwd(tmp.path).quiet() + // Now write a file - no watcher should be listening. + yield* noUpdate(test.directory, (e) => e.file === file, fs.writeFileString(file, "gone")).pipe( + provideInstance(test.directory), + ) + }), + { git: true }, + ) - await withWatcher( - tmp.path, - nextUpdate( - tmp.path, - (evt) => evt.file === head && evt.event !== "unlink", - Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)), - ).pipe( - Effect.tap((evt) => - Effect.sync(() => { - expect(evt.file).toBe(head) - expect(["add", "change"]).toContain(evt.event) - }), - ), - ), - ) - }) + it.instance( + "ignores .git/index changes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const gitIndex = path.join(test.directory, ".git", "index") + const edit = path.join(test.directory, "tracked.txt") + + yield* withWatcher( + test.directory, + noUpdate( + test.directory, + (e) => e.file === gitIndex, + fs.writeFileString(edit, "a").pipe(Effect.andThen(git.run(["add", "."], { cwd: test.directory }))), + ), + ) + }), + { git: true }, + ) + + it.instance( + "publishes .git/HEAD events", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const head = path.join(test.directory, ".git", "HEAD") + const branch = `watch-${Math.random().toString(36).slice(2)}` + yield* git.run(["branch", branch], { cwd: test.directory }) + + yield* withWatcher( + test.directory, + nextUpdate( + test.directory, + (evt) => evt.file === head && evt.event !== "unlink", + fs.writeFileString(head, `ref: refs/heads/${branch}\n`), + ).pipe( + Effect.tap((evt) => + Effect.sync(() => { + expect(evt.file).toBe(head) + expect(["add", "change"]).toContain(evt.event) + }), + ), + ), + ) + }), + { git: true }, + ) }) diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index 5b26b05655..9ca38e968d 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Installation } from "../../src/installation" import { InstallationChannel } from "@opencode-ai/core/installation/version" +import { testEffect } from "../lib/effect" const encoder = new TextEncoder() @@ -51,86 +52,84 @@ function testLayer( describe("installation", () => { describe("latest", () => { - test("reads release version from GitHub releases", async () => { - const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" })) + testEffect(testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))).effect( + "reads release version from GitHub releases", + () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("unknown")) + expect(result).toBe("1.2.3") + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("1.2.3") - }) + testEffect(testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))).effect( + "strips v prefix from GitHub release tag", + () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("curl")) + expect(result).toBe("4.0.0-beta.1") + }), + ) - test("strips v prefix from GitHub release tag", async () => { - const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" })) - - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("4.0.0-beta.1") - }) - - test("reads npm versions via registry", async () => { - const calls: string[] = [] - const layer = testLayer((request) => { - calls.push(request.url) + const npmCalls: string[] = [] + testEffect( + testLayer((request) => { + npmCalls.push(request.url) return jsonResponse({ version: "1.5.0" }) - }) + }), + ).effect("reads npm versions via registry", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("npm")) + expect(result).toBe("1.5.0") + expect(npmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("1.5.0") - expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) - }) - - test("reads bun versions via registry", async () => { - const calls: string[] = [] - const layer = testLayer((request) => { - calls.push(request.url) + const bunCalls: string[] = [] + testEffect( + testLayer((request) => { + bunCalls.push(request.url) return jsonResponse({ version: "1.6.0" }) - }) + }), + ).effect("reads bun versions via registry", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("bun")) + expect(result).toBe("1.6.0") + expect(bunCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("1.6.0") - expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) - }) - - test("reads pnpm versions via registry", async () => { - const calls: string[] = [] - const layer = testLayer((request) => { - calls.push(request.url) + const pnpmCalls: string[] = [] + testEffect( + testLayer((request) => { + pnpmCalls.push(request.url) return jsonResponse({ version: "1.7.0" }) - }) + }), + ).effect("reads pnpm versions via registry", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("pnpm")) + expect(result).toBe("1.7.0") + expect(pnpmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("1.7.0") - expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`) - }) + testEffect(testLayer(() => jsonResponse({ version: "2.3.4" }))).effect("reads scoop manifest versions", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("scoop")) + expect(result).toBe("2.3.4") + }), + ) - test("reads scoop manifest versions", async () => { - const layer = testLayer(() => jsonResponse({ version: "2.3.4" })) + testEffect(testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))).effect( + "reads chocolatey feed versions", + () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("choco")) + expect(result).toBe("3.4.5") + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("2.3.4") - }) - - test("reads chocolatey feed versions", async () => { - const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } })) - - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("3.4.5") - }) - - test("reads brew formulae API versions", async () => { - const layer = testLayer( + testEffect( + testLayer( () => jsonResponse({ versions: { stable: "2.0.0" } }), (cmd, args) => { // getBrewFormula: return core formula (no tap) @@ -138,31 +137,31 @@ describe("installation", () => { if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode" return "" }, - ) + ), + ).effect("reads brew formulae API versions", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("brew")) + expect(result).toBe("2.0.0") + }), + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("2.0.0") + const brewInfoJson = JSON.stringify({ + formulae: [{ versions: { stable: "2.1.0" } }], }) - - test("reads brew tap info JSON via CLI", async () => { - const brewInfoJson = JSON.stringify({ - formulae: [{ versions: { stable: "2.1.0" } }], - }) - const layer = testLayer( + testEffect( + testLayer( () => jsonResponse({}), // HTTP not used for tap formula (cmd, args) => { if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode" if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson return "" }, - ) - - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("2.1.0") - }) + ), + ).effect("reads brew tap info JSON via CLI", () => + Effect.gen(function* () { + const result = yield* Installation.Service.use((svc) => svc.latest("brew")) + expect(result).toBe("2.1.0") + }), + ) }) }) diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 20cb90a18e..f6222de43d 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,15 +1,19 @@ -import { test, expect, mock, beforeEach } from "bun:test" +import { expect, mock, beforeEach } from "bun:test" import { EventEmitter } from "events" -import { Effect } from "effect" +import { Deferred, Effect, Layer, Option } from "effect" +import type { Duration } from "effect" +import { testEffect } from "../lib/effect" import type { MCP as MCPNS } from "../../src/mcp/index" // Track open() calls and control failure behavior let openShouldFail = false let openCalledWith: string | undefined +let openDeferred: Deferred.Deferred | undefined void mock.module("open", () => ({ default: async (url: string) => { openCalledWith = url + if (openDeferred) Effect.runSync(Deferred.succeed(openDeferred, url).pipe(Effect.ignore)) // Return a mock subprocess that emits an error if openShouldFail is true const subprocess = new EventEmitter() @@ -97,173 +101,135 @@ void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({ beforeEach(() => { openShouldFail = false openCalledWith = undefined + openDeferred = undefined transportCalls.length = 0 }) // Import modules after mocking const { MCP } = await import("../../src/mcp/index") -const { AppRuntime } = await import("../../src/effect/app-runtime") const { Bus } = await import("../../src/bus") +const { Config } = await import("../../src/config/config") +const { McpAuth } = await import("../../src/mcp/auth") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") -const { Instance } = await import("../../src/project/instance") -const { WithInstance } = await import("../../src/project/with-instance") -const { tmpdir } = await import("../fixture/fixture") +const { AppFileSystem } = await import("@opencode-ai/core/filesystem") +const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") +const mcpTest = testEffect( + MCP.layer.pipe( + Layer.provide(McpAuth.defaultLayer), + Layer.provideMerge(Bus.layer), + Layer.provide(Config.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + ), +) const service = MCP.Service as unknown as Effect.Effect -test("BrowserOpenFailed event is published when open() throws", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - `${dir}/opencode.json`, - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "test-oauth-server": { - type: "remote", - url: "https://example.com/mcp", - }, - }, - }), - ) +const config = (name: string) => ({ + mcp: { + [name]: { + type: "remote" as const, + url: "https://example.com/mcp", }, + }, +}) + +const withCallbackStop = Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore)) + +const awaitWithTimeout = ( + self: Effect.Effect, + message: string, + duration: Duration.Input = "5 seconds", +) => + self.pipe( + Effect.timeoutOrElse({ + duration, + orElse: () => Effect.fail(new Error(message)), + }), + ) + +const trackBrowserOpen = Effect.gen(function* () { + const opened = yield* Deferred.make() + openDeferred = opened + yield* Effect.addFinalizer(() => Effect.sync(() => (openDeferred = undefined))) + return opened +}) + +const trackBrowserOpenFailed = Effect.gen(function* () { + const bus = yield* Bus.Service + const event = yield* Deferred.make<{ mcpName: string; url: string }>() + const unsubscribe = yield* bus.subscribeCallback(MCP.BrowserOpenFailed, (evt) => { + Effect.runSync(Deferred.succeed(event, evt.properties).pipe(Effect.ignore)) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) + return event +}) + +const authenticateScoped = (name: string) => + Effect.gen(function* () { + const mcp = yield* service + yield* mcp.authenticate(name).pipe( + Effect.ignore, + Effect.catchCause(() => Effect.void), + Effect.forkScoped, + ) }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { +mcpTest.instance( + "BrowserOpenFailed event is published when open() throws", + () => + Effect.gen(function* () { + yield* withCallbackStop openShouldFail = true - const events: Array<{ mcpName: string; url: string }> = [] - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - events.push(evt.properties) - }) + const event = yield* trackBrowserOpenFailed + yield* authenticateScoped("test-oauth-server") - // Run authenticate with a timeout to avoid waiting forever for the callback - // Attach a handler immediately so callback shutdown rejections - // don't show up as unhandled between tests. - const authPromise = AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* service - return yield* mcp.authenticate("test-oauth-server") - }), - ).catch(() => undefined) + const failure = yield* awaitWithTimeout(Deferred.await(event), "Timed out waiting for BrowserOpenFailed event") - // Config.get() can be slow in tests, so give it plenty of time. - await new Promise((resolve) => setTimeout(resolve, 2_000)) + expect(failure.mcpName).toBe("test-oauth-server") + expect(failure.url).toContain("https://") + }), + { config: config("test-oauth-server") }, +) - // Stop the callback server and cancel any pending auth - await McpOAuthCallback.stop() - - await authPromise - - unsubscribe() - - // Verify the BrowserOpenFailed event was published - expect(events.length).toBe(1) - expect(events[0].mcpName).toBe("test-oauth-server") - expect(events[0].url).toContain("https://") - }, - }) -}) - -test("BrowserOpenFailed event is NOT published when open() succeeds", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - `${dir}/opencode.json`, - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "test-oauth-server-2": { - type: "remote", - url: "https://example.com/mcp", - }, - }, - }), - ) - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { +mcpTest.instance( + "BrowserOpenFailed event is NOT published when open() succeeds", + () => + Effect.gen(function* () { + yield* withCallbackStop openShouldFail = false - const events: Array<{ mcpName: string; url: string }> = [] - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - events.push(evt.properties) - }) + const opened = yield* trackBrowserOpen + const event = yield* trackBrowserOpenFailed + yield* authenticateScoped("test-oauth-server-2") - // Run authenticate with a timeout to avoid waiting forever for the callback - const authPromise = AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* service - return yield* mcp.authenticate("test-oauth-server-2") - }), - ).catch(() => undefined) + yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()") + const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis")) - // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window. - await new Promise((resolve) => setTimeout(resolve, 2_000)) - - // Stop the callback server and cancel any pending auth - await McpOAuthCallback.stop() - - await authPromise - - unsubscribe() - - // Verify NO BrowserOpenFailed event was published - expect(events.length).toBe(0) - // Verify open() was still called + expect(failure).toEqual(Option.none()) expect(openCalledWith).toBeDefined() - }, - }) -}) + }), + { config: config("test-oauth-server-2") }, +) -test("open() is called with the authorization URL", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - `${dir}/opencode.json`, - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "test-oauth-server-3": { - type: "remote", - url: "https://example.com/mcp", - }, - }, - }), - ) - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { +mcpTest.instance( + "open() is called with the authorization URL", + () => + Effect.gen(function* () { + yield* withCallbackStop openShouldFail = false openCalledWith = undefined - // Run authenticate with a timeout to avoid waiting forever for the callback - const authPromise = AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* service - return yield* mcp.authenticate("test-oauth-server-3") - }), - ).catch(() => undefined) + const opened = yield* trackBrowserOpen + const event = yield* trackBrowserOpenFailed + yield* authenticateScoped("test-oauth-server-3") - // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window. - await new Promise((resolve) => setTimeout(resolve, 2_000)) + const url = yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()") + const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis")) - // Stop the callback server and cancel any pending auth - await McpOAuthCallback.stop() - - await authPromise - - // Verify open was called with a URL - expect(openCalledWith).toBeDefined() - expect(typeof openCalledWith).toBe("string") - expect(openCalledWith!).toContain("https://") - }, - }) -}) + expect(failure).toEqual(Option.none()) + expect(typeof url).toBe("string") + expect(url).toContain("https://") + }), + { config: config("test-oauth-server-3") }, +) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 64b93bb8bc..f2084b095d 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,16 +1,12 @@ -import { afterEach, describe, test, expect } from "bun:test" +import { describe, test, expect } from "bun:test" +import { Effect } from "effect" import { Permission } from "../src/permission" import { Config } from "@/config/config" -import { Instance } from "../src/project/instance" -import { WithInstance } from "../src/project/with-instance" -import { disposeAllInstances, tmpdir } from "./fixture/fixture" -import { AppRuntime } from "../src/effect/app-runtime" +import { testEffect } from "./lib/effect" -const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) +const it = testEffect(Config.defaultLayer) -afterEach(async () => { - await disposeAllInstances() -}) +const load = Config.Service.use((svc) => svc.get()) describe("Permission.evaluate for permission.task", () => { const createRuleset = (rules: Record): Permission.Ruleset => @@ -147,8 +143,18 @@ describe("Permission.disabled for task tool", () => { // Integration tests that load permissions from real config files describe("permission.task with real config files", () => { - test("loads task permissions from opencode.json config", async () => { - await using tmp = await tmpdir({ + it.instance( + "loads task permissions from opencode.json config", + () => + Effect.gen(function* () { + const config = yield* load + const ruleset = Permission.fromConfig(config.permission ?? {}) + // general and orchestrator-fast should be allowed, code-reviewer denied + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + }), + { git: true, config: { permission: { @@ -158,22 +164,21 @@ describe("permission.task with real config files", () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) - // general and orchestrator-fast should be allowed, code-reviewer denied - expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") - expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") - expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") - }, - }) - }) + }, + ) - test("loads task permissions with wildcard patterns from config", async () => { - await using tmp = await tmpdir({ + it.instance( + "loads task permissions with wildcard patterns from config", + () => + Effect.gen(function* () { + const config = yield* load + const ruleset = Permission.fromConfig(config.permission ?? {}) + // general and code-reviewer should be ask, orchestrator-* denied + expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") + expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") + }), + { git: true, config: { permission: { @@ -183,22 +188,21 @@ describe("permission.task with real config files", () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) - // general and code-reviewer should be ask, orchestrator-* denied - expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") - expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") - expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") - }, - }) - }) + }, + ) - test("evaluate respects task permission from config", async () => { - await using tmp = await tmpdir({ + it.instance( + "evaluate respects task permission from config", + () => + Effect.gen(function* () { + const config = yield* load + const ruleset = Permission.fromConfig(config.permission ?? {}) + expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") + expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + // Unspecified agents default to "ask" + expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask") + }), + { git: true, config: { permission: { @@ -208,38 +212,14 @@ describe("permission.task with real config files", () => { }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - const ruleset = Permission.fromConfig(config.permission ?? {}) - expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") - expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") - // Unspecified agents default to "ask" - expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask") - }, - }) - }) + }, + ) - test("mixed permission config with task and other tools", async () => { - await using tmp = await tmpdir({ - git: true, - config: { - permission: { - bash: "allow", - edit: "ask", - task: { - "*": "deny", - general: "allow", - }, - }, - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() + it.instance( + "mixed permission config with task and other tools", + () => + Effect.gen(function* () { + const config = yield* load const ruleset = Permission.fromConfig(config.permission ?? {}) // Verify task permissions @@ -257,27 +237,27 @@ describe("permission.task with real config files", () => { // task is NOT disabled because disabled() uses findLast, and the last rule // matching "task" permission is {pattern: "general", action: "allow"}, not pattern: "*" expect(disabled.has("task")).toBe(false) - }, - }) - }) - - test("task tool disabled when global deny comes last in config", async () => { - await using tmp = await tmpdir({ + }), + { git: true, config: { permission: { + bash: "allow", + edit: "ask", task: { - general: "allow", - "code-reviewer": "allow", "*": "deny", + general: "allow", }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() + }, + ) + + it.instance( + "task tool disabled when global deny comes last in config", + () => + Effect.gen(function* () { + const config = yield* load const ruleset = Permission.fromConfig(config.permission ?? {}) // Last matching rule wins - "*" deny is last, so all agents are denied @@ -289,26 +269,26 @@ describe("permission.task with real config files", () => { // and sees pattern: "*" with action: "deny", so task is disabled const disabled = Permission.disabled(["task"], ruleset) expect(disabled.has("task")).toBe(true) - }, - }) - }) - - test("task tool NOT disabled when specific allow comes last in config", async () => { - await using tmp = await tmpdir({ + }), + { git: true, config: { permission: { task: { - "*": "deny", general: "allow", + "code-reviewer": "allow", + "*": "deny", }, }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() + }, + ) + + it.instance( + "task tool NOT disabled when specific allow comes last in config", + () => + Effect.gen(function* () { + const config = yield* load const ruleset = Permission.fromConfig(config.permission ?? {}) // Evaluate uses findLast - "general" allow comes after "*" deny @@ -321,7 +301,17 @@ describe("permission.task with real config files", () => { // So the task tool is NOT disabled (even though most subagents are denied) const disabled = Permission.disabled(["task"], ruleset) expect(disabled.has("task")).toBe(false) + }), + { + git: true, + config: { + permission: { + task: { + "*": "deny", + general: "allow", + }, + }, }, - }) - }) + }, + ) }) diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index bef8604324..0cf603fa3b 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, describe, expect } from "bun:test" import { Effect, Layer, Option } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" @@ -17,6 +18,11 @@ import { Plugin } from "../../src/plugin/index" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { Vcs } from "../../src/project/vcs" +import { Session } from "../../src/session/session" +import { SessionPrompt } from "../../src/session/prompt" +import { SyncEvent } from "../../src/sync" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { NpmTest } from "../fake/npm" @@ -42,8 +48,16 @@ const pluginLayer = Plugin.layer.pipe( Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ) const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const workspaceLayer = Workspace.defaultLayer.pipe( +const workspaceLayer = Workspace.layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(SessionPrompt.defaultLayer), + Layer.provide(Project.defaultLayer), + Layer.provide(Vcs.defaultLayer), + Layer.provide(FetchHttpClient.layer), Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })), ) const it = testEffect(Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer)) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 6447c2fe93..24b804819e 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -35,6 +35,7 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true" +process.env["OPENCODE_EXPERIMENTAL_WORKSPACES"] = "true" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 0d6bec2dfe..293e2a3449 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -593,6 +593,12 @@ const scenarios: Scenario[] = [ check(auth.test === undefined, "auth remove should delete provider from isolated auth file") }), ), + http.protected.get("/api/model", "v2.model.list").json(200, array), + http.protected.get("/api/provider", "v2.provider.list").json(200, array), + http.protected + .get("/api/provider/{providerID}", "v2.provider.get") + .at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() })) + .json(404, object, "status"), http.protected .get("/api/session", "v2.session.list") .at((ctx) => ({ path: "/api/session?roots=true", headers: ctx.headers() })) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 8b686739da..3e5527761e 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -20,7 +20,8 @@ import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionMessage } from "../../src/v2/session-message" -import { Modelv2 } from "../../src/v2/model" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" @@ -110,9 +111,9 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType) => type: "assistant", agent: "build", model: { - id: Modelv2.ID.make("model"), - providerID: Modelv2.ProviderID.make("provider"), - variant: Modelv2.VariantID.make("default"), + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), }, time: { created: DateTime.makeUnsafe(1) }, content: [], diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index e5dc725463..1bd3c66474 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,19 +1,28 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" -import { Flag } from "@opencode-ai/core/flag/flag" import { mkdir } from "fs/promises" import path from "path" import { Database } from "@/storage/db" import { SessionTable } from "@/session/session.sql" import { eq } from "drizzle-orm" import { testEffect } from "../lib/effect" +import { Bus } from "@/bus" +import { Storage } from "@/storage/storage" +import { SyncEvent } from "@/sync" +import { RuntimeFlags } from "@/effect/runtime-flags" void Log.init({ print: false }) -const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES -const it = testEffect(SessionNs.defaultLayer) +const it = testEffect( + SessionNs.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), + ), +) const withSession = (input?: Parameters[0]) => Effect.acquireRelease( @@ -22,7 +31,6 @@ const withSession = (input?: Parameters[0]) => ) afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() }) @@ -31,7 +39,6 @@ describe("session.list", () => { "does not filter by directory when directory is omitted", () => Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const test = yield* TestInstance yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) @@ -60,7 +67,6 @@ describe("session.list", () => { "filters by directory when directory is provided", () => Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const test = yield* TestInstance yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true })) yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true })) @@ -91,7 +97,6 @@ describe("session.list", () => { "filters by path and ignores directory when path is provided", () => Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const test = yield* TestInstance yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode", "src", "deep"), { recursive: true }), @@ -129,7 +134,6 @@ describe("session.list", () => { "falls back to directory when filtering legacy sessions without path", () => Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false const test = yield* TestInstance yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true }), diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index ada55d1349..63920d2181 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -3,16 +3,29 @@ import { Deferred, Effect, Exit, Layer } from "effect" import { Session as SessionNs } from "@/session/session" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import * as Log from "@opencode-ai/core/util/log" -import { Flag } from "@opencode-ai/core/flag/flag" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Bus } from "@/bus" +import { Storage } from "@/storage/storage" +import { SyncEvent } from "@/sync" +import { RuntimeFlags } from "@/effect/runtime-flags" void Log.init({ print: false }) -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + Layer.mergeAll( + SessionNs.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), + ), + CrossSpawnSpawner.defaultLayer, + ), +) const awaitDeferred = (deferred: Deferred.Deferred, message: string) => Effect.race( @@ -56,8 +69,6 @@ describe("session.created event", () => { it.instance("session.created event should be emitted before session.updated", () => Effect.gen(function* () { - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return - const session = yield* SessionNs.Service const events: string[] = [] const received = yield* Deferred.make() diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 10f593a571..c4e5b86062 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, beforeEach, afterEach, afterAll } from "bun:test" +import { describe, expect, beforeEach, afterAll } from "bun:test" import { provideTmpdirInstance } from "../fixture/fixture" import { Effect, Layer, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -7,21 +7,19 @@ import { SyncEvent } from "../../src/sync" import { Database, eq } from "@/storage/db" import { EventSequenceTable, EventTable } from "../../src/sync/event.sql" import { MessageID } from "../../src/session/schema" -import { Flag } from "@opencode-ai/core/flag/flag" import { initProjectors } from "../../src/server/projectors" import { testEffect } from "../lib/effect" +import { RuntimeFlags } from "@/effect/runtime-flags" -const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES -const it = testEffect(Layer.mergeAll(SyncEvent.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + Layer.mergeAll( + SyncEvent.layer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true }))), + CrossSpawnSpawner.defaultLayer, + ), +) beforeEach(() => { Database.close() - - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true -}) - -afterEach(() => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original }) describe("SyncEvent", () => { diff --git a/packages/opencode/test/util/lock.test.ts b/packages/opencode/test/util/lock.test.ts deleted file mode 100644 index 79fbb58316..0000000000 --- a/packages/opencode/test/util/lock.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Lock } from "@/util/lock" - -function tick() { - return new Promise((r) => queueMicrotask(r)) -} - -async function flush(n = 5) { - for (let i = 0; i < n; i++) await tick() -} - -describe("util.lock", () => { - test("writer exclusivity: blocks reads and other writes while held", async () => { - const key = "lock:" + Math.random().toString(36).slice(2) - - const state = { - writer2: false, - reader: false, - writers: 0, - } - - // Acquire writer1 - using writer1 = await Lock.write(key) - state.writers++ - expect(state.writers).toBe(1) - - // Start writer2 candidate (should block) - const writer2Task = (async () => { - const w = await Lock.write(key) - state.writers++ - expect(state.writers).toBe(1) - state.writer2 = true - // Hold for a tick so reader cannot slip in - await tick() - return w - })() - - // Start reader candidate (should block) - const readerTask = (async () => { - const r = await Lock.read(key) - state.reader = true - return r - })() - - // Flush microtasks and assert neither acquired - await flush() - expect(state.writer2).toBe(false) - expect(state.reader).toBe(false) - - // Release writer1 - writer1[Symbol.dispose]() - state.writers-- - - // writer2 should acquire next - const writer2 = await writer2Task - expect(state.writer2).toBe(true) - - // Reader still blocked while writer2 held - await flush() - expect(state.reader).toBe(false) - - // Release writer2 - writer2[Symbol.dispose]() - state.writers-- - - // Reader should now acquire - const reader = await readerTask - expect(state.reader).toBe(true) - - reader[Symbol.dispose]() - }) -}) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 44ac031eda..180483937c 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -2,7 +2,8 @@ import { expect, test } from "bun:test" import * as DateTime from "effect/DateTime" import { SessionID } from "../../src/session/schema" import { EventV2 } from "../../src/v2/event" -import { Modelv2 } from "../../src/v2/model" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionEvent } from "../../src/v2/session-event" import { SessionMessageUpdater } from "../../src/v2/session-message-updater" @@ -18,9 +19,9 @@ test("step snapshots carry over to assistant messages", () => { timestamp: DateTime.makeUnsafe(1), agent: "build", model: { - id: Modelv2.ID.make("model"), - providerID: Modelv2.ProviderID.make("provider"), - variant: Modelv2.VariantID.make("default"), + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), }, snapshot: "before", }, @@ -62,9 +63,9 @@ test("text ended populates assistant text content", () => { timestamp: DateTime.makeUnsafe(1), agent: "build", model: { - id: Modelv2.ID.make("model"), - providerID: Modelv2.ProviderID.make("provider"), - variant: Modelv2.VariantID.make("default"), + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), }, }, } satisfies SessionEvent.Event) @@ -106,9 +107,9 @@ test("tool completion stores completed timestamp", () => { timestamp: DateTime.makeUnsafe(1), agent: "build", model: { - id: Modelv2.ID.make("model"), - providerID: Modelv2.ProviderID.make("provider"), - variant: Modelv2.VariantID.make("default"), + id: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("provider"), + variant: ModelV2.VariantID.make("default"), }, }, } satisfies SessionEvent.Event) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 1b36c1513a..37b9385743 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -197,6 +197,10 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + V2ModelListResponses, + V2ProviderGetErrors, + V2ProviderGetResponses, + V2ProviderListResponses, V2SessionCompactResponses, V2SessionContextResponses, V2SessionListErrors, @@ -4375,11 +4379,68 @@ export class Session3 extends HeyApiClient { } } +export class Model extends HeyApiClient { + /** + * List v2 models + * + * Retrieve available v2 models ordered by release date. + */ + public list(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/model", + ...options, + }) + } +} + +export class Provider2 extends HeyApiClient { + /** + * List v2 providers + * + * Retrieve active v2 AI providers so clients can show provider availability and configuration. + */ + public list(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/provider", + ...options, + }) + } + + /** + * Get v2 provider + * + * Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings. + */ + public get( + parameters: { + providerID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/provider/{providerID}", + ...options, + ...params, + }) + } +} + export class V2 extends HeyApiClient { private _session?: Session3 get session(): Session3 { return (this._session ??= new Session3({ client: this.client })) } + + private _model?: Model + get model(): Model { + return (this._model ??= new Model({ client: this.client })) + } + + private _provider?: Provider2 + get provider(): Provider2 { + return (this._provider ??= new Provider2({ client: this.client })) + } } export class Control extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 35fb6bf812..014a5fbabe 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3378,6 +3378,171 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction +export type ModelV2Info = { + id: string + apiID: string + providerID: string + family?: string + name: string + endpoint: + | { + type: "unknown" + } + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + | { + type: "aisdk" + package: string + url?: string + } + capabilities: { + tools: boolean + input: Array + output: Array + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + variant?: string + } + variants: Array<{ + id: string + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + }> + time: { + released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + cost: Array<{ + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } + }> + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { + context: number + input?: number + output: number + } +} + +export type ProviderV2Info = { + id: string + name: string + enabled: + | false + | { + via: "env" + name: string + } + | { + via: "auth" + service: string + } + | { + via: "custom" + data: { + [key: string]: unknown + } + } + env: Array + endpoint: + | { + type: "unknown" + } + | { + type: "openai/responses" + url: string + websocket?: boolean + } + | { + type: "openai/completions" + url: string + reasoning?: + | { + type: "reasoning_content" + } + | { + type: "reasoning_details" + } + } + | { + type: "anthropic/messages" + url: string + } + | { + type: "aisdk" + package: string + url?: string + } + options: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + aisdk: { + provider: { + [key: string]: unknown + } + request: { + [key: string]: unknown + } + } + } +} + export type EventTuiToastShow1 = { id: string type: "tui.toast.show" @@ -6505,6 +6670,65 @@ export type V2SessionMessagesResponses = { export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] +export type V2ModelListData = { + body?: never + path?: never + query?: never + url: "/api/model" +} + +export type V2ModelListResponses = { + /** + * Success + */ + 200: Array +} + +export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses] + +export type V2ProviderListData = { + body?: never + path?: never + query?: never + url: "/api/provider" +} + +export type V2ProviderListResponses = { + /** + * Success + */ + 200: Array +} + +export type V2ProviderListResponse = V2ProviderListResponses[keyof V2ProviderListResponses] + +export type V2ProviderGetData = { + body?: never + path: { + providerID: string + } + query?: never + url: "/api/provider/{providerID}" +} + +export type V2ProviderGetErrors = { + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type V2ProviderGetError = V2ProviderGetErrors[keyof V2ProviderGetErrors] + +export type V2ProviderGetResponses = { + /** + * ProviderV2.Info + */ + 200: ProviderV2Info +} + +export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses] + export type TuiAppendPromptData = { body?: { text: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 97890a5dc5..114db9cd74 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7606,6 +7606,112 @@ ] } }, + "/api/model": { + "get": { + "tags": ["v2 models"], + "operationId": "v2.model.list", + "parameters": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelV2Info" + } + } + } + } + } + }, + "description": "Retrieve available v2 models ordered by release date.", + "summary": "List v2 models", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.model.list({\n ...\n})" + } + ] + } + }, + "/api/provider": { + "get": { + "tags": ["v2 providers"], + "operationId": "v2.provider.list", + "parameters": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderV2Info" + } + } + } + } + } + }, + "description": "Retrieve active v2 AI providers so clients can show provider availability and configuration.", + "summary": "List v2 providers", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.provider.list({\n ...\n})" + } + ] + } + }, + "/api/provider/{providerID}": { + "get": { + "tags": ["v2 providers"], + "operationId": "v2.provider.get", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "ProviderV2.Info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderV2Info" + } + } + } + }, + "404": { + "description": "NotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings.", + "summary": "Get v2 provider", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.provider.get({\n ...\n})" + } + ] + } + }, "/tui/append-prompt": { "post": { "tags": ["tui"], @@ -18991,6 +19097,531 @@ } ] }, + "ModelV2Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "apiID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "family": { + "type": "string" + }, + "name": { + "type": "string" + }, + "endpoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/responses"] + }, + "url": { + "type": "string" + }, + "websocket": { + "type": "boolean" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/completions"] + }, + "url": { + "type": "string" + }, + "reasoning": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_content"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_details"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["anthropic/messages"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "package"], + "additionalProperties": false + } + ] + }, + "capabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["tools", "input", "output"], + "additionalProperties": false + }, + "options": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + }, + "variant": { + "type": "string" + } + }, + "required": ["headers", "body", "aisdk"], + "additionalProperties": false + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + } + }, + "required": ["id", "headers", "body", "aisdk"], + "additionalProperties": false + } + }, + "time": { + "type": "object", + "properties": { + "released": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["released"], + "additionalProperties": false + }, + "cost": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + } + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated", "active"] + }, + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "integer" + }, + "input": { + "type": "integer" + }, + "output": { + "type": "integer" + } + }, + "required": ["context", "output"], + "additionalProperties": false + } + }, + "required": [ + "id", + "apiID", + "providerID", + "name", + "endpoint", + "capabilities", + "options", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], + "additionalProperties": false + }, + "ProviderV2Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean", + "enum": [false] + }, + { + "type": "object", + "properties": { + "via": { + "type": "string", + "enum": ["env"] + }, + "name": { + "type": "string" + } + }, + "required": ["via", "name"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "via": { + "type": "string", + "enum": ["auth"] + }, + "service": { + "type": "string" + } + }, + "required": ["via", "service"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "via": { + "type": "string", + "enum": ["custom"] + }, + "data": { + "type": "object" + } + }, + "required": ["via", "data"], + "additionalProperties": false + } + ] + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "endpoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/responses"] + }, + "url": { + "type": "string" + }, + "websocket": { + "type": "boolean" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["openai/completions"] + }, + "url": { + "type": "string" + }, + "reasoning": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_content"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning_details"] + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["anthropic/messages"] + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "package"], + "additionalProperties": false + } + ] + }, + "options": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "aisdk": { + "type": "object", + "properties": { + "provider": { + "type": "object" + }, + "request": { + "type": "object" + } + }, + "required": ["provider", "request"], + "additionalProperties": false + } + }, + "required": ["headers", "body", "aisdk"], + "additionalProperties": false + } + }, + "required": ["id", "name", "enabled", "env", "endpoint", "options"], + "additionalProperties": false + }, "EventTuiToastShow1": { "type": "object", "properties": { @@ -19121,6 +19752,14 @@ "name": "v2 messages", "description": "Experimental v2 message routes." }, + { + "name": "v2 models", + "description": "Experimental v2 model routes." + }, + { + "name": "v2 providers", + "description": "Experimental v2 provider routes." + }, { "name": "tui", "description": "Experimental HttpApi TUI routes." diff --git a/specs/v2/api.html b/specs/v2/api.html new file mode 100644 index 0000000000..147d24f58b --- /dev/null +++ b/specs/v2/api.html @@ -0,0 +1,1161 @@ + + + + + + opencode v2 API + + + +
+
+
+
opencode v2
+

API map

+
+
+

+ A single /api route surface for simple clients and multi-directory frontends. The important + design question is not route nesting; it is where runtime context comes from. +

+
+ Server scoped + Request context + Session pinned +
+
+
+ +
+
+ Everything has one canonical route. Some routes are server-scoped; runtime routes use context; session item + routes use the session. +

+ Server-scoped routes manage the whole server: projects, workspace lifecycle, and auth accounts. Runtime + context is for anything resolved from an active directory, including config, provider capabilities, tools, + files, and VCS. +

+
+ +
+ +
+
+

Context Model

+ + API context resolution + + Non-session routes resolve from request context, session item routes resolve from session storage. + + + + + + + + + Non-session route + /api/file, /api/vcs/status + + + Request context + query params or default runtime + + + Runtime context + directory + workspaceID? + + + Session item route + /api/session/:id/prompt + + + Session row + contains pinned context + + + Runtime context + directory + workspaceID? + +
+ +
+

Request-context calls

+

+ These calls operate against a directory, optionally through a workspace. Simple clients omit context and use + the default runtime. +

+
GET /api/fs/tree?path=.&directory=/repo/app&workspace=ws_123
+
+ +
+

Session-pinned calls

+

+ These calls never take request context. The session is already pinned to the directory and workspace it was + created in. +

+
POST /api/session/ses_123/prompt
+
+// server resolves
+sessionID -> { directory, workspaceID? }
+
+
+ +
+
+

Operation Inventory

+

+ The SDK is the source of truth. HTTP routes are mounts for RPC-style operations. + server operations do not use runtime context. + request operations use request/default runtime context from + directory and workspace query parameters. + session operations use pinned session context and should not accept + context input. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperationInputContextHTTP mountPurpose
agent.list{}requestGET /api/agentAvailable agents.
auth.activate{ accountID: AccountID }serverPOST /api/auth/:accountID/activateSet the account as active for its service.
auth.create + { serviceID: ServiceID credential: | { type: "oauth", refresh: string, access: string, expires: + number } | { type: "api", key: string, metadata?: Record<string, string> } description?: + string active?: boolean } + serverPOST /api/authCreate an auth account.
auth.delete{ accountID: AccountID }serverDELETE /api/auth/:accountIDRemove an auth account.
auth.get{ accountID: AccountID }serverGET /api/auth/:accountIDGet one auth account.
auth.list{ serviceID?: ServiceID }serverGET /api/authList saved auth accounts. Response includes active account mapping.
auth.update + { accountID: AccountID description?: string credential?: | { type: "oauth", refresh: string, + access: string, expires: number } | { type: "api", key: string, metadata?: Record<string, + string> } } + serverPATCH /api/auth/:accountIDUpdate account description or credential.
catalog.model.get{ providerID: ProviderID modelID: ModelID }serverGET /api/catalog/model/:providerID/:modelIDGet one catalog model.
catalog.model.list{}serverGET /api/catalog/modelList flattened catalog models.
command.list{}requestGET /api/commandAvailable commands.
config.get{}requestGET /api/configResolved config.
config.update{ config: Config }requestPATCH /api/configUpdate config.
event.subscribe{}requestGET /api/eventServer-sent events for the resolved runtime context.
formatter.status{}requestGET /api/formatterFormatter status.
fs.file{ path: string }requestGET /api/fs/fileRead one file.
fs.grep{ pattern: string include?: string limit?: number }requestPOST /api/fs/grepSearch file contents.
fs.search{ query: string type?: "file" | "directory" limit?: number }requestPOST /api/fs/searchSearch paths by name.
fs.tree{ path: string }requestGET /api/fs/treeBrowse a directory.
lsp.status{}requestGET /api/lspLSP status.
mcp.prompt.list{}requestGET /api/mcp/promptList MCP prompts.
mcp.prompt.render + { server: string name: string arguments?: Record<string, string> } + requestPOST /api/mcp/prompt/renderRender one MCP prompt.
mcp.resource.list{}requestGET /api/mcp/resourceList MCP resources.
mcp.resource.read{ server: string uri: string }requestGET /api/mcp/resource/readRead one MCP resource.
mcp.server.create + { name: string config: | { type: "local", command: string, arguments?: string[], environment?: + Record<string, string> } | { type: "remote", url: string, headers?: Record<string, + string>, oauth?: boolean | object } } + requestPOST /api/mcp/serverAdd an MCP server to runtime config.
mcp.server.list{}requestGET /api/mcp/serverList MCP servers with status and auth state.
mcp.server.oauth.callback{ name: string code: string }requestPOST /api/mcp/server/:name/oauth/callbackComplete MCP OAuth.
mcp.server.oauth.delete{ name: string }requestDELETE /api/mcp/server/:name/oauthRemove MCP OAuth credentials.
mcp.server.oauth.start{ name: string }requestPOST /api/mcp/server/:name/oauthStart MCP OAuth.
permission.list{}requestGET /api/permissionPending permission requests.
permission.reply{ permissionID: PermissionID response: PermissionReply }requestPOST /api/permission/:permissionID/replyReply to a permission request.
project.get{ projectID: ProjectID }serverGET /api/project/:projectIDGet project metadata.
project.list{}serverGET /api/projectList projects known to this server.
project.update + { projectID: ProjectID name?: string icon?: string commands?: Array<{ name: string command: + string }> } + serverPATCH /api/project/:projectIDUpdate project metadata.
provider.list{}requestGET /api/providerProvider inventory for the runtime context.
pty.create{ command?: string cwd?: string shell?: string }requestPOST /api/ptyCreate PTY in the runtime context.
pty.delete{ ptyID: PtyID }requestDELETE /api/pty/:ptyIDDelete PTY.
pty.get{ ptyID: PtyID }requestGET /api/pty/:ptyIDGet PTY info.
pty.list{}requestGET /api/ptyList PTYs for the runtime.
pty.update + { ptyID: PtyID title?: string size?: { columns: number, rows: number } } + requestPATCH /api/pty/:ptyIDUpdate PTY.
question.list{}requestGET /api/questionPending user questions.
question.reject{ questionID: QuestionID }requestPOST /api/question/:questionID/rejectReject a question.
question.reply{ questionID: QuestionID response: QuestionResponse }requestPOST /api/question/:questionID/replyReply to a question.
session.compact{ sessionID: SessionID }sessionPOST /api/session/:sessionID/compactCompact the session conversation.
session.context{ sessionID: SessionID }sessionGET /api/session/:sessionID/contextReturn active context messages after the last compaction.
session.create + { title?: string agent?: string model?: { providerID: ProviderID, modelID: ModelID } permission?: + PermissionRule[] } + requestPOST /api/sessionCreate a session pinned to resolved runtime context.
session.delete{ sessionID: SessionID }sessionDELETE /api/session/:sessionIDDelete a session.
session.diff{ sessionID: SessionID }sessionGET /api/session/:sessionID/diffReturn session diff summary.
session.get{ sessionID: SessionID }sessionGET /api/session/:sessionIDGet one session.
session.list + { limit?: number order?: "asc" | "desc" path?: string roots?: boolean start?: number search?: + string cursor?: string } + requestGET /api/sessionList sessions for the current runtime context by default.
session.message.list + { sessionID: SessionID limit?: number order?: "asc" | "desc" cursor?: string } + sessionGET /api/session/:sessionID/messagePage through session messages.
session.prompt + { sessionID: SessionID prompt: Prompt delivery?: "immediate" | "deferred" } + sessionPOST /api/session/:sessionID/promptCreate a user message and queue the agent loop.
session.todo{ sessionID: SessionID }sessionGET /api/session/:sessionID/todoReturn todos associated with the session.
session.update + { sessionID: SessionID title?: string archived?: number permission?: PermissionRule[] } + sessionPATCH /api/session/:sessionIDUpdate title, archival state, or session metadata.
session.wait{ sessionID: SessionID }sessionPOST /api/session/:sessionID/waitWait until the session is idle.
skill.list{}requestGET /api/skillAvailable skills.
vcs.diff{ format?: "json" | "patch" mode?: "worktree" | "default" }requestGET /api/vcs/diffDiff for the runtime directory.
vcs.get{}requestGET /api/vcsVCS metadata.
vcs.patch{ patch: string }requestPOST /api/vcs/patchApply a patch to the runtime directory.
vcs.status{}requestGET /api/vcs/statusChanged files.
workspace.create + { projectID?: ProjectID name?: string directory?: string type: string metadata?: Record<string, + unknown> } + serverPOST /api/workspaceCreate or register a workspace.
workspace.delete{ workspaceID: WorkspaceID }serverDELETE /api/workspace/:workspaceIDRemove a workspace registration.
workspace.get{ workspaceID: WorkspaceID }serverGET /api/workspace/:workspaceIDGet workspace metadata.
workspace.list{ projectID?: ProjectID }serverGET /api/workspaceList workspaces, optionally filtered by project.
workspace.status{}serverGET /api/workspace/statusConnection/lifecycle status for all workspaces. Needs team discussion.
workspace.sync{}serverPOST /api/workspace/syncSync workspace metadata from adapters. Needs team discussion.
workspace.update + { workspaceID: WorkspaceID name?: string metadata?: Record<string, unknown> archived?: + boolean } + serverPATCH /api/workspace/:workspaceIDUpdate workspace metadata or lifecycle state.
workspace.warp + { workspaceID?: WorkspaceID sessionID: SessionID copyChanges: boolean } + serverPOST /api/workspace/warpMove a session into or out of a workspace. Needs team discussion.
+
+
+ +
+
+

Event Envelope

+

+ Every event uses the same envelope. Resource identity belongs in payload. Runtime identity + belongs in context. +

+
+
type ApiEvent<Payload> = {
+  id: string
+  type: string
+  time: number
+  context: {
+    directory: string
+    workspaceID?: string
+  }
+  payload: Payload
+}
+
{
+  "id": "evt_01",
+  "type": "message.part.delta",
+  "time": 1760000000000,
+  "context": {
+    "directory": "/repo/app",
+    "workspaceID": "ws_123"
+  },
+  "payload": {
+    "sessionID": "ses_123",
+    "messageID": "msg_456",
+    "partID": "part_789",
+    "field": "text",
+    "delta": "hello"
+  }
+}
+
+
+
+ +
+
+

Frontend Sync Store

+

+ A frontend can keep one giant store like the current TUI. Runtime data is partitioned by + contextKey. Durable entities such as sessions and messages are keyed by their own IDs. +

+
type RuntimeContext = {
+  directory: string
+  workspaceID?: string
+}
+
+type ContextKey = string
+type SessionID = string
+type MessageID = string
+
+type SyncStore = {
+  status: "loading" | "partial" | "complete"
+
+  shared: {
+    provider: Provider[]
+    provider_default: Record<string, string>
+    provider_next: ProviderListResponse
+    provider_auth: Record<string, ProviderAuthMethod[]>
+    console_state: ConsoleState
+  }
+
+  contexts: Record<
+    ContextKey,
+    {
+      context: RuntimeContext
+
+      config: Config
+      agent: Agent[]
+      command: Command[]
+      lsp: LspStatus[]
+      formatter: FormatterStatus[]
+      vcs: VcsInfo | undefined
+      mcp: Record<string, McpStatus>
+      mcp_resource: Record<string, McpResource>
+
+      session: SessionID[]
+      session_status: Record<SessionID, SessionStatus>
+    }
+  >
+
+  session: Record<SessionID, Session & { context: RuntimeContext }>
+  session_diff: Record<SessionID, Snapshot.FileDiff[]>
+  todo: Record<SessionID, Todo[]>
+  permission: Record<SessionID, PermissionRequest[]>
+  question: Record<SessionID, QuestionRequest[]>
+
+  message: Record<SessionID, Message[]>
+  part: Record<MessageID, Part[]>
+}
+
+function contextKey(context: RuntimeContext) {
+  return `${context.workspaceID ?? "local"}:${context.directory}`
+}
+
+
+
+ + diff --git a/specs/v2/provider-model.md b/specs/v2/provider-model.md new file mode 100644 index 0000000000..fb4598b58f --- /dev/null +++ b/specs/v2/provider-model.md @@ -0,0 +1,330 @@ +# Provider and Model Catalog + +## Provider Schema + +```ts +export const ID = Schema.String.pipe( + Schema.brand("ProviderV2.ID"), + withStatics((schema) => ({ + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ID = typeof ID.Type + +const OpenAIResponses = Schema.Struct({ + type: Schema.Literal("openai/responses"), + url: Schema.String, + websocket: Schema.optional(Schema.Boolean), +}) + +const OpenAICompletions = Schema.Struct({ + type: Schema.Literal("openai/completions"), + url: Schema.String, + reasoning: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("reasoning_content"), + }), + Schema.Struct({ + type: Schema.Literal("reasoning_details"), + }), + ]).pipe(Schema.optional), +}) +export type OpenAICompletions = typeof OpenAICompletions.Type + +const AISDK = Schema.Struct({ + type: Schema.Literal("aisdk"), + package: Schema.String, +}) + +const AnthropicMessages = Schema.Struct({ + type: Schema.Literal("anthropic/messages"), + url: Schema.String, +}) + +const UnknownEndpoint = Schema.Struct({ + type: Schema.Literal("unknown"), +}) + +export const Endpoint = Schema.Union([ + UnknownEndpoint, + OpenAIResponses, + OpenAICompletions, + AnthropicMessages, + AISDK, +]).pipe(Schema.toTaggedUnion("type")) +export type Endpoint = typeof Endpoint.Type + +export const Options = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), +}) +export type Options = typeof Options.Type + +export class Info extends Schema.Class("ProviderV2.Info")({ + id: ID, + name: Schema.String, + enabled: Schema.Boolean, + env: Schema.String.pipe(Schema.Array), + endpoint: Endpoint, + options: Options, +}) { + static empty(providerID: ID) { + return new Info({ + id: providerID, + name: providerID, + enabled: false, + env: [], + endpoint: { + type: "unknown", + }, + options: { + headers: {}, + body: {}, + }, + }) + } +} + +export class NotFound extends Schema.TaggedErrorClass("ProviderV2.NotFound")("ProviderV2.NotFound", { + providerID: ID, +}) {} +``` + +## Model Schema + +```ts +export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +export type ID = typeof ID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + input: Schema.String.pipe(Schema.Array), + output: Schema.String.pipe(Schema.Array), +}) +export type Capabilities = typeof Capabilities.Type + +export const Variant = Schema.Struct({ + id: VariantID, + ...ProviderV2.Options.fields, +}) +export type Variant = typeof Variant.Type + +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) +export type Cost = typeof Cost.Type + +export const Limit = Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, +}) +export type Limit = typeof Limit.Type + +export const Ref = Schema.Struct({ + id: ID, + providerID: ProviderV2.ID, + variant: VariantID, +}) +export type Ref = typeof Ref.Type + +export class Info extends Schema.Class("ModelV2.Info")({ + id: ID, + providerID: ProviderV2.ID, + family: Family.pipe(Schema.optional), + name: Schema.String, + endpoint: ProviderV2.Endpoint, + options: Schema.Struct({ + ...ProviderV2.Options.fields, + variant: Schema.String.pipe(Schema.optional), + }), + capabilities: Capabilities, + variants: Variant.pipe(Schema.Array), + time: Schema.Struct({ + released: DateTimeUtcFromMillis, + }), + cost: Cost.pipe(Schema.Array), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + limit: Limit, +}) { + static empty(providerID: ProviderV2.ID, modelID: ID) { + return new Info({ + id: modelID, + providerID, + name: modelID, + endpoint: { + type: "unknown", + }, + capabilities: { + tools: false, + input: [], + output: [], + }, + options: { + headers: {}, + body: {}, + }, + variants: [], + time: { + released: DateTime.makeUnsafe(0), + }, + cost: [], + status: "active", + limit: { + context: 0, + output: 0, + }, + }) + } +} +``` + +## Catalog Interface + +```ts +export interface Interface { + readonly provider: { + readonly get: (providerID: ProviderV2.ID) => Effect.Effect> + readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => Effect.Effect + readonly remove: (providerID: ProviderV2.ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly available: () => Effect.Effect + } + + readonly model: { + readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect> + readonly update: ( + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + fn: (model: Draft) => void, + ) => Effect.Effect + readonly remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly available: () => Effect.Effect + readonly default: () => Effect.Effect> + readonly small: (providerID: ProviderV2.ID) => Effect.Effect> + } +} +``` + +`ProviderV2.Info.enabled` is stored provider state. Provider plugins set this field after checking env, auth, config, or provider-specific availability. + +`ProviderV2.Endpoint` includes `{ type: "unknown" }`. `CatalogV2.model.get()` and `CatalogV2.model.all()` resolve `unknown` endpoints from the provider before returning models. + +Model storage is nested by provider because model ids are only unique within a provider. + +```ts +type ProviderRecord = { + provider: ProviderV2.Info + models: HashMap.HashMap +} + +let records = HashMap.empty() +``` + +`ModelV2.Info` does not have an `enabled` field. Model availability is derived by `CatalogV2.model.available()` from provider state and model status. + +```ts +const available = provider.enabled && model.status !== "deprecated" +``` + +## Plugin Interface + +```ts +export type Definition = Effect.Effect< + { + readonly order: number + readonly hooks: HookFunctions + }, + never, + R +> + +export interface Interface { + readonly add: (input: { id: ID; definition: Definition }) => Effect.Effect + + readonly remove: (id: ID) => Effect.Effect + + readonly trigger: (name: Name, input: HookInput) => Effect.Effect> +} +``` + +## Plugin Order + +```ts +export const Order = { + modelsDev: 0, + env: 10, + auth: 20, + provider: 30, + config: 40, + discovery: 50, +} as const +``` + +## Built-In Plugins + +```ts +export const ModelsDevPlugin: PluginV2.Definition + +export const EnvPlugin: PluginV2.Definition + +export const AuthPlugin: PluginV2.Definition + +export const ConfigPlugin: PluginV2.Definition + +export const AnthropicPlugin: PluginV2.Definition + +export const OpenRouterPlugin: PluginV2.Definition + +export const AmazonBedrockPlugin: PluginV2.Definition + +export const GoogleVertexPlugin: PluginV2.Definition + +export const GitLabPlugin: PluginV2.Definition + +export const GitLabDiscoveryPlugin: PluginV2.Definition +``` + +## Plugin Hooks + +```ts +export type Hooks = { + init: {} + + "provider.update": { + provider: Draft + cancel: boolean + } + + "model.update": { + model: Draft + cancel: boolean + } +} +```