provider-dynamic.test.ts 6.5 KB

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