ci / check (push) Waiting to run
The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64 and arm64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
5.4 KiB
TypeScript
140 lines
5.4 KiB
TypeScript
import { readFileSync } from "node:fs"
|
|
import { expandHome } from "../config/paths.ts"
|
|
import type { Connection } from "../config/schema.ts"
|
|
import { ProviderError } from "./types.ts"
|
|
import { duration } from "../duration.ts"
|
|
|
|
const caCache = new Map<string, string>()
|
|
|
|
/** Bun's fetch `tls` option for a connection, or undefined for the defaults. */
|
|
export function tlsFor(c: Pick<Connection, "tls">): { ca?: string; rejectUnauthorized?: boolean } | undefined {
|
|
if (!c.tls) return undefined
|
|
const out: { ca?: string; rejectUnauthorized?: boolean } = {}
|
|
if (c.tls.ca) {
|
|
const file = expandHome(c.tls.ca)
|
|
let pem = caCache.get(file)
|
|
if (!pem) {
|
|
try {
|
|
pem = readFileSync(file, "utf8")
|
|
} catch {
|
|
throw new ProviderError(`cannot read tls.ca file ${c.tls.ca}`)
|
|
}
|
|
caCache.set(file, pem)
|
|
}
|
|
out.ca = pem
|
|
}
|
|
if (c.tls.insecure) out.rejectUnauthorized = false
|
|
return out
|
|
}
|
|
|
|
const DEFAULT_AUTH: Record<Connection["dialect"], NonNullable<Connection["auth"]>> = {
|
|
"openai-chat": "bearer",
|
|
responses: "bearer",
|
|
anthropic: "x-api-key",
|
|
gemini: "x-goog-api-key",
|
|
ollama: "bearer",
|
|
}
|
|
|
|
/** Auth plus the connection's and model's own headers. */
|
|
export function authHeaders(c: Connection, key: string | undefined, extra: Record<string, string> = {}): Record<string, string> {
|
|
const h: Record<string, string> = { "content-type": "application/json", ...extra }
|
|
const style = c.auth ?? DEFAULT_AUTH[c.dialect]
|
|
if (key && style === "bearer") h.authorization = `Bearer ${key}`
|
|
else if (key && style !== "none") h[style] = key
|
|
return { ...h, ...c.headers }
|
|
}
|
|
|
|
export function joinUrl(base: string, path: string): string {
|
|
return base.replace(/\/+$/, "") + "/" + path.replace(/^\/+/, "")
|
|
}
|
|
|
|
/** Turn an HTTP error body into one readable line: provider JSON errors nest the message differently. */
|
|
export function describeError(status: number, body: string): string {
|
|
let msg = body.trim()
|
|
try {
|
|
const j = JSON.parse(body) as any
|
|
msg = j?.error?.message ?? j?.error ?? j?.message ?? j?.detail ?? msg
|
|
if (typeof msg !== "string") msg = JSON.stringify(msg)
|
|
} catch {
|
|
// not JSON
|
|
}
|
|
if (msg.length > 600) msg = msg.slice(0, 600) + "…"
|
|
return `HTTP ${status}: ${msg || "(empty body)"}`
|
|
}
|
|
|
|
/** At most `max` bytes of a body, as text: an error page can be endless, or a gzip bomb. */
|
|
async function cappedText(res: Response, max: number): Promise<string> {
|
|
if (!res.body) return ""
|
|
const reader = res.body.getReader()
|
|
const chunks: Uint8Array[] = []
|
|
let size = 0
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done || !value) break
|
|
chunks.push(value)
|
|
size += value.length
|
|
if (size >= max) {
|
|
await reader.cancel().catch(() => {})
|
|
break
|
|
}
|
|
}
|
|
return new TextDecoder().decode(Buffer.concat(chunks).subarray(0, max))
|
|
}
|
|
|
|
export async function request(
|
|
url: string,
|
|
init: RequestInit & { timeoutMs?: number; tls?: { ca?: string; rejectUnauthorized?: boolean } },
|
|
what: string,
|
|
): Promise<Response> {
|
|
// The timeout is for silence, not for length: waiting for the response, then any gap between two
|
|
// pieces of the body. A reply may stream for as long as it keeps streaming — with a large
|
|
// max_output a model can think for half an hour, and a total limit cut that off mid-reply.
|
|
const ms = init.timeoutMs ?? 600_000
|
|
const ctl = new AbortController()
|
|
let silent = false
|
|
let timer = setTimeout(() => ((silent = true), ctl.abort()), ms)
|
|
const signal = init.signal ? AbortSignal.any([init.signal, ctl.signal]) : ctl.signal
|
|
let res: Response
|
|
try {
|
|
res = await fetch(url, { ...init, signal })
|
|
} catch (e) {
|
|
clearTimeout(timer)
|
|
const err = e as Error
|
|
if (init.signal?.aborted) throw new ProviderError("cancelled")
|
|
if (silent || err.name === "TimeoutError") throw new ProviderError(`${what}: timed out — no response in ${duration(ms, true)}`, undefined, undefined, true, true)
|
|
const code = (err as { code?: string }).code ?? ""
|
|
if (/CERT|SIGNATURE|SELF_SIGNED/.test(code))
|
|
throw new ProviderError(`${what}: TLS certificate of ${new URL(url).host} is not trusted (${code}). Install its CA system-wide, or set tls.ca on the connection.`)
|
|
throw new ProviderError(`${what}: cannot reach ${new URL(url).host} — ${err.message}`, undefined, undefined, true, true)
|
|
}
|
|
clearTimeout(timer)
|
|
// A redirect asked to be handled by hand is a response, not an error.
|
|
if (!res.ok && !(init.redirect === "manual" && res.status >= 300 && res.status < 400)) {
|
|
const body = await cappedText(res, 64 * 1024).catch(() => "")
|
|
throw new ProviderError(`${what}: ${describeError(res.status, body)}`, res.status, body)
|
|
}
|
|
if (!res.body) return res
|
|
const reader = res.body.getReader()
|
|
const body = new ReadableStream<Uint8Array>({
|
|
async pull(c) {
|
|
timer = setTimeout(() => ((silent = true), ctl.abort()), ms)
|
|
try {
|
|
const r = await reader.read()
|
|
if (r.done) c.close()
|
|
else c.enqueue(r.value)
|
|
} catch (e) {
|
|
if (init.signal?.aborted) throw new ProviderError("cancelled")
|
|
if (silent) throw new ProviderError(`${what}: timed out — the server sent nothing for ${duration(ms, true)}`, undefined, undefined, false)
|
|
throw e
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
},
|
|
cancel(reason) {
|
|
clearTimeout(timer)
|
|
return reader.cancel(reason)
|
|
},
|
|
})
|
|
return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers })
|
|
}
|