provider-opencode.test.ts 9.3 KB

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