provider-dynamic.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import { Npm } from "@opencode-ai/core/npm"
  2. import { describe, expect } from "bun:test"
  3. import { Cause, Effect, Layer } from "effect"
  4. import fs from "fs/promises"
  5. import os from "os"
  6. import path from "path"
  7. import { fileURLToPath } from "url"
  8. import { AISDK } from "@opencode-ai/core/aisdk"
  9. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  10. import { ModelV2 } from "@opencode-ai/core/model"
  11. import { PluginV2 } from "@opencode-ai/core/plugin"
  12. import { PluginHost } from "@opencode-ai/core/plugin/host"
  13. import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
  14. import { ProviderV2 } from "@opencode-ai/core/provider"
  15. import { testEffect } from "../lib/effect"
  16. import { PluginTestLayer } from "./fixture"
  17. const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
  18. const fixtureProviderPath = fileURLToPath(fixtureProvider)
  19. const it = testEffect(PluginTestLayer)
  20. const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node)))
  21. function npmEntrypoint(entrypoint?: string) {
  22. return Npm.Service.of({
  23. add: () => Effect.succeed({ directory: "", entrypoint }),
  24. install: () => Effect.void,
  25. which: () => Effect.succeed(undefined),
  26. })
  27. }
  28. const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
  29. const plugin = yield* PluginV2.Service
  30. const host = yield* PluginHost.make(plugin)
  31. yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service)))
  32. })
  33. function tempEntrypoint(source: string) {
  34. return Effect.acquireRelease(
  35. Effect.promise(async () => {
  36. const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-dynamic-"))
  37. const entrypoint = path.join(directory, "provider.mjs")
  38. await Bun.write(entrypoint, source)
  39. return { directory, entrypoint }
  40. }),
  41. (tmp) => Effect.promise(() => fs.rm(tmp.directory, { recursive: true, force: true })),
  42. )
  43. }
  44. describe("DynamicProviderPlugin", () => {
  45. it.effect("creates an SDK from a provider factory export", () =>
  46. Effect.gen(function* () {
  47. const aisdk = yield* AISDK.Service
  48. yield* addPlugin()
  49. const result = yield* aisdk.runSDK({
  50. model: ModelV2.Info.make({
  51. ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
  52. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
  53. }),
  54. package: fixtureProvider,
  55. options: { name: "custom", marker: "dynamic" },
  56. })
  57. expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom" })
  58. expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "dynamic", name: "custom" } })
  59. }),
  60. )
  61. it.effect("does not override an SDK already supplied by an earlier plugin", () =>
  62. Effect.gen(function* () {
  63. const aisdk = yield* AISDK.Service
  64. const sdk = { marker: "existing" }
  65. yield* addPlugin()
  66. const result = yield* aisdk.runSDK({
  67. model: ModelV2.Info.make({
  68. ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
  69. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
  70. }),
  71. package: fixtureProvider,
  72. options: { name: "custom", marker: "dynamic" },
  73. sdk,
  74. })
  75. expect(result.sdk).toBe(sdk)
  76. }),
  77. )
  78. it.effect("injects the provider ID as the SDK factory name", () =>
  79. Effect.gen(function* () {
  80. const aisdk = yield* AISDK.Service
  81. yield* addPlugin()
  82. const result = yield* aisdk.runSDK({
  83. model: ModelV2.Info.make({
  84. ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
  85. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
  86. }),
  87. package: fixtureProvider,
  88. options: { name: "custom-provider", marker: "dynamic" },
  89. })
  90. expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" })
  91. }),
  92. )
  93. it.effect("loads npm packages through their resolved import entrypoint", () =>
  94. Effect.gen(function* () {
  95. const aisdk = yield* AISDK.Service
  96. yield* addPlugin(npmEntrypoint(fixtureProviderPath))
  97. const result = yield* aisdk.runSDK({
  98. model: ModelV2.Info.make({
  99. ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
  100. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" },
  101. }),
  102. package: "fixture-provider",
  103. options: { name: "npm-provider", marker: "npm" },
  104. })
  105. expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "npm", name: "npm-provider" } })
  106. }),
  107. )
  108. itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () =>
  109. Effect.gen(function* () {
  110. const aisdk = yield* AISDK.Service
  111. yield* addPlugin(npmEntrypoint())
  112. const exit = yield* aisdk
  113. .language(
  114. ModelV2.Info.make({
  115. ...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
  116. api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
  117. }),
  118. )
  119. .pipe(Effect.exit)
  120. expect(exit._tag).toBe("Failure")
  121. if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
  122. }),
  123. )
  124. itWithAISDK.effect("wraps dynamic import failures as AISDK init errors", () =>
  125. Effect.gen(function* () {
  126. const aisdk = yield* AISDK.Service
  127. yield* addPlugin()
  128. const exit = yield* aisdk
  129. .language(
  130. ModelV2.Info.make({
  131. ...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
  132. api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" },
  133. }),
  134. )
  135. .pipe(Effect.exit)
  136. expect(exit._tag).toBe("Failure")
  137. if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
  138. }),
  139. )
  140. itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
  141. Effect.gen(function* () {
  142. const plugin = yield* PluginV2.Service
  143. const aisdk = yield* AISDK.Service
  144. const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
  145. yield* addPlugin(npmEntrypoint(tmp.entrypoint))
  146. const exit = yield* aisdk
  147. .language(
  148. ModelV2.Info.make({
  149. ...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
  150. api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
  151. }),
  152. )
  153. .pipe(Effect.exit)
  154. expect(exit._tag).toBe("Failure")
  155. if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
  156. }),
  157. )
  158. itWithAISDK.effect("uses the model api.id for the default language model", () =>
  159. Effect.gen(function* () {
  160. const plugin = yield* PluginV2.Service
  161. const aisdk = yield* AISDK.Service
  162. yield* addPlugin()
  163. const language = yield* aisdk.language(
  164. ModelV2.Info.make({
  165. ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
  166. api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider },
  167. }),
  168. )
  169. expect(language).toMatchObject({ modelID: "test-model-api", options: { name: "custom" } })
  170. }),
  171. )
  172. })