provider-opencode.test.ts 9.4 KB

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