provider-opencode.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. import { describe, expect } from "bun:test"
  2. import { Money } from "@opencode-ai/schema/money"
  3. import { Effect } from "effect"
  4. import { Catalog } from "@opencode-ai/core/catalog"
  5. import { Credential } from "@opencode-ai/core/credential"
  6. import { EventV2 } from "@opencode-ai/core/event"
  7. import { Integration } from "@opencode-ai/core/integration"
  8. import { ModelV2 } from "@opencode-ai/core/model"
  9. import { PluginV2 } from "@opencode-ai/core/plugin"
  10. import { PluginHost } from "@opencode-ai/core/plugin/host"
  11. import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
  12. import { ProviderV2 } from "@opencode-ai/core/provider"
  13. import { testEffect } from "../lib/effect"
  14. import { PluginTestLayer } from "./fixture"
  15. const it = testEffect(PluginTestLayer)
  16. const addPlugin = Effect.fn(function* () {
  17. const plugin = yield* PluginV2.Service
  18. const host = yield* PluginHost.make(plugin)
  19. const events = yield* EventV2.Service
  20. const integration = yield* Integration.Service
  21. yield* OpencodePlugin.effect(host).pipe(
  22. Effect.provideService(EventV2.Service, events),
  23. Effect.provideService(Integration.Service, integration),
  24. )
  25. })
  26. function required<T>(value: T | undefined): T {
  27. if (value === undefined) throw new Error("Expected value")
  28. return value
  29. }
  30. function eventually<A>(
  31. effect: Effect.Effect<A>,
  32. predicate: (value: A) => boolean,
  33. remaining = 1000,
  34. ): Effect.Effect<A, Error> {
  35. return Effect.gen(function* () {
  36. const value = yield* effect
  37. if (predicate(value)) return value
  38. if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
  39. yield* Effect.promise(() => Bun.sleep(1))
  40. return yield* eventually(effect, predicate, remaining - 1)
  41. })
  42. }
  43. function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
  44. return Effect.acquireUseRelease(
  45. Effect.sync(() => {
  46. const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
  47. Object.entries(vars).forEach(([key, value]) => {
  48. if (value === undefined) delete process.env[key]
  49. else process.env[key] = value
  50. })
  51. return previous
  52. }),
  53. effect,
  54. (previous) =>
  55. Effect.sync(() =>
  56. Object.entries(previous).forEach(([key, value]) => {
  57. if (value === undefined) delete process.env[key]
  58. else process.env[key] = value
  59. }),
  60. ),
  61. )
  62. }
  63. const cost = (input: number, output = 0) => [
  64. {
  65. input: Money.USDPerMillionTokens.make(input),
  66. output: Money.USDPerMillionTokens.make(output),
  67. cache: {
  68. read: Money.USDPerMillionTokens.zero,
  69. write: Money.USDPerMillionTokens.zero,
  70. },
  71. },
  72. ]
  73. describe("OpencodePlugin", () => {
  74. it.effect("registers account and service account methods", () =>
  75. Effect.gen(function* () {
  76. yield* addPlugin()
  77. expect((yield* (yield* Integration.Service).get(Integration.ID.make("opencode")))?.methods).toEqual([
  78. {
  79. id: Integration.MethodID.make("device"),
  80. type: "oauth",
  81. label: "OpenCode Console account",
  82. },
  83. { type: "key", label: "API key (service account)" },
  84. ])
  85. }),
  86. )
  87. it.live("uses a canonical custom server throughout device authorization", () =>
  88. Effect.acquireUseRelease(
  89. Effect.sync(() => {
  90. const requests: string[] = []
  91. const server = Bun.serve({
  92. port: 0,
  93. fetch: (request) => {
  94. const url = new URL(request.url)
  95. requests.push(`${request.method} ${url.pathname}`)
  96. if (url.pathname.endsWith("/auth/device/code")) {
  97. return Response.json({
  98. device_code: "device",
  99. user_code: "user",
  100. verification_uri_complete: `${url.origin}/verify`,
  101. expires_in: 60,
  102. interval: 0,
  103. })
  104. }
  105. if (url.pathname.endsWith("/auth/device/token")) {
  106. return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600 })
  107. }
  108. if (url.pathname.endsWith("/api/user")) return Response.json({ id: "user", email: "user@example.com" })
  109. if (url.pathname.endsWith("/api/orgs")) return Response.json([{ id: "org", name: "Org" }])
  110. return new Response("Not found", { status: 404 })
  111. },
  112. })
  113. return { requests, server }
  114. }),
  115. ({ requests, server }) =>
  116. Effect.gen(function* () {
  117. yield* addPlugin()
  118. const integrations = yield* Integration.Service
  119. const attempt = yield* integrations.connection.oauth({
  120. integrationID: Integration.ID.make("opencode"),
  121. methodID: Integration.MethodID.make("device"),
  122. inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` },
  123. })
  124. expect(attempt.url).toBe(`${server.url.origin}/verify`)
  125. yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete")
  126. expect(requests).toContain("POST /console/auth/device/code")
  127. expect(requests).toContain("POST /console/auth/device/token")
  128. expect(requests).toContain("GET /console/api/user")
  129. expect(requests).toContain("GET /console/api/orgs")
  130. expect((yield* (yield* Credential.Service).list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
  131. metadata: { server: `${server.url.origin}/console` },
  132. })
  133. }),
  134. ({ server }) => Effect.promise(() => server.stop(true)),
  135. ),
  136. )
  137. it.effect("rejects non-HTTP OpenCode servers", () =>
  138. Effect.gen(function* () {
  139. yield* addPlugin()
  140. const error = yield* (yield* Integration.Service).connection
  141. .oauth({
  142. integrationID: Integration.ID.make("opencode"),
  143. methodID: Integration.MethodID.make("device"),
  144. inputs: { server: "ftp://console.example.com" },
  145. })
  146. .pipe(Effect.flip)
  147. expect(error).toBeInstanceOf(Integration.AuthorizationError)
  148. expect(String(error.cause)).toContain("Invalid OpenCode server URL: expected HTTP(S)")
  149. }),
  150. )
  151. it.live("loads providers and models from the connected OpenCode server", () =>
  152. Effect.acquireUseRelease(
  153. Effect.sync(() => {
  154. const authorization: Array<string | null> = []
  155. return {
  156. authorization,
  157. server: Bun.serve({
  158. port: 0,
  159. fetch: (request) => {
  160. authorization.push(request.headers.get("authorization"))
  161. const origin = new URL(request.url).origin
  162. return Response.json({
  163. config: {
  164. enterprise: { url: origin },
  165. provider: {
  166. remote: {
  167. name: "Remote",
  168. npm: "@ai-sdk/openai-compatible",
  169. api: `${origin}/v1`,
  170. env: ["REMOTE_API_KEY"],
  171. options: {
  172. apiKey: "{env:REMOTE_API_KEY}",
  173. headers: { "x-org-id": "org" },
  174. custom: "value",
  175. },
  176. models: {
  177. model: {
  178. name: "Remote Model",
  179. family: "remote",
  180. release_date: "2026-01-02",
  181. tool_call: true,
  182. modalities: { input: ["text", "image"], output: ["text"] },
  183. options: { apiKey: "model-secret", temperature: 0.5 },
  184. variants: { high: { apiKey: "variant-secret", temperature: 0.2 } },
  185. cost: { input: 1, output: 2, cache_read: 0.1 },
  186. limit: { context: 1000, output: 100 },
  187. },
  188. disabled: { name: "Disabled", status: "deprecated" },
  189. },
  190. },
  191. },
  192. },
  193. })
  194. },
  195. }),
  196. }
  197. }),
  198. ({ authorization, server }) =>
  199. Effect.gen(function* () {
  200. const credentials = yield* Credential.Service
  201. const catalog = yield* Catalog.Service
  202. yield* catalog.transform((draft) => {
  203. draft.provider.update(ProviderV2.ID.make("remote"), () => {})
  204. draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("model"), (model) => {
  205. model.variants = [
  206. {
  207. id: ModelV2.VariantID.make("custom"),
  208. settings: {},
  209. headers: { "x-custom": "true" },
  210. body: { custom: true },
  211. },
  212. ]
  213. })
  214. draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"), () => {})
  215. })
  216. yield* credentials.create({
  217. integrationID: Integration.ID.make("opencode"),
  218. value: Credential.Key.make({
  219. type: "key",
  220. key: "secret",
  221. metadata: { server: server.url.origin },
  222. }),
  223. })
  224. yield* addPlugin()
  225. expect(authorization).toEqual(["Bearer secret"])
  226. const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("remote")))
  227. expect(provider).toMatchObject({
  228. name: "Remote",
  229. integrationID: "opencode",
  230. package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
  231. settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
  232. headers: { "x-org-id": "org" },
  233. })
  234. expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
  235. const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))
  236. expect(model).toMatchObject({
  237. name: "Remote Model",
  238. family: "remote",
  239. capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
  240. cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }],
  241. limit: { context: 1000, output: 100 },
  242. package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
  243. settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 },
  244. headers: { "x-org-id": "org" },
  245. })
  246. expect(model.variants).toEqual([
  247. {
  248. id: ModelV2.VariantID.make("custom"),
  249. settings: {},
  250. headers: { "x-custom": "true" },
  251. body: { custom: true },
  252. },
  253. {
  254. id: ModelV2.VariantID.make("high"),
  255. settings: { temperature: 0.2 },
  256. headers: {},
  257. },
  258. ])
  259. expect(
  260. required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("disabled"))).enabled,
  261. ).toBe(false)
  262. expect(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"))).toBeDefined()
  263. }),
  264. ({ server }) => Effect.promise(() => server.stop(true)),
  265. ),
  266. )
  267. it.effect("uses a public key and disables paid models without credentials", () =>
  268. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  269. Effect.gen(function* () {
  270. const catalog = yield* Catalog.Service
  271. yield* catalog.transform((catalog) => {
  272. const provider = ProviderV2.Info.make({
  273. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  274. package: ProviderV2.aisdk("test-provider"),
  275. })
  276. const model = ModelV2.Info.make({
  277. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  278. modelID: ModelV2.ID.make("paid"),
  279. package: ProviderV2.aisdk("test-provider"),
  280. cost: cost(1),
  281. })
  282. catalog.provider.update(provider.id, () => {})
  283. catalog.model.update(provider.id, model.id, (draft) => {
  284. draft.cost = [...model.cost]
  285. })
  286. })
  287. yield* addPlugin()
  288. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public")
  289. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
  290. }),
  291. ),
  292. )
  293. it.effect("keeps free models without credentials", () =>
  294. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  295. Effect.gen(function* () {
  296. const catalog = yield* Catalog.Service
  297. yield* catalog.transform((catalog) => {
  298. const provider = ProviderV2.Info.make({
  299. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  300. package: ProviderV2.aisdk("test-provider"),
  301. })
  302. const model = ModelV2.Info.make({
  303. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
  304. modelID: ModelV2.ID.make("free"),
  305. package: ProviderV2.aisdk("test-provider"),
  306. cost: cost(0),
  307. })
  308. catalog.provider.update(provider.id, () => {})
  309. catalog.model.update(provider.id, model.id, (draft) => {
  310. draft.cost = [...model.cost]
  311. })
  312. })
  313. yield* addPlugin()
  314. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public")
  315. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
  316. }),
  317. ),
  318. )
  319. it.effect("treats output-only cost as free without credentials", () =>
  320. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  321. Effect.gen(function* () {
  322. const catalog = yield* Catalog.Service
  323. yield* catalog.transform((catalog) => {
  324. const provider = ProviderV2.Info.make({
  325. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  326. package: ProviderV2.aisdk("test-provider"),
  327. })
  328. const model = ModelV2.Info.make({
  329. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
  330. modelID: ModelV2.ID.make("output-only"),
  331. package: ProviderV2.aisdk("test-provider"),
  332. cost: cost(0, 1),
  333. })
  334. catalog.provider.update(provider.id, () => {})
  335. catalog.model.update(provider.id, model.id, (draft) => {
  336. draft.cost = [...model.cost]
  337. })
  338. })
  339. yield* addPlugin()
  340. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public")
  341. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
  342. true,
  343. )
  344. }),
  345. ),
  346. )
  347. it.effect("uses OPENCODE_API_KEY as credentials", () =>
  348. withEnv({ OPENCODE_API_KEY: "secret" }, () =>
  349. Effect.gen(function* () {
  350. const catalog = yield* Catalog.Service
  351. yield* catalog.transform((catalog) => {
  352. const provider = ProviderV2.Info.make({
  353. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  354. package: ProviderV2.aisdk("test-provider"),
  355. })
  356. const model = ModelV2.Info.make({
  357. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  358. modelID: ModelV2.ID.make("paid"),
  359. package: ProviderV2.aisdk("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.opencode)).settings?.apiKey).toBeUndefined()
  369. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  370. }),
  371. ),
  372. )
  373. it.effect("uses configured provider env vars as credentials", () =>
  374. withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
  375. Effect.gen(function* () {
  376. const catalog = yield* Catalog.Service
  377. const integrations = yield* Integration.Service
  378. yield* integrations.transform((editor) => {
  379. editor.method.update({
  380. integrationID: Integration.ID.make("opencode"),
  381. method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
  382. })
  383. })
  384. yield* catalog.transform((catalog) => {
  385. const provider = ProviderV2.Info.make({
  386. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  387. package: ProviderV2.aisdk("test-provider"),
  388. })
  389. const model = ModelV2.Info.make({
  390. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  391. modelID: ModelV2.ID.make("paid"),
  392. package: ProviderV2.aisdk("test-provider"),
  393. cost: cost(1),
  394. })
  395. catalog.provider.update(provider.id, () => {})
  396. catalog.model.update(provider.id, model.id, (draft) => {
  397. draft.cost = [...model.cost]
  398. })
  399. })
  400. yield* addPlugin()
  401. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBeUndefined()
  402. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  403. }),
  404. ),
  405. )
  406. it.effect("uses configured apiKey as credentials", () =>
  407. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  408. Effect.gen(function* () {
  409. const catalog = yield* Catalog.Service
  410. yield* catalog.transform((catalog) => {
  411. const provider = ProviderV2.Info.make({
  412. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  413. package: ProviderV2.aisdk("test-provider"),
  414. settings: { apiKey: "configured" },
  415. })
  416. const model = ModelV2.Info.make({
  417. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  418. modelID: ModelV2.ID.make("paid"),
  419. package: ProviderV2.aisdk("test-provider"),
  420. cost: cost(1),
  421. })
  422. catalog.provider.update(provider.id, (draft) => {
  423. draft.package = provider.package
  424. draft.settings = { apiKey: "configured" }
  425. })
  426. catalog.model.update(provider.id, model.id, (draft) => {
  427. draft.cost = [...model.cost]
  428. })
  429. })
  430. yield* addPlugin()
  431. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("configured")
  432. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  433. }),
  434. ),
  435. )
  436. it.effect("ignores non-opencode providers and models", () =>
  437. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  438. Effect.gen(function* () {
  439. const catalog = yield* Catalog.Service
  440. yield* catalog.transform((catalog) => {
  441. const provider = ProviderV2.Info.make({
  442. ...ProviderV2.Info.empty(ProviderV2.ID.openai),
  443. package: ProviderV2.aisdk("test-provider"),
  444. })
  445. const model = ModelV2.Info.make({
  446. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  447. modelID: ModelV2.ID.make("paid"),
  448. package: ProviderV2.aisdk("test-provider"),
  449. cost: cost(1),
  450. })
  451. catalog.provider.update(provider.id, () => {})
  452. catalog.model.update(provider.id, model.id, (draft) => {
  453. draft.cost = [...model.cost]
  454. })
  455. })
  456. yield* addPlugin()
  457. expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).settings?.apiKey).toBeUndefined()
  458. expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
  459. }),
  460. ),
  461. )
  462. it.effect("prefers gpt-5-nano as the opencode small model", () =>
  463. Effect.gen(function* () {
  464. const catalog = yield* Catalog.Service
  465. const providerID = ProviderV2.ID.opencode
  466. yield* catalog.transform((catalog) => {
  467. catalog.provider.update(providerID, () => {})
  468. catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
  469. model.capabilities.input = ["text"]
  470. model.capabilities.output = ["text"]
  471. model.cost = [...cost(1, 1)]
  472. model.time.released = Date.now()
  473. })
  474. catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
  475. model.capabilities.input = ["text"]
  476. model.capabilities.output = ["text"]
  477. model.cost = [...cost(10, 10)]
  478. model.time.released = Date.now()
  479. })
  480. })
  481. const selected = yield* catalog.model.small(providerID)
  482. expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
  483. }),
  484. )
  485. })