account.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import path from "path"
  2. import { describe, expect } from "bun:test"
  3. import { produce } from "immer"
  4. import { Effect, Fiber, Layer, Option, Stream } from "effect"
  5. import { Auth } from "@opencode-ai/core/auth"
  6. import { Catalog } from "@opencode-ai/core/catalog"
  7. import { EventV2 } from "@opencode-ai/core/event"
  8. import { FSUtil } from "@opencode-ai/core/fs-util"
  9. import { Global } from "@opencode-ai/core/global"
  10. import { PluginV2 } from "@opencode-ai/core/plugin"
  11. import { AccountPlugin } from "@opencode-ai/core/plugin/account"
  12. import { ModelV2 } from "@opencode-ai/core/model"
  13. import { ProviderV2 } from "@opencode-ai/core/provider"
  14. import { tmpdir } from "./fixture/tmpdir"
  15. import { testEffect } from "./lib/effect"
  16. const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))
  17. function context(
  18. records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
  19. updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>,
  20. ): Catalog.Editor {
  21. return {
  22. provider: {
  23. list: () => records,
  24. get: (providerID) => records.find((item) => item.provider.id === providerID),
  25. update: (providerID, fn) => {
  26. const record = records.find((item) => item.provider.id === providerID)
  27. const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
  28. if (record) record.provider = provider
  29. else records.push({ provider, models: new Map<ModelV2.ID, ModelV2.Info>() })
  30. updates.push({
  31. id: providerID,
  32. enabled: provider.enabled,
  33. apiKey: typeof provider.request.body.apiKey === "string" ? provider.request.body.apiKey : undefined,
  34. })
  35. },
  36. remove: (providerID) => {
  37. const index = records.findIndex((item) => item.provider.id === providerID)
  38. if (index !== -1) records.splice(index, 1)
  39. },
  40. },
  41. model: {
  42. get: () => undefined,
  43. update: () => {},
  44. remove: () => {},
  45. default: {
  46. get: () => undefined,
  47. set: () => {},
  48. },
  49. },
  50. }
  51. }
  52. function testLayer(dir: string) {
  53. return Auth.layer.pipe(
  54. Layer.provide(FSUtil.defaultLayer),
  55. Layer.provideMerge(EventV2.defaultLayer),
  56. Layer.provide(
  57. Global.layerWith({
  58. data: dir,
  59. cache: path.join(dir, "cache"),
  60. config: path.join(dir, "config"),
  61. state: path.join(dir, "state"),
  62. tmp: path.join(dir, "tmp"),
  63. bin: path.join(dir, "bin"),
  64. log: path.join(dir, "log"),
  65. repos: path.join(dir, "repos"),
  66. }),
  67. ),
  68. )
  69. }
  70. describe("Auth", () => {
  71. it.live("emits account lifecycle events", () =>
  72. Effect.acquireRelease(
  73. Effect.promise(() => tmpdir()),
  74. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  75. ).pipe(
  76. Effect.flatMap((tmp) =>
  77. Effect.gen(function* () {
  78. const accounts = yield* Auth.Service
  79. const eventSvc = yield* EventV2.Service
  80. const addedFiber = yield* eventSvc
  81. .subscribe(Auth.Event.Added)
  82. .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
  83. const switchedFiber = yield* eventSvc
  84. .subscribe(Auth.Event.Switched)
  85. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  86. const removedFiber = yield* eventSvc
  87. .subscribe(Auth.Event.Removed)
  88. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  89. yield* Effect.yieldNow
  90. const first = yield* accounts.create({
  91. serviceID: Auth.ServiceID.make("provider"),
  92. credential: new Auth.ApiKeyCredential({ type: "api", key: "raw-key" }),
  93. })
  94. expect(first).toBeDefined()
  95. if (!first) return
  96. expect(first.description).toBe("default")
  97. expect(first.credential.type).toBe("api")
  98. if (first.credential.type === "api") expect(first.credential.key).toBe("raw-key")
  99. yield* accounts.update(first.id, { description: "keep" })
  100. const updated = yield* accounts.get(first.id)
  101. expect(updated?.description).toBe("keep")
  102. expect(updated?.credential.type).toBe("api")
  103. if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key")
  104. const second = yield* accounts.create({
  105. serviceID: Auth.ServiceID.make("provider"),
  106. credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }),
  107. })
  108. expect(second).toBeDefined()
  109. if (!second) return
  110. yield* accounts.remove(second.id)
  111. const added = Array.from(yield* Fiber.join(addedFiber))
  112. const switched = Array.from(yield* Fiber.join(switchedFiber))
  113. const removed = Array.from(yield* Fiber.join(removedFiber))
  114. expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id])
  115. expect(switched.map((event) => event.data)).toEqual([
  116. { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id },
  117. { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id },
  118. { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: first.id },
  119. ])
  120. expect(removed[0]?.data.account.id).toBe(second.id)
  121. }).pipe(Effect.provide(testLayer(tmp.path))),
  122. ),
  123. ),
  124. )
  125. it.live("always switches to newly created accounts", () =>
  126. Effect.acquireRelease(
  127. Effect.promise(() => tmpdir()),
  128. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  129. ).pipe(
  130. Effect.flatMap((tmp) =>
  131. Effect.gen(function* () {
  132. const accounts = yield* Auth.Service
  133. const eventSvc = yield* EventV2.Service
  134. const switchedFiber = yield* eventSvc
  135. .subscribe(Auth.Event.Switched)
  136. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  137. yield* Effect.yieldNow
  138. const first = yield* accounts.create({
  139. serviceID: Auth.ServiceID.make("provider"),
  140. credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }),
  141. })
  142. const second = yield* accounts.create({
  143. serviceID: Auth.ServiceID.make("provider"),
  144. credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }),
  145. })
  146. const third = yield* accounts.create({
  147. serviceID: Auth.ServiceID.make("provider"),
  148. credential: new Auth.ApiKeyCredential({ type: "api", key: "third-key" }),
  149. })
  150. expect(first).toBeDefined()
  151. expect(second).toBeDefined()
  152. expect(third).toBeDefined()
  153. if (!first || !second || !third) return
  154. expect((yield* accounts.active(Auth.ServiceID.make("provider")))?.id).toBe(third.id)
  155. expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
  156. { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id },
  157. { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id },
  158. { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: third.id },
  159. ])
  160. }).pipe(Effect.provide(testLayer(tmp.path))),
  161. ),
  162. ),
  163. )
  164. it.live("account plugin refreshes providers on account lifecycle events", () =>
  165. Effect.acquireRelease(
  166. Effect.promise(() => tmpdir()),
  167. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  168. ).pipe(
  169. Effect.flatMap((tmp) =>
  170. Effect.gen(function* () {
  171. const accounts = yield* Auth.Service
  172. const plugin = yield* PluginV2.Service
  173. const records = [
  174. {
  175. provider: ProviderV2.Info.empty(ProviderV2.ID.make("provider")),
  176. models: new Map<ModelV2.ID, ModelV2.Info>(),
  177. },
  178. ]
  179. const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
  180. const catalog = Catalog.Service.of({
  181. transform: () => Effect.die("unexpected catalog.transform"),
  182. provider: {
  183. get: () => Effect.die("unexpected provider.get"),
  184. all: () => Effect.succeed([]),
  185. available: () => Effect.succeed([]),
  186. },
  187. model: {
  188. get: () => Effect.die("unexpected model.get"),
  189. all: () => Effect.succeed([]),
  190. available: () => Effect.succeed([]),
  191. default: () => Effect.succeed(Option.none<ModelV2.Info>()),
  192. small: () => Effect.succeed(Option.none<ModelV2.Info>()),
  193. },
  194. })
  195. const eventSvc = yield* EventV2.Service
  196. yield* plugin.add({
  197. ...AccountPlugin,
  198. effect: AccountPlugin.effect.pipe(
  199. Effect.provideService(Auth.Service, accounts),
  200. Effect.provideService(Catalog.Service, catalog),
  201. Effect.provideService(EventV2.Service, eventSvc),
  202. Effect.provideService(PluginV2.Service, plugin),
  203. ),
  204. })
  205. yield* Effect.yieldNow
  206. const first = yield* accounts.create({
  207. serviceID: Auth.ServiceID.make("provider"),
  208. credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }),
  209. })
  210. expect(first).toBeDefined()
  211. if (!first) return
  212. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  213. expect(updates).toEqual([
  214. {
  215. id: ProviderV2.ID.make("provider"),
  216. enabled: { via: "account", service: Auth.ServiceID.make("provider") },
  217. apiKey: "first-key",
  218. },
  219. ])
  220. updates.length = 0
  221. const second = yield* accounts.create({
  222. serviceID: Auth.ServiceID.make("provider"),
  223. credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }),
  224. })
  225. expect(second).toBeDefined()
  226. if (!second) return
  227. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  228. expect(updates).toEqual([
  229. {
  230. id: ProviderV2.ID.make("provider"),
  231. enabled: { via: "account", service: Auth.ServiceID.make("provider") },
  232. apiKey: "second-key",
  233. },
  234. ])
  235. updates.length = 0
  236. yield* accounts.activate(first.id)
  237. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  238. expect(updates).toEqual([
  239. {
  240. id: ProviderV2.ID.make("provider"),
  241. enabled: { via: "account", service: Auth.ServiceID.make("provider") },
  242. apiKey: "first-key",
  243. },
  244. ])
  245. updates.length = 0
  246. yield* accounts.remove(first.id)
  247. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  248. expect(updates).toEqual([
  249. {
  250. id: ProviderV2.ID.make("provider"),
  251. enabled: { via: "account", service: Auth.ServiceID.make("provider") },
  252. apiKey: "second-key",
  253. },
  254. ])
  255. updates.length = 0
  256. yield* accounts.remove(second.id)
  257. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  258. expect(updates).toEqual([])
  259. }).pipe(Effect.provide(testLayer(tmp.path))),
  260. ),
  261. ),
  262. )
  263. })