provider-opencode.test.ts 17 KB

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