provider-dynamic.test.ts 6.6 KB

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