provider-opencode.test.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  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)).request.body.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)).request.body.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)).request.body.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)).request.body.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)).request.body.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. request: {
  131. headers: {},
  132. body: { apiKey: "configured" },
  133. },
  134. })
  135. catalog.provider.update(item.id, (draft) => {
  136. draft.request = item.request
  137. })
  138. const paid = model("opencode", "paid", { cost: cost(1) })
  139. catalog.model.update(item.id, paid.id, (draft) => {
  140. draft.cost = [...paid.cost]
  141. })
  142. })
  143. expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
  144. expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  145. }),
  146. ),
  147. )
  148. it.effect("uses auth-enabled providers as credentials", () =>
  149. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  150. Effect.gen(function* () {
  151. const plugin = yield* PluginV2.Service
  152. const catalog = yield* Catalog.Service
  153. yield* plugin.add(OpencodePlugin)
  154. const transform = yield* catalog.transform()
  155. yield* transform((catalog) => {
  156. const item = provider("opencode", { enabled: { via: "account", service: "opencode" } })
  157. catalog.provider.update(item.id, (draft) => {
  158. draft.enabled = item.enabled
  159. })
  160. const paid = model("opencode", "paid", { cost: cost(1) })
  161. catalog.model.update(item.id, paid.id, (draft) => {
  162. draft.cost = [...paid.cost]
  163. })
  164. })
  165. expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
  166. expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  167. }),
  168. ),
  169. )
  170. it.effect("ignores non-opencode providers and models", () =>
  171. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  172. Effect.gen(function* () {
  173. const plugin = yield* PluginV2.Service
  174. const catalog = yield* Catalog.Service
  175. yield* plugin.add(OpencodePlugin)
  176. const transform = yield* catalog.transform()
  177. yield* transform((catalog) => {
  178. const item = provider("openai")
  179. catalog.provider.update(item.id, () => {})
  180. const paid = model("openai", "paid", { cost: cost(1) })
  181. catalog.model.update(item.id, paid.id, (draft) => {
  182. draft.cost = [...paid.cost]
  183. })
  184. })
  185. expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
  186. expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
  187. }),
  188. ),
  189. )
  190. it.effect("prefers gpt-5-nano as the opencode small model", () =>
  191. Effect.gen(function* () {
  192. const catalog = yield* Catalog.Service
  193. const providerID = ProviderV2.ID.opencode
  194. const transform = yield* catalog.transform()
  195. yield* transform((catalog) => {
  196. catalog.provider.update(providerID, () => {})
  197. catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
  198. model.capabilities.input = ["text"]
  199. model.capabilities.output = ["text"]
  200. model.cost = [...cost(1, 1)]
  201. model.time.released = DateTime.makeUnsafe(Date.now())
  202. })
  203. catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
  204. model.capabilities.input = ["text"]
  205. model.capabilities.output = ["text"]
  206. model.cost = [...cost(10, 10)]
  207. model.time.released = DateTime.makeUnsafe(Date.now())
  208. })
  209. })
  210. const selected = yield* catalog.model.small(providerID)
  211. expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
  212. }).pipe(
  213. Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))),
  214. ),
  215. )
  216. })