Ver Fonte

core: expose v2 model listing API (#25821)

Dax há 3 meses atrás
pai
commit
8345152319
100 ficheiros alterados com 6142 adições e 21 exclusões
  1. 23 0
      AGENTS.md
  2. 34 1
      bun.lock
  3. 30 2
      packages/core/package.json
  4. 172 0
      packages/core/src/aisdk.ts
  5. 33 15
      packages/core/src/auth.ts
  6. 258 0
      packages/core/src/catalog.ts
  7. 0 0
      packages/core/src/github-copilot/README.md
  8. 0 0
      packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts
  9. 0 0
      packages/core/src/github-copilot/chat/get-response-metadata.ts
  10. 0 0
      packages/core/src/github-copilot/chat/map-openai-compatible-finish-reason.ts
  11. 0 0
      packages/core/src/github-copilot/chat/openai-compatible-api-types.ts
  12. 0 0
      packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts
  13. 0 0
      packages/core/src/github-copilot/chat/openai-compatible-chat-options.ts
  14. 0 0
      packages/core/src/github-copilot/chat/openai-compatible-metadata-extractor.ts
  15. 0 0
      packages/core/src/github-copilot/chat/openai-compatible-prepare-tools.ts
  16. 0 0
      packages/core/src/github-copilot/copilot-provider.ts
  17. 0 0
      packages/core/src/github-copilot/openai-compatible-error.ts
  18. 0 0
      packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts
  19. 0 0
      packages/core/src/github-copilot/responses/map-openai-responses-finish-reason.ts
  20. 0 0
      packages/core/src/github-copilot/responses/openai-config.ts
  21. 0 0
      packages/core/src/github-copilot/responses/openai-error.ts
  22. 0 0
      packages/core/src/github-copilot/responses/openai-responses-api-types.ts
  23. 0 0
      packages/core/src/github-copilot/responses/openai-responses-language-model.ts
  24. 0 0
      packages/core/src/github-copilot/responses/openai-responses-prepare-tools.ts
  25. 0 0
      packages/core/src/github-copilot/responses/openai-responses-settings.ts
  26. 0 0
      packages/core/src/github-copilot/responses/tool/code-interpreter.ts
  27. 0 0
      packages/core/src/github-copilot/responses/tool/file-search.ts
  28. 0 0
      packages/core/src/github-copilot/responses/tool/image-generation.ts
  29. 0 0
      packages/core/src/github-copilot/responses/tool/local-shell.ts
  30. 0 0
      packages/core/src/github-copilot/responses/tool/web-search-preview.ts
  31. 0 0
      packages/core/src/github-copilot/responses/tool/web-search.ts
  32. 116 0
      packages/core/src/model.ts
  33. 146 0
      packages/core/src/plugin.ts
  34. 27 0
      packages/core/src/plugin/auth.ts
  35. 18 0
      packages/core/src/plugin/env.ts
  36. 1 0
      packages/core/src/plugin/provider.ts
  37. 15 0
      packages/core/src/plugin/provider/alibaba.ts
  38. 94 0
      packages/core/src/plugin/provider/amazon-bedrock.ts
  39. 21 0
      packages/core/src/plugin/provider/anthropic.ts
  40. 67 0
      packages/core/src/plugin/provider/azure.ts
  41. 20 0
      packages/core/src/plugin/provider/cerebras.ts
  42. 81 0
      packages/core/src/plugin/provider/cloudflare-ai-gateway.ts
  43. 69 0
      packages/core/src/plugin/provider/cloudflare-workers-ai.ts
  44. 15 0
      packages/core/src/plugin/provider/cohere.ts
  45. 15 0
      packages/core/src/plugin/provider/deepinfra.ts
  46. 31 0
      packages/core/src/plugin/provider/dynamic.ts
  47. 15 0
      packages/core/src/plugin/provider/gateway.ts
  48. 44 0
      packages/core/src/plugin/provider/github-copilot.ts
  49. 64 0
      packages/core/src/plugin/provider/gitlab.ts
  50. 124 0
      packages/core/src/plugin/provider/google-vertex.ts
  51. 15 0
      packages/core/src/plugin/provider/google.ts
  52. 15 0
      packages/core/src/plugin/provider/groq.ts
  53. 67 0
      packages/core/src/plugin/provider/index.ts
  54. 16 0
      packages/core/src/plugin/provider/kilo.ts
  55. 18 0
      packages/core/src/plugin/provider/llmgateway.ts
  56. 15 0
      packages/core/src/plugin/provider/mistral.ts
  57. 16 0
      packages/core/src/plugin/provider/nvidia.ts
  58. 17 0
      packages/core/src/plugin/provider/openai-compatible.ts
  59. 27 0
      packages/core/src/plugin/provider/openai.ts
  60. 27 0
      packages/core/src/plugin/provider/opencode.ts
  61. 29 0
      packages/core/src/plugin/provider/openrouter.ts
  62. 15 0
      packages/core/src/plugin/provider/perplexity.ts
  63. 40 0
      packages/core/src/plugin/provider/sap-ai-core.ts
  64. 15 0
      packages/core/src/plugin/provider/togetherai.ts
  65. 15 0
      packages/core/src/plugin/provider/venice.ts
  66. 21 0
      packages/core/src/plugin/provider/vercel.ts
  67. 20 0
      packages/core/src/plugin/provider/xai.ts
  68. 16 0
      packages/core/src/plugin/provider/zenmux.ts
  69. 120 0
      packages/core/src/provider.ts
  70. 0 0
      packages/core/src/session-prompt.ts
  71. 0 0
      packages/core/src/tool-output.ts
  72. 1 1
      packages/core/src/v2-schema.ts
  73. 1 1
      packages/core/test/github-copilot/convert-to-copilot-messages.test.ts
  74. 1 1
      packages/core/test/github-copilot/copilot-chat-model.test.ts
  75. 188 0
      packages/core/test/plugin/provider-github-copilot.test.ts
  76. 199 0
      packages/core/test/v2/catalog.test.ts
  77. 9 0
      packages/core/test/v2/plugin/fixtures/provider-factory.ts
  78. 67 0
      packages/core/test/v2/plugin/provider-alibaba.test.ts
  79. 464 0
      packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts
  80. 91 0
      packages/core/test/v2/plugin/provider-anthropic.test.ts
  81. 127 0
      packages/core/test/v2/plugin/provider-azure-cognitive-services.test.ts
  82. 245 0
      packages/core/test/v2/plugin/provider-azure.test.ts
  83. 102 0
      packages/core/test/v2/plugin/provider-cerebras.test.ts
  84. 384 0
      packages/core/test/v2/plugin/provider-cloudflare-ai-gateway.test.ts
  85. 267 0
      packages/core/test/v2/plugin/provider-cloudflare-workers-ai.test.ts
  86. 86 0
      packages/core/test/v2/plugin/provider-cohere.test.ts
  87. 129 0
      packages/core/test/v2/plugin/provider-deepinfra.test.ts
  88. 172 0
      packages/core/test/v2/plugin/provider-dynamic.test.ts
  89. 87 0
      packages/core/test/v2/plugin/provider-gateway.test.ts
  90. 346 0
      packages/core/test/v2/plugin/provider-gitlab.test.ts
  91. 147 0
      packages/core/test/v2/plugin/provider-google-vertex-anthropic.test.ts
  92. 300 0
      packages/core/test/v2/plugin/provider-google-vertex.test.ts
  93. 70 0
      packages/core/test/v2/plugin/provider-google.test.ts
  94. 101 0
      packages/core/test/v2/plugin/provider-groq.test.ts
  95. 100 0
      packages/core/test/v2/plugin/provider-helper.ts
  96. 90 0
      packages/core/test/v2/plugin/provider-kilo.test.ts
  97. 63 0
      packages/core/test/v2/plugin/provider-llmgateway.test.ts
  98. 106 0
      packages/core/test/v2/plugin/provider-mistral.test.ts
  99. 41 0
      packages/core/test/v2/plugin/provider-nvidia.test.ts
  100. 101 0
      packages/core/test/v2/plugin/provider-openai-compatible.test.ts

+ 23 - 0
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.

+ 34 - 1
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",
@@ -5423,6 +5450,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=="],

+ 30 - 2
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:"

+ 172 - 0
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<Uint8Array>({
+    async pull(ctrl) {
+      const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((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<string, any> = { 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<typeof fetch>[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<InitError>()("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<LanguageModelV3, InitError>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const plugin = yield* PluginV2.Service
+    const languages = new Map<string, LanguageModelV3>()
+    const sdks = new Map<string, SDK>()
+
+    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))

+ 33 - 15
packages/opencode/src/v2/auth.ts → 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,25 +106,43 @@ 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 writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
+      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<Writable, AuthError> = Effect.fnUntraced(function* () {
       if (process.env.OPENCODE_AUTH_CONTENT) {
-        try {
-          return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
-        } catch {}
+        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<string, unknown>)
+        }
+        return { version: 2, accounts: {}, active: {} }
       }
 
-      const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
+      const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
+      if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
 
-      if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} }
+      const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
 
-      if ("version" in raw && raw.version === 2) return raw as Writable
+      if (raw && typeof raw === "object") {
+        if ("version" in raw && raw.version === 2) return raw as Writable
+        return yield* writeMigrated(raw as Record<string, unknown>)
+      }
 
-      const migrated = migrate(raw as Record<string, unknown>)
-      yield* fsys
-        .writeJson(file, migrated, 0o600)
-        .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
-      return migrated
+      return { version: 2, accounts: {}, active: {} }
     })
 
     const write = (data: Writable) =>

+ 258 - 0
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<ModelV2.ID, ModelV2.Info>
+}
+
+export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
+  "CatalogV2.ProviderNotFound",
+  {
+    providerID: ProviderV2.ID,
+  },
+) {}
+
+export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
+  providerID: ProviderV2.ID,
+  modelID: ModelV2.ID,
+}) {}
+
+export interface Interface {
+  readonly provider: {
+    readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
+    readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => Effect.Effect<void>
+    readonly all: () => Effect.Effect<ProviderV2.Info[]>
+    readonly available: () => Effect.Effect<ProviderV2.Info[]>
+  }
+  readonly model: {
+    readonly get: (
+      providerID: ProviderV2.ID,
+      modelID: ModelV2.ID,
+    ) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
+    readonly update: (
+      providerID: ProviderV2.ID,
+      modelID: ModelV2.ID,
+      fn: (model: Draft<ModelV2.Info>) => void,
+    ) => Effect.Effect<void, ProviderNotFoundError>
+    readonly all: () => Effect.Effect<ModelV2.Info[]>
+    readonly available: () => Effect.Effect<ModelV2.Info[]>
+    readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
+    readonly setDefault: (
+      providerID: ProviderV2.ID,
+      modelID: ModelV2.ID,
+    ) => Effect.Effect<void, ProviderNotFoundError | ModelNotFoundError>
+    readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
+  }
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
+    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<ModelV2.ID, ModelV2.Info>(),
+          })
+        }),
+
+        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<ModelV2.Info>()
+
+          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))

+ 0 - 0
packages/opencode/src/provider/sdk/copilot/README.md → packages/core/src/github-copilot/README.md


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts → packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/chat/get-response-metadata.ts → packages/core/src/github-copilot/chat/get-response-metadata.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts → packages/core/src/github-copilot/chat/map-openai-compatible-finish-reason.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts → packages/core/src/github-copilot/chat/openai-compatible-api-types.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts → packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts → packages/core/src/github-copilot/chat/openai-compatible-chat-options.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts → packages/core/src/github-copilot/chat/openai-compatible-metadata-extractor.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts → packages/core/src/github-copilot/chat/openai-compatible-prepare-tools.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/copilot-provider.ts → packages/core/src/github-copilot/copilot-provider.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts → packages/core/src/github-copilot/openai-compatible-error.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts → packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/map-openai-responses-finish-reason.ts → packages/core/src/github-copilot/responses/map-openai-responses-finish-reason.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/openai-config.ts → packages/core/src/github-copilot/responses/openai-config.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts → packages/core/src/github-copilot/responses/openai-error.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/openai-responses-api-types.ts → packages/core/src/github-copilot/responses/openai-responses-api-types.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts → packages/core/src/github-copilot/responses/openai-responses-language-model.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/openai-responses-prepare-tools.ts → packages/core/src/github-copilot/responses/openai-responses-prepare-tools.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/openai-responses-settings.ts → packages/core/src/github-copilot/responses/openai-responses-settings.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/tool/code-interpreter.ts → packages/core/src/github-copilot/responses/tool/code-interpreter.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/tool/file-search.ts → packages/core/src/github-copilot/responses/tool/file-search.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/tool/image-generation.ts → packages/core/src/github-copilot/responses/tool/image-generation.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/tool/local-shell.ts → packages/core/src/github-copilot/responses/tool/local-shell.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/tool/web-search-preview.ts → packages/core/src/github-copilot/responses/tool/web-search-preview.ts


+ 0 - 0
packages/opencode/src/provider/sdk/copilot/responses/tool/web-search.ts → packages/core/src/github-copilot/responses/tool/web-search.ts


+ 116 - 0
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<Info>("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"

+ 146 - 0
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<string, any>
+    }
+    output: {
+      language?: LanguageModelV3
+    }
+  }
+  "aisdk.sdk": {
+    input: {
+      model: ModelV2.Info
+      package: string
+      options: Record<string, any>
+    }
+    output: {
+      sdk?: any
+    }
+  }
+}
+
+export type Hooks = {
+  [Name in keyof HookSpec]: Readonly<HookSpec[Name]["input"]> & {
+    -readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object
+      ? Draft<HookSpec[Name]["output"][Field]>
+      : HookSpec[Name]["output"][Field]
+  }
+}
+
+export type HookFunctions = {
+  [key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect<void>
+}
+
+export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
+export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
+
+export type Effect = Effect.Effect<HookFunctions | void, never, never>
+
+export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) {
+  return input
+}
+
+export interface Interface {
+  readonly add: (input: { id: ID; effect: Effect }) => Effect.Effect<void>
+  readonly remove: (id: ID) => Effect.Effect<void>
+  readonly trigger: <Name extends keyof Hooks>(
+    name: Name,
+    input: HookInput<Name>,
+    output: HookOutput<Name>,
+  ) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@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<string, ReturnType<typeof createDraft>>()
+        const event = {
+          ...input,
+          ...output,
+        } as Record<string, unknown>
+
+        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

+ 27 - 0
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
+        }
+      }),
+    }
+  }),
+})

+ 18 - 0
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,
+        }
+      }),
+    }
+  }),
+})

+ 1 - 0
packages/core/src/plugin/provider.ts

@@ -0,0 +1 @@
+export { ProviderPlugins } from "./provider/index"

+ 15 - 0
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)
+      }),
+    }
+  }),
+})

+ 94 - 0
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))
+      }),
+    }
+  }),
+})

+ 21 - 0
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)
+      }),
+    }
+  }),
+})

+ 67 - 0
packages/core/src/plugin/provider/azure.ts

@@ -0,0 +1,67 @@
+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),
+        )
+      }),
+    }
+  }),
+})

+ 20 - 0
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)
+      }),
+    }
+  }),
+})

+ 81 - 0
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<string, unknown>): 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<string, unknown>) {
+  // 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<string, string> | undefined)?.["cf-aig-metadata"]
+  return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined
+}
+
+function gatewayOptions(options: Record<string, unknown>, 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<string, unknown>, key: string) {
+  return typeof options[key] === "string" ? options[key] : undefined
+}

+ 69 - 0
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<string, unknown>) {
+  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<string, any>) {
+  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<string, unknown>, key: string) {
+  return typeof options[key] === "string" ? options[key] : undefined
+}

+ 15 - 0
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)
+      }),
+    }
+  }),
+})

+ 15 - 0
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)
+      }),
+    }
+  }),
+})

+ 31 - 0
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<string, (options: any) => 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)
+      }),
+    }
+  }),
+})

+ 15 - 0
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)
+      }),
+    }
+  }),
+})

+ 44 - 0
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
+      }),
+    }
+  }),
+})

+ 64 - 0
packages/core/src/plugin/provider/gitlab.ts

@@ -0,0 +1,64 @@
+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,
+        })
+      }),
+    }
+  }),
+})

+ 124 - 0
packages/core/src/plugin/provider/google-vertex.ts

@@ -0,0 +1,124 @@
+import { Effect } from "effect"
+import { PluginV2 } from "../../plugin"
+import { ProviderV2 } from "../../provider"
+
+function resolveProject(options: Record<string, any>) {
+  // 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<string, any>) {
+  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<typeof fetch>[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())
+      }),
+    }
+  }),
+})

+ 15 - 0
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)
+      }),
+    }
+  }),
+})

+ 15 - 0
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)
+      }),
+    }
+  }),
+})

+ 67 - 0
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,
+]

+ 16 - 0
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"
+      }),
+    }
+  }),
+})

+ 18 - 0
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"
+      }),
+    }
+  }),
+})

+ 15 - 0
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)
+      }),
+    }
+  }),
+})

+ 16 - 0
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"
+      }),
+    }
+  }),
+})

+ 17 - 0
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)
+      }),
+    }
+  }),
+})

+ 27 - 0
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
+      }),
+    }
+  }),
+})

+ 27 - 0
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
+      }),
+    }
+  }),
+})

+ 29 - 0
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
+      }),
+    }
+  }),
+})

+ 15 - 0
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)
+      }),
+    }
+  }),
+})

+ 40 - 0
packages/core/src/plugin/provider/sap-ai-core.ts

@@ -0,0 +1,40 @@
+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<string, (options: any) => 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)
+      }),
+    }
+  }),
+})

+ 15 - 0
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)
+      }),
+    }
+  }),
+})

+ 15 - 0
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)
+      }),
+    }
+  }),
+})

+ 21 - 0
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)
+      }),
+    }
+  }),
+})

+ 20 - 0
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)
+      }),
+    }
+  }),
+})

+ 16 - 0
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"
+      }),
+    }
+  }),
+})

+ 120 - 0
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<Info>("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: {},
+        },
+      },
+    })
+  }
+}

+ 0 - 0
packages/opencode/src/v2/session-prompt.ts → packages/core/src/session-prompt.ts


+ 0 - 0
packages/opencode/src/v2/tool-output.ts → packages/core/src/tool-output.ts


+ 1 - 1
packages/opencode/src/v2/schema.ts → 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"

+ 1 - 1
packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts → 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", () => {

+ 1 - 1
packages/opencode/test/provider/copilot/copilot-chat-model.test.ts → 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"
 

+ 188 - 0
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()
+    }),
+  )
+})

+ 199 - 0
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")
+    }),
+  )
+})

+ 9 - 0
packages/core/test/v2/plugin/fixtures/provider-factory.ts

@@ -0,0 +1,9 @@
+export function createFixtureProvider(options: Record<string, unknown>) {
+  const captured = Object.fromEntries(Object.entries(options))
+  return Object.assign((modelID: string) => ({ modelID, options: captured }), {
+    options: captured,
+    languageModel(modelID: string) {
+      return { modelID, options: captured }
+    },
+  })
+}

+ 67 - 0
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")
+    }),
+  )
+})

+ 464 - 0
packages/core/test/v2/plugin/provider-amazon-bedrock.test.ts

@@ -0,0 +1,464 @@
+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<typeof fetch>[0], init?: RequestInit) => Promise<Response> } }).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<string | null> = []
+        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<typeof fetch>[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<string | null> = []
+        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<typeof fetch>[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<string | null> = []
+          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<typeof fetch>[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()
+    }),
+  )
+})

+ 91 - 0
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"])
+    }),
+  )
+})

+ 127 - 0
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",
+      ])
+    }),
+  )
+})

+ 245 - 0
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"])
+    }),
+  )
+})

+ 102 - 0
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<string, unknown>[] = []
+
+void mock.module("@ai-sdk/cerebras", () => ({
+  createCerebras: (options: Record<string, unknown>) => {
+    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()
+    }),
+  )
+})

+ 384 - 0
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<string, unknown>[] = []
+const unifiedCalls: string[] = []
+const gatewayModelCalls: unknown[] = []
+
+function captureAiGatewayOptions(options: Record<string, unknown>) {
+  const nested =
+    options.options && typeof options.options === "object" ? (options.options as Record<string, unknown>) : undefined
+  return {
+    ...options,
+    ...(nested
+      ? {
+          options: {
+            ...nested,
+            headers:
+              nested.headers && typeof nested.headers === "object"
+                ? { ...(nested.headers as Record<string, unknown>) }
+                : nested.headers,
+          },
+        }
+      : {}),
+  }
+}
+
+function resetCalls() {
+  aiGatewayCalls.length = 0
+  unifiedCalls.length = 0
+  gatewayModelCalls.length = 0
+}
+
+function cloudflareEnv(overrides: Record<string, string | undefined> = {}) {
+  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<string, unknown>) {
+    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)
+      }),
+    ),
+  )
+})

+ 267 - 0
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<string, string> | Promise<Record<string, string>>
+}
+
+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()
+      }),
+    ),
+  )
+})

+ 86 - 0
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<string, any>[] = []
+
+void mock.module("@ai-sdk/cohere", () => ({
+  createCohere: (options: Record<string, any>) => {
+    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"])
+    }),
+  )
+})

+ 129 - 0
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<string, any>[] = []
+const deepinfraLanguageModels: string[] = []
+
+void mock.module("@ai-sdk/deepinfra", () => ({
+  createDeepInfra: (options: Record<string, any>) => {
+    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"])
+    }),
+  )
+})

+ 172 - 0
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<string>) {
+  return Layer.succeed(
+    Npm.Service,
+    Npm.Service.of({
+      add: () => Effect.succeed({ directory: "", entrypoint }),
+      install: () => Effect.void,
+      which: () => Effect.succeed(Option.none<string>()),
+    }),
+  )
+}
+
+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<string>())))
+      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" } })
+    }),
+  )
+})

+ 87 - 0
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<string, unknown>[] = []
+const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"]
+
+mock.module("@ai-sdk/gateway", () => ({
+  createGateway(options: Record<string, unknown>) {
+    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)
+    }),
+  )
+})

+ 346 - 0
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<string, unknown>[] = []
+
+void mock.module("gitlab-ai-provider", () => ({
+  VERSION: "test-version",
+  createGitLab: (options: Record<string, unknown>) => {
+    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<string, string>)["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<string, string>
+                featureFlags?: Record<string, boolean>
+              }
+              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 } }],
+      ])
+    }),
+  )
+})

+ 147 - 0
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()
+    }),
+  )
+})

+ 300 - 0
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<string, any>[] = []
+
+void mock.module("@ai-sdk/google-vertex", () => ({
+  createVertex: (options: Record<string, any>) => {
+    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<typeof fetch>[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<typeof fetch>[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"])
+    }),
+  )
+})

+ 70 - 0
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")
+    }),
+  )
+})

+ 101 - 0
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<typeof createGroq>[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")
+    }),
+  )
+})

+ 100 - 0
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<string>() }),
+    install: () => Effect.void,
+    which: () => Effect.succeed(Option.none<string>()),
+  }),
+)
+
+export const it = testEffect(Layer.mergeAll(PluginV2.defaultLayer, npmLayer))
+
+export function provider(providerID: string, options?: Partial<ProviderV2.Info>) {
+  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<ModelV2.Info>) {
+  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<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
+  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))
+}

+ 90 - 0
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({})
+    }),
+  )
+})

+ 63 - 0
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({})
+    }),
+  )
+})

+ 106 - 0
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()
+    }),
+  )
+})

+ 41 - 0
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({})
+    }),
+  )
+})

+ 101 - 0
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)
+    }),
+  )
+})

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff