provider-dynamic.test.ts 6.5 KB

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