provider-opencode.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import { describe, expect } from "bun:test"
  2. import { DateTime, Effect, Layer, Option } from "effect"
  3. import { Catalog } from "@opencode-ai/core/catalog"
  4. import { Location } from "@opencode-ai/core/location"
  5. import { ModelV2 } from "@opencode-ai/core/model"
  6. import { PluginV2 } from "@opencode-ai/core/plugin"
  7. import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
  8. import { ProviderV2 } from "@opencode-ai/core/provider"
  9. import { it, model, provider, withEnv } from "./provider-helper"
  10. const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
  11. const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
  12. describe("OpencodePlugin", () => {
  13. it.effect("uses a public key and cancels paid models without credentials", () =>
  14. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  15. Effect.gen(function* () {
  16. const plugin = yield* PluginV2.Service
  17. yield* plugin.add(OpencodePlugin)
  18. const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
  19. const paid = yield* plugin.trigger(
  20. "model.update",
  21. {},
  22. { model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
  23. )
  24. expect(updated.provider.options.aisdk.provider.apiKey).toBe("public")
  25. expect(paid.cancel).toBe(true)
  26. }),
  27. ),
  28. )
  29. it.effect("keeps free models without credentials", () =>
  30. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  31. Effect.gen(function* () {
  32. const plugin = yield* PluginV2.Service
  33. yield* plugin.add(OpencodePlugin)
  34. yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
  35. const free = yield* plugin.trigger(
  36. "model.update",
  37. {},
  38. { model: model("opencode", "free", { cost: cost(0) }), cancel: false },
  39. )
  40. expect(free.cancel).toBe(false)
  41. }),
  42. ),
  43. )
  44. it.effect("treats output-only cost as free without credentials", () =>
  45. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  46. Effect.gen(function* () {
  47. const plugin = yield* PluginV2.Service
  48. yield* plugin.add(OpencodePlugin)
  49. yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
  50. const outputOnly = yield* plugin.trigger(
  51. "model.update",
  52. {},
  53. { model: model("opencode", "output-only", { cost: cost(0, 1) }), cancel: false },
  54. )
  55. expect(outputOnly.cancel).toBe(false)
  56. }),
  57. ),
  58. )
  59. it.effect("uses OPENCODE_API_KEY as credentials", () =>
  60. withEnv({ OPENCODE_API_KEY: "secret" }, () =>
  61. Effect.gen(function* () {
  62. const plugin = yield* PluginV2.Service
  63. yield* plugin.add(OpencodePlugin)
  64. const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
  65. const paid = yield* plugin.trigger(
  66. "model.update",
  67. {},
  68. { model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
  69. )
  70. expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
  71. expect(paid.cancel).toBe(false)
  72. }),
  73. ),
  74. )
  75. it.effect("uses configured provider env vars as credentials", () =>
  76. withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
  77. Effect.gen(function* () {
  78. const plugin = yield* PluginV2.Service
  79. yield* plugin.add(OpencodePlugin)
  80. const updated = yield* plugin.trigger(
  81. "provider.update",
  82. {},
  83. { provider: provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }), cancel: false },
  84. )
  85. const paid = yield* plugin.trigger(
  86. "model.update",
  87. {},
  88. { model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
  89. )
  90. expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
  91. expect(paid.cancel).toBe(false)
  92. }),
  93. ),
  94. )
  95. it.effect("uses configured apiKey as credentials", () =>
  96. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  97. Effect.gen(function* () {
  98. const plugin = yield* PluginV2.Service
  99. yield* plugin.add(OpencodePlugin)
  100. const updated = yield* plugin.trigger(
  101. "provider.update",
  102. {},
  103. {
  104. provider: provider("opencode", {
  105. options: {
  106. headers: {},
  107. body: {},
  108. aisdk: {
  109. provider: { apiKey: "configured" },
  110. request: {},
  111. },
  112. },
  113. }),
  114. cancel: false,
  115. },
  116. )
  117. const paid = yield* plugin.trigger(
  118. "model.update",
  119. {},
  120. { model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
  121. )
  122. expect(updated.provider.options.aisdk.provider.apiKey).toBe("configured")
  123. expect(paid.cancel).toBe(false)
  124. }),
  125. ),
  126. )
  127. it.effect("uses auth-enabled providers as credentials", () =>
  128. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  129. Effect.gen(function* () {
  130. const plugin = yield* PluginV2.Service
  131. yield* plugin.add(OpencodePlugin)
  132. const updated = yield* plugin.trigger(
  133. "provider.update",
  134. {},
  135. { provider: provider("opencode", { enabled: { via: "auth", service: "opencode" } }), cancel: false },
  136. )
  137. const paid = yield* plugin.trigger(
  138. "model.update",
  139. {},
  140. { model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
  141. )
  142. expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
  143. expect(paid.cancel).toBe(false)
  144. }),
  145. ),
  146. )
  147. it.effect("ignores non-opencode providers and models", () =>
  148. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  149. Effect.gen(function* () {
  150. const plugin = yield* PluginV2.Service
  151. yield* plugin.add(OpencodePlugin)
  152. const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
  153. const paid = yield* plugin.trigger(
  154. "model.update",
  155. {},
  156. { model: model("openai", "paid", { cost: cost(1) }), cancel: false },
  157. )
  158. expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
  159. expect(paid.cancel).toBe(false)
  160. }),
  161. ),
  162. )
  163. it.effect("prefers gpt-5-nano as the opencode small model", () =>
  164. Effect.gen(function* () {
  165. const catalog = yield* Catalog.Service
  166. const providerID = ProviderV2.ID.opencode
  167. yield* catalog.provider.update(providerID, () => {})
  168. yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
  169. model.capabilities.input = ["text"]
  170. model.capabilities.output = ["text"]
  171. model.cost = cost(1, 1)
  172. model.time.released = DateTime.makeUnsafe(Date.now())
  173. })
  174. yield* catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
  175. model.capabilities.input = ["text"]
  176. model.capabilities.output = ["text"]
  177. model.cost = cost(10, 10)
  178. model.time.released = DateTime.makeUnsafe(Date.now())
  179. })
  180. const selected = yield* catalog.model.small(providerID)
  181. expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
  182. }).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))),
  183. )
  184. })