client.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import type {
  2. SessionsListInput,
  3. SessionsListOutput,
  4. SessionsCreateInput,
  5. SessionsCreateOutput,
  6. SessionsActiveOutput,
  7. SessionsGetInput,
  8. SessionsGetOutput,
  9. SessionsSwitchAgentInput,
  10. SessionsSwitchAgentOutput,
  11. SessionsSwitchModelInput,
  12. SessionsSwitchModelOutput,
  13. SessionsPromptInput,
  14. SessionsPromptOutput,
  15. SessionsCompactInput,
  16. SessionsCompactOutput,
  17. SessionsWaitInput,
  18. SessionsWaitOutput,
  19. SessionsStageInput,
  20. SessionsStageOutput,
  21. SessionsClearInput,
  22. SessionsClearOutput,
  23. SessionsCommitInput,
  24. SessionsCommitOutput,
  25. SessionsContextInput,
  26. SessionsContextOutput,
  27. SessionsEventsInput,
  28. SessionsEventsOutput,
  29. SessionsInterruptInput,
  30. SessionsInterruptOutput,
  31. SessionsMessageInput,
  32. SessionsMessageOutput,
  33. } from "./types"
  34. import { ClientError } from "./client-error"
  35. export interface ClientOptions {
  36. readonly baseUrl: string
  37. readonly fetch?: typeof globalThis.fetch
  38. readonly headers?: HeadersInit
  39. }
  40. export interface RequestOptions {
  41. readonly signal?: AbortSignal
  42. readonly headers?: HeadersInit
  43. }
  44. interface RequestDescriptor {
  45. readonly method: string
  46. readonly path: string
  47. readonly query?: Record<string, unknown>
  48. readonly headers?: Record<string, unknown>
  49. readonly body?: unknown
  50. readonly successStatus: number
  51. readonly declaredStatuses: ReadonlyArray<number>
  52. readonly empty: boolean
  53. }
  54. export function make(options: ClientOptions) {
  55. const fetch = options.fetch ?? globalThis.fetch
  56. const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
  57. const url = new URL(descriptor.path, options.baseUrl)
  58. for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)
  59. const headers = new Headers(options.headers)
  60. for (const [key, value] of Object.entries(descriptor.headers ?? {})) {
  61. if (value !== undefined && value !== null) headers.set(key, String(value))
  62. }
  63. for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)
  64. if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
  65. return {
  66. url,
  67. init: {
  68. method: descriptor.method,
  69. signal: requestOptions?.signal,
  70. headers,
  71. body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),
  72. } satisfies RequestInit,
  73. }
  74. }
  75. const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
  76. try {
  77. const prepared = prepare(descriptor, requestOptions)
  78. return await fetch(prepared.url, prepared.init)
  79. } catch (cause) {
  80. throw new ClientError("Transport", { cause })
  81. }
  82. }
  83. const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {
  84. if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)
  85. try {
  86. await response.body?.cancel()
  87. } catch {}
  88. throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })
  89. }
  90. const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
  91. const response = await execute(descriptor, requestOptions)
  92. if (response.status !== descriptor.successStatus) return responseError(response, descriptor)
  93. if (descriptor.empty) {
  94. try {
  95. await response.body?.cancel()
  96. } catch {}
  97. return undefined as A
  98. }
  99. return (await json(response)) as A
  100. }
  101. const sse = <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable<A> => ({
  102. async *[Symbol.asyncIterator]() {
  103. const response = await execute(descriptor, requestOptions)
  104. if (response.status !== descriptor.successStatus) await responseError(response, descriptor)
  105. if (!isContentType(response, "text/event-stream")) {
  106. try {
  107. await response.body?.cancel()
  108. } catch {}
  109. throw new ClientError("UnsupportedContentType")
  110. }
  111. if (response.body === null) throw new ClientError("MalformedResponse")
  112. const reader = response.body.getReader()
  113. const decoder = new TextDecoder()
  114. let buffer = ""
  115. try {
  116. while (true) {
  117. let next
  118. try {
  119. next = await reader.read()
  120. } catch (cause) {
  121. throw new ClientError("Transport", { cause })
  122. }
  123. buffer += decoder.decode(next.value, { stream: !next.done })
  124. if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
  125. const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
  126. if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
  127. buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
  128. if (trailingCarriageReturn) buffer += "\r"
  129. if (next.done && buffer !== "") buffer += "\n\n"
  130. let boundary = buffer.indexOf("\n\n")
  131. while (boundary >= 0) {
  132. const block = buffer.slice(0, boundary)
  133. buffer = buffer.slice(boundary + 2)
  134. const data = block
  135. .split("\n")
  136. .flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : []))
  137. .join("\n")
  138. if (data !== "") {
  139. try {
  140. yield JSON.parse(data) as A
  141. } catch (cause) {
  142. throw new ClientError("MalformedResponse", { cause })
  143. }
  144. }
  145. boundary = buffer.indexOf("\n\n")
  146. }
  147. if (next.done) return
  148. }
  149. } finally {
  150. try {
  151. await reader.cancel()
  152. } catch {}
  153. reader.releaseLock()
  154. }
  155. },
  156. })
  157. return {
  158. sessions: {
  159. list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
  160. request<SessionsListOutput>(
  161. {
  162. method: "GET",
  163. path: `/api/session`,
  164. query: {
  165. workspace: input?.workspace,
  166. limit: input?.limit,
  167. order: input?.order,
  168. search: input?.search,
  169. directory: input?.directory,
  170. project: input?.project,
  171. subpath: input?.subpath,
  172. cursor: input?.cursor,
  173. },
  174. successStatus: 200,
  175. declaredStatuses: [400, 401],
  176. empty: false,
  177. },
  178. requestOptions,
  179. ),
  180. create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) =>
  181. request<{ readonly data: SessionsCreateOutput }>(
  182. {
  183. method: "POST",
  184. path: `/api/session`,
  185. body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
  186. successStatus: 200,
  187. declaredStatuses: [401, 400],
  188. empty: false,
  189. },
  190. requestOptions,
  191. ).then((value) => value.data),
  192. active: (requestOptions?: RequestOptions) =>
  193. request<{ readonly data: SessionsActiveOutput }>(
  194. {
  195. method: "GET",
  196. path: `/api/session/active`,
  197. successStatus: 200,
  198. declaredStatuses: [401, 400],
  199. empty: false,
  200. },
  201. requestOptions,
  202. ).then((value) => value.data),
  203. get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
  204. request<{ readonly data: SessionsGetOutput }>(
  205. {
  206. method: "GET",
  207. path: `/api/session/${encodeURIComponent(input.sessionID)}`,
  208. successStatus: 200,
  209. declaredStatuses: [404, 400, 401],
  210. empty: false,
  211. },
  212. requestOptions,
  213. ).then((value) => value.data),
  214. switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) =>
  215. request<SessionsSwitchAgentOutput>(
  216. {
  217. method: "POST",
  218. path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
  219. body: { agent: input.agent },
  220. successStatus: 204,
  221. declaredStatuses: [404, 400, 401],
  222. empty: true,
  223. },
  224. requestOptions,
  225. ),
  226. switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) =>
  227. request<SessionsSwitchModelOutput>(
  228. {
  229. method: "POST",
  230. path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
  231. body: { model: input.model },
  232. successStatus: 204,
  233. declaredStatuses: [404, 400, 401],
  234. empty: true,
  235. },
  236. requestOptions,
  237. ),
  238. prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
  239. request<{ readonly data: SessionsPromptOutput }>(
  240. {
  241. method: "POST",
  242. path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
  243. body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
  244. successStatus: 200,
  245. declaredStatuses: [409, 404, 400, 401],
  246. empty: false,
  247. },
  248. requestOptions,
  249. ).then((value) => value.data),
  250. compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) =>
  251. request<SessionsCompactOutput>(
  252. {
  253. method: "POST",
  254. path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
  255. successStatus: 204,
  256. declaredStatuses: [404, 503, 400, 401],
  257. empty: true,
  258. },
  259. requestOptions,
  260. ),
  261. wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) =>
  262. request<SessionsWaitOutput>(
  263. {
  264. method: "POST",
  265. path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`,
  266. successStatus: 204,
  267. declaredStatuses: [404, 503, 400, 401],
  268. empty: true,
  269. },
  270. requestOptions,
  271. ),
  272. stage: (input: SessionsStageInput, requestOptions?: RequestOptions) =>
  273. request<{ readonly data: SessionsStageOutput }>(
  274. {
  275. method: "POST",
  276. path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
  277. body: { messageID: input.messageID, files: input.files },
  278. successStatus: 200,
  279. declaredStatuses: [404, 500, 400, 401],
  280. empty: false,
  281. },
  282. requestOptions,
  283. ).then((value) => value.data),
  284. clear: (input: SessionsClearInput, requestOptions?: RequestOptions) =>
  285. request<SessionsClearOutput>(
  286. {
  287. method: "POST",
  288. path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
  289. successStatus: 204,
  290. declaredStatuses: [404, 500, 400, 401],
  291. empty: true,
  292. },
  293. requestOptions,
  294. ),
  295. commit: (input: SessionsCommitInput, requestOptions?: RequestOptions) =>
  296. request<SessionsCommitOutput>(
  297. {
  298. method: "POST",
  299. path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
  300. successStatus: 204,
  301. declaredStatuses: [404, 400, 401],
  302. empty: true,
  303. },
  304. requestOptions,
  305. ),
  306. context: (input: SessionsContextInput, requestOptions?: RequestOptions) =>
  307. request<{ readonly data: SessionsContextOutput }>(
  308. {
  309. method: "GET",
  310. path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
  311. successStatus: 200,
  312. declaredStatuses: [404, 500, 400, 401],
  313. empty: false,
  314. },
  315. requestOptions,
  316. ).then((value) => value.data),
  317. events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
  318. sse<SessionsEventsOutput>(
  319. {
  320. method: "GET",
  321. path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
  322. query: { after: input.after },
  323. successStatus: 200,
  324. declaredStatuses: [404, 400, 401],
  325. empty: false,
  326. },
  327. requestOptions,
  328. ),
  329. interrupt: (input: SessionsInterruptInput, requestOptions?: RequestOptions) =>
  330. request<SessionsInterruptOutput>(
  331. {
  332. method: "POST",
  333. path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
  334. successStatus: 204,
  335. declaredStatuses: [404, 400, 401],
  336. empty: true,
  337. },
  338. requestOptions,
  339. ),
  340. message: (input: SessionsMessageInput, requestOptions?: RequestOptions) =>
  341. request<{ readonly data: SessionsMessageOutput }>(
  342. {
  343. method: "GET",
  344. path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
  345. successStatus: 200,
  346. declaredStatuses: [404, 400, 401],
  347. empty: false,
  348. },
  349. requestOptions,
  350. ).then((value) => value.data),
  351. },
  352. }
  353. }
  354. function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
  355. if (value === undefined || value === null) return
  356. if (Array.isArray(value)) {
  357. for (const item of value) appendQuery(params, key, item)
  358. return
  359. }
  360. if (typeof value === "object") {
  361. for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item)
  362. return
  363. }
  364. params.append(key, String(value))
  365. }
  366. async function json(response: Response): Promise<unknown> {
  367. if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {
  368. try {
  369. await response.body?.cancel()
  370. } catch {}
  371. throw new ClientError("UnsupportedContentType")
  372. }
  373. let text: string
  374. try {
  375. text = await response.text()
  376. } catch (cause) {
  377. throw new ClientError("Transport", { cause })
  378. }
  379. if (text === "") throw new ClientError("MalformedResponse")
  380. try {
  381. return JSON.parse(text)
  382. } catch (cause) {
  383. throw new ClientError("MalformedResponse", { cause })
  384. }
  385. }
  386. function isContentType(response: Response, expected: string) {
  387. return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected
  388. }