provider-opencode.test.ts 9.5 KB

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