httpapi-provider.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import { describe, expect } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { FSUtil } from "@kirincode-ai/core/fs-util"
  4. import { Effect, Layer } from "effect"
  5. import path from "path"
  6. import { resetDatabase } from "../fixture/db"
  7. import { TestInstance } from "../fixture/fixture"
  8. import { markPluginDependenciesReady } from "../fixture/plugin"
  9. import { testEffect } from "../lib/effect"
  10. import { httpApiLayer, request } from "./httpapi-layer"
  11. const testStateLayer = Layer.effectDiscard(
  12. Effect.acquireRelease(
  13. Effect.promise(() => resetDatabase()),
  14. () => Effect.promise(() => resetDatabase()),
  15. ),
  16. )
  17. const it = testEffect(Layer.mergeAll(testStateLayer, LayerNode.compile(FSUtil.node), httpApiLayer))
  18. const projectOptions = { config: { formatter: false, lsp: false } }
  19. const providerID = "test-oauth-parity"
  20. const oauthURL = "https://example.com/oauth"
  21. const oauthInstructions = "Finish OAuth"
  22. function providerListHasFetch(list: unknown) {
  23. if (!Array.isArray(list)) return false
  24. return list.some((item: unknown) => {
  25. if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false
  26. if (item.id !== "google") return false
  27. if (typeof item.options !== "object" || item.options === null) return false
  28. return "fetch" in item.options
  29. })
  30. }
  31. function hasProviderWithFetch(input: unknown, key: "all" | "providers") {
  32. if (typeof input !== "object" || input === null) return false
  33. if (key === "all") return "all" in input && providerListHasFetch(input.all)
  34. return "providers" in input && providerListHasFetch(input.providers)
  35. }
  36. function isRecord(value: unknown): value is Record<string, unknown> {
  37. return typeof value === "object" && value !== null && !Array.isArray(value)
  38. }
  39. function providerList(input: unknown, key: "all" | "providers") {
  40. if (!isRecord(input)) return []
  41. if (!Array.isArray(input[key])) return []
  42. return input[key]
  43. }
  44. function providerByID(input: unknown, key: "all" | "providers", id: string) {
  45. return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id)
  46. }
  47. function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) {
  48. const provider = providerByID(input, key, id)
  49. if (!isRecord(provider) || !isRecord(provider.models)) return false
  50. return Object.values(provider.models).some((model) => {
  51. if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false
  52. return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some(
  53. (cost) => typeof cost === "number" && cost > 0,
  54. )
  55. })
  56. }
  57. function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) {
  58. const provider = providerByID(input, key, id)
  59. if (!isRecord(provider)) return false
  60. if (provider.name === "mutated-provider") return true
  61. return isRecord(provider.options) && provider.options.mutatedByPlugin === true
  62. }
  63. function requestAuthorize(input: {
  64. providerID: string
  65. method: number
  66. headers: HeadersInit
  67. inputs?: Record<string, string>
  68. }) {
  69. return Effect.gen(function* () {
  70. const response = yield* request(`/provider/${input.providerID}/oauth/authorize`, {
  71. method: "POST",
  72. headers: input.headers,
  73. body: JSON.stringify({ method: input.method, ...(input.inputs ? { inputs: input.inputs } : {}) }),
  74. })
  75. return {
  76. status: response.status,
  77. body: yield* response.text,
  78. }
  79. })
  80. }
  81. function requestCallback(input: { providerID: string; method: number; headers: HeadersInit; code?: string }) {
  82. return Effect.gen(function* () {
  83. const response = yield* request(`/provider/${input.providerID}/oauth/callback`, {
  84. method: "POST",
  85. headers: input.headers,
  86. body: JSON.stringify({ method: input.method, ...(input.code ? { code: input.code } : {}) }),
  87. })
  88. return {
  89. status: response.status,
  90. body: yield* response.text,
  91. }
  92. })
  93. }
  94. function writeProviderAuthPlugin(dir: string) {
  95. return Effect.gen(function* () {
  96. const fs = yield* FSUtil.Service
  97. yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".kirincode")))
  98. yield* fs.writeWithDirs(
  99. path.join(dir, ".kirincode", "plugin", "provider-oauth-parity.ts"),
  100. [
  101. "export default {",
  102. ' id: "test.provider-oauth-parity",',
  103. " server: async () => ({",
  104. " auth: {",
  105. ` provider: "${providerID}",`,
  106. " methods: [",
  107. ' { type: "api", label: "API key" },',
  108. " {",
  109. ' type: "oauth",',
  110. ' label: "OAuth",',
  111. " authorize: async () => ({",
  112. ` url: "${oauthURL}",`,
  113. ' method: "code",',
  114. ` instructions: "${oauthInstructions}",`,
  115. " callback: async () => ({ type: 'success', key: 'token' }),",
  116. " }),",
  117. " },",
  118. " ],",
  119. " },",
  120. " }),",
  121. "}",
  122. "",
  123. ].join("\n"),
  124. )
  125. })
  126. }
  127. function writeProviderAuthValidationPlugin(dir: string) {
  128. return Effect.gen(function* () {
  129. const fs = yield* FSUtil.Service
  130. yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".kirincode")))
  131. yield* fs.writeWithDirs(
  132. path.join(dir, ".kirincode", "plugin", "provider-oauth-validation.ts"),
  133. [
  134. "export default {",
  135. ' id: "test.provider-oauth-validation",',
  136. " server: async () => ({",
  137. " auth: {",
  138. ' provider: "test-oauth-validation",',
  139. " methods: [",
  140. " {",
  141. ' type: "oauth",',
  142. ' label: "OAuth",',
  143. " prompts: [",
  144. " {",
  145. ' type: "text",',
  146. ' key: "token",',
  147. ' message: "Token",',
  148. " validate: (value) => value === 'ok' ? undefined : 'Token must be ok',",
  149. " },",
  150. " ],",
  151. " authorize: async () => ({",
  152. ` url: "${oauthURL}",`,
  153. ' method: "code",',
  154. ` instructions: "${oauthInstructions}",`,
  155. " callback: async () => ({ type: 'success', key: 'token' }),",
  156. " }),",
  157. " },",
  158. " ],",
  159. " },",
  160. " }),",
  161. "}",
  162. "",
  163. ].join("\n"),
  164. )
  165. })
  166. }
  167. function writeFunctionOptionsPlugin(dir: string) {
  168. return Effect.gen(function* () {
  169. const fs = yield* FSUtil.Service
  170. yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".kirincode")))
  171. yield* fs.writeWithDirs(
  172. path.join(dir, ".kirincode", "plugin", "provider-function-options.ts"),
  173. [
  174. "export default {",
  175. ' id: "test.provider-function-options",',
  176. " server: async () => ({",
  177. " auth: {",
  178. ' provider: "google",',
  179. " loader: async (_getAuth, provider) => {",
  180. " for (const model of Object.values(provider.models ?? {})) {",
  181. " model.cost = { input: 0, output: 0 }",
  182. " }",
  183. " return {",
  184. ' apiKey: "",',
  185. " fetch: async (input, init) => fetch(input, init),",
  186. " }",
  187. " },",
  188. " methods: [{ type: 'api', label: 'API key' }],",
  189. " },",
  190. " }),",
  191. "}",
  192. "",
  193. ].join("\n"),
  194. )
  195. })
  196. }
  197. function writeProviderModelsMutationPlugin(dir: string) {
  198. return Effect.gen(function* () {
  199. const fs = yield* FSUtil.Service
  200. yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".kirincode")))
  201. yield* fs.writeWithDirs(
  202. path.join(dir, ".kirincode", "plugin", "provider-models-mutation.ts"),
  203. [
  204. "export default {",
  205. ' id: "test.provider-models-mutation",',
  206. " server: async () => ({",
  207. " provider: {",
  208. ' id: "google",',
  209. " models: async (provider) => {",
  210. " const models = Object.fromEntries(",
  211. " Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),",
  212. " )",
  213. ' provider.name = "mutated-provider"',
  214. " provider.options = { ...provider.options, mutatedByPlugin: true }",
  215. " for (const model of Object.values(provider.models ?? {})) {",
  216. " model.cost = { input: 0, output: 0 }",
  217. " }",
  218. " return models",
  219. " },",
  220. " },",
  221. " }),",
  222. "}",
  223. "",
  224. ].join("\n"),
  225. )
  226. })
  227. }
  228. function setEnvScoped(key: string, value: string) {
  229. return Effect.acquireRelease(
  230. Effect.sync(() => {
  231. const previous = process.env[key]
  232. process.env[key] = value
  233. return previous
  234. }),
  235. (previous) =>
  236. Effect.sync(() => {
  237. if (previous === undefined) delete process.env[key]
  238. else process.env[key] = previous
  239. }),
  240. )
  241. }
  242. describe("provider HttpApi", () => {
  243. it.instance.skip(
  244. "returns public v2 provider not found errors",
  245. Effect.gen(function* () {
  246. const directory = (yield* TestInstance).directory
  247. const response = yield* request("/api/provider/missing", {
  248. headers: { "x-opencode-directory": directory },
  249. })
  250. expect(response.status).toBe(404)
  251. expect(yield* response.json).toEqual({
  252. _tag: "ProviderNotFoundError",
  253. providerID: "missing",
  254. message: "Provider not found: missing",
  255. })
  256. }),
  257. projectOptions,
  258. )
  259. it.instance(
  260. "serves OAuth authorize response shapes",
  261. Effect.gen(function* () {
  262. const directory = (yield* TestInstance).directory
  263. const headers = { "x-opencode-directory": directory, "content-type": "application/json" }
  264. const api = yield* requestAuthorize({
  265. providerID,
  266. method: 0,
  267. headers,
  268. })
  269. // method 0 (api-key style) — authorize() resolves with no further
  270. // redirect; #26474 changed the wire format to JSON `null` so clients
  271. // can `.json()` parse uniformly instead of getting an empty body
  272. // that throws.
  273. expect(api).toEqual({ status: 200, body: "null" })
  274. const oauth = yield* requestAuthorize({
  275. providerID,
  276. method: 1,
  277. headers,
  278. })
  279. expect(JSON.parse(oauth.body)).toEqual({
  280. url: oauthURL,
  281. method: "code",
  282. instructions: oauthInstructions,
  283. })
  284. }),
  285. { ...projectOptions, init: writeProviderAuthPlugin },
  286. 30000,
  287. )
  288. it.instance(
  289. "returns declared provider auth validation errors",
  290. Effect.gen(function* () {
  291. const directory = (yield* TestInstance).directory
  292. const response = yield* requestAuthorize({
  293. providerID: "test-oauth-validation",
  294. method: 0,
  295. inputs: { token: "nope" },
  296. headers: { "x-opencode-directory": directory, "content-type": "application/json" },
  297. })
  298. expect(response.status).toBe(400)
  299. expect(JSON.parse(response.body)).toEqual({
  300. name: "ProviderAuthValidationFailed",
  301. data: { field: "token", message: "Token must be ok" },
  302. })
  303. }),
  304. { ...projectOptions, init: writeProviderAuthValidationPlugin },
  305. 30000,
  306. )
  307. it.instance(
  308. "returns declared provider auth callback errors",
  309. Effect.gen(function* () {
  310. const directory = (yield* TestInstance).directory
  311. const response = yield* requestCallback({
  312. providerID,
  313. method: 0,
  314. headers: { "x-opencode-directory": directory, "content-type": "application/json" },
  315. })
  316. expect(response.status).toBe(400)
  317. expect(JSON.parse(response.body)).toEqual({
  318. name: "ProviderAuthOauthMissing",
  319. data: { providerID },
  320. })
  321. }),
  322. projectOptions,
  323. 30000,
  324. )
  325. it.instance(
  326. "serves provider lists when auth loaders add runtime fetch options",
  327. Effect.gen(function* () {
  328. const directory = (yield* TestInstance).directory
  329. yield* setEnvScoped(
  330. "KIRINCODE_AUTH_CONTENT",
  331. JSON.stringify({
  332. google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 },
  333. }),
  334. )
  335. const headers = { "x-opencode-directory": directory }
  336. const providerResponse = yield* request("/provider", { headers })
  337. const configResponse = yield* request("/config/providers", { headers })
  338. expect(providerResponse.status).toBe(200)
  339. expect(configResponse.status).toBe(200)
  340. const providerBody = yield* providerResponse.json
  341. const configBody = yield* configResponse.json
  342. expect(hasProviderWithFetch(providerBody, "all")).toBe(false)
  343. expect(hasProviderWithFetch(configBody, "providers")).toBe(false)
  344. expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
  345. expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true)
  346. }),
  347. { ...projectOptions, init: writeFunctionOptionsPlugin },
  348. )
  349. it.instance(
  350. "keeps provider.models hook input mutations out of provider state",
  351. Effect.gen(function* () {
  352. const directory = (yield* TestInstance).directory
  353. const headers = { "x-opencode-directory": directory }
  354. const providerResponse = yield* request("/provider", { headers })
  355. const configResponse = yield* request("/config/providers", { headers })
  356. expect(providerResponse.status).toBe(200)
  357. expect(configResponse.status).toBe(200)
  358. const providerBody = yield* providerResponse.json
  359. const configBody = yield* configResponse.json
  360. expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false)
  361. expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false)
  362. expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
  363. }),
  364. { ...projectOptions, init: writeProviderModelsMutationPlugin },
  365. )
  366. })