350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
import type {
|
|
SessionsListInput,
|
|
SessionsListOutput,
|
|
SessionsCreateInput,
|
|
SessionsCreateOutput,
|
|
SessionsGetInput,
|
|
SessionsGetOutput,
|
|
SessionsSwitchAgentInput,
|
|
SessionsSwitchAgentOutput,
|
|
SessionsSwitchModelInput,
|
|
SessionsSwitchModelOutput,
|
|
SessionsPromptInput,
|
|
SessionsPromptOutput,
|
|
SessionsCompactInput,
|
|
SessionsCompactOutput,
|
|
SessionsWaitInput,
|
|
SessionsWaitOutput,
|
|
SessionsStageInput,
|
|
SessionsStageOutput,
|
|
SessionsClearInput,
|
|
SessionsClearOutput,
|
|
SessionsCommitInput,
|
|
SessionsCommitOutput,
|
|
SessionsContextInput,
|
|
SessionsContextOutput,
|
|
} from "./types"
|
|
import { ClientError } from "./client-error"
|
|
|
|
export interface ClientOptions {
|
|
readonly baseUrl: string
|
|
readonly fetch?: typeof globalThis.fetch
|
|
readonly headers?: HeadersInit
|
|
}
|
|
|
|
export interface RequestOptions {
|
|
readonly signal?: AbortSignal
|
|
readonly headers?: HeadersInit
|
|
}
|
|
|
|
interface RequestDescriptor {
|
|
readonly method: string
|
|
readonly path: string
|
|
readonly query?: Record<string, unknown>
|
|
readonly headers?: Record<string, unknown>
|
|
readonly body?: unknown
|
|
readonly successStatus: number
|
|
readonly declaredStatuses: ReadonlyArray<number>
|
|
readonly empty: boolean
|
|
}
|
|
|
|
export function make(options: ClientOptions) {
|
|
const fetch = options.fetch ?? globalThis.fetch
|
|
|
|
const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
|
|
const url = new URL(descriptor.path, options.baseUrl)
|
|
for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)
|
|
const headers = new Headers(options.headers)
|
|
for (const [key, value] of Object.entries(descriptor.headers ?? {})) {
|
|
if (value !== undefined && value !== null) headers.set(key, String(value))
|
|
}
|
|
for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)
|
|
if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
|
|
return {
|
|
url,
|
|
init: {
|
|
method: descriptor.method,
|
|
signal: requestOptions?.signal,
|
|
headers,
|
|
body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),
|
|
} satisfies RequestInit,
|
|
}
|
|
}
|
|
|
|
const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
|
|
try {
|
|
const prepared = prepare(descriptor, requestOptions)
|
|
return await fetch(prepared.url, prepared.init)
|
|
} catch (cause) {
|
|
throw new ClientError("Transport", { cause })
|
|
}
|
|
}
|
|
|
|
const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {
|
|
if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)
|
|
try {
|
|
await response.body?.cancel()
|
|
} catch {}
|
|
throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })
|
|
}
|
|
|
|
const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
|
|
const response = await execute(descriptor, requestOptions)
|
|
if (response.status !== descriptor.successStatus) return responseError(response, descriptor)
|
|
if (descriptor.empty) {
|
|
try {
|
|
await response.body?.cancel()
|
|
} catch {}
|
|
return undefined as A
|
|
}
|
|
return (await json(response)) as A
|
|
}
|
|
|
|
const sse = <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable<A> => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
const response = await execute(descriptor, requestOptions)
|
|
if (response.status !== descriptor.successStatus) await responseError(response, descriptor)
|
|
if (!isContentType(response, "text/event-stream")) {
|
|
try {
|
|
await response.body?.cancel()
|
|
} catch {}
|
|
throw new ClientError("UnsupportedContentType")
|
|
}
|
|
if (response.body === null) throw new ClientError("MalformedResponse")
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ""
|
|
try {
|
|
while (true) {
|
|
let next
|
|
try {
|
|
next = await reader.read()
|
|
} catch (cause) {
|
|
throw new ClientError("Transport", { cause })
|
|
}
|
|
buffer += decoder.decode(next.value, { stream: !next.done })
|
|
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
|
|
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
|
|
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
|
|
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
|
|
if (trailingCarriageReturn) buffer += "\r"
|
|
if (next.done && buffer !== "") buffer += "\n\n"
|
|
let boundary = buffer.indexOf("\n\n")
|
|
while (boundary >= 0) {
|
|
const block = buffer.slice(0, boundary)
|
|
buffer = buffer.slice(boundary + 2)
|
|
const data = block
|
|
.split("\n")
|
|
.flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : []))
|
|
.join("\n")
|
|
if (data !== "") {
|
|
try {
|
|
yield JSON.parse(data) as A
|
|
} catch (cause) {
|
|
throw new ClientError("MalformedResponse", { cause })
|
|
}
|
|
}
|
|
boundary = buffer.indexOf("\n\n")
|
|
}
|
|
if (next.done) return
|
|
}
|
|
} finally {
|
|
try {
|
|
await reader.cancel()
|
|
} catch {}
|
|
reader.releaseLock()
|
|
}
|
|
},
|
|
})
|
|
|
|
return {
|
|
sessions: {
|
|
list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
|
|
request<SessionsListOutput>(
|
|
{
|
|
method: "GET",
|
|
path: `/api/session`,
|
|
query: {
|
|
workspace: input?.workspace,
|
|
limit: input?.limit,
|
|
order: input?.order,
|
|
search: input?.search,
|
|
directory: input?.directory,
|
|
project: input?.project,
|
|
subpath: input?.subpath,
|
|
cursor: input?.cursor,
|
|
},
|
|
successStatus: 200,
|
|
declaredStatuses: [400, 401],
|
|
empty: false,
|
|
},
|
|
requestOptions,
|
|
),
|
|
create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) =>
|
|
request<{ readonly data: SessionsCreateOutput }>(
|
|
{
|
|
method: "POST",
|
|
path: `/api/session`,
|
|
body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
|
|
successStatus: 200,
|
|
declaredStatuses: [401, 400],
|
|
empty: false,
|
|
},
|
|
requestOptions,
|
|
).then((value) => value.data),
|
|
get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
|
|
request<{ readonly data: SessionsGetOutput }>(
|
|
{
|
|
method: "GET",
|
|
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
|
|
successStatus: 200,
|
|
declaredStatuses: [404, 400, 401],
|
|
empty: false,
|
|
},
|
|
requestOptions,
|
|
).then((value) => value.data),
|
|
switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) =>
|
|
request<SessionsSwitchAgentOutput>(
|
|
{
|
|
method: "POST",
|
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
|
|
body: { agent: input.agent },
|
|
successStatus: 204,
|
|
declaredStatuses: [404, 400, 401],
|
|
empty: true,
|
|
},
|
|
requestOptions,
|
|
),
|
|
switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) =>
|
|
request<SessionsSwitchModelOutput>(
|
|
{
|
|
method: "POST",
|
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
|
|
body: { model: input.model },
|
|
successStatus: 204,
|
|
declaredStatuses: [404, 400, 401],
|
|
empty: true,
|
|
},
|
|
requestOptions,
|
|
),
|
|
prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
|
|
request<{ readonly data: SessionsPromptOutput }>(
|
|
{
|
|
method: "POST",
|
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
|
|
body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
|
successStatus: 200,
|
|
declaredStatuses: [409, 404, 400, 401],
|
|
empty: false,
|
|
},
|
|
requestOptions,
|
|
).then((value) => value.data),
|
|
compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) =>
|
|
request<SessionsCompactOutput>(
|
|
{
|
|
method: "POST",
|
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
|
|
successStatus: 204,
|
|
declaredStatuses: [404, 503, 400, 401],
|
|
empty: true,
|
|
},
|
|
requestOptions,
|
|
),
|
|
wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) =>
|
|
request<SessionsWaitOutput>(
|
|
{
|
|
method: "POST",
|
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`,
|
|
successStatus: 204,
|
|
declaredStatuses: [404, 503, 400, 401],
|
|
empty: true,
|
|
},
|
|
requestOptions,
|
|
),
|
|
stage: (input: SessionsStageInput, requestOptions?: RequestOptions) =>
|
|
request<{ readonly data: SessionsStageOutput }>(
|
|
{
|
|
method: "POST",
|
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
|
|
body: { messageID: input.messageID, files: input.files },
|
|
successStatus: 200,
|
|
declaredStatuses: [404, 500, 400, 401],
|
|
empty: false,
|
|
},
|
|
requestOptions,
|
|
).then((value) => value.data),
|
|
clear: (input: SessionsClearInput, requestOptions?: RequestOptions) =>
|
|
request<SessionsClearOutput>(
|
|
{
|
|
method: "POST",
|
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
|
|
successStatus: 204,
|
|
declaredStatuses: [404, 500, 400, 401],
|
|
empty: true,
|
|
},
|
|
requestOptions,
|
|
),
|
|
commit: (input: SessionsCommitInput, requestOptions?: RequestOptions) =>
|
|
request<SessionsCommitOutput>(
|
|
{
|
|
method: "POST",
|
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
|
|
successStatus: 204,
|
|
declaredStatuses: [404, 400, 401],
|
|
empty: true,
|
|
},
|
|
requestOptions,
|
|
),
|
|
context: (input: SessionsContextInput, requestOptions?: RequestOptions) =>
|
|
request<{ readonly data: SessionsContextOutput }>(
|
|
{
|
|
method: "GET",
|
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
|
|
successStatus: 200,
|
|
declaredStatuses: [404, 500, 400, 401],
|
|
empty: false,
|
|
},
|
|
requestOptions,
|
|
).then((value) => value.data),
|
|
},
|
|
}
|
|
}
|
|
|
|
function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
|
|
if (value === undefined || value === null) return
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) appendQuery(params, key, item)
|
|
return
|
|
}
|
|
if (typeof value === "object") {
|
|
for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item)
|
|
return
|
|
}
|
|
params.append(key, String(value))
|
|
}
|
|
|
|
async function json(response: Response): Promise<unknown> {
|
|
if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {
|
|
try {
|
|
await response.body?.cancel()
|
|
} catch {}
|
|
throw new ClientError("UnsupportedContentType")
|
|
}
|
|
let text: string
|
|
try {
|
|
text = await response.text()
|
|
} catch (cause) {
|
|
throw new ClientError("Transport", { cause })
|
|
}
|
|
if (text === "") throw new ClientError("MalformedResponse")
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch (cause) {
|
|
throw new ClientError("MalformedResponse", { cause })
|
|
}
|
|
}
|
|
|
|
function isContentType(response: Response, expected: string) {
|
|
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected
|
|
}
|