provider-kirincode.test.ts 17 KB

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