provider-opencode.test.ts 9.8 KB

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