provider-opencode.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { Catalog } from "@opencode-ai/core/catalog"
  4. import { Credential } from "@opencode-ai/core/credential"
  5. import { Integration } from "@opencode-ai/core/integration"
  6. import { ModelV2 } from "@opencode-ai/core/model"
  7. import { PluginV2 } from "@opencode-ai/core/plugin"
  8. import { PluginHost } from "@opencode-ai/core/plugin/host"
  9. import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
  10. import { ProviderV2 } from "@opencode-ai/core/provider"
  11. import { testEffect } from "../lib/effect"
  12. import { PluginTestLayer } from "./fixture"
  13. const it = testEffect(PluginTestLayer)
  14. const addPlugin = Effect.fn(function* () {
  15. const plugin = yield* PluginV2.Service
  16. const host = yield* PluginHost.make(plugin)
  17. const integration = yield* Integration.Service
  18. yield* OpencodePlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
  19. })
  20. function required<T>(value: T | undefined): T {
  21. if (value === undefined) throw new Error("Expected value")
  22. return value
  23. }
  24. function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
  25. return Effect.acquireUseRelease(
  26. Effect.sync(() => {
  27. const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
  28. Object.entries(vars).forEach(([key, value]) => {
  29. if (value === undefined) delete process.env[key]
  30. else process.env[key] = value
  31. })
  32. return previous
  33. }),
  34. effect,
  35. (previous) =>
  36. Effect.sync(() =>
  37. Object.entries(previous).forEach(([key, value]) => {
  38. if (value === undefined) delete process.env[key]
  39. else process.env[key] = value
  40. }),
  41. ),
  42. )
  43. }
  44. const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
  45. describe("OpencodePlugin", () => {
  46. it.effect("registers account and service account methods", () =>
  47. Effect.gen(function* () {
  48. yield* addPlugin()
  49. expect((yield* (yield* Integration.Service).get(Integration.ID.make("opencode")))?.methods).toEqual([
  50. {
  51. id: Integration.MethodID.make("device"),
  52. type: "oauth",
  53. label: "OpenCode Console account",
  54. },
  55. { type: "key", label: "API key (service account)" },
  56. ])
  57. }),
  58. )
  59. it.live("loads providers and models from the connected OpenCode server", () =>
  60. Effect.acquireUseRelease(
  61. Effect.sync(() => {
  62. const authorization: Array<string | null> = []
  63. return {
  64. authorization,
  65. server: Bun.serve({
  66. port: 0,
  67. fetch: (request) => {
  68. authorization.push(request.headers.get("authorization"))
  69. const origin = new URL(request.url).origin
  70. return Response.json({
  71. config: {
  72. enterprise: { url: origin },
  73. provider: {
  74. remote: {
  75. name: "Remote",
  76. npm: "@ai-sdk/openai-compatible",
  77. api: `${origin}/v1`,
  78. env: ["REMOTE_API_KEY"],
  79. options: {
  80. apiKey: "{env:REMOTE_API_KEY}",
  81. headers: { "x-org-id": "org" },
  82. custom: "value",
  83. },
  84. models: {
  85. model: {
  86. name: "Remote Model",
  87. family: "remote",
  88. release_date: "2026-01-02",
  89. tool_call: true,
  90. modalities: { input: ["text", "image"], output: ["text"] },
  91. options: { apiKey: "model-secret", temperature: 0.5 },
  92. variants: { high: { apiKey: "variant-secret", temperature: 0.2 } },
  93. cost: { input: 1, output: 2, cache_read: 0.1 },
  94. limit: { context: 1000, output: 100 },
  95. },
  96. disabled: { name: "Disabled", status: "deprecated" },
  97. },
  98. },
  99. },
  100. },
  101. })
  102. },
  103. }),
  104. }
  105. }),
  106. ({ authorization, server }) =>
  107. Effect.gen(function* () {
  108. const credentials = yield* Credential.Service
  109. const catalog = yield* Catalog.Service
  110. yield* catalog.transform((draft) => {
  111. draft.provider.update(ProviderV2.ID.make("remote"), () => {})
  112. draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"), () => {})
  113. })
  114. yield* credentials.create({
  115. integrationID: Integration.ID.make("opencode"),
  116. value: Credential.Key.make({
  117. type: "key",
  118. key: "secret",
  119. metadata: { server: server.url.origin },
  120. }),
  121. })
  122. yield* addPlugin()
  123. const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("remote")))
  124. expect(provider).toMatchObject({
  125. name: "Remote",
  126. integrationID: "opencode",
  127. api: {
  128. type: "aisdk",
  129. package: "@ai-sdk/openai-compatible",
  130. url: `${server.url.origin}/v1`,
  131. },
  132. })
  133. expect(provider.request).toEqual({ headers: { "x-org-id": "org" }, body: { custom: "value" } })
  134. expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
  135. const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))
  136. expect(model).toMatchObject({
  137. name: "Remote Model",
  138. family: "remote",
  139. capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
  140. cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }],
  141. limit: { context: 1000, output: 100 },
  142. })
  143. expect(model.request).toMatchObject({ body: { custom: "value" }, generation: { temperature: 0.5 } })
  144. expect(model.request.body).toEqual({ custom: "value" })
  145. expect(model.variants).toEqual([
  146. {
  147. id: ModelV2.VariantID.make("high"),
  148. headers: {},
  149. body: {},
  150. generation: { temperature: 0.2 },
  151. options: {},
  152. },
  153. ])
  154. expect(
  155. required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("disabled"))).enabled,
  156. ).toBe(false)
  157. expect(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"))).toBeUndefined()
  158. expect(authorization).toContain("Bearer secret")
  159. }),
  160. ({ server }) => Effect.promise(() => server.stop(true)),
  161. ),
  162. )
  163. it.effect("uses a public key and disables paid models without credentials", () =>
  164. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  165. Effect.gen(function* () {
  166. const catalog = yield* Catalog.Service
  167. yield* catalog.transform((catalog) => {
  168. const provider = ProviderV2.Info.make({
  169. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  170. api: { type: "aisdk", package: "test-provider" },
  171. })
  172. const model = ModelV2.Info.make({
  173. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  174. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  175. cost: cost(1),
  176. })
  177. catalog.provider.update(provider.id, () => {})
  178. catalog.model.update(provider.id, model.id, (draft) => {
  179. draft.cost = [...model.cost]
  180. })
  181. })
  182. yield* addPlugin()
  183. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  184. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
  185. }),
  186. ),
  187. )
  188. it.effect("keeps free models without credentials", () =>
  189. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  190. Effect.gen(function* () {
  191. const catalog = yield* Catalog.Service
  192. yield* catalog.transform((catalog) => {
  193. const provider = ProviderV2.Info.make({
  194. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  195. api: { type: "aisdk", package: "test-provider" },
  196. })
  197. const model = ModelV2.Info.make({
  198. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
  199. api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" },
  200. cost: cost(0),
  201. })
  202. catalog.provider.update(provider.id, () => {})
  203. catalog.model.update(provider.id, model.id, (draft) => {
  204. draft.cost = [...model.cost]
  205. })
  206. })
  207. yield* addPlugin()
  208. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  209. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
  210. }),
  211. ),
  212. )
  213. it.effect("treats output-only cost as free without credentials", () =>
  214. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  215. Effect.gen(function* () {
  216. const catalog = yield* Catalog.Service
  217. yield* catalog.transform((catalog) => {
  218. const provider = ProviderV2.Info.make({
  219. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  220. api: { type: "aisdk", package: "test-provider" },
  221. })
  222. const model = ModelV2.Info.make({
  223. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
  224. api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" },
  225. cost: cost(0, 1),
  226. })
  227. catalog.provider.update(provider.id, () => {})
  228. catalog.model.update(provider.id, model.id, (draft) => {
  229. draft.cost = [...model.cost]
  230. })
  231. })
  232. yield* addPlugin()
  233. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  234. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
  235. true,
  236. )
  237. }),
  238. ),
  239. )
  240. it.effect("uses OPENCODE_API_KEY as credentials", () =>
  241. withEnv({ OPENCODE_API_KEY: "secret" }, () =>
  242. Effect.gen(function* () {
  243. const catalog = yield* Catalog.Service
  244. yield* catalog.transform((catalog) => {
  245. const provider = ProviderV2.Info.make({
  246. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  247. api: { type: "aisdk", package: "test-provider" },
  248. })
  249. const model = ModelV2.Info.make({
  250. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  251. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  252. cost: cost(1),
  253. })
  254. catalog.provider.update(provider.id, () => {})
  255. catalog.model.update(provider.id, model.id, (draft) => {
  256. draft.cost = [...model.cost]
  257. })
  258. })
  259. yield* addPlugin()
  260. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
  261. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  262. }),
  263. ),
  264. )
  265. it.effect("uses configured provider env vars as credentials", () =>
  266. withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
  267. Effect.gen(function* () {
  268. const catalog = yield* Catalog.Service
  269. const integrations = yield* Integration.Service
  270. yield* integrations.transform((editor) => {
  271. editor.method.update({
  272. integrationID: Integration.ID.make("opencode"),
  273. method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
  274. })
  275. })
  276. yield* catalog.transform((catalog) => {
  277. const provider = ProviderV2.Info.make({
  278. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  279. api: { type: "aisdk", package: "test-provider" },
  280. })
  281. const model = ModelV2.Info.make({
  282. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  283. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  284. cost: cost(1),
  285. })
  286. catalog.provider.update(provider.id, () => {})
  287. catalog.model.update(provider.id, model.id, (draft) => {
  288. draft.cost = [...model.cost]
  289. })
  290. })
  291. yield* addPlugin()
  292. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
  293. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  294. }),
  295. ),
  296. )
  297. it.effect("uses configured apiKey as credentials", () =>
  298. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  299. Effect.gen(function* () {
  300. const catalog = yield* Catalog.Service
  301. yield* catalog.transform((catalog) => {
  302. const provider = ProviderV2.Info.make({
  303. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  304. api: { type: "aisdk", package: "test-provider" },
  305. request: {
  306. headers: {},
  307. body: { apiKey: "configured" },
  308. },
  309. })
  310. const model = ModelV2.Info.make({
  311. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  312. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  313. cost: cost(1),
  314. })
  315. catalog.provider.update(provider.id, (draft) => {
  316. draft.request = provider.request
  317. })
  318. catalog.model.update(provider.id, model.id, (draft) => {
  319. draft.cost = [...model.cost]
  320. })
  321. })
  322. yield* addPlugin()
  323. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
  324. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  325. }),
  326. ),
  327. )
  328. it.effect("ignores non-opencode providers and models", () =>
  329. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  330. Effect.gen(function* () {
  331. const catalog = yield* Catalog.Service
  332. yield* catalog.transform((catalog) => {
  333. const provider = ProviderV2.Info.make({
  334. ...ProviderV2.Info.empty(ProviderV2.ID.openai),
  335. api: { type: "aisdk", package: "test-provider" },
  336. })
  337. const model = ModelV2.Info.make({
  338. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  339. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  340. cost: cost(1),
  341. })
  342. catalog.provider.update(provider.id, () => {})
  343. catalog.model.update(provider.id, model.id, (draft) => {
  344. draft.cost = [...model.cost]
  345. })
  346. })
  347. yield* addPlugin()
  348. expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
  349. expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
  350. }),
  351. ),
  352. )
  353. it.effect("prefers gpt-5-nano as the opencode small model", () =>
  354. Effect.gen(function* () {
  355. const catalog = yield* Catalog.Service
  356. const providerID = ProviderV2.ID.opencode
  357. yield* catalog.transform((catalog) => {
  358. catalog.provider.update(providerID, () => {})
  359. catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
  360. model.capabilities.input = ["text"]
  361. model.capabilities.output = ["text"]
  362. model.cost = [...cost(1, 1)]
  363. model.time.released = Date.now()
  364. })
  365. catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
  366. model.capabilities.input = ["text"]
  367. model.capabilities.output = ["text"]
  368. model.cost = [...cost(10, 10)]
  369. model.time.released = Date.now()
  370. })
  371. })
  372. const selected = yield* catalog.model.small(providerID)
  373. expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
  374. }),
  375. )
  376. })